Skip to main content

aven_core/sync/client/
keys.rs

1//! Protected local keys: package, seed and enrollment authority for one
2//! database, persisted by the host through [`ProtectedStorage`].
3mod adoption;
4mod membership;
5pub mod peer;
6pub mod rotation;
7mod seed;
8mod storage;
9pub use peer::EnrollmentReadiness;
10pub use storage::{FileProtectedStorage, KEYRING_ITEM, ProtectedStorage, ProtectedStorageLock};
11
12use std::collections::{HashMap, HashSet};
13use std::fmt;
14use std::path::Path;
15use std::sync::{Arc, Mutex};
16#[cfg(any(test, feature = "test-support"))]
17use std::{path::PathBuf, sync::atomic::AtomicU64};
18
19use crate::db::Database;
20use crate::sync::{
21    EncryptedLocalSharedStatePackage, LocalSharedStatePackageContext, LocalSharedStatePackageKey,
22    NeverDispatchedLocalSharedCapture,
23};
24use sha2::{Digest, Sha256};
25use zeroize::{Zeroize, Zeroizing};
26
27const KEYRING_MAGIC: &[u8; 8] = b"AVENLKR1";
28const MARKER_MAGIC: &[u8; 8] = b"AVENLKM1";
29const KEYRING_BYTES: usize = 8 + 32 + 32 + 32 + 32;
30const MARKER_BYTES: usize = 8 + 32 + 32;
31/// Nonsecret record that detects loss of an established package keyring.
32const AUTHORITY_RECORD: &str = "authority";
33
34/// Stable error classes for protected local key storage.
35///
36/// Messages intentionally contain neither key material nor database paths.
37#[derive(Clone, Copy, Debug, PartialEq, Eq)]
38pub enum ProtectedLocalKeyStoreErrorKind {
39    MissingAuthority,
40    Unavailable,
41    Corrupt,
42    WriteFailed,
43    WrongDatabase,
44    SetupMismatch,
45    UnsupportedPlatform,
46}
47
48#[derive(Debug)]
49pub struct ProtectedLocalKeyStoreError {
50    kind: ProtectedLocalKeyStoreErrorKind,
51}
52
53impl ProtectedLocalKeyStoreError {
54    pub fn new(kind: ProtectedLocalKeyStoreErrorKind) -> Self {
55        Self { kind }
56    }
57
58    pub fn kind(&self) -> ProtectedLocalKeyStoreErrorKind {
59        self.kind
60    }
61}
62
63impl fmt::Display for ProtectedLocalKeyStoreError {
64    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
65        let message = match self.kind {
66            ProtectedLocalKeyStoreErrorKind::MissingAuthority => {
67                "protected local key authority is missing"
68            }
69            ProtectedLocalKeyStoreErrorKind::Unavailable => {
70                "protected local key storage is unavailable"
71            }
72            ProtectedLocalKeyStoreErrorKind::Corrupt => {
73                "protected local key storage is corrupt or unsafe"
74            }
75            ProtectedLocalKeyStoreErrorKind::WriteFailed => {
76                "protected local key storage could not be saved"
77            }
78            ProtectedLocalKeyStoreErrorKind::WrongDatabase => {
79                "protected local key store belongs to a different database installation"
80            }
81            ProtectedLocalKeyStoreErrorKind::SetupMismatch => {
82                "protected setup invitation does not match"
83            }
84            ProtectedLocalKeyStoreErrorKind::UnsupportedPlatform => {
85                "protected local key storage is unsupported on this platform"
86            }
87        };
88        formatter.write_str(message)
89    }
90}
91
92impl std::error::Error for ProtectedLocalKeyStoreError {}
93
94pub type StoreResult<T> = Result<T, ProtectedLocalKeyStoreError>;
95
96/// The one generation identity and secret needed by a local encrypted capture.
97///
98/// The secret has redacted debug output and is zeroized by the core key type.
99pub struct ProtectedLocalPackageKey {
100    context: LocalSharedStatePackageContext,
101    key: LocalSharedStatePackageKey,
102}
103
104impl ProtectedLocalPackageKey {
105    pub fn context(&self) -> LocalSharedStatePackageContext {
106        self.context
107    }
108
109    pub fn package_key(&self) -> &LocalSharedStatePackageKey {
110        &self.key
111    }
112}
113
114impl fmt::Debug for ProtectedLocalPackageKey {
115    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
116        formatter
117            .debug_struct("ProtectedLocalPackageKey")
118            .field("context", &self.context)
119            .field("key", &"[REDACTED]")
120            .finish()
121    }
122}
123
124/// Host-owned protected storage scoped to one database at one location.
125///
126/// Key material lives in the host's secret items; a separate nonsecret
127/// marker detects loss of an established authority. Neither is part of
128/// Aven's database export, SQLite backup, or archive backup.
129pub struct ProtectedLocalKeyStore {
130    account: String,
131    storage: Arc<dyn ProtectedStorage>,
132    index: Arc<Mutex<Option<Index>>>,
133    /// Root of test file storage, for tests that inspect or damage files.
134    #[cfg(any(test, feature = "test-support"))]
135    directory: PathBuf,
136    #[cfg(any(test, feature = "test-support"))]
137    enrollment_clock: Option<Arc<AtomicU64>>,
138}
139
140/// What this instance has observed of its protected namespace while it holds
141/// the store lock. `Some` only between `lock()` and release of that lock.
142/// Protected items, their markers and enrollment pins are write-once, so an
143/// observation stays true until the lock is released. External tampering
144/// during the lock is indistinguishable from tampering right after it.
145#[derive(Default)]
146struct Index {
147    listing: Option<Listing>,
148    pins: Option<HashMap<String, Vec<u8>>>,
149    values: HashMap<String, Zeroizing<Vec<u8>>>,
150}
151
152/// Protected item kinds and `{kind}-authority` markers that exist.
153struct Listing {
154    items: HashSet<String>,
155    markers: HashSet<String>,
156}
157
158/// Exclusive store lock. Dropping it discards the index.
159pub(crate) struct StoreLock {
160    _storage: ProtectedStorageLock,
161    index: Arc<Mutex<Option<Index>>>,
162}
163
164impl Drop for StoreLock {
165    fn drop(&mut self) {
166        *self.index.lock().unwrap_or_else(|e| e.into_inner()) = None;
167    }
168}
169
170#[cfg(any(test, feature = "test-support"))]
171impl ProtectedLocalKeyStore {
172    pub fn set_enrollment_clock(&mut self, clock: Arc<AtomicU64>) {
173        self.enrollment_clock = Some(clock);
174    }
175}
176
177impl ProtectedLocalKeyStore {
178    /// Opens the namespace of `database` in `storage`.
179    pub async fn open(
180        database: &Database,
181        storage: Arc<dyn ProtectedStorage>,
182    ) -> StoreResult<Self> {
183        let account = database_account(storage.as_ref(), database).await?;
184        Ok(Self::with_account(account, storage))
185    }
186
187    fn with_account(account: String, storage: Arc<dyn ProtectedStorage>) -> Self {
188        Self {
189            account,
190            storage,
191            index: Arc::default(),
192            #[cfg(any(test, feature = "test-support"))]
193            directory: PathBuf::new(),
194            #[cfg(any(test, feature = "test-support"))]
195            enrollment_clock: None,
196        }
197    }
198
199    /// Loads the established keyring, or creates and durably marks a new one.
200    ///
201    /// An existing marker with missing key material is an error. Existing or
202    /// corrupt material is never replaced. If key persistence succeeds but the
203    /// marker write fails, retry loads the same key and completes the marker.
204    pub fn load_or_create(&self) -> StoreResult<ProtectedLocalPackageKey> {
205        self.prepare()?;
206        let _guard = self.lock()?;
207        let marker = self.read_marker()?;
208        match self.load_secret(KEYRING_ITEM, KEYRING_BYTES)? {
209            Some(bytes) => {
210                let key = decode_keyring(bytes)?;
211                match marker {
212                    Some(expected) if expected != key.context => {
213                        Err(error(ProtectedLocalKeyStoreErrorKind::Corrupt))
214                    }
215                    Some(_) => Ok(key),
216                    None => {
217                        self.write_marker(key.context)?;
218                        Ok(key)
219                    }
220                }
221            }
222            None if marker.is_some() => {
223                Err(error(ProtectedLocalKeyStoreErrorKind::MissingAuthority))
224            }
225            None => {
226                if self.seed_authority_exists()? || self.peer_authority_exists()? {
227                    return Err(error(ProtectedLocalKeyStoreErrorKind::MissingAuthority));
228                }
229                let key = generate_keyring()?;
230                let encoded = encode_keyring(&key);
231                self.create_secret(KEYRING_ITEM, &encoded)?;
232                self.write_marker(key.context)?;
233                Ok(key)
234            }
235        }
236    }
237
238    /// Loads an established authority without generating key material.
239    ///
240    /// If key persistence completed before its nonsecret marker, this completes
241    /// the marker using the existing key identity.
242    pub fn load_required(&self) -> StoreResult<ProtectedLocalPackageKey> {
243        self.prepare()?;
244        let _guard = self.lock()?;
245        self.load_required_locked()
246    }
247
248    fn load_required_locked(&self) -> StoreResult<ProtectedLocalPackageKey> {
249        let marker = self.read_marker()?;
250        let bytes = self
251            .load_secret(KEYRING_ITEM, KEYRING_BYTES)?
252            .ok_or_else(|| error(ProtectedLocalKeyStoreErrorKind::MissingAuthority))?;
253        let key = decode_keyring(bytes)?;
254        match marker {
255            Some(expected) if expected != key.context => {
256                Err(error(ProtectedLocalKeyStoreErrorKind::Corrupt))
257            }
258            Some(_) => Ok(key),
259            None => {
260                self.write_marker(key.context)?;
261                Ok(key)
262            }
263        }
264    }
265
266    /// Creates or reuses protected key material before freezing package bytes.
267    /// The predecessor is caller-supplied context, not membership authorization.
268    /// An existing freeze requires its established authority even if bytes are missing.
269    pub async fn package_local_capture(
270        &self,
271        database: &Database,
272        blob_dir: &Path,
273        membership_predecessor: [u8; 32],
274    ) -> Result<EncryptedLocalSharedStatePackage, anyhow::Error> {
275        self.validate_database(database).await?;
276        let protected = if database
277            .has_local_shared_state_package_never_dispatched()
278            .await?
279            || database.local_seed_genesis_commitment().await?.is_some()
280        {
281            self.load_required()?
282        } else {
283            self.load_or_create()?
284        };
285        database
286            .package_local_shared_state_never_dispatched(
287                blob_dir,
288                protected.context(),
289                protected.package_key(),
290                membership_predecessor,
291            )
292            .await
293    }
294
295    pub(crate) async fn package_local_capture_validated(
296        &self,
297        database: &Database,
298        blob_dir: &Path,
299        membership_predecessor: [u8; 32],
300        capture: Option<NeverDispatchedLocalSharedCapture>,
301    ) -> Result<crate::sync::shared_state::validated::ValidatedSeed, anyhow::Error> {
302        self.validate_database(database).await?;
303        let protected = if database
304            .has_local_shared_state_package_never_dispatched()
305            .await?
306            || database.local_seed_genesis_commitment().await?.is_some()
307        {
308            self.load_required()?
309        } else {
310            self.load_or_create()?
311        };
312        database
313            .package_and_validate(
314                blob_dir,
315                protected.context(),
316                protected.package_key(),
317                membership_predecessor,
318                capture,
319            )
320            .await
321    }
322
323    /// Deletes every protected item and record of this database, including
324    /// authority markers, so the database can start sync again as if it never
325    /// took part. Secrets go before markers, so an interrupted erase reads as
326    /// missing authority until it is rerun, never as a fresh namespace.
327    pub fn erase(&self) -> StoreResult<()> {
328        self.prepare()?;
329        let _guard = self.lock()?;
330        for item in self.storage.list_secrets(&self.account)? {
331            self.delete_secret(&item)?;
332        }
333        for name in self.storage.list_records(&self.account)? {
334            self.remove_record(&name)?;
335        }
336        Ok(())
337    }
338
339    async fn validate_database(&self, database: &Database) -> StoreResult<()> {
340        if database_account(self.storage.as_ref(), database).await? != self.account {
341            return Err(error(ProtectedLocalKeyStoreErrorKind::WrongDatabase));
342        }
343        Ok(())
344    }
345
346    fn read_marker(&self) -> StoreResult<Option<LocalSharedStatePackageContext>> {
347        let Some(bytes) = self.read_record(AUTHORITY_RECORD, MARKER_BYTES)? else {
348            return Ok(None);
349        };
350        if &bytes[..8] != MARKER_MAGIC {
351            return Err(error(ProtectedLocalKeyStoreErrorKind::Corrupt));
352        }
353        let mut vault_id = [0_u8; 32];
354        let mut generation_id = [0_u8; 32];
355        vault_id.copy_from_slice(&bytes[8..40]);
356        generation_id.copy_from_slice(&bytes[40..]);
357        Ok(Some(LocalSharedStatePackageContext {
358            vault_id,
359            generation_id,
360        }))
361    }
362
363    fn write_marker(&self, context: LocalSharedStatePackageContext) -> StoreResult<()> {
364        self.write_record(AUTHORITY_RECORD, &marker_bytes(context))
365    }
366
367    fn prepare(&self) -> StoreResult<()> {
368        self.storage.prepare(&self.account)
369    }
370
371    fn lock(&self) -> StoreResult<StoreLock> {
372        let storage = self.storage.lock(&self.account)?;
373        *self.index.lock().unwrap_or_else(|e| e.into_inner()) = Some(Index::default());
374        Ok(StoreLock {
375            _storage: storage,
376            index: self.index.clone(),
377        })
378    }
379
380    /// Runs `f` on the index while the store lock is held, or returns `None`.
381    fn with_index<T>(&self, f: impl FnOnce(&mut Index) -> T) -> Option<T> {
382        self.index
383            .lock()
384            .unwrap_or_else(|e| e.into_inner())
385            .as_mut()
386            .map(f)
387    }
388
389    /// Kinds with a protected item, and kinds with a `-authority` marker.
390    fn list_kinds(&self) -> StoreResult<Listing> {
391        let items = self.storage.list_secrets(&self.account)?;
392        let markers = self
393            .storage
394            .list_records(&self.account)?
395            .iter()
396            .filter_map(|name| name.strip_suffix("-authority").map(str::to_string))
397            .collect();
398        Ok(Listing { items, markers })
399    }
400
401    /// One secret item of exactly `expected_len` bytes, if present.
402    fn load_secret(
403        &self,
404        item: &str,
405        expected_len: usize,
406    ) -> StoreResult<Option<Zeroizing<Vec<u8>>>> {
407        #[cfg(any(test, feature = "test-support"))]
408        test_support::BACKEND_LOADS.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
409        let bytes = self
410            .storage
411            .load_secret(&self.account, item, expected_len)?;
412        if bytes
413            .as_ref()
414            .is_some_and(|bytes| bytes.len() != expected_len)
415        {
416            return Err(error(ProtectedLocalKeyStoreErrorKind::Corrupt));
417        }
418        Ok(bytes)
419    }
420
421    fn create_secret(&self, item: &str, bytes: &[u8]) -> StoreResult<()> {
422        self.storage.create_secret(&self.account, item, bytes)
423    }
424
425    fn delete_secret(&self, item: &str) -> StoreResult<()> {
426        self.storage.delete_secret(&self.account, item)
427    }
428
429    /// One nonsecret record of exactly `expected_len` bytes, if present.
430    fn read_record(&self, name: &str, expected_len: usize) -> StoreResult<Option<Vec<u8>>> {
431        let bytes = self
432            .storage
433            .read_record(&self.account, name, expected_len)?;
434        if bytes
435            .as_ref()
436            .is_some_and(|bytes| bytes.len() != expected_len)
437        {
438            return Err(error(ProtectedLocalKeyStoreErrorKind::Corrupt));
439        }
440        Ok(bytes)
441    }
442
443    fn write_record(&self, name: &str, bytes: &[u8]) -> StoreResult<()> {
444        self.storage.create_record(&self.account, name, bytes)
445    }
446
447    fn remove_record(&self, name: &str) -> StoreResult<()> {
448        self.storage.remove_record(&self.account, name)
449    }
450}
451
452fn error(kind: ProtectedLocalKeyStoreErrorKind) -> ProtectedLocalKeyStoreError {
453    ProtectedLocalKeyStoreError::new(kind)
454}
455
456/// Names one database's protected items by its location and random
457/// `client_id`. A new database at a reused location gets an empty namespace,
458/// while rollbacks of the same database keep theirs and its markers.
459async fn database_account(
460    storage: &dyn ProtectedStorage,
461    database: &Database,
462) -> StoreResult<String> {
463    let unavailable = || error(ProtectedLocalKeyStoreErrorKind::Unavailable);
464    let location = storage.database_location(database.path())?;
465    let client_id = database
466        .meta("client_id")
467        .await
468        .map_err(|_| unavailable())?
469        .filter(|id| !id.is_empty())
470        .ok_or_else(unavailable)?;
471    Ok(account_for(&location, &client_id))
472}
473
474fn account_for(location: &[u8], client_id: &str) -> String {
475    let mut digest = Sha256::new();
476    digest.update(b"aven-protected-keys-v2\0");
477    digest.update(location);
478    digest.update(b"\0");
479    digest.update(client_id.as_bytes());
480    hex::encode(digest.finalize())
481}
482
483fn generate_keyring() -> StoreResult<ProtectedLocalPackageKey> {
484    let mut bytes = Zeroizing::new([0_u8; 96]);
485    getrandom::fill(bytes.as_mut())
486        .map_err(|_| error(ProtectedLocalKeyStoreErrorKind::Unavailable))?;
487    let mut vault_id = [0_u8; 32];
488    let mut generation_id = [0_u8; 32];
489    let mut key = [0_u8; 32];
490    vault_id.copy_from_slice(&bytes[..32]);
491    generation_id.copy_from_slice(&bytes[32..64]);
492    key.copy_from_slice(&bytes[64..]);
493    Ok(ProtectedLocalPackageKey {
494        context: LocalSharedStatePackageContext {
495            vault_id,
496            generation_id,
497        },
498        key: LocalSharedStatePackageKey::new(key),
499    })
500}
501
502fn encode_keyring(key: &ProtectedLocalPackageKey) -> Zeroizing<Vec<u8>> {
503    let mut encoded = Zeroizing::new(Vec::with_capacity(KEYRING_BYTES));
504    encoded.extend_from_slice(KEYRING_MAGIC);
505    encoded.extend_from_slice(&key.context.vault_id);
506    encoded.extend_from_slice(&key.context.generation_id);
507    // LocalSharedStatePackageKey intentionally does not expose bytes to host
508    // callers. Encoding is performed through its bounded persistence bridge.
509    encoded.extend_from_slice(key.key.protected_storage_bytes());
510    let checksum = Sha256::digest(encoded.as_slice());
511    encoded.extend_from_slice(&checksum);
512    encoded
513}
514
515fn decode_keyring(mut encoded: Zeroizing<Vec<u8>>) -> StoreResult<ProtectedLocalPackageKey> {
516    if encoded.len() != KEYRING_BYTES || &encoded[..8] != KEYRING_MAGIC {
517        return Err(error(ProtectedLocalKeyStoreErrorKind::Corrupt));
518    }
519    let checksum_offset = KEYRING_BYTES - 32;
520    let expected = Sha256::digest(&encoded[..checksum_offset]);
521    if expected.as_slice() != &encoded[checksum_offset..] {
522        return Err(error(ProtectedLocalKeyStoreErrorKind::Corrupt));
523    }
524    let mut vault_id = [0_u8; 32];
525    let mut generation_id = [0_u8; 32];
526    let mut key = [0_u8; 32];
527    vault_id.copy_from_slice(&encoded[8..40]);
528    generation_id.copy_from_slice(&encoded[40..72]);
529    key.copy_from_slice(&encoded[72..104]);
530    encoded.zeroize();
531    Ok(ProtectedLocalPackageKey {
532        context: LocalSharedStatePackageContext {
533            vault_id,
534            generation_id,
535        },
536        key: LocalSharedStatePackageKey::new(key),
537    })
538}
539
540fn marker_bytes(context: LocalSharedStatePackageContext) -> [u8; MARKER_BYTES] {
541    let mut bytes = [0_u8; MARKER_BYTES];
542    bytes[..8].copy_from_slice(MARKER_MAGIC);
543    bytes[8..40].copy_from_slice(&context.vault_id);
544    bytes[40..].copy_from_slice(&context.generation_id);
545    bytes
546}
547
548/// Test constructors and counters for protected key stores.
549#[cfg(any(test, feature = "test-support"))]
550pub mod test_support {
551    use super::storage::canonical_location;
552    use super::*;
553
554    tokio::task_local! {
555        static SCOPED_BACKEND_LOADS: std::sync::atomic::AtomicU64;
556    }
557
558    pub struct BackendLoads(std::sync::atomic::AtomicU64);
559    impl BackendLoads {
560        pub fn fetch_add(&self, value: u64, ordering: std::sync::atomic::Ordering) -> u64 {
561            let previous = self.0.fetch_add(value, ordering);
562            let _ = SCOPED_BACKEND_LOADS.try_with(|loads| {
563                loads.fetch_add(value, ordering);
564            });
565            previous
566        }
567
568        pub fn load(&self, ordering: std::sync::atomic::Ordering) -> u64 {
569            self.0.load(ordering)
570        }
571
572        pub async fn measure<F: std::future::Future>(&self, future: F) -> (F::Output, u64) {
573            SCOPED_BACKEND_LOADS
574                .scope(std::sync::atomic::AtomicU64::new(0), async {
575                    let output = future.await;
576                    let loads = SCOPED_BACKEND_LOADS
577                        .with(|count| count.load(std::sync::atomic::Ordering::Relaxed));
578                    (output, loads)
579                })
580                .await
581        }
582    }
583
584    /// Protected backend loads in this process: each is one Keychain lookup on macOS.
585    pub static BACKEND_LOADS: BackendLoads = BackendLoads(std::sync::atomic::AtomicU64::new(0));
586    /// A store for `database` whose protected files live in `root`.
587    pub async fn isolated_store(database: &Database, root: &Path) -> ProtectedLocalKeyStore {
588        let storage = FileProtectedStorage::new(root.to_path_buf());
589        account_store(database_account(&storage, database).await.unwrap(), root)
590    }
591
592    /// Account for a placeholder file that is never opened as a database.
593    pub fn raw_account(path: &Path) -> String {
594        account_for(&canonical_location(path).unwrap(), "raw-file")
595    }
596
597    /// A store for a path that need not hold an opened database.
598    pub fn account_store(account: String, root: &Path) -> ProtectedLocalKeyStore {
599        let storage = Arc::new(FileProtectedStorage::new(root.to_path_buf()));
600        let mut store = ProtectedLocalKeyStore::with_account(account, storage);
601        store.directory = root.to_path_buf();
602        store
603    }
604
605    /// Secret failures injected in front of file storage.
606    #[derive(Clone, Copy, Debug)]
607    pub enum Fault {
608        /// Items read as absent and writes fail.
609        FailWrite,
610        /// Every secret operation reports unavailable storage.
611        Unavailable,
612    }
613
614    struct FaultyStorage {
615        files: FileProtectedStorage,
616        fault: Fault,
617    }
618
619    impl ProtectedStorage for FaultyStorage {
620        fn prepare(&self, namespace: &str) -> StoreResult<()> {
621            self.files.prepare(namespace)
622        }
623        fn lock(&self, namespace: &str) -> StoreResult<ProtectedStorageLock> {
624            self.files.lock(namespace)
625        }
626        fn load_secret(
627            &self,
628            _: &str,
629            _: &str,
630            _: usize,
631        ) -> StoreResult<Option<Zeroizing<Vec<u8>>>> {
632            match self.fault {
633                Fault::FailWrite => Ok(None),
634                Fault::Unavailable => Err(error(ProtectedLocalKeyStoreErrorKind::Unavailable)),
635            }
636        }
637        fn create_secret(&self, _: &str, _: &str, _: &[u8]) -> StoreResult<()> {
638            Err(error(match self.fault {
639                Fault::FailWrite => ProtectedLocalKeyStoreErrorKind::WriteFailed,
640                Fault::Unavailable => ProtectedLocalKeyStoreErrorKind::Unavailable,
641            }))
642        }
643        fn delete_secret(&self, _: &str, _: &str) -> StoreResult<()> {
644            self.create_secret("", "", &[])
645        }
646        fn read_record(
647            &self,
648            namespace: &str,
649            name: &str,
650            len: usize,
651        ) -> StoreResult<Option<Vec<u8>>> {
652            self.files.read_record(namespace, name, len)
653        }
654        fn create_record(&self, namespace: &str, name: &str, bytes: &[u8]) -> StoreResult<()> {
655            self.files.create_record(namespace, name, bytes)
656        }
657        fn remove_record(&self, namespace: &str, name: &str) -> StoreResult<()> {
658            self.files.remove_record(namespace, name)
659        }
660        fn list_secrets(&self, _: &str) -> StoreResult<HashSet<String>> {
661            match self.fault {
662                Fault::FailWrite => Ok(HashSet::new()),
663                Fault::Unavailable => Err(error(ProtectedLocalKeyStoreErrorKind::Unavailable)),
664            }
665        }
666        fn list_records(&self, namespace: &str) -> StoreResult<HashSet<String>> {
667            self.files.list_records(namespace)
668        }
669    }
670
671    /// A store whose secret operations fail as `fault` describes, with
672    /// records in `root`.
673    pub fn faulty_store(account: String, root: &Path, fault: Fault) -> ProtectedLocalKeyStore {
674        let storage = Arc::new(FaultyStorage {
675            files: FileProtectedStorage::new(root.to_path_buf()),
676            fault,
677        });
678        let mut store = ProtectedLocalKeyStore::with_account(account, storage);
679        store.directory = root.to_path_buf();
680        store
681    }
682
683    impl ProtectedLocalKeyStore {
684        pub fn account(&self) -> &str {
685            &self.account
686        }
687
688        /// Root of this store's test files.
689        pub fn directory(&self) -> &Path {
690            &self.directory
691        }
692
693        /// Test file holding secret item or record `name`.
694        pub fn file_path(&self, name: &str) -> PathBuf {
695            self.directory.join(format!("{}.{name}", self.account))
696        }
697
698        pub fn marker_path(&self) -> PathBuf {
699            self.file_path(AUTHORITY_RECORD)
700        }
701    }
702}
703
704#[cfg(test)]
705mod tests {
706    use super::test_support::*;
707    use super::*;
708    use crate::api::{CreateTask, Store};
709    use crate::choices::{TaskPriority, TaskStatus};
710    use std::fs::{self, File};
711    use std::io::Read;
712    use std::path::PathBuf;
713
714    pub(super) async fn captured_database(root: &Path) -> Database {
715        let path = root.join("source.sqlite");
716        let store = Store::open(&path).await.unwrap();
717        let workspace = store.list_workspaces().await.unwrap().remove(0);
718        store
719            .create_task(
720                &workspace.id,
721                CreateTask {
722                    title: "protected package".into(),
723                    description: String::new(),
724                    project: "app".into(),
725                    status: TaskStatus::Todo,
726                    priority: TaskPriority::None,
727                    metadata: vec![],
728                    available_at: None,
729                    due_on: None,
730                },
731            )
732            .await
733            .unwrap();
734        drop(store);
735        let database = Database::open(&path).await.unwrap();
736        database
737            .capture_local_shared_state_never_dispatched()
738            .await
739            .unwrap();
740        database
741    }
742
743    #[tokio::test]
744    async fn protected_store_reopens_and_reuses_the_package_key() {
745        let temp = tempfile::tempdir().unwrap();
746        let database = captured_database(temp.path()).await;
747        let key_root = temp.path().join("authority");
748        let store = isolated_store(&database, &key_root).await;
749        let first = store
750            .package_local_capture(&database, temp.path(), [0x64; 32])
751            .await
752            .unwrap();
753        drop(store);
754        drop(database);
755
756        let database = Database::open(&temp.path().join("source.sqlite"))
757            .await
758            .unwrap();
759        let reopened = isolated_store(&database, &key_root).await;
760        fs::remove_file(reopened.marker_path()).unwrap();
761        let retry = reopened
762            .package_local_capture(&database, temp.path(), [0x64; 32])
763            .await
764            .unwrap();
765        assert_eq!(first, retry);
766        assert!(reopened.marker_path().exists());
767        let key_path = reopened.file_path(KEYRING_ITEM);
768        let encoded = Zeroizing::new(fs::read(key_path).unwrap());
769        let raw_key = &encoded[72..104];
770        for path in [
771            database.path().to_path_buf(),
772            PathBuf::from(format!("{}-wal", database.path().display())),
773        ] {
774            match fs::read(path) {
775                Ok(bytes) => assert!(!bytes.windows(raw_key.len()).any(|window| window == raw_key)),
776                Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
777                Err(error) => panic!("cannot inspect database bytes: {error}"),
778            }
779        }
780        let exported = serde_json::to_vec(
781            &database
782                .export_data("2026-09-22T00:00:00Z".into())
783                .await
784                .unwrap(),
785        )
786        .unwrap();
787        assert!(
788            !exported
789                .windows(raw_key.len())
790                .any(|window| window == raw_key)
791        );
792
793        let backup = temp.path().join("backup.tar.zst");
794        database
795            .create_backup_archive(&temp.path().join("blobs"), &backup)
796            .await
797            .unwrap();
798
799        let target_dir = tempfile::tempdir().unwrap();
800        let target = Database::open(&target_dir.path().join("target.sqlite"))
801            .await
802            .unwrap();
803        let protected = reopened.load_required().unwrap();
804        target
805            .decrypt_and_install_local_shared_state_package(&retry, protected.package_key())
806            .await
807            .unwrap();
808        let installed = target
809            .export_data("2026-09-22T00:00:00Z".into())
810            .await
811            .unwrap();
812        assert_eq!(installed.tables.tasks[0].title, "protected package");
813
814        database
815            .cancel_local_shared_state_never_dispatched(first.candidate_id())
816            .await
817            .unwrap();
818        database
819            .create_backup_archive(&temp.path().join("blobs"), &backup)
820            .await
821            .unwrap();
822        let decoder = zstd::Decoder::new(File::open(backup).unwrap()).unwrap();
823        let mut archive = tar::Archive::new(decoder);
824        for entry in archive.entries().unwrap() {
825            let mut entry = entry.unwrap();
826            let mut bytes = Vec::new();
827            entry.read_to_end(&mut bytes).unwrap();
828            assert!(!bytes.windows(raw_key.len()).any(|window| window == raw_key));
829        }
830    }
831
832    #[tokio::test]
833    async fn existing_package_never_regenerates_destroyed_authority() {
834        let temp = tempfile::tempdir().unwrap();
835        let database = captured_database(temp.path()).await;
836        let key_root = temp.path().join("authority");
837        let store = isolated_store(&database, &key_root).await;
838        store
839            .package_local_capture(&database, temp.path(), [0x64; 32])
840            .await
841            .unwrap();
842        let key_path = store.file_path(KEYRING_ITEM);
843        fs::remove_file(&key_path).unwrap();
844        fs::remove_file(store.marker_path()).unwrap();
845
846        let error = store
847            .package_local_capture(&database, temp.path(), [0x64; 32])
848            .await
849            .unwrap_err();
850        assert_eq!(
851            error
852                .downcast_ref::<ProtectedLocalKeyStoreError>()
853                .unwrap()
854                .kind(),
855            ProtectedLocalKeyStoreErrorKind::MissingAuthority
856        );
857        assert!(!key_path.exists());
858        assert!(!store.marker_path().exists());
859    }
860
861    #[tokio::test]
862    async fn package_store_rejects_a_different_database_before_storage_effects() {
863        let first_root = tempfile::tempdir().unwrap();
864        let first = Database::open(&first_root.path().join("first.sqlite"))
865            .await
866            .unwrap();
867        let second_root = tempfile::tempdir().unwrap();
868        let second = captured_database(second_root.path()).await;
869        let key_root = first_root.path().join("authority");
870        let store = isolated_store(&first, &key_root).await;
871
872        let error = store
873            .package_local_capture(&second, second_root.path(), [0x64; 32])
874            .await
875            .unwrap_err();
876        assert_eq!(
877            error
878                .downcast_ref::<ProtectedLocalKeyStoreError>()
879                .unwrap()
880                .kind(),
881            ProtectedLocalKeyStoreErrorKind::WrongDatabase
882        );
883        assert!(!key_root.exists());
884    }
885
886    /// Deletes a stopped database and its SQLite sidecars.
887    fn remove_database(path: &Path) {
888        for suffix in ["", "-wal", "-shm"] {
889            let _ = fs::remove_file(format!("{}{suffix}", path.display()));
890        }
891    }
892
893    #[tokio::test]
894    async fn new_database_at_reused_path_ignores_stale_markers() {
895        let temp = tempfile::tempdir().unwrap();
896        let path = temp.path().join("db.sqlite");
897        let key_root = temp.path().join("keys");
898        let database = Database::open(&path).await.unwrap();
899        let old = isolated_store(&database, &key_root).await;
900        old.prepare_seed_claim(&database, [9; 32]).await.unwrap();
901        drop(database);
902        remove_database(&path);
903        // The keys are gone but the nonsecret markers remain.
904        fs::remove_file(key_root.join(format!("{}.keyring", old.account))).unwrap();
905        old.delete_secret("seed").unwrap();
906        assert_eq!(
907            old.load_or_create().unwrap_err().kind(),
908            ProtectedLocalKeyStoreErrorKind::MissingAuthority
909        );
910
911        let database = Database::open(&path).await.unwrap();
912        let store = isolated_store(&database, &key_root).await;
913        assert_ne!(store.account, old.account);
914        store.prepare_seed_claim(&database, [7; 32]).await.unwrap();
915        assert!(old.marker_path().exists());
916    }
917
918    #[tokio::test]
919    async fn new_database_at_reused_path_never_inherits_old_keys() {
920        let temp = tempfile::tempdir().unwrap();
921        let path = temp.path().join("db.sqlite");
922        let key_root = temp.path().join("keys");
923        let database = Database::open(&path).await.unwrap();
924        let old = isolated_store(&database, &key_root).await;
925        let old_seed = old.prepare_seed_claim(&database, [9; 32]).await.unwrap();
926        drop(database);
927        remove_database(&path);
928
929        let database = Database::open(&path).await.unwrap();
930        let store = isolated_store(&database, &key_root).await;
931        let seed = store.prepare_seed_claim(&database, [7; 32]).await.unwrap();
932        assert_ne!(seed.genesis().context(), old_seed.genesis().context());
933        assert_ne!(seed.genesis().commitment(), old_seed.genesis().commitment());
934        // The old namespace is left untouched.
935        assert_eq!(
936            old.load_required().unwrap().context(),
937            old_seed.genesis().context()
938        );
939    }
940
941    #[tokio::test]
942    async fn rolled_back_copy_of_the_same_database_keeps_missing_authority_detection() {
943        let temp = tempfile::tempdir().unwrap();
944        let path = temp.path().join("db.sqlite");
945        let before_setup = temp.path().join("before-setup.sqlite");
946        let key_root = temp.path().join("keys");
947        let database = Database::open(&path).await.unwrap();
948        let mut conn = crate::test_support::acquire(&database).await.unwrap();
949        sqlx::query("VACUUM INTO ?")
950            .bind(before_setup.to_str().unwrap())
951            .execute(&mut *conn)
952            .await
953            .unwrap();
954        drop(conn);
955        let store = isolated_store(&database, &key_root).await;
956        store.prepare_seed_claim(&database, [9; 32]).await.unwrap();
957        drop(database);
958        remove_database(&path);
959        fs::copy(&before_setup, &path).unwrap();
960        fs::remove_file(key_root.join(format!("{}.keyring", store.account))).unwrap();
961
962        let database = Database::open(&path).await.unwrap();
963        let rolled_back = isolated_store(&database, &key_root).await;
964        assert_eq!(rolled_back.account, store.account);
965        let error = rolled_back
966            .prepare_seed_claim(&database, [9; 32])
967            .await
968            .unwrap_err();
969        assert_eq!(
970            error
971                .downcast_ref::<ProtectedLocalKeyStoreError>()
972                .unwrap()
973                .kind(),
974            ProtectedLocalKeyStoreErrorKind::MissingAuthority
975        );
976    }
977
978    #[test]
979    fn missing_and_corrupt_authority_never_regenerate() {
980        let temp = tempfile::tempdir().unwrap();
981        let database_path = temp.path().join("database.sqlite");
982        File::create(&database_path).unwrap();
983        let root = temp.path().join("authority");
984        let store = account_store(raw_account(&database_path), &root);
985        let original = store.load_or_create().unwrap().context();
986        let key_path = store.file_path(KEYRING_ITEM);
987        fs::remove_file(&key_path).unwrap();
988
989        let error = store.load_or_create().unwrap_err();
990        assert_eq!(
991            error.kind(),
992            ProtectedLocalKeyStoreErrorKind::MissingAuthority
993        );
994        assert!(!key_path.exists());
995
996        fs::remove_file(store.marker_path()).unwrap();
997        fs::write(&key_path, vec![0_u8; KEYRING_BYTES]).unwrap();
998        let mut permissions = fs::metadata(&key_path).unwrap().permissions();
999        use std::os::unix::fs::PermissionsExt;
1000        permissions.set_mode(0o600);
1001        fs::set_permissions(&key_path, permissions).unwrap();
1002        let error = store.load_or_create().unwrap_err();
1003        assert_eq!(error.kind(), ProtectedLocalKeyStoreErrorKind::Corrupt);
1004        assert_ne!(
1005            store.load_or_create().unwrap_err().kind(),
1006            ProtectedLocalKeyStoreErrorKind::WriteFailed
1007        );
1008        let _ = original;
1009    }
1010
1011    #[test]
1012    fn unavailable_and_failed_writes_do_not_create_authority() {
1013        let temp = tempfile::tempdir().unwrap();
1014        let database_path = temp.path().join("database.sqlite");
1015        File::create(&database_path).unwrap();
1016        for (fault, expected) in [
1017            (
1018                Fault::Unavailable,
1019                ProtectedLocalKeyStoreErrorKind::Unavailable,
1020            ),
1021            (
1022                Fault::FailWrite,
1023                ProtectedLocalKeyStoreErrorKind::WriteFailed,
1024            ),
1025        ] {
1026            let root = temp.path().join(format!("authority-{expected:?}"));
1027            let store = faulty_store(raw_account(&database_path), &root, fault);
1028            let error = store.load_or_create().unwrap_err();
1029            assert_eq!(error.kind(), expected);
1030            assert!(!store.marker_path().exists());
1031        }
1032    }
1033
1034    #[test]
1035    fn incomplete_marker_write_recovers_existing_key_without_replacement() {
1036        let temp = tempfile::tempdir().unwrap();
1037        let database_path = temp.path().join("database.sqlite");
1038        File::create(&database_path).unwrap();
1039        let root = temp.path().join("authority");
1040        let store = account_store(raw_account(&database_path), &root);
1041        store.prepare().unwrap();
1042        let key = generate_keyring().unwrap();
1043        let encoded = encode_keyring(&key);
1044        store.create_secret(KEYRING_ITEM, &encoded).unwrap();
1045
1046        let recovered = store.load_or_create().unwrap();
1047        assert_eq!(recovered.context(), key.context());
1048        assert!(store.marker_path().exists());
1049    }
1050
1051    #[test]
1052    fn unsafe_file_permissions_fail_closed_without_secret_diagnostics() {
1053        let temp = tempfile::tempdir().unwrap();
1054        let database_path = temp.path().join("database.sqlite");
1055        File::create(&database_path).unwrap();
1056        let root = temp.path().join("authority");
1057        let store = account_store(raw_account(&database_path), &root);
1058        let protected = store.load_or_create().unwrap();
1059        let key_path = store.file_path(KEYRING_ITEM);
1060        use std::os::unix::fs::PermissionsExt;
1061        fs::set_permissions(&key_path, fs::Permissions::from_mode(0o644)).unwrap();
1062
1063        let error = store.load_required().unwrap_err();
1064        assert_eq!(error.kind(), ProtectedLocalKeyStoreErrorKind::Corrupt);
1065        let diagnostic = format!("{error:?} {error}");
1066        assert!(!diagnostic.contains(&hex::encode(protected.context().vault_id)));
1067        assert!(!diagnostic.contains(database_path.to_string_lossy().as_ref()));
1068    }
1069}