diff --git a/AGENTS.md b/AGENTS.md index de955d6eb..714955a91 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -3,3 +3,14 @@ ## UniFFI Naming Never name a UniFFI-exported method `to_string`. UniFFI maps Rust's `to_string` to Kotlin's `toString`, which conflicts with `Any.toString()` and causes a compilation error (`'toString' hides member of supertype 'Any' and needs 'override' modifier`). Use a descriptive name instead (e.g., `to_hex_string`, `to_decimal_string`, `to_json`). + +## Coding style + +- **On-disk format is byte-stable.** Schemas, CBOR layouts, and `compute_content_id` derivations are part of the contract. Existing user databases must keep opening without migration; guard format-sensitive code with frozen-byte tests next to it. +- **Don't layer a flock around SQLite writes.** WAL mode serializes writers itself. The `Lock` primitive is only for the envelope-init bootstrap and operations that mix SQL with filesystem state. +- **Per-consumer isolation is host wiring.** Separate keystore entry, AD label, and envelope/vault/lock files. `walletkit-db` enforces only the AEAD-AD binding. +- **`walletkit-db` is consumer-agnostic.** It owns `blob_objects` and the storage primitives (vault, envelope, lock, traits). Credential-specific tables, schemas, and APIs live in `walletkit-core/storage/credential_vault`. Don't put consumer logic in `walletkit-db`, and don't put primitives in consumer crates. +- **`#[expect(lint, reason = "...")]` over `#[allow(lint)]`.** `#[expect]` fails to compile when the suppression is no longer needed, so dead suppressions don't accumulate. +- **Inline single-consumer helper modules.** A `mod foo;` file with `pub(super) fn`s used only by its parent adds boundary without payoff; put the functions as private free `fn`s at the bottom of the parent module. +- **Colocate constants with the code that reads them, not their conceptual home.** A `const` used only by `mod.rs` belongs in `mod.rs`, not in a sibling `schema.rs`. +- **Tests live next to the code they test.** One `#[cfg(test)] mod tests` per source file, including inside `cfg`-gated blocks. For code gated by `#[cfg(not(target_arch = "wasm32"))]` (e.g. `Lock`'s native `imp` module), tests go inside that same block so they only compile for the platform they cover. diff --git a/Cargo.lock b/Cargo.lock index cbdeed641..983ed1592 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -8245,11 +8245,15 @@ name = "walletkit-db" version = "0.18.0" dependencies = [ "cc", + "ciborium", + "getrandom 0.3.4", "hex", "secrecy", + "serde", "sha2 0.10.9", "sqlite-wasm-rs", "tempfile", + "thiserror 2.0.18", "zeroize", "zip", ] diff --git a/README.md b/README.md index 777084acc..f17a8d440 100644 --- a/README.md +++ b/README.md @@ -97,6 +97,7 @@ cargo fmt -- --check WalletKit is broken down into separate crates, offering the following functionality. - `walletkit-core` - Enables basic usage of a World ID to generate ZKPs using different credentials. +- `walletkit-db` - Encrypted on-device storage primitives for WalletKit: `SQLCipher` (`sqlite3mc`) wrapper, vault opener, content-addressed blob storage, sealed key envelope, and cross-process lock. Used by `walletkit-core` for credential storage and consumable by sibling SDKs (e.g. OrbKit's planned `OrbPcpStore`). ### World ID Secret diff --git a/walletkit-core/src/storage/cache/maintenance.rs b/walletkit-core/src/storage/cache/maintenance.rs index 8d7c904ca..d81030fee 100644 --- a/walletkit-core/src/storage/cache/maintenance.rs +++ b/walletkit-core/src/storage/cache/maintenance.rs @@ -1,4 +1,4 @@ -//! Cache DB maintenance helpers (integrity checks, rebuilds). +//! Cache DB maintenance helpers (open with rebuild-on-corruption). use std::fs; use std::path::Path; @@ -6,13 +6,17 @@ use std::path::Path; use secrecy::SecretBox; use crate::storage::error::StorageResult; -use walletkit_db::cipher; -use walletkit_db::Connection; +use walletkit_db::Vault; use super::schema; -use super::util::{map_db_err, map_io_err}; +use super::util::map_io_err; -/// Opens the cache DB or rebuilds it if integrity checks fail. +/// Opens the cache DB through `Vault`, rebuilding on any open / key / +/// integrity failure. +/// +/// Cache contents are non-authoritative and regenerable, so the policy +/// here is "blow it away and retry" rather than the credential vault's +/// fatal-on-integrity contract. /// /// # Errors /// @@ -20,55 +24,15 @@ use super::util::{map_db_err, map_io_err}; pub(super) fn open_or_rebuild( path: &Path, k_intermediate: &SecretBox<[u8; 32]>, -) -> StorageResult { - match open_prepared(path, k_intermediate) { - Ok(conn) => { - let integrity_ok = - cipher::integrity_check(&conn).map_err(|e| map_db_err(&e))?; - if integrity_ok { - Ok(conn) - } else { - drop(conn); - rebuild(path, k_intermediate) - } - } - Err(err) => rebuild(path, k_intermediate).map_or_else(|_| Err(err), Ok), +) -> StorageResult { + if let Ok(vault) = Vault::open(path, k_intermediate, schema::ensure_schema) { + return Ok(vault); } -} - -/// Opens the cache DB, applies encryption settings, and ensures schema. -/// -/// # Errors -/// -/// Returns an error if the DB cannot be opened or configured. -fn open_prepared( - path: &Path, - k_intermediate: &SecretBox<[u8; 32]>, -) -> StorageResult { - let conn = cipher::open_encrypted(path, k_intermediate, false) - .map_err(|e| map_db_err(&e))?; - schema::ensure_schema(&conn)?; - Ok(conn) -} - -/// Rebuilds the cache database by deleting and recreating it. -/// -/// # Errors -/// -/// Returns an error if deletion or re-open fails. -fn rebuild( - path: &Path, - k_intermediate: &SecretBox<[u8; 32]>, -) -> StorageResult { delete_cache_files(path)?; - open_prepared(path, k_intermediate) + Vault::open(path, k_intermediate, schema::ensure_schema).map_err(Into::into) } /// Deletes the cache DB and its WAL/SHM sidecar files if present. -/// -/// # Errors -/// -/// Returns an error for IO failures other than missing files. fn delete_cache_files(path: &Path) -> StorageResult<()> { delete_if_exists(path)?; delete_if_exists(&path.with_extension("sqlite-wal"))?; @@ -77,10 +41,6 @@ fn delete_cache_files(path: &Path) -> StorageResult<()> { } /// Deletes the file at `path` if it exists. -/// -/// # Errors -/// -/// Returns an error for IO failures other than missing files. fn delete_if_exists(path: &Path) -> StorageResult<()> { match fs::remove_file(path) { Ok(()) => Ok(()), diff --git a/walletkit-core/src/storage/cache/mod.rs b/walletkit-core/src/storage/cache/mod.rs index 71eb05e47..64956a485 100644 --- a/walletkit-core/src/storage/cache/mod.rs +++ b/walletkit-core/src/storage/cache/mod.rs @@ -3,9 +3,8 @@ use std::path::Path; use crate::storage::error::StorageResult; -use crate::storage::lock::StorageLockGuard; use secrecy::SecretBox; -use walletkit_db::Connection; +use walletkit_db::Vault; mod maintenance; mod merkle; @@ -16,18 +15,22 @@ mod util; /// Encrypted cache database wrapper. /// -/// Stores non-authoritative, regenerable data (proof cache, session keys, replay guard) -/// to improve performance without affecting correctness if rebuilt. +/// Stores non-authoritative, regenerable data (proof cache, session keys, +/// replay guard). Wraps [`walletkit_db::Vault`]. +/// +/// Unlike the credential vault, cache corruption is recoverable: open +/// failures or integrity failures trigger a wipe-and-rebuild rather than +/// a fatal error. #[derive(Debug)] pub struct CacheDb { - conn: Connection, + vault: Vault, } impl CacheDb { - /// Opens or creates the encrypted cache database at `path`. + /// Opens or rebuilds the encrypted cache database at `path`. /// - /// If integrity checks fail, the cache is rebuilt since its contents can be - /// regenerated from authoritative sources. + /// If the database is corrupted or unreadable, the file is deleted + /// and a fresh empty cache is created. /// /// # Errors /// @@ -35,13 +38,12 @@ impl CacheDb { pub fn new( path: &Path, k_intermediate: &SecretBox<[u8; 32]>, - _lock: &StorageLockGuard, ) -> StorageResult { - let conn = maintenance::open_or_rebuild(path, k_intermediate)?; - Ok(Self { conn }) + let vault = maintenance::open_or_rebuild(path, k_intermediate)?; + Ok(Self { vault }) } - /// Fetches a cached Merkle proof if it remains valid beyond `valid_before`. + /// Fetches a cached Merkle proof if it remains valid beyond `valid_until`. /// /// Returns `None` when missing or expired so callers can refetch from the /// indexer without relying on stale proofs. @@ -50,26 +52,22 @@ impl CacheDb { /// /// Returns an error if the query fails. pub fn merkle_cache_get(&self, valid_until: u64) -> StorageResult>> { - merkle::get(&self.conn, valid_until) + merkle::get(self.vault.connection(), valid_until) } - /// Inserts a cached Merkle proof with a TTL. - /// Uses the database current time for `inserted_at`. - /// - /// Existing entries for the same (registry, root, leaf index) are replaced. + /// Inserts a cached Merkle proof with a TTL. Existing entries for the + /// same key are replaced. /// /// # Errors /// /// Returns an error if the insert fails. - #[allow(clippy::needless_pass_by_value)] pub fn merkle_cache_put( - &mut self, - _lock: &StorageLockGuard, - proof_bytes: Vec, + &self, + proof_bytes: &[u8], now: u64, ttl_seconds: u64, ) -> StorageResult<()> { - merkle::put(&self.conn, proof_bytes.as_ref(), now, ttl_seconds) + merkle::put(self.vault.connection(), proof_bytes, now, ttl_seconds) } /// Fetches a cached `session_id_r_seed` for the given `oprf_seed`. @@ -84,7 +82,7 @@ impl CacheDb { oprf_seed: [u8; 32], now: u64, ) -> StorageResult> { - session::get(&self.conn, oprf_seed, now) + session::get(self.vault.connection(), oprf_seed, now) } /// Stores a `session_id_r_seed` keyed by `oprf_seed` with a TTL. @@ -93,20 +91,27 @@ impl CacheDb { /// /// Returns an error if the insert fails. pub fn session_seed_put( - &mut self, - _lock: &StorageLockGuard, + &self, oprf_seed: [u8; 32], session_id_r_seed: [u8; 32], now: u64, ttl_seconds: u64, ) -> StorageResult<()> { - session::put(&self.conn, oprf_seed, session_id_r_seed, now, ttl_seconds) + session::put( + self.vault.connection(), + oprf_seed, + session_id_r_seed, + now, + ttl_seconds, + ) } /// Checks whether a replay guard entry exists for the given nullifier. /// /// # Returns - /// - bool: true if a replay guard entry exists (hence signalling a nullifier replay), false otherwise. + /// + /// - `true` if a replay guard entry exists (nullifier replay). + /// - `false` otherwise. /// /// # Errors /// @@ -116,29 +121,23 @@ impl CacheDb { nullifier: [u8; 32], now: u64, ) -> StorageResult { - nullifiers::is_nullifier_replay(&self.conn, nullifier, now) + nullifiers::is_nullifier_replay(self.vault.connection(), nullifier, now) } - /// After a proof has been successfully generated, creates a replay guard entry - /// locally to avoid future replays of the same nullifier. + /// After a proof has been successfully generated, creates a replay guard + /// entry locally to avoid future replays of the same nullifier. /// /// # Errors /// /// Returns an error if the query to the cache unexpectedly fails. - pub fn replay_guard_set( - &mut self, - _lock: &StorageLockGuard, - nullifier: [u8; 32], - now: u64, - ) -> StorageResult<()> { - nullifiers::replay_guard_set(&self.conn, nullifier, now) + pub fn replay_guard_set(&self, nullifier: [u8; 32], now: u64) -> StorageResult<()> { + nullifiers::replay_guard_set(self.vault.connection(), nullifier, now) } } #[cfg(test)] mod tests { use super::*; - use crate::storage::lock::StorageLock; use secrecy::SecretBox; use std::fs; use std::path::PathBuf; @@ -152,10 +151,8 @@ mod tests { fn cleanup_cache_files(path: &Path) { let _ = fs::remove_file(path); - let wal_path = path.with_extension("sqlite-wal"); - let shm_path = path.with_extension("sqlite-shm"); - let _ = fs::remove_file(wal_path); - let _ = fs::remove_file(shm_path); + let _ = fs::remove_file(path.with_extension("sqlite-wal")); + let _ = fs::remove_file(path.with_extension("sqlite-shm")); } fn temp_lock_path() -> PathBuf { @@ -173,11 +170,9 @@ mod tests { let path = temp_cache_path(); let key = SecretBox::init_with(|| [0x11u8; 32]); let lock_path = temp_lock_path(); - let lock = StorageLock::open(&lock_path).expect("open lock"); - let guard = lock.lock().expect("lock"); - let db = CacheDb::new(&path, &key, &guard).expect("create cache"); + let db = CacheDb::new(&path, &key).expect("create cache"); drop(db); - CacheDb::new(&path, &key, &guard).expect("open cache"); + CacheDb::new(&path, &key).expect("open cache"); cleanup_cache_files(&path); cleanup_lock_file(&lock_path); } @@ -187,19 +182,17 @@ mod tests { let path = temp_cache_path(); let key = SecretBox::init_with(|| [0x22u8; 32]); let lock_path = temp_lock_path(); - let lock = StorageLock::open(&lock_path).expect("open lock"); - let guard = lock.lock().expect("lock"); - let mut db = CacheDb::new(&path, &key, &guard).expect("create cache"); + let db = CacheDb::new(&path, &key).expect("create cache"); let oprf_seed = [0x01u8; 32]; let r_seed = [0x02u8; 32]; let now = 1_000; - db.session_seed_put(&guard, oprf_seed, r_seed, now, 1000) + db.session_seed_put(oprf_seed, r_seed, now, 1000) .expect("put session seed"); drop(db); fs::write(&path, b"corrupt").expect("corrupt cache file"); - let db = CacheDb::new(&path, &key, &guard).expect("rebuild cache"); + let db = CacheDb::new(&path, &key).expect("rebuild cache"); let value = db .session_seed_get(oprf_seed, now) .expect("get session seed"); @@ -213,13 +206,10 @@ mod tests { let path = temp_cache_path(); let key = SecretBox::init_with(|| [0x33u8; 32]); let lock_path = temp_lock_path(); - let lock = StorageLock::open(&lock_path).expect("open lock"); - let guard = lock.lock().expect("lock"); - let mut db = CacheDb::new(&path, &key, &guard).expect("create cache"); - db.merkle_cache_put(&guard, vec![1, 2, 3], 100, 10) + let db = CacheDb::new(&path, &key).expect("create cache"); + db.merkle_cache_put(&[1, 2, 3], 100, 10) .expect("put merkle proof"); - let valid_until = 105; - let hit = db.merkle_cache_get(valid_until).expect("get merkle proof"); + let hit = db.merkle_cache_get(105).expect("get merkle proof"); assert!(hit.is_some()); let miss = db.merkle_cache_get(111).expect("get merkle proof"); assert!(miss.is_none()); @@ -232,13 +222,11 @@ mod tests { let path = temp_cache_path(); let key = SecretBox::init_with(|| [0x44u8; 32]); let lock_path = temp_lock_path(); - let lock = StorageLock::open(&lock_path).expect("open lock"); - let guard = lock.lock().expect("lock"); - let mut db = CacheDb::new(&path, &key, &guard).expect("create cache"); + let db = CacheDb::new(&path, &key).expect("create cache"); let oprf_seed = [0x55u8; 32]; let r_seed = [0x66u8; 32]; let now = 100; - db.session_seed_put(&guard, oprf_seed, r_seed, now, 10) + db.session_seed_put(oprf_seed, r_seed, now, 10) .expect("put session seed"); let hit = db.session_seed_get(oprf_seed, now).expect("get"); assert_eq!(hit, Some(r_seed)); diff --git a/walletkit-core/src/storage/cache/schema.rs b/walletkit-core/src/storage/cache/schema.rs index c12b2f517..043c8ebec 100644 --- a/walletkit-core/src/storage/cache/schema.rs +++ b/walletkit-core/src/storage/cache/schema.rs @@ -1,9 +1,6 @@ //! Cache database schema management. -use crate::storage::error::StorageResult; -use walletkit_db::{params, Connection}; - -use super::util::map_db_err; +use walletkit_db::{params, Connection, DbResult}; const CACHE_SCHEMA_VERSION: i64 = 2; @@ -12,23 +9,20 @@ const CACHE_SCHEMA_VERSION: i64 = 2; /// # Errors /// /// Returns an error if schema creation or migration fails. -pub(super) fn ensure_schema(conn: &Connection) -> StorageResult<()> { +pub(super) fn ensure_schema(conn: &Connection) -> DbResult<()> { conn.execute_batch( "CREATE TABLE IF NOT EXISTS cache_meta ( schema_version INTEGER NOT NULL, created_at INTEGER NOT NULL, updated_at INTEGER NOT NULL );", - ) - .map_err(|err| map_db_err(&err))?; + )?; - let existing = conn - .query_row_optional( - "SELECT schema_version FROM cache_meta LIMIT 1;", - &[], - |stmt| Ok(stmt.column_i64(0)), - ) - .map_err(|err| map_db_err(&err))?; + let existing = conn.query_row_optional( + "SELECT schema_version FROM cache_meta LIMIT 1;", + &[], + |stmt| Ok(stmt.column_i64(0)), + )?; match existing { Some(version) if version == CACHE_SCHEMA_VERSION => { @@ -45,12 +39,7 @@ pub(super) fn ensure_schema(conn: &Connection) -> StorageResult<()> { Ok(()) } -/// Ensures the `cache_entries` table and indexes exist. -/// -/// # Errors -/// -/// Returns an error if schema creation fails. -fn ensure_entries_schema(conn: &Connection) -> StorageResult<()> { +fn ensure_entries_schema(conn: &Connection) -> DbResult<()> { conn.execute_batch( "CREATE TABLE IF NOT EXISTS cache_entries ( key_bytes BLOB NOT NULL, @@ -63,41 +52,26 @@ fn ensure_entries_schema(conn: &Connection) -> StorageResult<()> { CREATE INDEX IF NOT EXISTS idx_cache_entries_expiry ON cache_entries (expires_at);", ) - .map_err(|err| map_db_err(&err))?; - Ok(()) } -/// Drops legacy cache tables and recreates the current schema. -/// -/// # Errors -/// -/// Returns an error if the reset or re-init fails. -fn reset_schema(conn: &Connection) -> StorageResult<()> { +fn reset_schema(conn: &Connection) -> DbResult<()> { conn.execute_batch( "DROP TABLE IF EXISTS used_nullifiers; DROP TABLE IF EXISTS merkle_proof_cache; DROP TABLE IF EXISTS session_keys; DROP TABLE IF EXISTS cache_entries;", - ) - .map_err(|err| map_db_err(&err))?; + )?; ensure_entries_schema(conn)?; - conn.execute("DELETE FROM cache_meta;", &[]) - .map_err(|err| map_db_err(&err))?; + conn.execute("DELETE FROM cache_meta;", &[])?; insert_meta(conn)?; Ok(()) } -/// Inserts the current schema version into `cache_meta`. -/// -/// # Errors -/// -/// Returns an error if the insert fails. -fn insert_meta(conn: &Connection) -> StorageResult<()> { +fn insert_meta(conn: &Connection) -> DbResult<()> { conn.execute( "INSERT INTO cache_meta (schema_version, created_at, updated_at) VALUES (?1, strftime('%s','now'), strftime('%s','now'))", params![CACHE_SCHEMA_VERSION], - ) - .map_err(|err| map_db_err(&err))?; + )?; Ok(()) } diff --git a/walletkit-core/src/storage/credential_storage.rs b/walletkit-core/src/storage/credential_storage.rs index 566a36fa0..df34539aa 100644 --- a/walletkit-core/src/storage/credential_storage.rs +++ b/walletkit-core/src/storage/credential_storage.rs @@ -8,7 +8,6 @@ use world_id_core::FieldElement as CoreFieldElement; use super::error::{StorageError, StorageResult}; use super::keys::StorageKeys; -use super::lock::{StorageLock, StorageLockGuard}; use super::paths::StoragePaths; use super::traits::StorageProvider; #[cfg(not(target_arch = "wasm32"))] @@ -16,7 +15,8 @@ use super::traits::VaultChangedListener; use super::traits::{AtomicBlobStore, DeviceKeystore}; use super::types::CredentialRecord; use super::ACCOUNT_KEYS_FILENAME; -use super::{CacheDb, VaultDb}; +use super::{CacheDb, CredentialVault}; +use super::{StorageLock, StorageLockGuard}; use crate::{Credential, FieldElement}; use world_id_core::primitives::merkle::AccountInclusionProof; use world_id_core::primitives::TREE_DEPTH; @@ -87,7 +87,7 @@ struct CredentialStoreInner { struct StorageState { #[allow(dead_code)] keys: StorageKeys, - vault: VaultDb, + vault: CredentialVault, cache: CacheDb, leaf_index: u64, } @@ -128,7 +128,7 @@ impl CredentialStoreInner { } fn guard(&self) -> StorageResult { - self.lock.lock() + self.lock.lock().map_err(Into::into) } fn state(&self) -> StorageResult<&StorageState> { @@ -508,9 +508,8 @@ impl CredentialStore { impl CredentialStoreInner { fn init(&mut self, leaf_index: u64, now: u64) -> StorageResult<()> { - let guard = self.guard()?; if let Some(state) = &mut self.state { - state.vault.init_leaf_index(&guard, leaf_index, now)?; + state.vault.init_leaf_index(leaf_index, now)?; state.leaf_index = leaf_index; return Ok(()); } @@ -518,19 +517,19 @@ impl CredentialStoreInner { let keys = StorageKeys::init( self.keystore.as_ref(), self.blob_store.as_ref(), - &guard, + &self.lock, now, )?; let k_intermediate = keys.intermediate_key(); - let vault = VaultDb::new(&self.paths.vault_db_path(), k_intermediate, &guard)?; - let cache = CacheDb::new(&self.paths.cache_db_path(), k_intermediate, &guard)?; - let mut state = StorageState { + let vault = CredentialVault::new(&self.paths.vault_db_path(), k_intermediate)?; + let cache = CacheDb::new(&self.paths.cache_db_path(), k_intermediate)?; + let state = StorageState { keys, vault, cache, leaf_index, }; - state.vault.init_leaf_index(&guard, leaf_index, now)?; + state.vault.init_leaf_index(leaf_index, now)?; self.state = Some(state); Ok(()) } @@ -545,9 +544,8 @@ impl CredentialStoreInner { } fn delete_credential(&mut self, credential_id: u64) -> StorageResult<()> { - let guard = self.guard()?; let state = self.state_mut()?; - state.vault.delete_credential(&guard, credential_id) + state.vault.delete_credential(credential_id) } fn get_credential( @@ -598,10 +596,8 @@ impl CredentialStoreInner { .map_err(|e| StorageError::Serialization(e.to_string()))?; let subject_blinding_factor = blinding_factor.to_bytes(); - let guard = self.guard()?; let state = self.state_mut()?; state.vault.store_credential( - &guard, issuer_schema_id, subject_blinding_factor, genesis_issued_at, @@ -618,10 +614,8 @@ impl CredentialStoreInner { session_id_r_seed: CoreFieldElement, now: u64, ) -> StorageResult<()> { - let guard = self.guard()?; let state = self.state_mut()?; state.cache.session_seed_put( - &guard, oprf_seed.to_be_bytes(), session_id_r_seed.to_be_bytes(), now, @@ -669,7 +663,6 @@ impl CredentialStoreInner { now: u64, ttl_seconds: u64, ) -> StorageResult<()> { - let guard = self.guard()?; let state = self.state_mut()?; // Use JSON for storage instead of CBOR because the `#[serde(flatten)]` property // causes the deserialization of `FieldElement`s to appear as human-readable @@ -679,9 +672,7 @@ impl CredentialStoreInner { ) })?; - state - .cache - .merkle_cache_put(&guard, bytes, now, ttl_seconds) + state.cache.merkle_cache_put(&bytes, now, ttl_seconds) } /// Checks whether a replay guard entry exists for the given nullifier. @@ -713,20 +704,23 @@ impl CredentialStoreInner { nullifier: CoreFieldElement, now: u64, ) -> StorageResult<()> { - let guard = self.guard()?; let nullifier = nullifier.to_be_bytes(); let state = self.state_mut()?; - state.cache.replay_guard_set(&guard, nullifier, now) + state.cache.replay_guard_set(nullifier, now) } /// Exports the vault to a temporary plaintext file in the worldid directory. /// Returns the path to the file. The caller is responsible for cleanup. + /// + /// Holds the cross-process lock for the duration of the export so a + /// concurrent writer can't interleave between the stale-file cleanup + /// and the ATTACH-based plaintext copy. #[cfg(not(target_arch = "wasm32"))] fn export_vault_for_backup_to_file(&self) -> StorageResult { - let guard = self.guard()?; + let _guard = self.guard()?; let state = self.state()?; let dest = self.temp_backup_path(); - state.vault.export_plaintext(&dest, &guard)?; + state.vault.export_plaintext(&dest)?; Ok(dest.to_string_lossy().to_string()) } @@ -747,12 +741,15 @@ impl CredentialStoreInner { } /// Imports from a plaintext vault file on disk. + /// + /// Holds the cross-process lock for the duration of the import so a + /// concurrent writer can't interleave with the ATTACH-based copy. #[cfg(not(target_arch = "wasm32"))] fn import_vault_from_file(&self, backup_path: &str) -> StorageResult<()> { - let guard = self.guard()?; + let _guard = self.guard()?; let state = self.state()?; let source = std::path::Path::new(backup_path); - state.vault.import_plaintext(source, &guard) + state.vault.import_plaintext(source) } /// Removes any stale plaintext backup temp files left behind by a @@ -794,9 +791,8 @@ impl CredentialStoreInner { /// /// Returns an error if the delete operation fails. fn danger_delete_all_credentials(&mut self) -> StorageResult { - let guard = self.guard()?; let state = self.state_mut()?; - state.vault.danger_delete_all_credentials(&guard) + state.vault.danger_delete_all_credentials() } /// Permanently destroys all storage data: encryption keys, vault, and cache. @@ -1277,7 +1273,7 @@ mod tests { #[test] fn test_import_vault_backup_transaction_atomicity() { - use walletkit_db::cipher::BACKUP_TABLES; + use crate::storage::credential_vault::BACKUP_TABLES; use walletkit_db::Connection; use world_id_core::Credential as CoreCredential; diff --git a/walletkit-core/src/storage/vault/mod.rs b/walletkit-core/src/storage/credential_vault/mod.rs similarity index 65% rename from walletkit-core/src/storage/vault/mod.rs rename to walletkit-core/src/storage/credential_vault/mod.rs index e23665026..bc52172db 100644 --- a/walletkit-core/src/storage/vault/mod.rs +++ b/walletkit-core/src/storage/credential_vault/mod.rs @@ -1,6 +1,10 @@ //! Encrypted vault database for credential storage. +//! +//! Thin wrapper over [`walletkit_db::Vault`]: the credential-specific schema, +//! queries, and backup-table list live here; the underlying open / key / +//! integrity-check machinery and the shared `blob_objects` table come from +//! [`walletkit_db`]. -mod helpers; mod schema; #[cfg(test)] mod tests; @@ -8,60 +12,59 @@ mod tests; use std::path::Path; use crate::storage::error::{StorageError, StorageResult}; -use crate::storage::lock::StorageLockGuard; use crate::storage::types::{BlobKind, CredentialRecord}; -use helpers::{compute_content_id, map_db_err, map_record, to_i64, to_u64}; use schema::{ensure_schema, VAULT_SCHEMA_VERSION}; use secrecy::SecretBox; -use walletkit_db::cipher; -use walletkit_db::{params, Connection, StepResult, Value}; - -/// Encrypted vault database wrapper. +use walletkit_db::{blobs, cipher, params, DbError, Row, StepResult, Value, Vault}; + +/// Tables included in plaintext vault backups, in order. +/// +/// `vault_meta` is intentionally excluded: on restore, the destination vault +/// already has its own `vault_meta` (created by `schema::ensure_schema` + +/// `init_leaf_index`) with the authoritative `leaf_index` from the +/// authenticator. +/// +/// **Note:** New tables added to the vault schema must be added here too. +pub(crate) const BACKUP_TABLES: &[&str] = &["credential_records", "blob_objects"]; + +/// Encrypted vault database wrapper around [`walletkit_db::Vault`]. #[derive(Debug)] -pub struct VaultDb { - conn: Connection, +pub struct CredentialVault { + vault: Vault, } -impl VaultDb { +impl CredentialVault { /// Opens or creates the encrypted vault database at `path`. /// /// # Errors /// - /// Returns an error if the database cannot be opened, keyed, or initialized. + /// Returns an error if the database cannot be opened, keyed, or + /// initialized. pub fn new( path: &Path, k_intermediate: &SecretBox<[u8; 32]>, - _lock: &StorageLockGuard, ) -> StorageResult { - let conn = cipher::open_encrypted(path, k_intermediate, false) - .map_err(|e| map_db_err(&e))?; - ensure_schema(&conn)?; - let db = Self { conn }; - if !db.check_integrity()? { - return Err(StorageError::CorruptedVault( - "integrity_check failed".to_string(), - )); - } - Ok(db) + let vault = Vault::open(path, k_intermediate, |conn| { + blobs::ensure_schema(conn)?; + ensure_schema(conn) + })?; + Ok(Self { vault }) } /// Initializes or validates the leaf index for this vault. /// - /// The leaf index is the account's position in the registry tree and must be - /// consistent for all subsequent operations. A mismatch returns an error. + /// The leaf index is the account's position in the registry tree and must + /// be consistent for all subsequent operations. A mismatch returns an + /// error. /// /// # Errors /// /// Returns an error if the stored leaf index does not match. - pub fn init_leaf_index( - &mut self, - _lock: &StorageLockGuard, - leaf_index: u64, - now: u64, - ) -> StorageResult<()> { + pub fn init_leaf_index(&self, leaf_index: u64, now: u64) -> StorageResult<()> { let leaf_index_i64 = to_i64(leaf_index, "leaf_index")?; let now_i64 = to_i64(now, "now")?; - let tx = self.conn.transaction().map_err(|err| map_db_err(&err))?; + let conn = self.vault.connection(); + let tx = conn.transaction().map_err(|err| map_db_err(&err))?; let stored = tx .query_row( "INSERT INTO vault_meta (schema_version, leaf_index, created_at, updated_at) @@ -73,11 +76,7 @@ impl VaultDb { ELSE vault_meta.leaf_index END RETURNING leaf_index", - params![ - VAULT_SCHEMA_VERSION, - leaf_index_i64, - now_i64, - ], + params![VAULT_SCHEMA_VERSION, leaf_index_i64, now_i64], |stmt| Ok(stmt.column_i64(0)), ) .map_err(|err| map_db_err(&err))?; @@ -100,11 +99,16 @@ impl VaultDb { /// # Errors /// /// Returns an error if any insert fails. - #[allow(clippy::too_many_arguments)] - #[allow(clippy::needless_pass_by_value)] + #[expect( + clippy::too_many_arguments, + reason = "fields mirror the credential record schema" + )] + #[expect( + clippy::needless_pass_by_value, + reason = "byte buffers are consumed here; callers don't reuse them" + )] pub fn store_credential( - &mut self, - _lock: &StorageLockGuard, + &self, issuer_schema_id: u64, subject_blinding_factor: Vec, genesis_issued_at: u64, @@ -113,45 +117,27 @@ impl VaultDb { associated_data: Option>, now: u64, ) -> StorageResult { - let credential_blob_id = - compute_content_id(BlobKind::CredentialBlob, &credential_blob); - let associated_data_id = associated_data - .as_ref() - .map(|bytes| compute_content_id(BlobKind::AssociatedData, bytes)); let now_i64 = to_i64(now, "now")?; let issuer_schema_id_i64 = to_i64(issuer_schema_id, "issuer_schema_id")?; let genesis_issued_at_i64 = to_i64(genesis_issued_at, "genesis_issued_at")?; let expires_at_i64 = to_i64(expires_at, "expires_at")?; - let tx = self.conn.transaction().map_err(|err| map_db_err(&err))?; - tx.execute( - "INSERT OR IGNORE INTO blob_objects (content_id, blob_kind, created_at, bytes) - VALUES (?1, ?2, ?3, ?4)", - params![ - credential_blob_id.as_ref(), - BlobKind::CredentialBlob.as_i64(), - now_i64, - credential_blob.as_slice(), - ], - ) - .map_err(|err| map_db_err(&err))?; + let conn = self.vault.connection(); + let tx = conn.transaction().map_err(|err| map_db_err(&err))?; - if let Some(data) = associated_data { - let cid = associated_data_id.as_ref().ok_or_else(|| { - StorageError::VaultDb("associated data CID must be present".to_string()) - })?; - tx.execute( - "INSERT OR IGNORE INTO blob_objects (content_id, blob_kind, created_at, bytes) - VALUES (?1, ?2, ?3, ?4)", - params![ - cid.as_ref(), - BlobKind::AssociatedData.as_i64(), - now_i64, - data.as_slice(), - ], - ) - .map_err(|err| map_db_err(&err))?; - } + let credential_blob_id = blobs::put( + conn, + BlobKind::CredentialBlob as u8, + credential_blob.as_slice(), + now, + )?; + + let associated_data_id = associated_data + .as_ref() + .map(|data| { + blobs::put(conn, BlobKind::AssociatedData as u8, data.as_slice(), now) + }) + .transpose()?; let ad_cid_value: Value = associated_data_id .as_ref() @@ -175,7 +161,7 @@ impl VaultDb { genesis_issued_at_i64, expires_at_i64, now_i64, - credential_blob_id.as_ref(), + credential_blob_id.as_slice(), ad_cid_value, ], |stmt| Ok(stmt.column_i64(0)), @@ -220,7 +206,11 @@ impl VaultDb { WHERE (?2 IS NULL OR cr.issuer_schema_id = ?2) ORDER BY cr.updated_at DESC"; - let mut stmt = self.conn.prepare(sql).map_err(|err| map_db_err(&err))?; + let mut stmt = self + .vault + .connection() + .prepare(sql) + .map_err(|err| map_db_err(&err))?; stmt.bind_values(&[Value::Integer(now_i64), issuer_filter]) .map_err(|err| map_db_err(&err))?; while let StepResult::Row(row) = stmt.step().map_err(|err| map_db_err(&err))? { @@ -237,15 +227,12 @@ impl VaultDb { /// /// # Errors /// - /// Returns an error if the delete query fails or the credential ID does not - /// exist. - pub fn delete_credential( - &mut self, - _lock: &StorageLockGuard, - credential_id: u64, - ) -> StorageResult<()> { + /// Returns an error if the delete query fails or the credential ID does + /// not exist. + pub fn delete_credential(&self, credential_id: u64) -> StorageResult<()> { let credential_id_i64 = to_i64(credential_id, "credential_id")?; - let tx = self.conn.transaction().map_err(|err| map_db_err(&err))?; + let conn = self.vault.connection(); + let tx = conn.transaction().map_err(|err| map_db_err(&err))?; let deleted = tx .execute( @@ -290,7 +277,8 @@ impl VaultDb { /// Retrieves the credential bytes and blinding factor by issuer schema ID. /// - /// Returns the most recent non-expired credential matching the issuer schema ID. + /// Returns the most recent non-expired credential matching the issuer + /// schema ID. /// /// # Errors /// @@ -312,7 +300,11 @@ impl VaultDb { ORDER BY cr.updated_at DESC LIMIT 1"; - let mut stmt = self.conn.prepare(sql).map_err(|err| map_db_err(&err))?; + let mut stmt = self + .vault + .connection() + .prepare(sql) + .map_err(|err| map_db_err(&err))?; stmt.bind_values(params![expires, issuer_schema_id_i64]) .map_err(|err| map_db_err(&err))?; match stmt.step().map_err(|err| map_db_err(&err))? { @@ -328,17 +320,15 @@ impl VaultDb { /// **Development only.** Permanently deletes all credentials and their /// associated blob data from the vault. /// - /// This is a destructive, unrecoverable operation. Do not call in production. - /// Vault metadata (leaf index, schema version) is preserved. + /// This is a destructive, unrecoverable operation. Do not call in + /// production. Vault metadata (leaf index, schema version) is preserved. /// /// # Errors /// /// Returns an error if the delete operation fails. - pub fn danger_delete_all_credentials( - &mut self, - _lock: &StorageLockGuard, - ) -> StorageResult { - let tx = self.conn.transaction().map_err(|err| map_db_err(&err))?; + pub fn danger_delete_all_credentials(&self) -> StorageResult { + let conn = self.vault.connection(); + let tx = conn.transaction().map_err(|err| map_db_err(&err))?; let deleted = tx .execute("DELETE FROM credential_records", &[]) @@ -357,44 +347,75 @@ impl VaultDb { /// /// Returns an error if the check cannot be executed. pub fn check_integrity(&self) -> StorageResult { - cipher::integrity_check(&self.conn).map_err(|e| map_db_err(&e)) + cipher::integrity_check(self.vault.connection()).map_err(|e| map_db_err(&e)) } /// Exports a plaintext (unencrypted) copy of the vault to `dest`. /// - /// The caller is responsible for deleting the exported file after use. + /// Callers that need cross-process exclusion (to keep a concurrent + /// writer from interleaving between stale-file cleanup and the + /// `ATTACH`-based copy) must hold [`crate::storage::StorageLock`] + /// themselves. The caller is also responsible for deleting the + /// exported file after use. /// /// # Errors /// /// Returns an error if the export fails. - pub fn export_plaintext( - &self, - dest: &Path, - _lock: &StorageLockGuard, - ) -> StorageResult<()> { - // Remove any stale export from a previous failed run. + pub fn export_plaintext(&self, dest: &Path) -> StorageResult<()> { + let conn = self.vault.connection(); if dest.exists() { std::fs::remove_file(dest).map_err(|e| { StorageError::VaultDb(format!("failed to remove stale backup: {e}")) })?; } - cipher::export_plaintext_copy(&self.conn, dest).map_err(|e| map_db_err(&e)) + cipher::export_plaintext_copy(conn, dest, BACKUP_TABLES) + .map_err(|e| map_db_err(&e)) } /// Imports credentials from a plaintext (unencrypted) vault backup into /// an empty vault. Intended for restore on a fresh install. /// - /// The caller is responsible for deleting the source file after the - /// import completes. + /// Callers that need cross-process exclusion must hold + /// [`crate::storage::StorageLock`] themselves. The caller is also + /// responsible for deleting the source file after the import completes. /// /// # Errors /// /// Returns an error if the import fails. - pub fn import_plaintext( - &self, - source: &Path, - _lock: &StorageLockGuard, - ) -> StorageResult<()> { - cipher::import_plaintext_copy(&self.conn, source).map_err(|e| map_db_err(&e)) + pub fn import_plaintext(&self, source: &Path) -> StorageResult<()> { + let conn = self.vault.connection(); + cipher::import_plaintext_copy(conn, source, BACKUP_TABLES) + .map_err(|e| map_db_err(&e)) } } + +fn map_record(row: &Row<'_, '_>) -> StorageResult { + let credential_id = row.column_i64(0); + let issuer_schema_id = row.column_i64(1); + let genesis_issued_at = row.column_i64(2); + let expires_at = row.column_i64(3); + let is_expired = row.column_i64(4); + Ok(CredentialRecord { + credential_id: to_u64(credential_id, "credential_id")?, + issuer_schema_id: to_u64(issuer_schema_id, "issuer_schema_id")?, + genesis_issued_at: to_u64(genesis_issued_at, "genesis_issued_at")?, + expires_at: to_u64(expires_at, "expires_at")?, + is_expired: is_expired != 0, + }) +} + +fn to_i64(value: u64, label: &str) -> StorageResult { + i64::try_from(value).map_err(|_| { + StorageError::VaultDb(format!("{label} out of range for i64: {value}")) + }) +} + +fn to_u64(value: i64, label: &str) -> StorageResult { + u64::try_from(value).map_err(|_| { + StorageError::VaultDb(format!("{label} out of range for u64: {value}")) + }) +} + +fn map_db_err(err: &DbError) -> StorageError { + StorageError::VaultDb(err.to_string()) +} diff --git a/walletkit-core/src/storage/vault/schema.rs b/walletkit-core/src/storage/credential_vault/schema.rs similarity index 63% rename from walletkit-core/src/storage/vault/schema.rs rename to walletkit-core/src/storage/credential_vault/schema.rs index 2602fb6f5..db5b1896e 100644 --- a/walletkit-core/src/storage/vault/schema.rs +++ b/walletkit-core/src/storage/credential_vault/schema.rs @@ -1,18 +1,21 @@ -//! Vault database schema management. +//! Credential-vault schema (vault metadata + credential records). +//! +//! The shared `blob_objects` table comes from +//! [`walletkit_db::Blobs::ensure_schema`]; this module owns only the +//! credential-specific tables. -use crate::storage::error::StorageResult; -use walletkit_db::Connection; - -use super::helpers::map_db_err; +use walletkit_db::{Connection, DbResult}; pub(super) const VAULT_SCHEMA_VERSION: i64 = 1; -/// **Backup sensitivity:** Schema changes here affect vault backups made into the backup system. -/// - New tables must be added to `BACKUP_TABLES` in `walletkit-db/src/cipher.rs`. -/// - Column changes (especially new `NOT NULL` columns without defaults) will -/// break restoring older backups into a newer schema. See the schema migration -/// note on `import_plaintext_copy` in `walletkit-db/src/cipher.rs`. -pub(super) fn ensure_schema(conn: &Connection) -> StorageResult<()> { +/// Creates the credential-vault tables, indexes, and triggers. +/// +/// **Backup sensitivity:** Schema changes here affect plaintext vault +/// backups. +/// - New tables must be added to [`super::BACKUP_TABLES`]. +/// - Column changes (especially new `NOT NULL` columns without defaults) can +/// break restoring older backups into a newer schema. +pub(super) fn ensure_schema(conn: &Connection) -> DbResult<()> { conn.execute_batch( "CREATE TABLE IF NOT EXISTS vault_meta ( schema_version INTEGER NOT NULL, @@ -49,17 +52,6 @@ pub(super) fn ensure_schema(conn: &Connection) -> StorageResult<()> { CREATE INDEX IF NOT EXISTS idx_cred_by_expiry ON credential_records (expires_at); - - CREATE TABLE IF NOT EXISTS blob_objects ( - content_id BLOB NOT NULL, - blob_kind INTEGER NOT NULL, - created_at INTEGER NOT NULL, - bytes BLOB NOT NULL, - PRIMARY KEY (content_id) - ); - ", ) - .map_err(|err| map_db_err(&err))?; - Ok(()) } diff --git a/walletkit-core/src/storage/vault/tests.rs b/walletkit-core/src/storage/credential_vault/tests.rs similarity index 74% rename from walletkit-core/src/storage/vault/tests.rs rename to walletkit-core/src/storage/credential_vault/tests.rs index 699ceaec9..13317191f 100644 --- a/walletkit-core/src/storage/vault/tests.rs +++ b/walletkit-core/src/storage/credential_vault/tests.rs @@ -1,8 +1,6 @@ //! Vault database unit tests. -use super::helpers::{compute_content_id, map_db_err}; use super::*; -use crate::storage::lock::StorageLock; use secrecy::SecretBox; use std::fs; use std::path::{Path, PathBuf}; @@ -41,11 +39,9 @@ fn test_vault_create_and_open() { let path = temp_vault_path(); let key = SecretBox::init_with(|| [0x42u8; 32]); let lock_path = temp_lock_path(); - let lock = StorageLock::open(&lock_path).expect("open lock"); - let guard = lock.lock().expect("lock"); - let db = VaultDb::new(&path, &key, &guard).expect("create vault"); + let db = CredentialVault::new(&path, &key).expect("create vault"); drop(db); - VaultDb::new(&path, &key, &guard).expect("open vault"); + CredentialVault::new(&path, &key).expect("open vault"); cleanup_vault_files(&path); cleanup_lock_file(&lock_path); } @@ -55,11 +51,9 @@ fn test_vault_wrong_key_fails() { let path = temp_vault_path(); let key = SecretBox::init_with(|| [0x01u8; 32]); let lock_path = temp_lock_path(); - let lock = StorageLock::open(&lock_path).expect("open lock"); - let guard = lock.lock().expect("lock"); - VaultDb::new(&path, &key, &guard).expect("create vault"); + CredentialVault::new(&path, &key).expect("create vault"); let wrong_key = SecretBox::init_with(|| [0x02u8; 32]); - let err = VaultDb::new(&path, &wrong_key, &guard).expect_err("wrong key"); + let err = CredentialVault::new(&path, &wrong_key).expect_err("wrong key"); match err { StorageError::VaultDb(_) | StorageError::CorruptedVault(_) => {} _ => panic!("unexpected error: {err}"), @@ -72,14 +66,10 @@ fn test_vault_wrong_key_fails() { fn test_leaf_index_set_once() { let path = temp_vault_path(); let lock_path = temp_lock_path(); - let lock = StorageLock::open(&lock_path).expect("open lock"); - let guard = lock.lock().expect("lock"); let key = SecretBox::init_with(|| [0x03u8; 32]); - let mut db = VaultDb::new(&path, &key, &guard).expect("create vault"); - db.init_leaf_index(&guard, 42, 100) - .expect("init leaf index"); - db.init_leaf_index(&guard, 42, 200) - .expect("init leaf index again"); + let db = CredentialVault::new(&path, &key).expect("create vault"); + db.init_leaf_index(42, 100).expect("init leaf index"); + db.init_leaf_index(42, 200).expect("init leaf index again"); cleanup_vault_files(&path); cleanup_lock_file(&lock_path); } @@ -88,12 +78,10 @@ fn test_leaf_index_set_once() { fn test_leaf_index_immutable() { let path = temp_vault_path(); let lock_path = temp_lock_path(); - let lock = StorageLock::open(&lock_path).expect("open lock"); - let guard = lock.lock().expect("lock"); let key = SecretBox::init_with(|| [0x04u8; 32]); - let mut db = VaultDb::new(&path, &key, &guard).expect("create vault"); - db.init_leaf_index(&guard, 7, 100).expect("init leaf index"); - let err = db.init_leaf_index(&guard, 8, 200).expect_err("mismatch"); + let db = CredentialVault::new(&path, &key).expect("create vault"); + db.init_leaf_index(7, 100).expect("init leaf index"); + let err = db.init_leaf_index(8, 200).expect_err("mismatch"); match err { StorageError::InvalidLeafIndex { .. } => {} _ => panic!("unexpected error: {err}"), @@ -106,13 +94,10 @@ fn test_leaf_index_immutable() { fn test_store_credential_without_associated_data() { let path = temp_vault_path(); let lock_path = temp_lock_path(); - let lock = StorageLock::open(&lock_path).expect("open lock"); - let guard = lock.lock().expect("lock"); let key = SecretBox::init_with(|| [0x05u8; 32]); - let mut db = VaultDb::new(&path, &key, &guard).expect("create vault"); + let db = CredentialVault::new(&path, &key).expect("create vault"); let credential_id = db .store_credential( - &guard, 10, sample_blinding_factor(), 123, @@ -136,12 +121,9 @@ fn test_store_credential_without_associated_data() { fn test_store_credential_with_associated_data() { let path = temp_vault_path(); let lock_path = temp_lock_path(); - let lock = StorageLock::open(&lock_path).expect("open lock"); - let guard = lock.lock().expect("lock"); let key = SecretBox::init_with(|| [0x06u8; 32]); - let mut db = VaultDb::new(&path, &key, &guard).expect("create vault"); + let db = CredentialVault::new(&path, &key).expect("create vault"); db.store_credential( - &guard, 11, sample_blinding_factor(), 456, @@ -160,24 +142,14 @@ fn test_store_credential_with_associated_data() { cleanup_lock_file(&lock_path); } -#[test] -fn test_content_id_determinism() { - let a = compute_content_id(BlobKind::CredentialBlob, b"data"); - let b = compute_content_id(BlobKind::CredentialBlob, b"data"); - assert_eq!(a, b); -} - #[test] fn test_content_id_deduplication() { let path = temp_vault_path(); let lock_path = temp_lock_path(); - let lock = StorageLock::open(&lock_path).expect("open lock"); - let guard = lock.lock().expect("lock"); let key = SecretBox::init_with(|| [0x07u8; 32]); - let mut db = VaultDb::new(&path, &key, &guard).expect("create vault"); + let db = CredentialVault::new(&path, &key).expect("create vault"); let first_id = db .store_credential( - &guard, 12, sample_blinding_factor(), 1, @@ -189,7 +161,6 @@ fn test_content_id_deduplication() { .expect("store credential"); let second_id = db .store_credential( - &guard, 12, sample_blinding_factor(), 1, @@ -200,7 +171,8 @@ fn test_content_id_deduplication() { ) .expect("store credential"); let count = db - .conn + .vault + .connection() .query_row("SELECT COUNT(*) FROM blob_objects", &[], |stmt| { Ok(stmt.column_i64(0)) }) @@ -208,11 +180,12 @@ fn test_content_id_deduplication() { .expect("count blobs"); assert_eq!(count, 1); - db.delete_credential(&guard, first_id) + db.delete_credential(first_id) .expect("delete first credential"); let count_after_first_delete = db - .conn + .vault + .connection() .query_row("SELECT COUNT(*) FROM blob_objects", &[], |stmt| { Ok(stmt.column_i64(0)) }) @@ -220,11 +193,12 @@ fn test_content_id_deduplication() { .expect("count blobs after first delete"); assert_eq!(count_after_first_delete, 1); - db.delete_credential(&guard, second_id) + db.delete_credential(second_id) .expect("delete second credential"); let count_after_second_delete = db - .conn + .vault + .connection() .query_row("SELECT COUNT(*) FROM blob_objects", &[], |stmt| { Ok(stmt.column_i64(0)) }) @@ -240,12 +214,9 @@ fn test_content_id_deduplication() { fn test_list_credentials_by_issuer() { let path = temp_vault_path(); let lock_path = temp_lock_path(); - let lock = StorageLock::open(&lock_path).expect("open lock"); - let guard = lock.lock().expect("lock"); let key = SecretBox::init_with(|| [0x08u8; 32]); - let mut db = VaultDb::new(&path, &key, &guard).expect("create vault"); + let db = CredentialVault::new(&path, &key).expect("create vault"); db.store_credential( - &guard, 100, sample_blinding_factor(), 1, @@ -256,7 +227,6 @@ fn test_list_credentials_by_issuer() { ) .expect("store credential"); db.store_credential( - &guard, 200, sample_blinding_factor(), 1, @@ -279,12 +249,9 @@ fn test_list_credentials_by_issuer() { fn test_list_credentials_marks_expired() { let path = temp_vault_path(); let lock_path = temp_lock_path(); - let lock = StorageLock::open(&lock_path).expect("open lock"); - let guard = lock.lock().expect("lock"); let key = SecretBox::init_with(|| [0x09u8; 32]); - let mut db = VaultDb::new(&path, &key, &guard).expect("create vault"); + let db = CredentialVault::new(&path, &key).expect("create vault"); db.store_credential( - &guard, 300, sample_blinding_factor(), 1, @@ -295,7 +262,6 @@ fn test_list_credentials_marks_expired() { ) .expect("store expired credential"); db.store_credential( - &guard, 301, sample_blinding_factor(), 1, @@ -319,12 +285,9 @@ fn test_list_credentials_marks_expired() { fn test_list_credentials_by_issuer_includes_expired() { let path = temp_vault_path(); let lock_path = temp_lock_path(); - let lock = StorageLock::open(&lock_path).expect("open lock"); - let guard = lock.lock().expect("lock"); let key = SecretBox::init_with(|| [0x0Au8; 32]); - let mut db = VaultDb::new(&path, &key, &guard).expect("create vault"); + let db = CredentialVault::new(&path, &key).expect("create vault"); db.store_credential( - &guard, 500, sample_blinding_factor(), 1, @@ -349,13 +312,10 @@ fn test_list_credentials_by_issuer_includes_expired() { fn test_delete_credential_by_id() { let path = temp_vault_path(); let lock_path = temp_lock_path(); - let lock = StorageLock::open(&lock_path).expect("open lock"); - let guard = lock.lock().expect("lock"); let key = SecretBox::init_with(|| [0x0Bu8; 32]); - let mut db = VaultDb::new(&path, &key, &guard).expect("create vault"); + let db = CredentialVault::new(&path, &key).expect("create vault"); let credential_id = db .store_credential( - &guard, 400, sample_blinding_factor(), 1, @@ -367,7 +327,8 @@ fn test_delete_credential_by_id() { .expect("store credential"); let blob_count_before = db - .conn + .vault + .connection() .query_row("SELECT COUNT(*) FROM blob_objects", &[], |stmt| { Ok(stmt.column_i64(0)) }) @@ -375,14 +336,15 @@ fn test_delete_credential_by_id() { .expect("count blobs before delete"); assert_eq!(blob_count_before, 1); - db.delete_credential(&guard, credential_id) + db.delete_credential(credential_id) .expect("delete credential"); let records = db.list_credentials(None, 1000).expect("list credentials"); assert!(records.is_empty()); let blob_count_after = db - .conn + .vault + .connection() .query_row("SELECT COUNT(*) FROM blob_objects", &[], |stmt| { Ok(stmt.column_i64(0)) }) @@ -391,7 +353,7 @@ fn test_delete_credential_by_id() { assert_eq!(blob_count_after, 0); let err = db - .delete_credential(&guard, credential_id) + .delete_credential(credential_id) .expect_err("delete credential again should fail"); match err { StorageError::CredentialIdNotFound { @@ -410,14 +372,11 @@ fn test_delete_credential_by_id() { fn test_delete_credential_cleans_up_orphaned_associated_data() { let path = temp_vault_path(); let lock_path = temp_lock_path(); - let lock = StorageLock::open(&lock_path).expect("open lock"); - let guard = lock.lock().expect("lock"); let key = SecretBox::init_with(|| [0x0Cu8; 32]); - let mut db = VaultDb::new(&path, &key, &guard).expect("create vault"); + let db = CredentialVault::new(&path, &key).expect("create vault"); let credential_id = db .store_credential( - &guard, 401, sample_blinding_factor(), 1, @@ -429,7 +388,8 @@ fn test_delete_credential_cleans_up_orphaned_associated_data() { .expect("store credential"); let associated_before = db - .conn + .vault + .connection() .query_row( "SELECT COUNT(*) FROM blob_objects WHERE blob_kind = ?1", params![BlobKind::AssociatedData.as_i64()], @@ -439,11 +399,12 @@ fn test_delete_credential_cleans_up_orphaned_associated_data() { .expect("count associated data before delete"); assert_eq!(associated_before, 1); - db.delete_credential(&guard, credential_id) + db.delete_credential(credential_id) .expect("delete credential"); let associated_after = db - .conn + .vault + .connection() .query_row( "SELECT COUNT(*) FROM blob_objects WHERE blob_kind = ?1", params![BlobKind::AssociatedData.as_i64()], @@ -461,12 +422,9 @@ fn test_delete_credential_cleans_up_orphaned_associated_data() { fn test_danger_delete_all_credentials() { let path = temp_vault_path(); let lock_path = temp_lock_path(); - let lock = StorageLock::open(&lock_path).expect("open lock"); - let guard = lock.lock().expect("lock"); let key = SecretBox::init_with(|| [0x0Cu8; 32]); - let mut db = VaultDb::new(&path, &key, &guard).expect("create vault"); + let db = CredentialVault::new(&path, &key).expect("create vault"); db.store_credential( - &guard, 100, sample_blinding_factor(), 1, @@ -477,7 +435,6 @@ fn test_danger_delete_all_credentials() { ) .expect("store credential 1"); db.store_credential( - &guard, 200, sample_blinding_factor(), 2, @@ -488,16 +445,15 @@ fn test_danger_delete_all_credentials() { ) .expect("store credential 2"); - let deleted = db - .danger_delete_all_credentials(&guard) - .expect("delete all"); + let deleted = db.danger_delete_all_credentials().expect("delete all"); assert_eq!(deleted, 2); let records = db.list_credentials(None, 1000).expect("list credentials"); assert!(records.is_empty()); let blob_count = db - .conn + .vault + .connection() .query_row("SELECT COUNT(*) FROM blob_objects", &[], |stmt| { Ok(stmt.column_i64(0)) }) @@ -513,13 +469,11 @@ fn test_danger_delete_all_credentials() { fn test_danger_delete_all_credentials_empty() { let path = temp_vault_path(); let lock_path = temp_lock_path(); - let lock = StorageLock::open(&lock_path).expect("open lock"); - let guard = lock.lock().expect("lock"); let key = SecretBox::init_with(|| [0x0Du8; 32]); - let mut db = VaultDb::new(&path, &key, &guard).expect("create vault"); + let db = CredentialVault::new(&path, &key).expect("create vault"); let deleted = db - .danger_delete_all_credentials(&guard) + .danger_delete_all_credentials() .expect("delete all on empty"); assert_eq!(deleted, 0); @@ -531,27 +485,84 @@ fn test_danger_delete_all_credentials_empty() { fn test_vault_integrity_check() { let path = temp_vault_path(); let lock_path = temp_lock_path(); - let lock = StorageLock::open(&lock_path).expect("open lock"); - let guard = lock.lock().expect("lock"); let key = SecretBox::init_with(|| [0x0Au8; 32]); - let db = VaultDb::new(&path, &key, &guard).expect("create vault"); + let db = CredentialVault::new(&path, &key).expect("create vault"); assert!(db.check_integrity().expect("integrity")); cleanup_vault_files(&path); cleanup_lock_file(&lock_path); } +#[test] +fn test_credential_vault_on_disk_format_guard() { + // On-disk format guard. Stores a credential via the public API and + // asserts that the row lands in `blob_objects` with a frozen + // content_id, then reopens the vault and reads the payload back + // byte-for-byte. Catches: + // - changes to `compute_content_id` (hash domain, kind-tag wiring) + // - changes to `BlobKind::CredentialBlob` (= 0x01) + // - schema or cipher drift in `credential_records` + let path = temp_vault_path(); + let lock_path = temp_lock_path(); + let key = SecretBox::init_with(|| [0xA5u8; 32]); + let credential_bytes = b"on-disk-guard-credential-payload".to_vec(); + let blinding = sample_blinding_factor(); + + let credential_id = { + let db = CredentialVault::new(&path, &key).expect("create vault"); + db.store_credential( + 42, + blinding.clone(), + 1_700_000_000, + 1_800_000_000, + credential_bytes.clone(), + None, + 1_700_000_001, + ) + .expect("store credential") + }; + + // SHA-256(b"worldid:blob" || [BlobKind::CredentialBlob as u8 = 0x01] + // || credential_bytes). Reproducible via: + // printf 'worldid:blob\x01on-disk-guard-credential-payload' \ + // | shasum -a 256 + let expected_cid_hex = + "9281febbd42d05857b399f8481d6842f1e3e4b78401081ca7f0d0fb3a80e9264"; + + let db = CredentialVault::new(&path, &key).expect("reopen vault"); + let stored_cid_hex: String = db + .vault.connection() + .query_row( + "SELECT lower(hex(credential_blob_cid)) FROM credential_records WHERE credential_id = ?1", + params![i64::try_from(credential_id).unwrap()], + |row| Ok(row.column_text(0)), + ) + .map_err(|err| map_db_err(&err)) + .expect("fetch cid"); + assert_eq!( + stored_cid_hex, expected_cid_hex, + "credential_blob content_id drifted — schema, kind tag, or hash domain changed" + ); + + let (fetched_blob, fetched_blinding) = db + .fetch_credential_and_blinding_factor(42, 1_700_000_500) + .expect("fetch credential") + .expect("credential present"); + assert_eq!(fetched_blob, credential_bytes); + assert_eq!(fetched_blinding, blinding); + + cleanup_vault_files(&path); + cleanup_lock_file(&lock_path); +} + #[test] fn test_list_credentials_round_trips_genesis_issued_at() { let path = temp_vault_path(); let lock_path = temp_lock_path(); - let lock = StorageLock::open(&lock_path).expect("open lock"); - let guard = lock.lock().expect("lock"); let key = SecretBox::init_with(|| [0x0Cu8; 32]); - let mut db = VaultDb::new(&path, &key, &guard).expect("create vault"); + let db = CredentialVault::new(&path, &key).expect("create vault"); let genesis_issued_at = 123_456_789u64; db.store_credential( - &guard, 100, sample_blinding_factor(), genesis_issued_at, @@ -574,11 +585,9 @@ fn test_vault_corruption_handling() { let path = temp_vault_path(); let key = SecretBox::init_with(|| [0x0Bu8; 32]); let lock_path = temp_lock_path(); - let lock = StorageLock::open(&lock_path).expect("open lock"); - let guard = lock.lock().expect("lock"); - VaultDb::new(&path, &key, &guard).expect("create vault"); + CredentialVault::new(&path, &key).expect("create vault"); fs::write(&path, b"corrupt").expect("corrupt file"); - let err = VaultDb::new(&path, &key, &guard).expect_err("corrupt vault"); + let err = CredentialVault::new(&path, &key).expect_err("corrupt vault"); match err { StorageError::VaultDb(_) | StorageError::CorruptedVault(_) => {} _ => panic!("unexpected error: {err}"), diff --git a/walletkit-core/src/storage/envelope.rs b/walletkit-core/src/storage/envelope.rs deleted file mode 100644 index abad8e190..000000000 --- a/walletkit-core/src/storage/envelope.rs +++ /dev/null @@ -1,73 +0,0 @@ -//! Account key envelope persistence helpers. - -use serde::{Deserialize, Serialize}; -use zeroize::{Zeroize, ZeroizeOnDrop}; - -use super::error::{StorageError, StorageResult}; - -const ENVELOPE_VERSION: u32 = 1; - -#[derive(Clone, Serialize, Deserialize, Zeroize, ZeroizeOnDrop)] -pub(crate) struct AccountKeyEnvelope { - pub(crate) version: u32, - pub(crate) wrapped_k_intermediate: Vec, - pub(crate) created_at: u64, - pub(crate) updated_at: u64, -} - -impl AccountKeyEnvelope { - pub(crate) const fn new(wrapped_k_intermediate: Vec, now: u64) -> Self { - Self { - version: ENVELOPE_VERSION, - wrapped_k_intermediate, - created_at: now, - updated_at: now, - } - } - - pub(crate) fn serialize(&self) -> StorageResult> { - let mut bytes = Vec::new(); - ciborium::ser::into_writer(self, &mut bytes) - .map_err(|err| StorageError::Serialization(err.to_string()))?; - Ok(bytes) - } - - pub(crate) fn deserialize(bytes: &[u8]) -> StorageResult { - let envelope: Self = ciborium::de::from_reader(bytes) - .map_err(|err| StorageError::Serialization(err.to_string()))?; - if envelope.version != ENVELOPE_VERSION { - return Err(StorageError::UnsupportedEnvelopeVersion(envelope.version)); - } - Ok(envelope) - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn test_envelope_round_trip() { - let envelope = AccountKeyEnvelope::new(vec![1, 2, 3], 123); - let bytes = envelope.serialize().expect("serialize"); - let decoded = AccountKeyEnvelope::deserialize(&bytes).expect("deserialize"); - assert_eq!(decoded.version, ENVELOPE_VERSION); - assert_eq!(decoded.wrapped_k_intermediate, vec![1, 2, 3]); - assert_eq!(decoded.created_at, 123); - assert_eq!(decoded.updated_at, 123); - } - - #[test] - fn test_envelope_version_mismatch() { - let mut envelope = AccountKeyEnvelope::new(vec![1, 2, 3], 123); - envelope.version = ENVELOPE_VERSION + 1; - let bytes = envelope.serialize().expect("serialize"); - match AccountKeyEnvelope::deserialize(&bytes) { - Err(StorageError::UnsupportedEnvelopeVersion(version)) => { - assert_eq!(version, ENVELOPE_VERSION + 1); - } - Err(err) => panic!("unexpected error: {err}"), - Ok(_) => panic!("expected error"), - } - } -} diff --git a/walletkit-core/src/storage/error.rs b/walletkit-core/src/storage/error.rs index dd49ef3fb..8871c7bde 100644 --- a/walletkit-core/src/storage/error.rs +++ b/walletkit-core/src/storage/error.rs @@ -93,3 +93,30 @@ impl From for StorageError { Self::UnexpectedUniFFICallbackError(error.reason) } } + +/// 1-1 variant mapping is intentional: hosts pattern-match on `StorageError` +/// for UX, and `walletkit-db` is uniffi-free by design, so the translation +/// has to live in each FFI-exporting consumer. Don't flatten. +/// +/// TODO: when a second consumer (`OrbKit`, `IssuerKit`) ships its own +/// uniffi-exported error mirroring `StoreError`, extract this mapping into a +/// shared `walletkit-ffi-shared` crate. Not worth it for one consumer. +impl From for StorageError { + fn from(err: walletkit_db::StoreError) -> Self { + match err { + walletkit_db::StoreError::Keystore(s) => Self::Keystore(s), + walletkit_db::StoreError::BlobStore(s) => Self::BlobStore(s), + walletkit_db::StoreError::Lock(s) => Self::Lock(s), + walletkit_db::StoreError::Serialization(s) => Self::Serialization(s), + walletkit_db::StoreError::Crypto(s) => Self::Crypto(s), + walletkit_db::StoreError::InvalidEnvelope(s) => Self::InvalidEnvelope(s), + walletkit_db::StoreError::UnsupportedEnvelopeVersion(v) => { + Self::UnsupportedEnvelopeVersion(v) + } + walletkit_db::StoreError::Db(e) => Self::VaultDb(e.to_string()), + walletkit_db::StoreError::IntegrityCheckFailed(s) => { + Self::CorruptedVault(s) + } + } + } +} diff --git a/walletkit-core/src/storage/keys.rs b/walletkit-core/src/storage/keys.rs index e1b20af37..21d94d525 100644 --- a/walletkit-core/src/storage/keys.rs +++ b/walletkit-core/src/storage/keys.rs @@ -1,16 +1,27 @@ //! Key hierarchy management for credential storage. +//! +//! ## Key structure +//! +//! - `K_device`: device-bound root key managed by `DeviceKeystore`. +//! - `account_keys.bin`: account key envelope stored via `AtomicBlobStore` and +//! containing `DeviceKeystore::seal` of `K_intermediate` with associated data +//! `worldid:account-key-envelope`. +//! - `K_intermediate`: 32-byte per-account key unsealed at init and kept in +//! memory for the lifetime of the storage handle. +//! - `SQLCipher` databases: `account.vault.sqlite` (authoritative) and +//! `account.cache.sqlite` (non-authoritative) are opened with `K_intermediate`. +//! - Derived keys: per relying-party session keys may be derived from +//! `K_intermediate` and cached in `account.cache.sqlite` for performance. -use rand::{rngs::OsRng, RngCore}; use secrecy::SecretBox; -use zeroize::{Zeroize, ZeroizeOnDrop, Zeroizing}; +use zeroize::{Zeroize, ZeroizeOnDrop}; use super::{ - envelope::AccountKeyEnvelope, - error::{StorageError, StorageResult}, - lock::StorageLockGuard, + error::StorageResult, traits::{AtomicBlobStore, DeviceKeystore}, ACCOUNT_KEYS_FILENAME, ACCOUNT_KEY_ENVELOPE_AD, }; +use walletkit_db::Lock; /// In-memory account keys derived from the account key envelope. /// @@ -31,35 +42,18 @@ impl StorageKeys { pub fn init( keystore: &dyn DeviceKeystore, blob_store: &dyn AtomicBlobStore, - _lock: &StorageLockGuard, + lock: &Lock, now: u64, ) -> StorageResult { - if let Some(bytes) = blob_store.read(ACCOUNT_KEYS_FILENAME.to_string())? { - let envelope = AccountKeyEnvelope::deserialize(&bytes)?; - let wrapped_k_intermediate = envelope.wrapped_k_intermediate.clone(); - let k_intermediate_bytes = Zeroizing::new(keystore.open_sealed( - ACCOUNT_KEY_ENVELOPE_AD.to_vec(), - wrapped_k_intermediate, - )?); - let k_intermediate = - parse_key_32(k_intermediate_bytes.as_slice(), "K_intermediate")?; - Ok(Self { - intermediate_key: SecretBox::init_with(|| k_intermediate), - }) - } else { - let k_intermediate = random_key(); - // TODO: At this moment, the key needs to be temporarily heap allocated in order - // to be bridged via UniFFI. This needs to be improved to use pointers that can - // be zeroized after use. - let wrapped_k_intermediate = keystore - .seal(ACCOUNT_KEY_ENVELOPE_AD.to_vec(), k_intermediate.to_vec())?; - let envelope = AccountKeyEnvelope::new(wrapped_k_intermediate, now); - let bytes = envelope.serialize()?; - blob_store.write_atomic(ACCOUNT_KEYS_FILENAME.to_string(), bytes)?; - Ok(Self { - intermediate_key: SecretBox::init_with(|| k_intermediate), - }) - } + let intermediate_key = walletkit_db::init_or_open_envelope_key( + &Ks(keystore), + &Bs(blob_store), + lock, + ACCOUNT_KEYS_FILENAME, + ACCOUNT_KEY_ENVELOPE_AD, + now, + )?; + Ok(Self { intermediate_key }) } /// Returns a reference to the intermediate key's [`SecretBox`]. @@ -69,31 +63,60 @@ impl StorageKeys { } } -fn random_key() -> [u8; 32] { - let mut key = [0u8; 32]; - OsRng.fill_bytes(&mut key); - key +// Trait-object bridge from walletkit-core's uniffi-annotated traits onto +// walletkit-db's plain-Rust trait surface. Required because Rust's orphan +// rule prevents a blanket impl across crates; the wrappers are pure +// delegation since both trait shapes already use `Vec` / `String`. + +struct Ks<'a>(&'a dyn DeviceKeystore); +impl walletkit_db::Keystore for Ks<'_> { + fn seal(&self, aad: Vec, pt: Vec) -> walletkit_db::StoreResult> { + self.0 + .seal(aad, pt) + .map_err(|e| walletkit_db::StoreError::Keystore(e.to_string())) + } + fn open_sealed( + &self, + aad: Vec, + ct: Vec, + ) -> walletkit_db::StoreResult> { + self.0 + .open_sealed(aad, ct) + .map_err(|e| walletkit_db::StoreError::Keystore(e.to_string())) + } } -fn parse_key_32(bytes: &[u8], label: &str) -> StorageResult<[u8; 32]> { - if bytes.len() != 32 { - return Err(StorageError::InvalidEnvelope(format!( - "{label} length mismatch: expected 32, got {}", - bytes.len() - ))); +struct Bs<'a>(&'a dyn AtomicBlobStore); +impl walletkit_db::AtomicBlobStore for Bs<'_> { + fn read(&self, path: String) -> walletkit_db::StoreResult>> { + self.0 + .read(path) + .map_err(|e| walletkit_db::StoreError::BlobStore(e.to_string())) + } + fn write_atomic( + &self, + path: String, + bytes: Vec, + ) -> walletkit_db::StoreResult<()> { + self.0 + .write_atomic(path, bytes) + .map_err(|e| walletkit_db::StoreError::BlobStore(e.to_string())) + } + fn delete(&self, path: String) -> walletkit_db::StoreResult<()> { + self.0 + .delete(path) + .map_err(|e| walletkit_db::StoreError::BlobStore(e.to_string())) } - let mut out = [0u8; 32]; - out.copy_from_slice(bytes); - Ok(out) } #[cfg(test)] mod tests { use super::*; - use crate::storage::lock::StorageLock; + use crate::storage::error::StorageError; use crate::storage::tests_utils::{InMemoryBlobStore, InMemoryKeystore}; use secrecy::ExposeSecret; use uuid::Uuid; + use walletkit_db::Lock; fn temp_lock_path() -> std::path::PathBuf { let mut path = std::env::temp_dir(); @@ -106,12 +129,11 @@ mod tests { let keystore = InMemoryKeystore::new(); let blob_store = InMemoryBlobStore::new(); let lock_path = temp_lock_path(); - let lock = StorageLock::open(&lock_path).expect("open lock"); - let guard = lock.lock().expect("lock"); + let lock = Lock::open(&lock_path).expect("open lock"); let keys_first = - StorageKeys::init(&keystore, &blob_store, &guard, 100).expect("init"); + StorageKeys::init(&keystore, &blob_store, &lock, 100).expect("init"); let keys_second = - StorageKeys::init(&keystore, &blob_store, &guard, 200).expect("init"); + StorageKeys::init(&keystore, &blob_store, &lock, 200).expect("init"); assert_eq!( keys_first.intermediate_key.expose_secret(), @@ -125,12 +147,11 @@ mod tests { let keystore = InMemoryKeystore::new(); let blob_store = InMemoryBlobStore::new(); let lock_path = temp_lock_path(); - let lock = StorageLock::open(&lock_path).expect("open lock"); - let guard = lock.lock().expect("lock"); - StorageKeys::init(&keystore, &blob_store, &guard, 123).expect("init"); + let lock = Lock::open(&lock_path).expect("open lock"); + StorageKeys::init(&keystore, &blob_store, &lock, 123).expect("init"); let other_keystore = InMemoryKeystore::new(); - match StorageKeys::init(&other_keystore, &blob_store, &guard, 456) { + match StorageKeys::init(&other_keystore, &blob_store, &lock, 456) { Err( StorageError::Crypto(_) | StorageError::InvalidEnvelope(_) @@ -147,9 +168,8 @@ mod tests { let keystore = InMemoryKeystore::new(); let blob_store = InMemoryBlobStore::new(); let lock_path = temp_lock_path(); - let lock = StorageLock::open(&lock_path).expect("open lock"); - let guard = lock.lock().expect("lock"); - StorageKeys::init(&keystore, &blob_store, &guard, 123).expect("init"); + let lock = Lock::open(&lock_path).expect("open lock"); + StorageKeys::init(&keystore, &blob_store, &lock, 123).expect("init"); let mut bytes = blob_store .read(ACCOUNT_KEYS_FILENAME.to_string()) @@ -160,7 +180,7 @@ mod tests { .write_atomic(ACCOUNT_KEYS_FILENAME.to_string(), bytes) .expect("write"); - match StorageKeys::init(&keystore, &blob_store, &guard, 456) { + match StorageKeys::init(&keystore, &blob_store, &lock, 456) { Err( StorageError::Serialization(_) | StorageError::Crypto(_) diff --git a/walletkit-core/src/storage/mod.rs b/walletkit-core/src/storage/mod.rs index 16c705419..56803ed60 100644 --- a/walletkit-core/src/storage/mod.rs +++ b/walletkit-core/src/storage/mod.rs @@ -2,24 +2,22 @@ pub mod cache; pub mod credential_storage; -pub mod envelope; +pub mod credential_vault; pub mod error; #[cfg(all(not(target_arch = "wasm32"), feature = "embed-zkeys"))] pub mod groth16_cache; pub mod keys; -pub mod lock; pub mod paths; pub mod traits; pub mod types; -pub mod vault; pub use cache::CacheDb; pub use credential_storage::CredentialStore; +pub use credential_vault::CredentialVault; pub use error::{StorageError, StorageResult}; #[cfg(all(not(target_arch = "wasm32"), feature = "embed-zkeys"))] pub use groth16_cache::cache_embedded_groth16_material; pub use keys::StorageKeys; -pub use lock::{StorageLock, StorageLockGuard}; pub use paths::StoragePaths; pub use traits::{ AtomicBlobStore, DeviceKeystore, StorageProvider, VaultChangedListener, @@ -28,7 +26,7 @@ pub use types::{ BlobKind, ContentId, CredentialRecord, Nullifier, ReplayGuardKind, ReplayGuardResult, RequestId, }; -pub use vault::VaultDb; +pub use walletkit_db::{Lock as StorageLock, LockGuard as StorageLockGuard}; pub(crate) const ACCOUNT_KEYS_FILENAME: &str = "account_keys.bin"; pub(crate) const ACCOUNT_KEY_ENVELOPE_AD: &[u8] = b"worldid:account-key-envelope"; diff --git a/walletkit-core/src/storage/traits.rs b/walletkit-core/src/storage/traits.rs index 2d0304af6..8e9be0eba 100644 --- a/walletkit-core/src/storage/traits.rs +++ b/walletkit-core/src/storage/traits.rs @@ -1,18 +1,4 @@ //! Platform interfaces for credential storage. -//! -//! ## Key structure -//! -//! - `K_device`: device-bound root key managed by `DeviceKeystore`. -//! - `account_keys.bin`: account key envelope stored via `AtomicBlobStore` and -//! containing `DeviceKeystore::seal` of `K_intermediate` with associated data -//! `worldid:account-key-envelope`. -//! - `K_intermediate`: 32-byte per-account key unsealed at init and kept in -//! memory for the lifetime of the storage handle. -//! - `SQLCipher` databases: `account.vault.sqlite` (authoritative) and -//! `account.cache.sqlite` (non-authoritative) are opened with `K_intermediate`. -//! - Derived keys: per relying-party session keys may be derived from -//! `K_intermediate` and cached in `account.cache.sqlite` for performance. -//! cached in `account.cache.sqlite` for performance. use std::sync::Arc; diff --git a/walletkit-core/src/storage/types.rs b/walletkit-core/src/storage/types.rs index 522c8bc6c..7758cae0c 100644 --- a/walletkit-core/src/storage/types.rs +++ b/walletkit-core/src/storage/types.rs @@ -33,8 +33,7 @@ impl TryFrom for BlobKind { } } -/// Content identifier for stored blobs. -pub type ContentId = [u8; 32]; +pub use walletkit_db::ContentId; /// Request identifier for replay guard. pub type RequestId = [u8; 32]; diff --git a/walletkit-core/src/storage/vault/helpers.rs b/walletkit-core/src/storage/vault/helpers.rs deleted file mode 100644 index 736f8b0c9..000000000 --- a/walletkit-core/src/storage/vault/helpers.rs +++ /dev/null @@ -1,51 +0,0 @@ -//! Vault database helpers for content addressing and type conversion. - -use sha2::{Digest, Sha256}; - -use crate::storage::error::{StorageError, StorageResult}; -use crate::storage::types::{BlobKind, ContentId, CredentialRecord}; -use walletkit_db::{DbError, Row}; - -const CONTENT_ID_PREFIX: &[u8] = b"worldid:blob"; - -pub(super) fn compute_content_id(blob_kind: BlobKind, plaintext: &[u8]) -> ContentId { - let mut hasher = Sha256::new(); - hasher.update(CONTENT_ID_PREFIX); - hasher.update([blob_kind as u8]); - hasher.update(plaintext); - let digest = hasher.finalize(); - let mut out = [0u8; 32]; - out.copy_from_slice(&digest); - out -} - -pub(super) fn map_record(row: &Row<'_, '_>) -> StorageResult { - let credential_id = row.column_i64(0); - let issuer_schema_id = row.column_i64(1); - let genesis_issued_at = row.column_i64(2); - let expires_at = row.column_i64(3); - let is_expired = row.column_i64(4); - Ok(CredentialRecord { - credential_id: to_u64(credential_id, "credential_id")?, - issuer_schema_id: to_u64(issuer_schema_id, "issuer_schema_id")?, - genesis_issued_at: to_u64(genesis_issued_at, "genesis_issued_at")?, - expires_at: to_u64(expires_at, "expires_at")?, - is_expired: is_expired != 0, - }) -} - -pub(super) fn to_i64(value: u64, label: &str) -> StorageResult { - i64::try_from(value).map_err(|_| { - StorageError::VaultDb(format!("{label} out of range for i64: {value}")) - }) -} - -pub(super) fn to_u64(value: i64, label: &str) -> StorageResult { - u64::try_from(value).map_err(|_| { - StorageError::VaultDb(format!("{label} out of range for u64: {value}")) - }) -} - -pub(super) fn map_db_err(err: &DbError) -> StorageError { - StorageError::VaultDb(err.to_string()) -} diff --git a/walletkit-db/Cargo.toml b/walletkit-db/Cargo.toml index cdc4971fd..f11101d1d 100644 --- a/walletkit-db/Cargo.toml +++ b/walletkit-db/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "walletkit-db" -description = "Internal SQLite wrapper crate for WalletKit storage." +description = "Encrypted on-device storage primitives for WalletKit (SQLCipher wrapper, vault, content-addressed blobs, key envelope)." publish = true version.workspace = true edition.workspace = true @@ -10,16 +10,25 @@ license.workspace = true homepage.workspace = true repository.workspace = true exclude.workspace = true -readme.workspace = true +readme = "README.md" keywords.workspace = true categories.workspace = true [dependencies] +ciborium = "0.2.2" +getrandom = "0.3" hex = "0.4" secrecy = "0.10" -zeroize = "1" +serde = { version = "1", features = ["derive"] } +sha2 = "0.10" +thiserror = "2" +zeroize = { version = "1", features = ["derive"] } + +[target.'cfg(target_os = "android")'.dependencies] +sha2 = { version = "0.10", features = ["force-soft"] } [target.'cfg(target_arch = "wasm32")'.dependencies] +getrandom = { version = "0.3", features = ["wasm_js"] } sqlite-wasm-rs = { version = "0.5", features = ["sqlite3mc"] } [build-dependencies] diff --git a/walletkit-db/README.md b/walletkit-db/README.md new file mode 100644 index 000000000..f10149347 --- /dev/null +++ b/walletkit-db/README.md @@ -0,0 +1,137 @@ +# walletkit-db + +Encrypted on-device storage primitives for WalletKit. SQLCipher (`sqlite3mc`) wrapper, vault opener, content-addressed blobs, sealed key envelope, cross-process lock. Plain Rust, no `uniffi`. + +Consumed by `walletkit-core::storage` (credential vault) and by sibling SDKs in the WalletKit workspace that need an encrypted on-device store. + +## Concepts + +Five physical pieces. Knowing what each one is and isn't makes everything else straightforward. + +- **Vault** — the encrypted SQLite file on disk (e.g. `account.vault.sqlite`). Opened by `Vault::open`; accessed via `Vault::connection() -> &Connection`. SQLite's WAL-mode file locks serialize cross-process writers; walletkit-db doesn't layer another lock on top. +- **Envelope** — a small CBOR file (e.g. `account_keys.bin`) holding the sealed 32-byte `K_intermediate`. The seal is done by the host's hardware keystore. Managed by `init_or_open_envelope_key` + `KeyEnvelope`. +- **Lock** — a separate empty file used as a cross-process mutex via `flock` / `LockFileEx`. Acquired internally by `init_or_open_envelope_key` (envelope-init bootstrap race) and by consumers around operations that mix SQL with filesystem state (e.g. plaintext export/import). +- **`blob_objects` table** — one shared table inside the vault for content-addressed bytes, keyed by SHA-256. Consumer-specific tables reference rows here by `content_id`. Managed by `blobs::*`. +- **`Keystore` + `AtomicBlobStore`** — two traits the host implements. `Keystore` seals/unseals bytes under `K_device`; `AtomicBlobStore` reads/writes the envelope file. walletkit-db never touches the OS keystore or the filesystem directly. + +## Architecture + +```mermaid +flowchart TB + subgraph Host["Host platform (Kotlin / Swift)"] + KS["DeviceKeystore (uniffi)"] + BS["AtomicBlobStore (uniffi)"] + end + subgraph WKDB["walletkit-db (this crate)"] + OV["Vault::open / connection"] + Blobs["blobs::{ensure_schema, put, get, delete}"] + Env["init_or_open_envelope_key"] + Lock["Lock / LockGuard"] + Cipher["sqlite3mc"] + OV --> Cipher + Blobs --> Cipher + end + subgraph Consumer["Consumer (e.g. walletkit-core)"] + Wrapper["Domain wrapper
(e.g. CredentialVault)"] + Tables["domain tables
+ blob_objects (shared)"] + Wrapper --> Tables + end + KS -.bridged via newtype.-> Env + BS -.bridged via newtype.-> Env + WKDB --> Wrapper + style WKDB fill:#e8f4f8 +``` + +Dependency direction is one-way: walletkit-db doesn't know about its consumers, uniffi, or any specific schema. Each consumer brings its own filename, AD namespace, lock file, vault file, and SQL schema. + +## Key hierarchy + +- **`K_device`** — hardware keystore root key (iOS Secure Enclave / Android Keystore). Cannot be extracted; the chip seals and unseals bytes on the program's behalf. Provided by the host via the `Keystore` trait. +- **`K_intermediate`** — 32-byte random key per consumer-vault. Generated once via `getrandom`, sealed under `K_device`, persisted as a CBOR `KeyEnvelope`. Used as the SQLite page-encryption key by sqlite3mc. +- **AD** — non-secret label bound into the AEAD seal (e.g. `worldid:account-key-envelope`). Per-consumer so envelopes can't be swapped between vaults. + +## Startup + +**Cold start:** open `Lock` → `init_or_open_envelope_key` generates fresh `K_intermediate`, seals it via `Keystore`, writes the envelope via `AtomicBlobStore`. `Vault::open` opens the SQLite file via `sqlite3mc`, runs the consumer's schema callback, runs `PRAGMA integrity_check`. + +**Warm start:** same flow, but the envelope already exists. `init_or_open_envelope_key` reads and unseals it to recover the bit-for-bit original `K_intermediate`. Schema callback is idempotent (`CREATE TABLE IF NOT EXISTS`). + +**Device wipe / app uninstall:** `K_device` is destroyed. The envelope on disk becomes permanently unsealable. Recovery requires a separate backup path that re-wraps the data under a non-device-bound key. + +## Encryption + +After `PRAGMA key`, sqlite3mc takes over inside SQLite's pager. ChaCha20-Poly1305 AEAD per page; per-page subkeys derived via PBKDF2-SHA256 from `(K_intermediate, page_number)`. Tamper-detected via Poly1305 MAC. Wrong key → `SQLITE_NOTADB` on first page read. Bit-flip on disk → `SQLITE_CORRUPT`. WAL mode for concurrent readers. + +## Threat model + +| Tier | Status | What protects you | +|---|---|---| +| Disk copy / lost device / backup extraction | **Safe** | Vault + envelope are encrypted; attacker lacks `K_device`. | +| Code running inside the app session | **Exposed** | Attacker calls the legitimate keystore as the app and unseals envelopes. Defense lives at the keystore-entry access policy layer. | +| File corruption / envelope swap | **Safe** | Per-page MAC fails; AD binding fails AEAD auth on swapped envelopes. | +| Hardware keystore compromise | Out of scope | — | + +**Defense-in-depth lever:** host policy on the keystore entry (iOS `kSecAccessControlBiometryCurrentSet`, Android `setUserAuthenticationRequired(true)`). walletkit-db is neutral; the policy lives in the Kotlin/Swift code that creates `K_device`. + +## Per-consumer isolation + +If multiple consumers share the device (today the credential vault; later an OrbKit PCP store, etc.), the host has to give each one its own secrets and its own files: + +1. A separate hardware keystore entry (Secure Enclave key / Android Keystore alias). +2. A separate AD label passed to `init_or_open_envelope_key`. +3. A separate envelope filename, vault file, and lock file. + +walletkit-db cryptographically binds operations to AD: an envelope sealed under one AD won't open under another. Everything else is host wiring. Sharing a keystore entry across consumers breaks the isolation. + +## Usage + +A consumer wires up storage in four steps: + +```rust +use walletkit_db::{blobs, init_or_open_envelope_key, Lock, Vault}; + +// 1. Cross-process lock. One file per consumer. +let lock = Lock::open(&paths.lock_path())?; + +// 2. Unseal or generate the consumer's intermediate key. +// Filename + AD are per-consumer so different vaults never share keys. +let k_intermediate = init_or_open_envelope_key( + &my_keystore_adapter, + &my_blob_store_adapter, + &lock, + "my_consumer_keys.bin", + b"my-consumer:key-envelope", + now, +)?; + +// 3. Open the encrypted SQLite database with the consumer's own schema. +let vault = Vault::open(&paths.db_path(), &k_intermediate, |conn| { + blobs::ensure_schema(conn)?; + my_schema::ensure_schema(conn) +})?; + +// 4. Store / read / delete. +let conn = vault.connection(); +let cid = blobs::put(conn, MY_KIND_TAG, &payload_bytes, now)?; +let bytes = blobs::get(conn, &cid)?.expect("present"); +blobs::delete(conn, &cid)?; +``` + +The consumer brings a `Keystore` impl, an `AtomicBlobStore` impl, a `kind: u8` tag space, and its own SQL schema. The crate handles cipher setup, schema dispatch, integrity check, content hashing (`SHA-256("worldid:blob" || [kind] || plaintext)`), CBOR envelope persistence, and the lock. + +## Public surface + +- `Vault::open(path, key, ensure_schema) -> StoreResult`, `Vault::connection(&self) -> &Connection`. +- `blobs::{ensure_schema, put, get, delete, compute_content_id}` plus `pub type ContentId = [u8; 32]`. +- `init_or_open_envelope_key(...) -> StoreResult>`. +- `Lock` / `LockGuard` — native `flock` / `LockFileEx`, no-op on WASM. +- `Keystore` / `AtomicBlobStore` traits — plain Rust. +- `Connection`, `Transaction`, `Statement`, `Row`, `StepResult`, `Value`, `cipher::*`, `DbError`, `DbResult`, `StoreError`, `StoreResult`. + +## On-disk format + +Schemas, CBOR envelope layout, content_id derivation, and the `account_keys.bin` / `worldid:account-key-envelope` filename + AD tags are byte-stable. Existing user databases keep working without migration. Frozen-byte tests live next to the code they cover (`blobs.rs`, `envelope.rs`). + +## Platforms + +Native (macOS, Linux, Windows): static `sqlite3mc` from the build script. `wasm32-unknown-unknown`: `sqlite-wasm-rs` with the `sqlite3mc` feature; `Lock` collapses to a no-op. diff --git a/walletkit-db/src/blobs.rs b/walletkit-db/src/blobs.rs new file mode 100644 index 000000000..48525a40b --- /dev/null +++ b/walletkit-db/src/blobs.rs @@ -0,0 +1,163 @@ +//! Content-addressed blob storage shared across consumer vaults. +//! +//! Blobs are stored in a single table (`blob_objects`) keyed by the SHA-256 +//! of `b"worldid:blob" || [kind] || plaintext`. Each consumer passes its own +//! one-byte `kind` tag so credential payloads, PCP packages, etc. share the +//! table without colliding by content. +//! +//! ### On-disk schema (must remain byte-stable) +//! +//! ```sql +//! CREATE TABLE IF NOT EXISTS blob_objects ( +//! content_id BLOB NOT NULL, +//! blob_kind INTEGER NOT NULL, +//! created_at INTEGER NOT NULL, +//! bytes BLOB NOT NULL, +//! PRIMARY KEY (content_id) +//! ); +//! ``` + +use sha2::{Digest, Sha256}; + +use crate::error::{StoreError, StoreResult}; +use crate::params; +use crate::sqlite::{Connection, DbResult, Error as DbError}; + +const CONTENT_ID_PREFIX: &[u8] = b"worldid:blob"; + +/// 32-byte content identifier for a stored blob. +pub type ContentId = [u8; 32]; + +/// Computes the content id for a blob. +/// +/// Layout: `SHA-256(b"worldid:blob" || [kind] || plaintext)`. The output is +/// byte-stable; changes to this function break every existing user database. +#[must_use] +pub fn compute_content_id(kind: u8, plaintext: &[u8]) -> ContentId { + let mut hasher = Sha256::new(); + hasher.update(CONTENT_ID_PREFIX); + hasher.update([kind]); + hasher.update(plaintext); + let digest = hasher.finalize(); + let mut out = [0u8; 32]; + out.copy_from_slice(&digest); + out +} + +/// Creates the `blob_objects` table if it does not exist. +/// +/// Idempotent. The exact DDL is part of the on-disk format contract; +/// callers must not alter the schema. +/// +/// # Errors +/// +/// Returns a database error if the `CREATE TABLE` statement fails. +pub fn ensure_schema(conn: &Connection) -> DbResult<()> { + conn.execute_batch( + "CREATE TABLE IF NOT EXISTS blob_objects ( + content_id BLOB NOT NULL, + blob_kind INTEGER NOT NULL, + created_at INTEGER NOT NULL, + bytes BLOB NOT NULL, + PRIMARY KEY (content_id) + );", + ) +} + +/// Inserts a blob with `INSERT OR IGNORE` semantics. +/// +/// Returns the content id (deterministic from `kind` + `bytes`); if a row +/// with that id already exists the call is a no-op and the existing row is +/// left in place. +/// +/// # Errors +/// +/// Returns a [`StoreError`] if `now` overflows `i64` or the insert fails. +pub fn put( + conn: &Connection, + kind: u8, + bytes: &[u8], + now: u64, +) -> StoreResult { + let now_i64 = i64::try_from(now).map_err(|_| { + StoreError::Db(DbError::new(-1, format!("now out of range for i64: {now}"))) + })?; + let cid = compute_content_id(kind, bytes); + conn.execute( + "INSERT OR IGNORE INTO blob_objects (content_id, blob_kind, created_at, bytes) + VALUES (?1, ?2, ?3, ?4)", + params![cid.as_ref(), i64::from(kind), now_i64, bytes], + )?; + Ok(cid) +} + +/// Fetches blob bytes by content id, if present. +/// +/// Accepts any byte slice so callers can pass `&ContentId`, a slice read +/// out of another table column, or a `Vec` without copying. The slice +/// must be exactly 32 bytes — non-32-byte input would silently match no row +/// and is rejected up front. +/// +/// # Errors +/// +/// Returns a [`StoreError`] if `cid` is not 32 bytes or the query fails. +pub fn get(conn: &Connection, cid: &[u8]) -> StoreResult>> { + check_cid_len(cid)?; + let bytes = conn.query_row_optional( + "SELECT bytes FROM blob_objects WHERE content_id = ?1", + params![cid], + |row| Ok(row.column_blob(0)), + )?; + Ok(bytes) +} + +/// Deletes the blob row with the given content id, if it exists. +/// +/// Consumers handling status transitions that orphan bytes (e.g. a credential +/// or PCP becoming unreferenced) call this to GC the row. Same 32-byte +/// requirement as [`get`]. +/// +/// # Errors +/// +/// Returns a [`StoreError`] if `cid` is not 32 bytes or the delete fails. +pub fn delete(conn: &Connection, cid: &[u8]) -> StoreResult<()> { + check_cid_len(cid)?; + conn.execute( + "DELETE FROM blob_objects WHERE content_id = ?1", + params![cid], + )?; + Ok(()) +} + +fn check_cid_len(cid: &[u8]) -> StoreResult<()> { + if cid.len() == 32 { + Ok(()) + } else { + Err(StoreError::Db(DbError::new( + -1, + format!("content_id must be 32 bytes, got {}", cid.len()), + ))) + } +} + +#[cfg(test)] +mod tests { + use super::compute_content_id; + + #[test] + fn test_compute_content_id_byte_stable() { + // SHA-256(b"worldid:blob" || [0x01] || b"hello"). Frozen value; + // changing this hash means breaking every existing user database. + let cid = compute_content_id(1, b"hello"); + let expected: [u8; 32] = hex::decode( + "ed4eba40f11beec64d0607586f09b7529418ef31bf2c46cf9b8b905615f2e7ca", + ) + .expect("decode hex") + .try_into() + .expect("32 bytes"); + assert_eq!(cid, expected); + + let cid2 = compute_content_id(2, b"hello"); + assert_ne!(cid, cid2, "kind tag must affect content id"); + } +} diff --git a/walletkit-db/src/envelope.rs b/walletkit-db/src/envelope.rs new file mode 100644 index 000000000..4cbd50318 --- /dev/null +++ b/walletkit-db/src/envelope.rs @@ -0,0 +1,270 @@ +//! Sealed key envelope persisted via [`AtomicBlobStore`]. +//! +//! A 32-byte intermediate key is sealed under a device-bound [`Keystore`] and +//! persisted as a CBOR-serialized [`KeyEnvelope`]. On subsequent runs the +//! envelope is read, opened, and the unsealed key returned in a [`SecretBox`]. +//! +//! Each consumer chooses its own filename and associated-data namespace so +//! independent vaults (e.g. credential vault and `OrbPcpStore`) cannot share +//! intermediate keys. + +use secrecy::SecretBox; +use serde::{Deserialize, Serialize}; +use zeroize::{Zeroize, ZeroizeOnDrop, Zeroizing}; + +use crate::error::{StoreError, StoreResult}; +use crate::lock::Lock; +use crate::traits::{AtomicBlobStore, Keystore}; + +const ENVELOPE_VERSION: u32 = 1; + +/// CBOR-serialized envelope holding a sealed 32-byte intermediate key. +/// +/// On-disk layout is byte-stable: changing field order, names, or types +/// breaks existing user databases. +#[derive(Clone, Serialize, Deserialize, Zeroize, ZeroizeOnDrop)] +pub struct KeyEnvelope { + pub(crate) version: u32, + pub(crate) wrapped_k_intermediate: Vec, + pub(crate) created_at: u64, + pub(crate) updated_at: u64, +} + +impl KeyEnvelope { + /// Constructs a fresh envelope for `wrapped_k_intermediate` at `now`. + #[must_use] + pub const fn new(wrapped_k_intermediate: Vec, now: u64) -> Self { + Self { + version: ENVELOPE_VERSION, + wrapped_k_intermediate, + created_at: now, + updated_at: now, + } + } + + /// CBOR-serializes the envelope. + /// + /// # Errors + /// + /// Returns [`StoreError::Serialization`] if encoding fails. + pub fn serialize(&self) -> StoreResult> { + let mut bytes = Vec::new(); + ciborium::ser::into_writer(self, &mut bytes) + .map_err(|err| StoreError::Serialization(err.to_string()))?; + Ok(bytes) + } + + /// CBOR-deserializes an envelope and verifies its version. + /// + /// # Errors + /// + /// Returns [`StoreError::Serialization`] if decoding fails or + /// [`StoreError::UnsupportedEnvelopeVersion`] if the version mismatches. + pub fn deserialize(bytes: &[u8]) -> StoreResult { + let envelope: Self = ciborium::de::from_reader(bytes) + .map_err(|err| StoreError::Serialization(err.to_string()))?; + if envelope.version != ENVELOPE_VERSION { + return Err(StoreError::UnsupportedEnvelopeVersion(envelope.version)); + } + Ok(envelope) + } +} + +/// Initialize or open the envelope-sealed intermediate key. +/// +/// On first run, generates a fresh 32-byte key, seals it under `keystore` +/// authenticated by `ad`, persists the envelope at `filename` via +/// `blob_store`, and returns the unsealed key. +/// +/// On subsequent runs, reads the envelope at `filename`, opens it under +/// `keystore` authenticated by `ad`, and returns the unsealed key. +/// +/// `lock` is acquired internally to serialize the read-open / generate-write +/// sequence across processes, and released before this returns. +/// +/// # Errors +/// +/// Propagates errors from the lock, keystore, blob store, CBOR codec, or +/// RNG. +pub fn init_or_open_envelope_key( + keystore: &dyn Keystore, + blob_store: &dyn AtomicBlobStore, + lock: &Lock, + filename: &str, + ad: &[u8], + now: u64, +) -> StoreResult> { + let _guard = lock.lock()?; + if let Some(bytes) = blob_store.read(filename.to_string())? { + let envelope = KeyEnvelope::deserialize(&bytes)?; + let k_intermediate_bytes = Zeroizing::new( + keystore + .open_sealed(ad.to_vec(), envelope.wrapped_k_intermediate.clone())?, + ); + let k_intermediate = parse_key_32(&k_intermediate_bytes, "intermediate key")?; + Ok(SecretBox::init_with(|| k_intermediate)) + } else { + let mut k_intermediate = Zeroizing::new([0u8; 32]); + getrandom::fill(k_intermediate.as_mut()) + .map_err(|err| StoreError::Crypto(format!("rng failure: {err}")))?; + // TODO: `keystore.seal(_, Vec)` requires the plaintext as an + // owned heap allocation because the trait shape matches + // walletkit-core's uniffi `DeviceKeystore` so the adapter stays + // zero-copy. That `Vec` is NOT zeroized on drop — key bytes + // can linger in the allocator's freelist. Improve by either + // (a) changing the trait to take a stack reference and updating + // the host bridges, or (b) wrapping the `to_vec()` result in + // `Zeroizing` and ensuring `Keystore` impls don't clone it. + let wrapped = keystore.seal(ad.to_vec(), k_intermediate.to_vec())?; + let envelope = KeyEnvelope::new(wrapped, now); + let bytes = envelope.serialize()?; + blob_store.write_atomic(filename.to_string(), bytes)?; + let key_copy = *k_intermediate; + Ok(SecretBox::init_with(move || key_copy)) + } +} + +fn parse_key_32(bytes: &[u8], label: &str) -> StoreResult<[u8; 32]> { + if bytes.len() != 32 { + return Err(StoreError::InvalidEnvelope(format!( + "{label} length mismatch: expected 32, got {}", + bytes.len() + ))); + } + let mut out = [0u8; 32]; + out.copy_from_slice(bytes); + Ok(out) +} + +#[cfg(test)] +mod tests { + use super::{init_or_open_envelope_key, KeyEnvelope}; + use crate::{AtomicBlobStore, Keystore, Lock, StoreError, StoreResult}; + use secrecy::ExposeSecret; + use std::sync::Mutex; + + #[test] + fn test_key_envelope_round_trip() { + let envelope = KeyEnvelope::new(vec![1, 2, 3], 123); + let bytes = envelope.serialize().expect("serialize"); + let decoded = KeyEnvelope::deserialize(&bytes).expect("deserialize"); + assert_eq!(decoded.version, 1); + assert_eq!(decoded.wrapped_k_intermediate, vec![1, 2, 3]); + assert_eq!(decoded.created_at, 123); + assert_eq!(decoded.updated_at, 123); + } + + #[test] + fn test_key_envelope_cbor_bytes_frozen() { + // Frozen CBOR encoding for the canonical envelope. Round-trip alone + // doesn't catch field-order or type drift; this byte-level check + // does. Updating this hex without an on-disk format review breaks + // every existing user database. + let envelope = KeyEnvelope::new(vec![1, 2, 3], 123); + let bytes = envelope.serialize().expect("serialize"); + // CBOR map of 4 entries: version=1, wrapped_k_intermediate=[1,2,3], + // created_at=123, updated_at=123. Reproducible from the struct; + // hex captured by serializing the canonical envelope above. + let expected = hex::decode( + "a46776657273696f6e0176777261707065645f6b5f696e7465726d656469617465830102036a637265617465645f6174187b6a757064617465645f6174187b", + ).expect("decode hex"); + assert_eq!( + bytes, expected, + "KeyEnvelope CBOR layout changed; on-disk envelope format would drift" + ); + } + + #[test] + fn test_key_envelope_unsupported_version() { + let mut envelope = KeyEnvelope::new(vec![1, 2, 3], 123); + envelope.version = 99; + let bytes = envelope.serialize().expect("serialize"); + match KeyEnvelope::deserialize(&bytes) { + Err(StoreError::UnsupportedEnvelopeVersion(v)) => assert_eq!(v, 99), + Err(err) => panic!("expected UnsupportedEnvelopeVersion, got: {err}"), + Ok(_) => panic!("expected UnsupportedEnvelopeVersion, got Ok"), + } + } + + /// Stub `Keystore` that XORs with a fixed pad. Good enough to verify + /// the seal → persist → open round-trip on the envelope wiring. + struct XorKeystore { + pad: [u8; 32], + } + + impl Keystore for XorKeystore { + fn seal(&self, _ad: Vec, plaintext: Vec) -> StoreResult> { + Ok(plaintext + .iter() + .enumerate() + .map(|(i, b)| b ^ self.pad[i % 32]) + .collect()) + } + fn open_sealed( + &self, + _ad: Vec, + ciphertext: Vec, + ) -> StoreResult> { + Ok(ciphertext + .iter() + .enumerate() + .map(|(i, b)| b ^ self.pad[i % 32]) + .collect()) + } + } + + struct InMemoryBlobs { + inner: Mutex>>, + } + impl InMemoryBlobs { + fn new() -> Self { + Self { + inner: Mutex::new(std::collections::HashMap::new()), + } + } + } + impl AtomicBlobStore for InMemoryBlobs { + fn read(&self, path: String) -> StoreResult>> { + Ok(self.inner.lock().unwrap().get(&path).cloned()) + } + fn write_atomic(&self, path: String, bytes: Vec) -> StoreResult<()> { + self.inner.lock().unwrap().insert(path, bytes); + Ok(()) + } + fn delete(&self, path: String) -> StoreResult<()> { + self.inner.lock().unwrap().remove(&path); + Ok(()) + } + } + + #[test] + #[cfg(not(target_arch = "wasm32"))] + fn test_init_or_open_envelope_key_round_trip() { + let dir = tempfile::tempdir().expect("create temp dir"); + let lock_path = dir.path().join("envelope.lock"); + let lock = Lock::open(&lock_path).expect("open lock"); + + let keystore = XorKeystore { pad: [0xAA; 32] }; + let blob_store = InMemoryBlobs::new(); + let key_a = init_or_open_envelope_key( + &keystore, + &blob_store, + &lock, + "k.bin", + b"test-ad", + 100, + ) + .expect("init"); + let key_b = init_or_open_envelope_key( + &keystore, + &blob_store, + &lock, + "k.bin", + b"test-ad", + 200, + ) + .expect("re-open"); + + assert_eq!(key_a.expose_secret(), key_b.expose_secret()); + } +} diff --git a/walletkit-db/src/error.rs b/walletkit-db/src/error.rs index 2fb42fbba..f65925285 100644 --- a/walletkit-db/src/error.rs +++ b/walletkit-db/src/error.rs @@ -1,43 +1,42 @@ -//! Database error types for the safe `SQLite` wrapper. - -use std::fmt; - -/// Error code returned by `SQLite` operations. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub struct DbErrorCode(pub i32); - -impl fmt::Display for DbErrorCode { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - write!(f, "{}", self.0) - } +//! Error types for the storage primitives layer. + +use crate::sqlite::Error as DbError; + +/// Result alias for [`StoreError`]. +pub type StoreResult = Result; + +/// Errors raised by the storage primitives (vault, blobs, envelope, lock). +/// +/// Variants carry a stringified detail rather than a concrete cause to keep +/// the error type cheap to clone and FFI-friendly when consumers wrap it in +/// their own typed error. +#[derive(Debug, thiserror::Error)] +pub enum StoreError { + /// Errors coming from the device keystore. + #[error("keystore error: {0}")] + Keystore(String), + /// Errors coming from the blob store. + #[error("blob store error: {0}")] + BlobStore(String), + /// Errors coming from the storage lock. + #[error("storage lock error: {0}")] + Lock(String), + /// Serialization / deserialization failures (envelope CBOR, etc.). + #[error("serialization error: {0}")] + Serialization(String), + /// Cryptographic failures (AEAD seal/open, RNG, etc.). + #[error("crypto error: {0}")] + Crypto(String), + /// Invalid or malformed key envelope (e.g. wrong length, bad format). + #[error("invalid envelope: {0}")] + InvalidEnvelope(String), + /// Envelope written by an unsupported version. + #[error("unsupported envelope version: {0}")] + UnsupportedEnvelopeVersion(u32), + /// Underlying database error from the encrypted-`SQLite` wrapper. + #[error("database error: {0}")] + Db(#[from] DbError), + /// `PRAGMA integrity_check` reported corruption. + #[error("integrity check failed: {0}")] + IntegrityCheckFailed(String), } - -/// Error returned by database operations. -#[derive(Debug, PartialEq, Eq)] -pub struct DbError { - /// `SQLite` result code. - pub code: DbErrorCode, - /// Human-readable error message (from `sqlite3_errmsg` when available). - pub message: String, -} - -impl DbError { - /// Creates a new database error. - pub(crate) fn new(code: i32, message: impl Into) -> Self { - Self { - code: DbErrorCode(code), - message: message.into(), - } - } -} - -impl fmt::Display for DbError { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - write!(f, "sqlite error {}: {}", self.code, self.message) - } -} - -impl std::error::Error for DbError {} - -/// Result type for database operations. -pub type DbResult = Result; diff --git a/walletkit-db/src/lib.rs b/walletkit-db/src/lib.rs index b34162730..314e9a93d 100644 --- a/walletkit-db/src/lib.rs +++ b/walletkit-db/src/lib.rs @@ -1,32 +1,44 @@ -//! Minimal safe `SQLite` wrapper backed by `sqlite3mc`. +//! Encrypted on-device storage primitives for `WalletKit`. //! -//! This crate provides a small, safe Rust API over the `SQLite` C FFI. -//! The raw symbols are resolved at compile time: +//! The crate provides building blocks shared by `walletkit-core::storage` and +//! sibling SDKs (e.g. `OrbKit`'s `OrbPcpStore`): //! -//! * **Native** (`not(wasm32)`): linked against the `sqlite3mc` static library -//! compiled from the downloaded amalgamation by `build.rs`. -//! * **WASM** (`wasm32`): delegated to `sqlite-wasm-rs` (with the `sqlite3mc` -//! feature) which ships its own WASM-compiled `sqlite3mc`. +//! - [`Connection`], [`Transaction`], [`Statement`], [`cipher`] — encrypted +//! `SQLite` (`sqlite3mc`) wrapper with safe Rust types. +//! - [`Vault`] — encrypted-database wrapper around a caller-supplied schema, +//! exposing the underlying [`Connection`]. +//! - [`blobs`] — content-addressed blob storage (`ensure_schema`, `put`, +//! `get`), [`ContentId`], and [`compute_content_id`]. +//! - [`init_or_open_envelope_key`] — sealed intermediate key persisted via +//! [`AtomicBlobStore`]. +//! - [`Lock`] / [`LockGuard`] — cross-process exclusive lock (`flock` / +//! `LockFileEx` native, no-op on WASM). +//! - [`Keystore`] / [`AtomicBlobStore`] — plain-Rust trait surface for +//! consumer-supplied platform integrations. Consumers that need FFI define +//! their own annotated traits and adapt to these. //! -//! Consumer code (vault, cache, cipher config) uses only the safe types -//! defined here and never touches raw FFI directly. The `ffi` module is the -//! **only** file that contains `unsafe` code or C types. +//! Consumers own their schemas, FFI surfaces, and storage policy on top of +//! these primitives. -mod ffi; +pub mod blobs; -mod connection; -pub mod error; -mod statement; -mod transaction; -pub mod value; +mod envelope; +mod error; +mod lock; +mod sqlite; +mod traits; +mod vault; -pub mod cipher; - -pub use connection::Connection; -pub use error::DbError; -pub use statement::{Row, Statement, StepResult}; -pub use transaction::Transaction; -pub use value::Value; +pub use blobs::{compute_content_id, ContentId}; +pub use envelope::init_or_open_envelope_key; +pub use error::{StoreError, StoreResult}; +pub use lock::{Lock, LockGuard}; +pub use sqlite::{ + cipher, Connection, DbResult, Error as DbError, Row, Statement, StepResult, + Transaction, Value, +}; +pub use traits::{AtomicBlobStore, Keystore}; +pub use vault::Vault; #[cfg(test)] mod tests; diff --git a/walletkit-core/src/storage/lock.rs b/walletkit-db/src/lock.rs similarity index 64% rename from walletkit-core/src/storage/lock.rs rename to walletkit-db/src/lock.rs index b69f25761..15807daa5 100644 --- a/walletkit-core/src/storage/lock.rs +++ b/walletkit-db/src/lock.rs @@ -1,15 +1,19 @@ -//! Storage lock for serializing writes. +//! Cross-process exclusive lock. //! -//! On native platforms (Unix, Windows) a file-based `flock`/`LockFileEx` lock -//! is used to serialize writes across processes. +//! Used by [`crate::init_or_open_envelope_key`] to serialize the first-install +//! envelope bootstrap, and acquired by consumers for operations that mix +//! `SQLite` with filesystem state (e.g. plaintext export / import). `SQLite` +//! handles cross-process writer serialization for ordinary mutations itself +//! via WAL-mode file locks; this lock is not required for those. //! -//! On WASM targets the lock is a no-op because the runtime is single-threaded -//! (sqlite-wasm-rs is compiled with `SQLITE_THREADSAFE=0`) and runs in a -//! dedicated Web Worker. +//! On native platforms (Unix, Windows) the lock is backed by a file via +//! `flock` / `LockFileEx`. On WASM it is a no-op because the runtime is +//! single-threaded (`sqlite-wasm-rs` is compiled with `SQLITE_THREADSAFE=0`) +//! and runs in a dedicated Web Worker. use std::path::Path; -use super::error::StorageResult; +use crate::error::StoreResult; // WASM: no-op lock (single-threaded worker, SQLITE_THREADSAFE=0) @@ -19,26 +23,26 @@ mod imp { /// No-op storage lock for WASM. #[derive(Debug, Clone)] - pub struct StorageLock; + pub struct Lock; /// No-op lock guard. #[derive(Debug)] - pub struct StorageLockGuard; + pub struct LockGuard; - impl StorageLock { + impl Lock { /// Opens a no-op lock (WASM is single-threaded). - pub fn open(_path: &Path) -> StorageResult { + pub fn open(_path: &Path) -> StoreResult { Ok(Self) } /// Acquires a no-op lock (always succeeds). - pub fn lock(&self) -> StorageResult { - Ok(StorageLockGuard) + pub fn lock(&self) -> StoreResult { + Ok(LockGuard) } /// Attempts to acquire a no-op lock (always succeeds). - pub fn try_lock(&self) -> StorageResult> { - Ok(Some(StorageLockGuard)) + pub fn try_lock(&self) -> StoreResult> { + Ok(Some(LockGuard)) } } } @@ -47,30 +51,31 @@ mod imp { #[cfg(not(target_arch = "wasm32"))] mod imp { - use super::{Path, StorageResult}; - use crate::storage::error::StorageError; + use super::{Path, StoreResult}; + use crate::error::StoreError; use std::fs::{self, File, OpenOptions}; use std::sync::Arc; - /// A file-backed lock that serializes storage mutations across processes. + /// File-backed cross-process exclusive lock. See the module docs for + /// what it's for (and what it isn't). #[derive(Debug, Clone)] - pub struct StorageLock { + pub struct Lock { file: Arc, } /// Guard that holds an exclusive lock for its lifetime. #[derive(Debug)] - pub struct StorageLockGuard { + pub struct LockGuard { file: Arc, } - impl StorageLock { + impl Lock { /// Opens or creates the lock file at `path`. /// /// # Errors /// /// Returns an error if the file cannot be opened or created. - pub fn open(path: &Path) -> StorageResult { + pub fn open(path: &Path) -> StoreResult { if let Some(parent) = path.parent() { fs::create_dir_all(parent).map_err(|err| map_io_err(&err))?; } @@ -91,9 +96,9 @@ mod imp { /// # Errors /// /// Returns an error if the lock cannot be acquired. - pub fn lock(&self) -> StorageResult { + pub fn lock(&self) -> StoreResult { lock_exclusive(&self.file).map_err(|err| map_io_err(&err))?; - Ok(StorageLockGuard { + Ok(LockGuard { file: Arc::clone(&self.file), }) } @@ -104,9 +109,9 @@ mod imp { /// /// Returns an error if the lock attempt fails for reasons other than /// the lock being held by another process. - pub fn try_lock(&self) -> StorageResult> { + pub fn try_lock(&self) -> StoreResult> { if try_lock_exclusive(&self.file).map_err(|err| map_io_err(&err))? { - Ok(Some(StorageLockGuard { + Ok(Some(LockGuard { file: Arc::clone(&self.file), })) } else { @@ -115,14 +120,14 @@ mod imp { } } - impl Drop for StorageLockGuard { + impl Drop for LockGuard { fn drop(&mut self) { let _ = unlock(&self.file); } } - fn map_io_err(err: &std::io::Error) -> StorageError { - StorageError::Lock(err.to_string()) + fn map_io_err(err: &std::io::Error) -> StoreError { + StoreError::Lock(err.to_string()) } // ── Unix flock ────────────────────────────────────────────────────── @@ -272,68 +277,62 @@ mod imp { overlapped: *mut OVERLAPPED, ) -> i32; } -} - -pub use imp::{StorageLock, StorageLockGuard}; - -#[cfg(test)] -mod tests { - use super::*; - use uuid::Uuid; - fn temp_lock_path() -> std::path::PathBuf { - let mut path = std::env::temp_dir(); - path.push(format!("walletkit-lock-{}.lock", Uuid::new_v4())); - path - } + #[cfg(test)] + mod tests { + use super::Lock; - #[test] - fn test_lock_is_exclusive() { - let path = temp_lock_path(); - let lock_a = StorageLock::open(&path).expect("open lock"); - let guard = lock_a.lock().expect("acquire lock"); + #[test] + fn test_lock_is_exclusive() { + let dir = tempfile::tempdir().expect("create temp dir"); + let path = dir.path().join("lock.lock"); + let lock_a = Lock::open(&path).expect("open lock"); + let guard = lock_a.lock().expect("acquire lock"); - let lock_b = StorageLock::open(&path).expect("open lock"); - let blocked = lock_b.try_lock().expect("try lock"); - assert!(blocked.is_none()); + let lock_b = Lock::open(&path).expect("open lock"); + let blocked = lock_b.try_lock().expect("try lock"); + assert!(blocked.is_none()); - drop(guard); - let guard = lock_b.try_lock().expect("try lock"); - assert!(guard.is_some()); + drop(guard); + let guard = lock_b.try_lock().expect("try lock"); + assert!(guard.is_some()); + } - let _ = std::fs::remove_file(path); - } + #[test] + fn test_lock_serializes_across_threads() { + use std::sync::mpsc; + use std::thread; - #[test] - fn test_lock_serializes_across_threads() { - let path = temp_lock_path(); - let lock = StorageLock::open(&path).expect("open lock"); + let dir = tempfile::tempdir().expect("create temp dir"); + let path = dir.path().join("lock.lock"); + let lock = Lock::open(&path).expect("open lock"); - let (locked_tx, locked_rx) = std::sync::mpsc::channel(); - let (release_tx, release_rx) = std::sync::mpsc::channel(); - let (released_tx, released_rx) = std::sync::mpsc::channel(); + let (locked_tx, locked_rx) = mpsc::channel(); + let (release_tx, release_rx) = mpsc::channel(); + let (released_tx, released_rx) = mpsc::channel(); - let path_clone = path.clone(); - let thread_a = std::thread::spawn(move || { - let guard = lock.lock().expect("lock in thread"); - locked_tx.send(()).expect("signal locked"); - release_rx.recv().expect("wait release"); - drop(guard); - released_tx.send(()).expect("signal released"); - let _ = std::fs::remove_file(path_clone); - }); + let thread_a = thread::spawn(move || { + let guard = lock.lock().expect("lock in thread"); + locked_tx.send(()).expect("signal locked"); + release_rx.recv().expect("wait release"); + drop(guard); + released_tx.send(()).expect("signal released"); + }); - locked_rx.recv().expect("wait locked"); - let lock_b = StorageLock::open(&path).expect("open lock"); - let blocked = lock_b.try_lock().expect("try lock"); - assert!(blocked.is_none()); + locked_rx.recv().expect("wait locked"); + let lock_b = Lock::open(&path).expect("open lock"); + let blocked = lock_b.try_lock().expect("try lock"); + assert!(blocked.is_none()); - release_tx.send(()).expect("release"); - released_rx.recv().expect("wait released"); + release_tx.send(()).expect("release"); + released_rx.recv().expect("wait released"); - let guard = lock_b.try_lock().expect("try lock"); - assert!(guard.is_some()); + let guard = lock_b.try_lock().expect("try lock"); + assert!(guard.is_some()); - thread_a.join().expect("thread join"); + thread_a.join().expect("thread join"); + } } } + +pub use imp::{Lock, LockGuard}; diff --git a/walletkit-db/src/cipher.rs b/walletkit-db/src/sqlite/cipher.rs similarity index 77% rename from walletkit-db/src/cipher.rs rename to walletkit-db/src/sqlite/cipher.rs index 2051f04ba..b95f50258 100644 --- a/walletkit-db/src/cipher.rs +++ b/walletkit-db/src/sqlite/cipher.rs @@ -2,10 +2,10 @@ //! //! # Encryption flow //! -//! The credential storage uses `sqlite3mc` (`SQLite3` Multiple Ciphers) to -//! encrypt both the vault and cache databases at rest. The encryption is -//! transparent to SQL -- once a database is opened and keyed, all reads and -//! writes are automatically encrypted/decrypted by the `SQLite` pager layer. +//! This crate uses `sqlite3mc` (`SQLite3` Multiple Ciphers) to encrypt +//! `SQLite` databases at rest. The encryption is transparent to SQL -- once a +//! database is opened and keyed, all reads and writes are automatically +//! encrypted/decrypted by the `SQLite` pager layer. //! //! The flow when opening a database is: //! @@ -37,18 +37,18 @@ use secrecy::{ExposeSecret, SecretBox}; use zeroize::Zeroizing; use super::connection::Connection; -use super::error::{DbError, DbResult}; +use super::error::{DbResult, Error}; /// Opens a database, applies the encryption key, and configures the connection. /// -/// This is the standard open sequence used by both vault and cache databases: -/// open -> key -> verify -> configure (WAL + foreign keys). +/// This is the standard open sequence for encrypted databases: open -> key -> +/// verify -> configure (WAL + foreign keys). /// /// See the [module-level documentation](self) for the full encryption flow. /// /// # Errors /// -/// Returns `DbError` if opening, keying, or configuring the connection fails. +/// Returns `Error` if opening, keying, or configuring the connection fails. pub fn open_encrypted( path: &Path, k_intermediate: &SecretBox<[u8; 32]>, @@ -83,7 +83,7 @@ fn apply_key(conn: &Connection, k_intermediate: &SecretBox<[u8; 32]>) -> DbResul // error rather than a confusing "not a database" later during schema setup. conn.execute_batch("SELECT count(*) FROM sqlite_master;") .map_err(|e| { - DbError::new( + Error::new( e.code.0, format!( "encryption key verification failed (is the key correct?): {}", @@ -114,21 +114,11 @@ fn configure_connection(conn: &Connection) -> DbResult<()> { ) } -/// Tables included in plaintext vault backups. -/// -/// `vault_meta` is intentionally excluded: on restore, the destination vault -/// already has its own `vault_meta` (created by `ensure_schema` + `init_leaf_index`) -/// with the authoritative `leaf_index` from the authenticator. -/// -/// **Note:** If new tables are added to the vault schema, this list must be -/// updated to include them. -pub const BACKUP_TABLES: &[&str] = &["credential_records", "blob_objects"]; - /// Creates a plaintext (unencrypted) copy of an already-open encrypted database. /// /// The copy is produced by `ATTACH`-ing a new unencrypted database and copying -/// all rows via `CREATE TABLE ... AS SELECT *`. The destination file must not -/// already exist. +/// the caller-specified tables via `CREATE TABLE ... AS SELECT *`. The +/// destination file must not already exist. /// /// We use `ATTACH` + SQL instead of the `sqlite3_backup` API because /// `sqlite3mc` requires both source and destination to share the same @@ -137,8 +127,12 @@ pub const BACKUP_TABLES: &[&str] = &["credential_records", "blob_objects"]; /// /// # Errors /// -/// Returns `DbError` if the `ATTACH`, copy, or `DETACH` fails. -pub fn export_plaintext_copy(conn: &Connection, dest_path: &Path) -> DbResult<()> { +/// Returns `Error` if the `ATTACH`, copy, or `DETACH` fails. +pub fn export_plaintext_copy( + conn: &Connection, + dest_path: &Path, + tables: &[&str], +) -> DbResult<()> { let dest_str = dest_path.to_string_lossy(); let attach_sql = format!( "ATTACH DATABASE '{}' AS backup KEY '';", @@ -148,7 +142,7 @@ pub fn export_plaintext_copy(conn: &Connection, dest_path: &Path) -> DbResult<() let result = (|| { let tx = conn.transaction()?; - for table in BACKUP_TABLES { + for table in tables { tx.execute_batch(&format!( "CREATE TABLE backup.{table} AS SELECT * FROM {table};" ))?; @@ -168,24 +162,27 @@ pub fn export_plaintext_copy(conn: &Connection, dest_path: &Path) -> DbResult<() /// encrypted database. /// /// The source database is `ATTACH`ed with an empty key and its contents are -/// copied into the main (empty) encrypted database. This is intended for -/// restore on a fresh install where the vault tables exist but contain no data. +/// copied into the main (empty) encrypted database. /// /// See [`export_plaintext_copy`] for why `ATTACH` + SQL is used instead of /// the `sqlite3_backup` API. /// /// **Schema migration:** The import uses `SELECT *`, so column changes are -/// handled automatically as long as both sides share the same schema. If the -/// vault schema evolves (e.g. new columns with `NOT NULL` constraints), +/// handled automatically as long as both sides share the same schema. If a +/// caller's schema evolves (e.g. new columns with `NOT NULL` constraints), /// restoring an older backup into a newer schema will fail. When that happens, -/// this function will need version-aware import logic. +/// the caller needs version-aware import logic. /// /// # Errors /// -/// Returns `DbError` if the `ATTACH`, copy, or `DETACH` fails. -pub fn import_plaintext_copy(conn: &Connection, source_path: &Path) -> DbResult<()> { +/// Returns `Error` if the `ATTACH`, copy, or `DETACH` fails. +pub fn import_plaintext_copy( + conn: &Connection, + source_path: &Path, + tables: &[&str], +) -> DbResult<()> { if !source_path.exists() { - return Err(DbError::new( + return Err(Error::new( -1, format!("backup file does not exist: {}", source_path.display()), )); @@ -199,25 +196,27 @@ pub fn import_plaintext_copy(conn: &Connection, source_path: &Path) -> DbResult< conn.execute_batch(&attach_sql)?; // Verify the destination tables are empty before importing. Importing into - // a non-empty vault could silently merge data if primary keys don't collide. + // a non-empty destination could silently merge data if primary keys don't + // collide. let result = (|| { - for table in BACKUP_TABLES { + for table in tables { let count: i64 = conn.query_row(&format!("SELECT COUNT(*) FROM {table}"), &[], |row| { Ok(row.column_i64(0)) })?; if count > 0 { - return Err(DbError::new( + return Err(Error::new( -1, format!("cannot import into non-empty table: {table}"), )); } } - // Wrap in a transaction so the restore is atomic — if any INSERT fails, - // everything is rolled back and the vault stays empty for a retry. + // Wrap in a transaction so the restore is atomic — if any INSERT + // fails, everything is rolled back and the destination stays empty for + // a retry. let tx = conn.transaction()?; - for table in BACKUP_TABLES { + for table in tables { tx.execute_batch(&format!( "INSERT INTO {table} SELECT * FROM backup.{table};" ))?; @@ -237,7 +236,7 @@ pub fn import_plaintext_copy(conn: &Connection, source_path: &Path) -> DbResult< /// /// # Errors /// -/// Returns `DbError` if the integrity check query fails. +/// Returns `Error` if the integrity check query fails. pub fn integrity_check(conn: &Connection) -> DbResult { let result = conn.query_row("PRAGMA integrity_check;", &[], |stmt| { Ok(stmt.column_text(0)) diff --git a/walletkit-db/src/connection.rs b/walletkit-db/src/sqlite/connection.rs similarity index 87% rename from walletkit-db/src/connection.rs rename to walletkit-db/src/sqlite/connection.rs index 0b87ba48a..970f15a63 100644 --- a/walletkit-db/src/connection.rs +++ b/walletkit-db/src/sqlite/connection.rs @@ -5,7 +5,7 @@ use std::path::Path; -use super::error::{DbError, DbResult}; +use super::error::{DbResult, Error}; use super::ffi::{self, RawDb}; use super::statement::{Row, Statement, StepResult}; use super::transaction::Transaction; @@ -25,7 +25,7 @@ impl Connection { /// /// # Errors /// - /// Returns `DbError` if `SQLite` cannot open the file. + /// Returns `Error` if `SQLite` cannot open the file. pub fn open(path: &Path, read_only: bool) -> DbResult { let path_str = path.to_string_lossy(); let flags = if read_only { @@ -46,7 +46,7 @@ impl Connection { /// /// # Errors /// - /// Returns `DbError` if any statement fails. + /// Returns `Error` if any statement fails. pub fn execute_batch(&self, sql: &str) -> DbResult<()> { self.db.exec(sql) } @@ -57,7 +57,7 @@ impl Connection { /// /// # Errors /// - /// Returns `DbError` if the statement fails. + /// Returns `Error` if the statement fails. pub fn execute_batch_zeroized(&self, sql: &str) -> DbResult<()> { self.db.exec_zeroized(sql) } @@ -66,7 +66,7 @@ impl Connection { /// /// # Errors /// - /// Returns `DbError` if the SQL is invalid. + /// Returns `Error` if the SQL is invalid. pub fn prepare(&self, sql: &str) -> DbResult> { let raw_stmt = self.db.prepare(sql)?; Ok(Statement::new(raw_stmt)) @@ -78,7 +78,7 @@ impl Connection { /// /// # Errors /// - /// Returns `DbError` if preparation or execution fails. + /// Returns `Error` if preparation or execution fails. pub fn execute(&self, sql: &str, params: &[Value]) -> DbResult { let mut stmt = self.prepare(sql)?; stmt.bind_values(params)?; @@ -92,7 +92,7 @@ impl Connection { /// /// # Errors /// - /// Returns `DbError` if preparation, execution, or the mapper fails, + /// Returns `Error` if preparation, execution, or the mapper fails, /// or if the query returns no rows. pub fn query_row( &self, @@ -105,7 +105,7 @@ impl Connection { match stmt.step()? { StepResult::Row(row) => mapper(&row), StepResult::Done => { - Err(DbError::new(ffi::SQLITE_DONE, "query returned no rows")) + Err(Error::new(ffi::SQLITE_DONE, "query returned no rows")) } } } @@ -115,7 +115,7 @@ impl Connection { /// /// # Errors /// - /// Returns `DbError` if preparation, execution, or the mapper fails. + /// Returns `Error` if preparation, execution, or the mapper fails. pub fn query_row_optional( &self, sql: &str, @@ -134,7 +134,7 @@ impl Connection { /// /// # Errors /// - /// Returns `DbError` if `BEGIN DEFERRED` fails. + /// Returns `Error` if `BEGIN DEFERRED` fails. pub fn transaction(&self) -> DbResult> { Transaction::begin(self, false) } @@ -143,7 +143,7 @@ impl Connection { /// /// # Errors /// - /// Returns `DbError` if `BEGIN IMMEDIATE` fails. + /// Returns `Error` if `BEGIN IMMEDIATE` fails. pub fn transaction_immediate(&self) -> DbResult> { Transaction::begin(self, true) } @@ -175,7 +175,7 @@ impl Connection { /// /// # Errors /// - /// Returns `DbError` if the in-memory database cannot be opened. + /// Returns `Error` if the in-memory database cannot be opened. pub fn open_in_memory() -> DbResult { Self::open(Path::new(":memory:"), false) } diff --git a/walletkit-db/src/sqlite/error.rs b/walletkit-db/src/sqlite/error.rs new file mode 100644 index 000000000..65ea4ef08 --- /dev/null +++ b/walletkit-db/src/sqlite/error.rs @@ -0,0 +1,43 @@ +//! Error types for the safe `SQLite` wrapper. + +use std::fmt; + +/// Error code returned by `SQLite` operations. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct ErrorCode(pub i32); + +impl fmt::Display for ErrorCode { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "{}", self.0) + } +} + +/// Error returned by database operations. +#[derive(Debug, PartialEq, Eq)] +pub struct Error { + /// `SQLite` result code. + pub code: ErrorCode, + /// Human-readable error message (from `sqlite3_errmsg` when available). + pub message: String, +} + +impl Error { + /// Creates a new database error. + pub(crate) fn new(code: i32, message: impl Into) -> Self { + Self { + code: ErrorCode(code), + message: message.into(), + } + } +} + +impl fmt::Display for Error { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "sqlite error {}: {}", self.code, self.message) + } +} + +impl std::error::Error for Error {} + +/// Result alias for [`Error`]. +pub type DbResult = std::result::Result; diff --git a/walletkit-db/src/ffi.rs b/walletkit-db/src/sqlite/ffi.rs similarity index 97% rename from walletkit-db/src/ffi.rs rename to walletkit-db/src/sqlite/ffi.rs index 286cd4437..d1fce0b1f 100644 --- a/walletkit-db/src/ffi.rs +++ b/walletkit-db/src/sqlite/ffi.rs @@ -26,7 +26,7 @@ use std::os::raw::{c_char, c_int, c_void}; use zeroize::Zeroize; -use super::error::{DbError, DbResult}; +use super::error::{DbResult, Error}; // -- SQLite constants (plain `i32`, no C types leaked to callers) ------------- @@ -102,7 +102,7 @@ impl RawDb { } m }; - return Err(DbError::new(rc, msg)); + return Err(Error::new(rc, msg)); } Ok(Self { ptr }) @@ -141,7 +141,7 @@ impl RawDb { } s }; - Err(DbError::new(rc, msg)) + Err(Error::new(rc, msg)) } /// Like [`exec`](Self::exec) but zeroizes the internal `CString` buffer @@ -183,7 +183,7 @@ impl RawDb { } s }; - Err(DbError::new(rc, msg)) + Err(Error::new(rc, msg)) } /// Prepares a single SQL statement for execution. @@ -204,7 +204,7 @@ impl RawDb { }; if rc != SQLITE_OK as c_int || stmt_ptr.is_null() { - return Err(DbError::new(rc, self.errmsg())); + return Err(Error::new(rc, self.errmsg())); } Ok(RawStmt { @@ -258,7 +258,7 @@ impl RawStmt<'_> { match rc { SQLITE_ROW => Ok(SQLITE_ROW), SQLITE_DONE => Ok(SQLITE_DONE), - other => Err(DbError::new(other, self.errmsg())), + other => Err(Error::new(other, self.errmsg())), } } @@ -270,7 +270,7 @@ impl RawStmt<'_> { if rc == SQLITE_OK as c_int { Ok(()) } else { - Err(DbError::new(rc, self.errmsg())) + Err(Error::new(rc, self.errmsg())) } } @@ -387,7 +387,7 @@ impl Drop for RawStmt<'_> { fn to_cstring(s: &str) -> DbResult { CString::new(s) - .map_err(|e| DbError::new(SQLITE_ERROR, format!("nul byte in string: {e}"))) + .map_err(|e| Error::new(SQLITE_ERROR, format!("nul byte in string: {e}"))) } fn errmsg_from_ptr(db: *mut c_void) -> String { @@ -408,7 +408,7 @@ fn check(rc: c_int, stmt: &RawStmt) -> DbResult<()> { if rc == SQLITE_OK as c_int { Ok(()) } else { - Err(DbError::new(rc, stmt.errmsg())) + Err(Error::new(rc, stmt.errmsg())) } } diff --git a/walletkit-db/src/sqlite/mod.rs b/walletkit-db/src/sqlite/mod.rs new file mode 100644 index 000000000..9df5649ec --- /dev/null +++ b/walletkit-db/src/sqlite/mod.rs @@ -0,0 +1,28 @@ +//! Low-level `SQLCipher` (`sqlite3mc`) wrapper. +//! +//! Safe Rust types over the `SQLite` `C` FFI. Raw symbols are resolved at +//! compile time: +//! +//! - **Native** (`not(wasm32)`): linked against the `sqlite3mc` static library +//! compiled from the downloaded amalgamation by `build.rs`. +//! - **WASM** (`wasm32`): delegated to `sqlite-wasm-rs` (with the +//! `sqlite3mc` feature) which ships its own `WASM`-compiled `sqlite3mc`. +//! +//! The internal `ffi` module is the only file in this crate that contains +//! `unsafe` code or `C` types. + +mod ffi; + +pub mod cipher; +pub mod error; + +mod connection; +mod statement; +mod transaction; +mod value; + +pub use connection::Connection; +pub use error::{DbResult, Error}; +pub use statement::{Row, Statement, StepResult}; +pub use transaction::Transaction; +pub use value::Value; diff --git a/walletkit-db/src/statement.rs b/walletkit-db/src/sqlite/statement.rs similarity index 97% rename from walletkit-db/src/statement.rs rename to walletkit-db/src/sqlite/statement.rs index 3d859c301..7dd24ae6a 100644 --- a/walletkit-db/src/statement.rs +++ b/walletkit-db/src/sqlite/statement.rs @@ -95,7 +95,7 @@ impl<'conn> Statement<'conn> { /// /// # Errors /// - /// Returns `DbError` if any bind call fails. + /// Returns `Error` if any bind call fails. /// /// # Panics /// @@ -117,7 +117,7 @@ impl<'conn> Statement<'conn> { /// /// # Errors /// - /// Returns `DbError` if the step fails. + /// Returns `Error` if the step fails. pub fn step<'stmt>(&'stmt mut self) -> DbResult> { let rc = self.raw.step()?; if rc == ffi::SQLITE_ROW { diff --git a/walletkit-db/src/transaction.rs b/walletkit-db/src/sqlite/transaction.rs similarity index 89% rename from walletkit-db/src/transaction.rs rename to walletkit-db/src/sqlite/transaction.rs index e0b79d797..2579f65bb 100644 --- a/walletkit-db/src/transaction.rs +++ b/walletkit-db/src/sqlite/transaction.rs @@ -39,7 +39,7 @@ impl<'conn> Transaction<'conn> { /// /// # Errors /// - /// Returns `DbError` if the COMMIT statement fails. + /// Returns `Error` if the COMMIT statement fails. pub fn commit(mut self) -> DbResult<()> { self.conn.execute_batch("COMMIT")?; self.committed = true; @@ -52,7 +52,7 @@ impl<'conn> Transaction<'conn> { /// /// # Errors /// - /// Returns `DbError` if any statement fails. + /// Returns `Error` if any statement fails. #[allow(dead_code)] pub fn execute_batch(&self, sql: &str) -> DbResult<()> { self.conn.execute_batch(sql) @@ -62,7 +62,7 @@ impl<'conn> Transaction<'conn> { /// /// # Errors /// - /// Returns `DbError` if preparation or execution fails. + /// Returns `Error` if preparation or execution fails. pub fn execute(&self, sql: &str, params: &[Value]) -> DbResult { self.conn.execute(sql, params) } @@ -71,7 +71,7 @@ impl<'conn> Transaction<'conn> { /// /// # Errors /// - /// Returns `DbError` if preparation, execution, or the mapper fails. + /// Returns `Error` if preparation, execution, or the mapper fails. pub fn query_row( &self, sql: &str, @@ -85,7 +85,7 @@ impl<'conn> Transaction<'conn> { /// /// # Errors /// - /// Returns `DbError` if the SQL is invalid. + /// Returns `Error` if the SQL is invalid. pub fn prepare(&self, sql: &str) -> DbResult> { self.conn.prepare(sql) } diff --git a/walletkit-db/src/value.rs b/walletkit-db/src/sqlite/value.rs similarity index 100% rename from walletkit-db/src/value.rs rename to walletkit-db/src/sqlite/value.rs diff --git a/walletkit-db/src/tests.rs b/walletkit-db/src/tests.rs index 4f7ab1a76..431a5b4fb 100644 --- a/walletkit-db/src/tests.rs +++ b/walletkit-db/src/tests.rs @@ -2,7 +2,8 @@ use std::sync::OnceLock; -use super::*; +use crate::params; +use crate::sqlite::{cipher, Connection, Value}; use secrecy::SecretBox; /// Ensures sqlite3mc's global codec registration is complete before any test @@ -17,7 +18,7 @@ use secrecy::SecretBox; /// Calling this at the start of every test ensures exactly one thread /// performs the first open (all others block on the `OnceLock`) so that /// by the time any test-specific code runs, sqlite3mc is fully initialized. -fn init_sqlite() { +pub fn init_sqlite() { static INIT: OnceLock<()> = OnceLock::new(); INIT.get_or_init(|| { drop(Connection::open_in_memory().expect("sqlite3mc pre-init")); @@ -181,3 +182,100 @@ fn test_integrity_check() { let ok = cipher::integrity_check(&conn).expect("check"); assert!(ok); } + +#[test] +fn test_cipher_plaintext_export_import_roundtrip() { + init_sqlite(); + let dir = tempfile::tempdir().expect("create temp dir"); + let src_path = dir.path().join("source.sqlite"); + let dest_path = dir.path().join("backup.plain.sqlite"); + let restore_path = dir.path().join("restore.sqlite"); + let key = SecretBox::init_with(|| [0x11u8; 32]); + + { + let conn = cipher::open_encrypted(&src_path, &key, false).expect("open src"); + conn.execute_batch( + "CREATE TABLE widgets (id INTEGER PRIMARY KEY, val TEXT NOT NULL);", + ) + .expect("create table"); + conn.execute( + "INSERT INTO widgets (id, val) VALUES (?1, ?2)", + params![1_i64, "alpha"], + ) + .expect("insert"); + conn.execute( + "INSERT INTO widgets (id, val) VALUES (?1, ?2)", + params![2_i64, "beta"], + ) + .expect("insert"); + + cipher::export_plaintext_copy(&conn, &dest_path, &["widgets"]).expect("export"); + } + + { + let conn = + cipher::open_encrypted(&restore_path, &key, false).expect("open restore"); + conn.execute_batch( + "CREATE TABLE widgets (id INTEGER PRIMARY KEY, val TEXT NOT NULL);", + ) + .expect("create table"); + cipher::import_plaintext_copy(&conn, &dest_path, &["widgets"]).expect("import"); + + let count: i64 = conn + .query_row("SELECT COUNT(*) FROM widgets", &[], |row| { + Ok(row.column_i64(0)) + }) + .expect("count"); + assert_eq!(count, 2); + + let val = conn + .query_row("SELECT val FROM widgets WHERE id = 2", &[], |row| { + Ok(row.column_text(0)) + }) + .expect("query"); + assert_eq!(val, "beta"); + } +} + +#[test] +fn test_cipher_import_rejects_non_empty_destination() { + init_sqlite(); + let dir = tempfile::tempdir().expect("create temp dir"); + let src_path = dir.path().join("source.sqlite"); + let dest_path = dir.path().join("backup.plain.sqlite"); + let restore_path = dir.path().join("restore.sqlite"); + let key = SecretBox::init_with(|| [0x22u8; 32]); + + { + let conn = cipher::open_encrypted(&src_path, &key, false).expect("open src"); + conn.execute_batch( + "CREATE TABLE widgets (id INTEGER PRIMARY KEY, val TEXT NOT NULL);", + ) + .expect("create table"); + conn.execute( + "INSERT INTO widgets (id, val) VALUES (?1, ?2)", + params![1_i64, "alpha"], + ) + .expect("insert"); + cipher::export_plaintext_copy(&conn, &dest_path, &["widgets"]).expect("export"); + } + + let conn = + cipher::open_encrypted(&restore_path, &key, false).expect("open restore"); + conn.execute_batch( + "CREATE TABLE widgets (id INTEGER PRIMARY KEY, val TEXT NOT NULL);", + ) + .expect("create table"); + conn.execute( + "INSERT INTO widgets (id, val) VALUES (?1, ?2)", + params![99_i64, "preexisting"], + ) + .expect("insert"); + + let err = cipher::import_plaintext_copy(&conn, &dest_path, &["widgets"]) + .expect_err("import should refuse non-empty destination"); + assert!( + err.to_string().contains("non-empty table"), + "expected non-empty-table error, got: {err}" + ); +} diff --git a/walletkit-db/src/traits.rs b/walletkit-db/src/traits.rs new file mode 100644 index 000000000..e988c94b2 --- /dev/null +++ b/walletkit-db/src/traits.rs @@ -0,0 +1,67 @@ +//! Interfaces for consumer-supplied platform integrations. +//! +//! Argument shapes (`Vec`, owned `String`) mirror `WalletKit`'s existing +//! uniffi-annotated traits so consumers can bridge with a thin newtype that +//! just delegates and maps errors. (A blanket impl across crates is blocked +//! by Rust's orphan rule, so consumers do need a small wrapper.) + +use crate::error::StoreResult; + +/// Device keystore for sealing and opening secrets under a device-bound key. +/// +/// Implementations MUST use an AEAD construction (e.g. AES-GCM or +/// ChaCha20-Poly1305) so that `aad` (additional authenticated data) is +/// authenticated as part of the seal: any mismatch when opening must fail. +pub trait Keystore: Send + Sync { + /// Seals plaintext under the device-bound key, authenticating `aad` + /// (additional authenticated data). + /// + /// # Errors + /// + /// Returns an error if the keystore refuses the operation or the seal + /// fails. + fn seal(&self, aad: Vec, plaintext: Vec) -> StoreResult>; + + /// Opens ciphertext under the device-bound key, verifying `aad`. The + /// same `aad` supplied at seal time must be supplied here or the open + /// operation must fail. + /// + /// # Errors + /// + /// Returns an error if authentication fails or the keystore cannot open. + fn open_sealed(&self, aad: Vec, ciphertext: Vec) -> StoreResult>; +} + +/// Atomic blob store for small binary files (e.g. sealed key envelopes). +/// +/// Provided by the host rather than calling `std::fs` directly for two +/// reasons: +/// +/// - **WASM has no `std::fs`.** On `wasm32-unknown-unknown` the runtime +/// is a Web Worker; the host backs storage with `OPFS`, `IndexedDB`, +/// or similar. +/// - **Hosts own where data lives.** iOS sandboxed app-data containers, +/// Android per-UID data dirs, iCloud-skip flags, atomic-write +/// semantics — all platform-specific. walletkit-db stays neutral. +pub trait AtomicBlobStore: Send + Sync { + /// Reads the blob at `path`, if present. + /// + /// # Errors + /// + /// Returns an error if the read fails. + fn read(&self, path: String) -> StoreResult>>; + + /// Writes bytes atomically to `path`. + /// + /// # Errors + /// + /// Returns an error if the write fails. + fn write_atomic(&self, path: String, bytes: Vec) -> StoreResult<()>; + + /// Deletes the blob at `path`. + /// + /// # Errors + /// + /// Returns an error if the delete fails. + fn delete(&self, path: String) -> StoreResult<()>; +} diff --git a/walletkit-db/src/vault.rs b/walletkit-db/src/vault.rs new file mode 100644 index 000000000..b1188c915 --- /dev/null +++ b/walletkit-db/src/vault.rs @@ -0,0 +1,108 @@ +//! Encrypted vault: opens an encrypted database with a caller-supplied +//! schema and hands out the underlying [`Connection`]. +//! +//! `SQLite` handles cross-process writer serialization itself in WAL mode +//! (which `cipher::open_encrypted` configures), so `Vault` does not wrap +//! mutations in a flock. Where flock IS load-bearing — the first-install +//! bootstrap race in [`crate::init_or_open_envelope_key`] and any +//! file-level orchestration on top of plaintext export/import — callers +//! acquire a [`crate::Lock`] explicitly. Keeping the lock out of `Vault` +//! avoids belt-and-suspenders flock acquisitions that don't add safety +//! beyond what `SQLite`'s own locking already provides. + +use std::path::Path; + +use secrecy::SecretBox; + +use crate::error::{StoreError, StoreResult}; +use crate::sqlite::{cipher, Connection, DbResult}; + +/// Open encrypted database wrapper. +/// +/// Exposes the underlying [`Connection`] via [`Vault::connection`]. +#[derive(Debug)] +pub struct Vault { + conn: Connection, +} + +impl Vault { + /// Opens (or creates) the encrypted database at `db_path`, runs + /// `ensure_schema`, and verifies integrity. + /// + /// `ensure_schema` runs after the database is opened and keyed but + /// before the integrity check. + /// + /// # Errors + /// + /// Returns [`StoreError::Db`] if open / key / schema fails or + /// [`StoreError::IntegrityCheckFailed`] on corruption. + pub fn open( + db_path: &Path, + key: &SecretBox<[u8; 32]>, + ensure_schema: F, + ) -> StoreResult + where + F: FnOnce(&Connection) -> DbResult<()>, + { + let conn = cipher::open_encrypted(db_path, key, false)?; + ensure_schema(&conn)?; + if !cipher::integrity_check(&conn)? { + return Err(StoreError::IntegrityCheckFailed( + "integrity_check failed".to_string(), + )); + } + Ok(Self { conn }) + } + + /// Borrows the underlying connection. + #[must_use] + pub const fn connection(&self) -> &Connection { + &self.conn + } +} + +#[cfg(test)] +mod tests { + use super::Vault; + use crate::blobs; + use crate::error::StoreError; + use crate::tests::init_sqlite; + use secrecy::SecretBox; + + #[test] + #[cfg(not(target_arch = "wasm32"))] + fn test_vault_open_runs_schema_callback() { + init_sqlite(); + let dir = tempfile::tempdir().expect("create temp dir"); + let db_path = dir.path().join("vault.sqlite"); + let key = SecretBox::init_with(|| [0x42u8; 32]); + + let vault = Vault::open(&db_path, &key, |conn| { + blobs::ensure_schema(conn)?; + conn.execute_batch( + "CREATE TABLE IF NOT EXISTS items (id INTEGER PRIMARY KEY);", + ) + }) + .expect("open vault"); + + let cid = blobs::put(vault.connection(), 7, b"payload", 1000).expect("put"); + let bytes = blobs::get(vault.connection(), &cid) + .expect("get") + .expect("present"); + assert_eq!(bytes, b"payload"); + } + + #[test] + #[cfg(not(target_arch = "wasm32"))] + fn test_vault_open_rejects_wrong_key() { + init_sqlite(); + let dir = tempfile::tempdir().expect("create temp dir"); + let db_path = dir.path().join("vault.sqlite"); + let key = SecretBox::init_with(|| [0x11u8; 32]); + let _ = + Vault::open(&db_path, &key, blobs::ensure_schema).expect("create vault"); + let wrong = SecretBox::init_with(|| [0x22u8; 32]); + let err = Vault::open(&db_path, &wrong, |_| Ok(())).expect_err("wrong key"); + assert!(matches!(err, StoreError::Db(_))); + } +}