From 34cf1a040897eccc1907f3b4b6fea7b97a0f867e Mon Sep 17 00:00:00 2001 From: danielle-tfh Date: Thu, 7 May 2026 15:35:06 +0200 Subject: [PATCH 01/31] refactor: make walletkit-db a generic encrypted sqlite crate --- README.md | 1 + .../src/storage/cache/maintenance.rs | 4 +- walletkit-core/src/storage/cache/merkle.rs | 2 +- walletkit-core/src/storage/cache/mod.rs | 2 +- .../src/storage/cache/nullifiers.rs | 2 +- walletkit-core/src/storage/cache/schema.rs | 3 +- walletkit-core/src/storage/cache/session.rs | 2 +- walletkit-core/src/storage/cache/util.rs | 11 +-- .../src/storage/credential_storage.rs | 6 +- walletkit-core/src/storage/keys.rs | 7 +- walletkit-core/src/storage/lock.rs | 8 -- walletkit-core/src/storage/traits.rs | 14 --- walletkit-core/src/storage/types.rs | 6 -- walletkit-core/src/storage/vault/helpers.rs | 8 +- walletkit-core/src/storage/vault/mod.rs | 33 ++++--- walletkit-core/src/storage/vault/schema.rs | 35 ++++---- walletkit-core/src/storage/vault/tests.rs | 24 +++--- walletkit-db/Cargo.toml | 2 +- walletkit-db/src/lib.rs | 33 ++----- walletkit-db/src/{ => sqlite}/cipher.rs | 85 +++++++++---------- walletkit-db/src/{ => sqlite}/connection.rs | 48 +++++------ walletkit-db/src/{ => sqlite}/error.rs | 22 ++--- walletkit-db/src/{ => sqlite}/ffi.rs | 44 +++++----- walletkit-db/src/sqlite/mod.rs | 28 ++++++ walletkit-db/src/{ => sqlite}/statement.rs | 10 +-- walletkit-db/src/{ => sqlite}/transaction.rs | 26 +++--- walletkit-db/src/{ => sqlite}/value.rs | 2 +- walletkit-db/src/tests.rs | 3 +- 28 files changed, 231 insertions(+), 240 deletions(-) rename walletkit-db/src/{ => sqlite}/cipher.rs (75%) rename walletkit-db/src/{ => sqlite}/connection.rs (74%) rename walletkit-db/src/{ => sqlite}/error.rs (66%) rename walletkit-db/src/{ => sqlite}/ffi.rs (94%) create mode 100644 walletkit-db/src/sqlite/mod.rs rename walletkit-db/src/{ => sqlite}/statement.rs (92%) rename walletkit-db/src/{ => sqlite}/transaction.rs (75%) rename walletkit-db/src/{ => sqlite}/value.rs (95%) diff --git a/README.md b/README.md index e7230922c..da30ed3c3 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` - WalletKit's encrypted on-device storage crate. Houses the `SQLCipher` (`sqlite3mc`) wrapper plus opinionated vault primitives (`Vault::open` with integrity check, envelope-sealed key init, content-addressed blob table, cross-process lock). Used by `walletkit-core` for credential storage and consumed by sibling SDKs (e.g. OrbKit's planned `OrbPcpStore`) for their own typed stores. See its README for design rationale and a worked example of writing a consumer. ### World ID Secret diff --git a/walletkit-core/src/storage/cache/maintenance.rs b/walletkit-core/src/storage/cache/maintenance.rs index 8d7c904ca..5b46b9934 100644 --- a/walletkit-core/src/storage/cache/maintenance.rs +++ b/walletkit-core/src/storage/cache/maintenance.rs @@ -6,8 +6,8 @@ use std::path::Path; use secrecy::SecretBox; use crate::storage::error::StorageResult; -use walletkit_db::cipher; -use walletkit_db::Connection; +use walletkit_db::sqlite::cipher; +use walletkit_db::sqlite::Connection; use super::schema; use super::util::{map_db_err, map_io_err}; diff --git a/walletkit-core/src/storage/cache/merkle.rs b/walletkit-core/src/storage/cache/merkle.rs index 3fd75b679..a7cfccff9 100644 --- a/walletkit-core/src/storage/cache/merkle.rs +++ b/walletkit-core/src/storage/cache/merkle.rs @@ -1,7 +1,7 @@ //! Merkle proof cache helpers. use crate::storage::{cache::util::CACHE_KEY_PREFIX_MERKLE, error::StorageResult}; -use walletkit_db::Connection; +use walletkit_db::sqlite::Connection; use super::util::{ cache_entry_times, get_cache_entry, prune_expired_entries, upsert_cache_entry, diff --git a/walletkit-core/src/storage/cache/mod.rs b/walletkit-core/src/storage/cache/mod.rs index 71eb05e47..32f2e00be 100644 --- a/walletkit-core/src/storage/cache/mod.rs +++ b/walletkit-core/src/storage/cache/mod.rs @@ -5,7 +5,7 @@ use std::path::Path; use crate::storage::error::StorageResult; use crate::storage::lock::StorageLockGuard; use secrecy::SecretBox; -use walletkit_db::Connection; +use walletkit_db::sqlite::Connection; mod maintenance; mod merkle; diff --git a/walletkit-core/src/storage/cache/nullifiers.rs b/walletkit-core/src/storage/cache/nullifiers.rs index b5e6bc7fb..02d5d0c4f 100644 --- a/walletkit-core/src/storage/cache/nullifiers.rs +++ b/walletkit-core/src/storage/cache/nullifiers.rs @@ -4,7 +4,7 @@ //! remaining idempotent for retries within the TTL window. use crate::storage::error::StorageResult; -use walletkit_db::Connection; +use walletkit_db::sqlite::Connection; use super::util::{ cache_entry_times, get_cache_entry, get_cache_entry_tx, insert_cache_entry_tx, diff --git a/walletkit-core/src/storage/cache/schema.rs b/walletkit-core/src/storage/cache/schema.rs index c12b2f517..780e25b0b 100644 --- a/walletkit-core/src/storage/cache/schema.rs +++ b/walletkit-core/src/storage/cache/schema.rs @@ -1,7 +1,8 @@ //! Cache database schema management. use crate::storage::error::StorageResult; -use walletkit_db::{params, Connection}; +use walletkit_db::params; +use walletkit_db::sqlite::Connection; use super::util::map_db_err; diff --git a/walletkit-core/src/storage/cache/session.rs b/walletkit-core/src/storage/cache/session.rs index 003c330b2..4a0cbf1ee 100644 --- a/walletkit-core/src/storage/cache/session.rs +++ b/walletkit-core/src/storage/cache/session.rs @@ -1,7 +1,7 @@ //! Session seed cache helpers. use crate::storage::error::StorageResult; -use walletkit_db::Connection; +use walletkit_db::sqlite::Connection; use super::util::{ cache_entry_times, get_cache_entry, parse_fixed_bytes, prune_expired_entries, diff --git a/walletkit-core/src/storage/cache/util.rs b/walletkit-core/src/storage/cache/util.rs index abc33f176..d532a0143 100644 --- a/walletkit-core/src/storage/cache/util.rs +++ b/walletkit-core/src/storage/cache/util.rs @@ -3,7 +3,8 @@ use std::io; use crate::storage::error::{StorageError, StorageResult}; -use walletkit_db::{params, Connection, DbError, Transaction}; +use walletkit_db::params; +use walletkit_db::sqlite::{Connection, Error as DbError, Transaction}; /// Maps a database error into a cache storage error. pub(super) fn map_db_err(err: &DbError) -> StorageError { @@ -189,8 +190,8 @@ pub(super) fn get_cache_entry_tx( stmt.bind_values(params![key, now, insertion_before]) .map_err(|err| map_db_err(&err))?; match stmt.step().map_err(|err| map_db_err(&err))? { - walletkit_db::StepResult::Row(row) => Ok(Some(row.column_blob(0))), - walletkit_db::StepResult::Done => Ok(None), + walletkit_db::sqlite::StepResult::Row(row) => Ok(Some(row.column_blob(0))), + walletkit_db::sqlite::StepResult::Done => Ok(None), } } else { let mut stmt = tx.prepare( @@ -199,8 +200,8 @@ pub(super) fn get_cache_entry_tx( stmt.bind_values(params![key, now]) .map_err(|err| map_db_err(&err))?; match stmt.step().map_err(|err| map_db_err(&err))? { - walletkit_db::StepResult::Row(row) => Ok(Some(row.column_blob(0))), - walletkit_db::StepResult::Done => Ok(None), + walletkit_db::sqlite::StepResult::Row(row) => Ok(Some(row.column_blob(0))), + walletkit_db::sqlite::StepResult::Done => Ok(None), } } } diff --git a/walletkit-core/src/storage/credential_storage.rs b/walletkit-core/src/storage/credential_storage.rs index cbb2e37d0..b0bb16668 100644 --- a/walletkit-core/src/storage/credential_storage.rs +++ b/walletkit-core/src/storage/credential_storage.rs @@ -128,7 +128,7 @@ impl CredentialStoreInner { } fn guard(&self) -> StorageResult { - self.lock.lock() + Ok(self.lock.lock()?) } fn state(&self) -> StorageResult<&StorageState> { @@ -1281,8 +1281,8 @@ mod tests { #[test] fn test_import_vault_backup_transaction_atomicity() { - use walletkit_db::cipher::BACKUP_TABLES; - use walletkit_db::Connection; + use crate::storage::vault::BACKUP_TABLES; + use walletkit_db::sqlite::Connection; use world_id_core::Credential as CoreCredential; let src_root = temp_root_path(); diff --git a/walletkit-core/src/storage/keys.rs b/walletkit-core/src/storage/keys.rs index e1b20af37..ee7fb9f7b 100644 --- a/walletkit-core/src/storage/keys.rs +++ b/walletkit-core/src/storage/keys.rs @@ -48,9 +48,9 @@ impl StorageKeys { }) } 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. + // 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); @@ -90,6 +90,7 @@ fn parse_key_32(bytes: &[u8], label: &str) -> StorageResult<[u8; 32]> { #[cfg(test)] mod tests { use super::*; + use crate::storage::error::StorageError; use crate::storage::lock::StorageLock; use crate::storage::tests_utils::{InMemoryBlobStore, InMemoryKeystore}; use secrecy::ExposeSecret; diff --git a/walletkit-core/src/storage/lock.rs b/walletkit-core/src/storage/lock.rs index b69f25761..ae36c3af1 100644 --- a/walletkit-core/src/storage/lock.rs +++ b/walletkit-core/src/storage/lock.rs @@ -11,8 +11,6 @@ use std::path::Path; use super::error::StorageResult; -// WASM: no-op lock (single-threaded worker, SQLITE_THREADSAFE=0) - #[cfg(target_arch = "wasm32")] mod imp { use super::*; @@ -43,8 +41,6 @@ mod imp { } } -// Native: file-backed exclusive lock (flock on Unix, LockFileEx on Windows) - #[cfg(not(target_arch = "wasm32"))] mod imp { use super::{Path, StorageResult}; @@ -125,8 +121,6 @@ mod imp { StorageError::Lock(err.to_string()) } - // ── Unix flock ────────────────────────────────────────────────────── - #[cfg(unix)] fn lock_exclusive(file: &File) -> std::io::Result<()> { let fd = std::os::unix::io::AsRawFd::as_raw_fd(file); @@ -180,8 +174,6 @@ mod imp { fn flock(fd: c_int, operation: c_int) -> c_int; } - // ── Windows LockFileEx ────────────────────────────────────────────── - #[cfg(windows)] fn lock_exclusive(file: &File) -> std::io::Result<()> { lock_file(file, 0) 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 edbea91cd..3b15f3eff 100644 --- a/walletkit-core/src/storage/types.rs +++ b/walletkit-core/src/storage/types.rs @@ -15,12 +15,6 @@ pub enum BlobKind { AssociatedData = 2, } -impl BlobKind { - pub(crate) const fn as_i64(self) -> i64 { - self as i64 - } -} - impl TryFrom for BlobKind { type Error = StorageError; diff --git a/walletkit-core/src/storage/vault/helpers.rs b/walletkit-core/src/storage/vault/helpers.rs index d93feb8e5..1fc31bff6 100644 --- a/walletkit-core/src/storage/vault/helpers.rs +++ b/walletkit-core/src/storage/vault/helpers.rs @@ -3,15 +3,15 @@ use sha2::{Digest, Sha256}; use crate::storage::error::{StorageError, StorageResult}; -use crate::storage::types::{BlobKind, ContentId, CredentialRecord}; -use walletkit_db::{DbError, Row}; +use crate::storage::types::{ContentId, CredentialRecord}; +use walletkit_db::sqlite::{Error as DbError, Row}; const CONTENT_ID_PREFIX: &[u8] = b"worldid:blob"; -pub(super) fn compute_content_id(blob_kind: BlobKind, plaintext: &[u8]) -> ContentId { +pub(super) fn compute_content_id(kind_tag: u8, plaintext: &[u8]) -> ContentId { let mut hasher = Sha256::new(); hasher.update(CONTENT_ID_PREFIX); - hasher.update([blob_kind as u8]); + hasher.update([kind_tag]); hasher.update(plaintext); let digest = hasher.finalize(); let mut out = [0u8; 32]; diff --git a/walletkit-core/src/storage/vault/mod.rs b/walletkit-core/src/storage/vault/mod.rs index c22c572b5..44fc1274b 100644 --- a/walletkit-core/src/storage/vault/mod.rs +++ b/walletkit-core/src/storage/vault/mod.rs @@ -13,8 +13,8 @@ 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}; +use walletkit_db::params; +use walletkit_db::sqlite::{cipher, Connection, StepResult, Value}; /// Encrypted vault database wrapper. #[derive(Debug)] @@ -22,6 +22,8 @@ pub struct VaultDb { conn: Connection, } +pub(crate) const BACKUP_TABLES: &[&str] = &["credential_records", "blob_objects"]; + impl VaultDb { /// Opens or creates the encrypted vault database at `path`. /// @@ -35,7 +37,7 @@ impl VaultDb { ) -> StorageResult { let conn = cipher::open_encrypted(path, k_intermediate, false) .map_err(|e| map_db_err(&e))?; - ensure_schema(&conn)?; + ensure_schema(&conn).map_err(|err| map_db_err(&err))?; let db = Self { conn }; if !db.check_integrity()? { return Err(StorageError::CorruptedVault( @@ -114,10 +116,10 @@ impl VaultDb { now: u64, ) -> StorageResult { let credential_blob_id = - compute_content_id(BlobKind::CredentialBlob, &credential_blob); + compute_content_id(BlobKind::CredentialBlob as u8, &credential_blob); let associated_data_id = associated_data .as_ref() - .map(|bytes| compute_content_id(BlobKind::AssociatedData, bytes)); + .map(|bytes| compute_content_id(BlobKind::AssociatedData as u8, 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")?; @@ -129,7 +131,7 @@ impl VaultDb { VALUES (?1, ?2, ?3, ?4)", params![ credential_blob_id.as_ref(), - BlobKind::CredentialBlob.as_i64(), + i64::from(BlobKind::CredentialBlob as u8), now_i64, credential_blob.as_slice(), ], @@ -145,7 +147,7 @@ impl VaultDb { VALUES (?1, ?2, ?3, ?4)", params![ cid.as_ref(), - BlobKind::AssociatedData.as_i64(), + i64::from(BlobKind::AssociatedData as u8), now_i64, data.as_slice(), ], @@ -266,7 +268,7 @@ impl VaultDb { FROM credential_records cr WHERE cr.credential_blob_cid = blob_objects.content_id )", - params![BlobKind::CredentialBlob.as_i64()], + params![i64::from(BlobKind::CredentialBlob as u8)], ) .map_err(|err| map_db_err(&err))?; @@ -279,7 +281,7 @@ impl VaultDb { FROM credential_records cr WHERE cr.associated_data_cid = blob_objects.content_id )", - params![BlobKind::AssociatedData.as_i64()], + params![i64::from(BlobKind::AssociatedData as u8)], ) .map_err(|err| map_db_err(&err))?; @@ -371,13 +373,13 @@ impl VaultDb { dest: &Path, _lock: &StorageLockGuard, ) -> StorageResult<()> { - // Remove any stale export from a previous failed run. 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(&self.conn, dest, BACKUP_TABLES) + .map_err(|e| map_db_err(&e)) } /// Imports credentials from a plaintext (unencrypted) vault backup into @@ -394,6 +396,13 @@ impl VaultDb { source: &Path, _lock: &StorageLockGuard, ) -> StorageResult<()> { - cipher::import_plaintext_copy(&self.conn, source).map_err(|e| map_db_err(&e)) + cipher::import_plaintext_copy(&self.conn, source, BACKUP_TABLES) + .map_err(|e| map_db_err(&e)) + } + + /// Borrows the underlying connection for direct SQL access. **Test-only.** + #[cfg(test)] + pub(super) const fn raw_connection(&self) -> &walletkit_db::sqlite::Connection { + &self.conn } } diff --git a/walletkit-core/src/storage/vault/schema.rs b/walletkit-core/src/storage/vault/schema.rs index 2602fb6f5..e40133a78 100644 --- a/walletkit-core/src/storage/vault/schema.rs +++ b/walletkit-core/src/storage/vault/schema.rs @@ -1,18 +1,16 @@ //! Vault database schema management. +//! +//! Owns the credential vault tables and backup-sensitive schema. -use crate::storage::error::StorageResult; -use walletkit_db::Connection; - -use super::helpers::map_db_err; +use walletkit_db::sqlite::{Connection, Result as 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<()> { +/// - 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, @@ -21,6 +19,14 @@ pub(super) fn ensure_schema(conn: &Connection) -> StorageResult<()> { updated_at INTEGER NOT NULL ); + 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) + ); + CREATE UNIQUE INDEX IF NOT EXISTS idx_vault_meta_schema_version ON vault_meta (schema_version); @@ -49,17 +55,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/vault/tests.rs index 77de8a010..33c0e581a 100644 --- a/walletkit-core/src/storage/vault/tests.rs +++ b/walletkit-core/src/storage/vault/tests.rs @@ -162,8 +162,8 @@ fn test_store_credential_with_associated_data() { #[test] fn test_content_id_determinism() { - let a = compute_content_id(BlobKind::CredentialBlob, b"data"); - let b = compute_content_id(BlobKind::CredentialBlob, b"data"); + let a = compute_content_id(BlobKind::CredentialBlob as u8, b"data"); + let b = compute_content_id(BlobKind::CredentialBlob as u8, b"data"); assert_eq!(a, b); } @@ -200,7 +200,7 @@ fn test_content_id_deduplication() { ) .expect("store credential"); let count = db - .conn + .raw_connection() .query_row("SELECT COUNT(*) FROM blob_objects", &[], |stmt| { Ok(stmt.column_i64(0)) }) @@ -212,7 +212,7 @@ fn test_content_id_deduplication() { .expect("delete first credential"); let count_after_first_delete = db - .conn + .raw_connection() .query_row("SELECT COUNT(*) FROM blob_objects", &[], |stmt| { Ok(stmt.column_i64(0)) }) @@ -224,7 +224,7 @@ fn test_content_id_deduplication() { .expect("delete second credential"); let count_after_second_delete = db - .conn + .raw_connection() .query_row("SELECT COUNT(*) FROM blob_objects", &[], |stmt| { Ok(stmt.column_i64(0)) }) @@ -367,7 +367,7 @@ fn test_delete_credential_by_id() { .expect("store credential"); let blob_count_before = db - .conn + .raw_connection() .query_row("SELECT COUNT(*) FROM blob_objects", &[], |stmt| { Ok(stmt.column_i64(0)) }) @@ -382,7 +382,7 @@ fn test_delete_credential_by_id() { assert!(records.is_empty()); let blob_count_after = db - .conn + .raw_connection() .query_row("SELECT COUNT(*) FROM blob_objects", &[], |stmt| { Ok(stmt.column_i64(0)) }) @@ -429,10 +429,10 @@ fn test_delete_credential_cleans_up_orphaned_associated_data() { .expect("store credential"); let associated_before = db - .conn + .raw_connection() .query_row( "SELECT COUNT(*) FROM blob_objects WHERE blob_kind = ?1", - params![BlobKind::AssociatedData.as_i64()], + params![i64::from(BlobKind::AssociatedData as u8)], |stmt| Ok(stmt.column_i64(0)), ) .map_err(|err| map_db_err(&err)) @@ -443,10 +443,10 @@ fn test_delete_credential_cleans_up_orphaned_associated_data() { .expect("delete credential"); let associated_after = db - .conn + .raw_connection() .query_row( "SELECT COUNT(*) FROM blob_objects WHERE blob_kind = ?1", - params![BlobKind::AssociatedData.as_i64()], + params![i64::from(BlobKind::AssociatedData as u8)], |stmt| Ok(stmt.column_i64(0)), ) .map_err(|err| map_db_err(&err)) @@ -497,7 +497,7 @@ fn test_danger_delete_all_credentials() { assert!(records.is_empty()); let blob_count = db - .conn + .raw_connection() .query_row("SELECT COUNT(*) FROM blob_objects", &[], |stmt| { Ok(stmt.column_i64(0)) }) diff --git a/walletkit-db/Cargo.toml b/walletkit-db/Cargo.toml index cdc4971fd..71368eddf 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 = "Generic encrypted SQLite wrapper backed by sqlite3mc." publish = true version.workspace = true edition.workspace = true diff --git a/walletkit-db/src/lib.rs b/walletkit-db/src/lib.rs index b34162730..3b4dc6736 100644 --- a/walletkit-db/src/lib.rs +++ b/walletkit-db/src/lib.rs @@ -1,32 +1,15 @@ -//! Minimal safe `SQLite` wrapper backed by `sqlite3mc`. +//! Generic encrypted `SQLite` (`sqlite3mc`) wrapper. //! -//! This crate provides a small, safe Rust API over the `SQLite` C FFI. -//! The raw symbols are resolved at compile time: +//! The public API is exposed through [`sqlite`]: //! -//! * **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`. +//! - safe Rust connection / transaction / statement types +//! - encrypted open helpers and integrity checks +//! - plaintext export / import helpers parameterized by caller-owned tables //! -//! 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. +//! Raw FFI lives behind the `sqlite` module; consumer crates own their own +//! schema, queries, and higher-level storage policy. -mod ffi; - -mod connection; -pub mod error; -mod statement; -mod transaction; -pub mod value; - -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 mod sqlite; #[cfg(test)] mod tests; diff --git a/walletkit-db/src/cipher.rs b/walletkit-db/src/sqlite/cipher.rs similarity index 75% rename from walletkit-db/src/cipher.rs rename to walletkit-db/src/sqlite/cipher.rs index 2051f04ba..26333d550 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,23 +37,23 @@ use secrecy::{ExposeSecret, SecretBox}; use zeroize::Zeroizing; use super::connection::Connection; -use super::error::{DbError, DbResult}; +use super::error::{Error, Result}; /// 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]>, read_only: bool, -) -> DbResult { +) -> Result { let conn = Connection::open(path, read_only)?; apply_key(&conn, k_intermediate)?; configure_connection(&conn)?; @@ -70,7 +70,7 @@ pub fn open_encrypted( /// After keying, a lightweight read (`SELECT count(*) FROM sqlite_master`) /// verifies the key is correct. If it's wrong, `sqlite3mc` fails with /// `SQLITE_NOTADB` on the first page read. -fn apply_key(conn: &Connection, k_intermediate: &SecretBox<[u8; 32]>) -> DbResult<()> { +fn apply_key(conn: &Connection, k_intermediate: &SecretBox<[u8; 32]>) -> Result<()> { // Hex-encode the key and build the PRAGMA. Both are zeroized on drop. let key_hex = Zeroizing::new(hex::encode(k_intermediate.expose_secret())); let pragma = Zeroizing::new(format!("PRAGMA key = \"x'{}'\";", key_hex.as_str())); @@ -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?): {}", @@ -105,7 +105,7 @@ fn apply_key(conn: &Connection, k_intermediate: &SecretBox<[u8; 32]>) -> DbResul /// - `foreign_keys = ON` -- enforces referential integrity constraints. /// - `secure_delete = ON` -- overwrites deleted content with zeroes so /// sensitive data does not linger in free pages. -fn configure_connection(conn: &Connection) -> DbResult<()> { +fn configure_connection(conn: &Connection) -> Result<()> { conn.execute_batch( "PRAGMA foreign_keys = ON; PRAGMA journal_mode = WAL; @@ -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], +) -> Result<()> { 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], +) -> Result<()> { 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,8 +236,8 @@ pub fn import_plaintext_copy(conn: &Connection, source_path: &Path) -> DbResult< /// /// # Errors /// -/// Returns `DbError` if the integrity check query fails. -pub fn integrity_check(conn: &Connection) -> DbResult { +/// Returns `Error` if the integrity check query fails. +pub fn integrity_check(conn: &Connection) -> Result { 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 74% rename from walletkit-db/src/connection.rs rename to walletkit-db/src/sqlite/connection.rs index 0b87ba48a..c7dff985f 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::{Error, Result}; use super::ffi::{self, RawDb}; use super::statement::{Row, Statement, StepResult}; use super::transaction::Transaction; @@ -25,8 +25,8 @@ impl Connection { /// /// # Errors /// - /// Returns `DbError` if `SQLite` cannot open the file. - pub fn open(path: &Path, read_only: bool) -> DbResult { + /// Returns `Error` if `SQLite` cannot open the file. + pub fn open(path: &Path, read_only: bool) -> Result { let path_str = path.to_string_lossy(); let flags = if read_only { ffi::SQLITE_OPEN_READONLY | ffi::SQLITE_OPEN_FULLMUTEX @@ -46,8 +46,8 @@ impl Connection { /// /// # Errors /// - /// Returns `DbError` if any statement fails. - pub fn execute_batch(&self, sql: &str) -> DbResult<()> { + /// Returns `Error` if any statement fails. + pub fn execute_batch(&self, sql: &str) -> Result<()> { self.db.exec(sql) } @@ -57,8 +57,8 @@ impl Connection { /// /// # Errors /// - /// Returns `DbError` if the statement fails. - pub fn execute_batch_zeroized(&self, sql: &str) -> DbResult<()> { + /// Returns `Error` if the statement fails. + pub fn execute_batch_zeroized(&self, sql: &str) -> Result<()> { self.db.exec_zeroized(sql) } @@ -66,8 +66,8 @@ impl Connection { /// /// # Errors /// - /// Returns `DbError` if the SQL is invalid. - pub fn prepare(&self, sql: &str) -> DbResult> { + /// Returns `Error` if the SQL is invalid. + pub fn prepare(&self, sql: &str) -> Result> { let raw_stmt = self.db.prepare(sql)?; Ok(Statement::new(raw_stmt)) } @@ -78,8 +78,8 @@ impl Connection { /// /// # Errors /// - /// Returns `DbError` if preparation or execution fails. - pub fn execute(&self, sql: &str, params: &[Value]) -> DbResult { + /// Returns `Error` if preparation or execution fails. + pub fn execute(&self, sql: &str, params: &[Value]) -> Result { let mut stmt = self.prepare(sql)?; stmt.bind_values(params)?; stmt.step()?; @@ -92,20 +92,20 @@ 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, sql: &str, params: &[Value], - mapper: impl FnOnce(&Row<'_, '_>) -> DbResult, - ) -> DbResult { + mapper: impl FnOnce(&Row<'_, '_>) -> Result, + ) -> Result { let mut stmt = self.prepare(sql)?; stmt.bind_values(params)?; 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,13 +115,13 @@ 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, params: &[Value], - mapper: impl FnOnce(&Row<'_, '_>) -> DbResult, - ) -> DbResult> { + mapper: impl FnOnce(&Row<'_, '_>) -> Result, + ) -> Result> { let mut stmt = self.prepare(sql)?; stmt.bind_values(params)?; match stmt.step()? { @@ -134,8 +134,8 @@ impl Connection { /// /// # Errors /// - /// Returns `DbError` if `BEGIN DEFERRED` fails. - pub fn transaction(&self) -> DbResult> { + /// Returns `Error` if `BEGIN DEFERRED` fails. + pub fn transaction(&self) -> Result> { Transaction::begin(self, false) } @@ -143,8 +143,8 @@ impl Connection { /// /// # Errors /// - /// Returns `DbError` if `BEGIN IMMEDIATE` fails. - pub fn transaction_immediate(&self) -> DbResult> { + /// Returns `Error` if `BEGIN IMMEDIATE` fails. + pub fn transaction_immediate(&self) -> Result> { Transaction::begin(self, true) } @@ -175,8 +175,8 @@ impl Connection { /// /// # Errors /// - /// Returns `DbError` if the in-memory database cannot be opened. - pub fn open_in_memory() -> DbResult { + /// Returns `Error` if the in-memory database cannot be opened. + pub fn open_in_memory() -> Result { Self::open(Path::new(":memory:"), false) } } diff --git a/walletkit-db/src/error.rs b/walletkit-db/src/sqlite/error.rs similarity index 66% rename from walletkit-db/src/error.rs rename to walletkit-db/src/sqlite/error.rs index 2fb42fbba..b342d594c 100644 --- a/walletkit-db/src/error.rs +++ b/walletkit-db/src/sqlite/error.rs @@ -1,12 +1,12 @@ -//! Database error types for the safe `SQLite` wrapper. +//! 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); +pub struct ErrorCode(pub i32); -impl fmt::Display for DbErrorCode { +impl fmt::Display for ErrorCode { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { write!(f, "{}", self.0) } @@ -14,30 +14,30 @@ impl fmt::Display for DbErrorCode { /// Error returned by database operations. #[derive(Debug, PartialEq, Eq)] -pub struct DbError { +pub struct Error { /// `SQLite` result code. - pub code: DbErrorCode, + pub code: ErrorCode, /// Human-readable error message (from `sqlite3_errmsg` when available). pub message: String, } -impl DbError { +impl Error { /// Creates a new database error. pub(crate) fn new(code: i32, message: impl Into) -> Self { Self { - code: DbErrorCode(code), + code: ErrorCode(code), message: message.into(), } } } -impl fmt::Display for DbError { +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 DbError {} +impl std::error::Error for Error {} -/// Result type for database operations. -pub type DbResult = Result; +/// Result alias for [`Error`]. +pub type Result = std::result::Result; diff --git a/walletkit-db/src/ffi.rs b/walletkit-db/src/sqlite/ffi.rs similarity index 94% rename from walletkit-db/src/ffi.rs rename to walletkit-db/src/sqlite/ffi.rs index 286cd4437..b5d0b7bfe 100644 --- a/walletkit-db/src/ffi.rs +++ b/walletkit-db/src/sqlite/ffi.rs @@ -3,7 +3,7 @@ //! This module is the **only** place in the codebase that contains `unsafe` code //! or C types (`*mut c_void`, `CString`, etc.). It exposes two safe handle types //! -- [`RawDb`] and [`RawStmt`] -- whose methods perform the underlying FFI calls -//! and translate results into idiomatic Rust ([`DbResult`], `String`, `Vec`). +//! and translate results into idiomatic Rust ([`Result`], `String`, `Vec`). //! //! Why `unsafe` is required: `SQLite` is a C library. Calling any C function from //! Rust is `unsafe` by definition because the Rust compiler cannot verify memory @@ -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::{Error, Result}; // -- SQLite constants (plain `i32`, no C types leaked to callers) ------------- @@ -75,7 +75,7 @@ unsafe impl Send for RawDb {} impl RawDb { /// Opens (or creates) a database at the given `path`. - pub fn open(path: &str, flags: i32) -> DbResult { + pub fn open(path: &str, flags: i32) -> Result { let c_path = to_cstring(path)?; let mut ptr: *mut c_void = std::ptr::null_mut(); @@ -102,14 +102,14 @@ impl RawDb { } m }; - return Err(DbError::new(rc, msg)); + return Err(Error::new(rc, msg)); } Ok(Self { ptr }) } /// Executes one or more semicolon-separated SQL statements. No results. - pub fn exec(&self, sql: &str) -> DbResult<()> { + pub fn exec(&self, sql: &str) -> Result<()> { let c_sql = to_cstring(sql)?; let mut errmsg: *mut c_char = std::ptr::null_mut(); @@ -141,13 +141,13 @@ impl RawDb { } s }; - Err(DbError::new(rc, msg)) + Err(Error::new(rc, msg)) } /// Like [`exec`](Self::exec) but zeroizes the internal `CString` buffer /// after the FFI call. Use for SQL that contains sensitive material (e.g. /// `PRAGMA key`). - pub fn exec_zeroized(&self, sql: &str) -> DbResult<()> { + pub fn exec_zeroized(&self, sql: &str) -> Result<()> { let c_sql = to_cstring(sql)?; let mut errmsg: *mut c_char = std::ptr::null_mut(); @@ -183,11 +183,11 @@ impl RawDb { } s }; - Err(DbError::new(rc, msg)) + Err(Error::new(rc, msg)) } /// Prepares a single SQL statement for execution. - pub fn prepare(&self, sql: &str) -> DbResult> { + pub fn prepare(&self, sql: &str) -> Result> { let c_sql = to_cstring(sql)?; let mut stmt_ptr: *mut c_void = std::ptr::null_mut(); @@ -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 { @@ -252,37 +252,37 @@ impl std::fmt::Debug for RawDb { impl RawStmt<'_> { /// Executes a single step. Returns `SQLITE_ROW` or `SQLITE_DONE`. - pub fn step(&mut self) -> DbResult { + pub fn step(&mut self) -> Result { // Safety: self.ptr is a valid prepared statement. let rc = unsafe { raw::sqlite3_step(self.ptr) }; 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())), } } /// Resets the statement so it can be stepped again. #[allow(dead_code)] - pub fn reset(&mut self) -> DbResult<()> { + pub fn reset(&mut self) -> Result<()> { // Safety: self.ptr is valid. let rc = unsafe { raw::sqlite3_reset(self.ptr) }; if rc == SQLITE_OK as c_int { Ok(()) } else { - Err(DbError::new(rc, self.errmsg())) + Err(Error::new(rc, self.errmsg())) } } // -- Binding -------------------------------------------------------------- - pub fn bind_i64(&mut self, idx: i32, value: i64) -> DbResult<()> { + pub fn bind_i64(&mut self, idx: i32, value: i64) -> Result<()> { // Safety: self.ptr is valid; idx is a 1-based parameter index. let rc = unsafe { raw::sqlite3_bind_int64(self.ptr, idx as c_int, value) }; check(rc, self) } - pub fn bind_blob(&mut self, idx: i32, value: &[u8]) -> DbResult<()> { + pub fn bind_blob(&mut self, idx: i32, value: &[u8]) -> Result<()> { // Safety: value.as_ptr() is valid for value.len() bytes. // SQLITE_TRANSIENT tells SQLite to copy the data immediately. let rc = unsafe { @@ -297,7 +297,7 @@ impl RawStmt<'_> { check(rc, self) } - pub fn bind_text(&mut self, idx: i32, value: &str) -> DbResult<()> { + pub fn bind_text(&mut self, idx: i32, value: &str) -> Result<()> { // Safety: value.as_ptr() is valid for value.len() bytes. // SQLITE_TRANSIENT tells SQLite to copy the data immediately. let rc = unsafe { @@ -312,7 +312,7 @@ impl RawStmt<'_> { check(rc, self) } - pub fn bind_null(&mut self, idx: i32) -> DbResult<()> { + pub fn bind_null(&mut self, idx: i32) -> Result<()> { // Safety: self.ptr is valid. let rc = unsafe { raw::sqlite3_bind_null(self.ptr, idx as c_int) }; check(rc, self) @@ -385,9 +385,9 @@ impl Drop for RawStmt<'_> { // -- Helpers (private) -------------------------------------------------------- -fn to_cstring(s: &str) -> DbResult { +fn to_cstring(s: &str) -> Result { 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 { @@ -404,11 +404,11 @@ fn errmsg_from_ptr(db: *mut c_void) -> String { } } -fn check(rc: c_int, stmt: &RawStmt) -> DbResult<()> { +fn check(rc: c_int, stmt: &RawStmt) -> Result<()> { 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..8a0bacd76 --- /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 [`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::{Error, Result}; +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 92% rename from walletkit-db/src/statement.rs rename to walletkit-db/src/sqlite/statement.rs index 3d859c301..ee5f5118f 100644 --- a/walletkit-db/src/statement.rs +++ b/walletkit-db/src/sqlite/statement.rs @@ -3,7 +3,7 @@ //! This file contains **no `unsafe` code**. All FFI interaction is delegated to //! [`ffi::RawStmt`] which encapsulates the raw pointers and C type conversions. -use super::error::DbResult; +use super::error::Result; use super::ffi::{self, RawStmt}; use super::value::Value; @@ -95,12 +95,12 @@ impl<'conn> Statement<'conn> { /// /// # Errors /// - /// Returns `DbError` if any bind call fails. + /// Returns `Error` if any bind call fails. /// /// # Panics /// /// Panics if the number of values exceeds `i32::MAX`. - pub fn bind_values(&mut self, values: &[Value]) -> DbResult<()> { + pub fn bind_values(&mut self, values: &[Value]) -> Result<()> { for (i, val) in values.iter().enumerate() { let idx = i32::try_from(i + 1).expect("parameter index overflow"); match val { @@ -117,8 +117,8 @@ impl<'conn> Statement<'conn> { /// /// # Errors /// - /// Returns `DbError` if the step fails. - pub fn step<'stmt>(&'stmt mut self) -> DbResult> { + /// Returns `Error` if the step fails. + pub fn step<'stmt>(&'stmt mut self) -> Result> { let rc = self.raw.step()?; if rc == ffi::SQLITE_ROW { Ok(StepResult::Row(Row { stmt: self })) diff --git a/walletkit-db/src/transaction.rs b/walletkit-db/src/sqlite/transaction.rs similarity index 75% rename from walletkit-db/src/transaction.rs rename to walletkit-db/src/sqlite/transaction.rs index e0b79d797..0cb2fb483 100644 --- a/walletkit-db/src/transaction.rs +++ b/walletkit-db/src/sqlite/transaction.rs @@ -3,7 +3,7 @@ //! Automatically rolls back on drop unless explicitly committed. use super::connection::Connection; -use super::error::DbResult; +use super::error::Result; use super::statement::{Row, Statement}; use super::value::Value; @@ -22,7 +22,7 @@ impl<'conn> Transaction<'conn> { /// /// When `immediate` is true, the transaction acquires a RESERVED lock /// immediately (`BEGIN IMMEDIATE`) rather than deferring it. - pub(super) fn begin(conn: &'conn Connection, immediate: bool) -> DbResult { + pub(super) fn begin(conn: &'conn Connection, immediate: bool) -> Result { let sql = if immediate { "BEGIN IMMEDIATE" } else { @@ -39,8 +39,8 @@ impl<'conn> Transaction<'conn> { /// /// # Errors /// - /// Returns `DbError` if the COMMIT statement fails. - pub fn commit(mut self) -> DbResult<()> { + /// Returns `Error` if the COMMIT statement fails. + pub fn commit(mut self) -> Result<()> { self.conn.execute_batch("COMMIT")?; self.committed = true; Ok(()) @@ -52,9 +52,9 @@ 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<()> { + pub fn execute_batch(&self, sql: &str) -> Result<()> { self.conn.execute_batch(sql) } @@ -62,8 +62,8 @@ impl<'conn> Transaction<'conn> { /// /// # Errors /// - /// Returns `DbError` if preparation or execution fails. - pub fn execute(&self, sql: &str, params: &[Value]) -> DbResult { + /// Returns `Error` if preparation or execution fails. + pub fn execute(&self, sql: &str, params: &[Value]) -> Result { self.conn.execute(sql, params) } @@ -71,13 +71,13 @@ 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, params: &[Value], - mapper: impl FnOnce(&Row<'_, '_>) -> DbResult, - ) -> DbResult { + mapper: impl FnOnce(&Row<'_, '_>) -> Result, + ) -> Result { self.conn.query_row(sql, params, mapper) } @@ -85,8 +85,8 @@ impl<'conn> Transaction<'conn> { /// /// # Errors /// - /// Returns `DbError` if the SQL is invalid. - pub fn prepare(&self, sql: &str) -> DbResult> { + /// Returns `Error` if the SQL is invalid. + pub fn prepare(&self, sql: &str) -> Result> { self.conn.prepare(sql) } } diff --git a/walletkit-db/src/value.rs b/walletkit-db/src/sqlite/value.rs similarity index 95% rename from walletkit-db/src/value.rs rename to walletkit-db/src/sqlite/value.rs index 57a253a2f..8c0301d2e 100644 --- a/walletkit-db/src/value.rs +++ b/walletkit-db/src/sqlite/value.rs @@ -50,6 +50,6 @@ impl From<&str> for Value { #[macro_export] macro_rules! params { ($($val:expr),* $(,)?) => { - &[$($crate::Value::from($val)),*][..] + &[$($crate::sqlite::Value::from($val)),*][..] }; } diff --git a/walletkit-db/src/tests.rs b/walletkit-db/src/tests.rs index 4f7ab1a76..f51e82932 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 From 5b47d2f76abd81230f9f76f55ba2f2c07f428cad Mon Sep 17 00:00:00 2001 From: danielle-tfh Date: Thu, 7 May 2026 17:50:44 +0200 Subject: [PATCH 02/31] fix: address review feedback for PR #396 - README: rewrite walletkit-db blurb to generic-only; drop dangling sub-crate README pointer. - walletkit-db: re-export public types at crate root (Connection, Transaction, Statement, Row, StepResult, Value, cipher) and alias Error/Result back to DbError/DbResult for back-compat and to remove per-call-site aliasing in consumers. - vault: revert compute_content_id signature back to BlobKind (was weakened to u8 for no boundary reason); restore BlobKind::as_i64() and simplify the i64 cast call sites. - credential_storage: drop dead Ok(self.lock.lock()?) wrap. - vault: move BACKUP_TABLES into vault/schema.rs next to the schema it mirrors; revert blob_objects/credential_records ordering churn. - keys: relocate the 'Key structure' architecture doc block that was deleted from traits.rs in this refactor. - walletkit-db tests: add round-trip and non-empty-destination rejection coverage for cipher::export/import_plaintext_copy with a generic table. - docs: fix private intra-doc link to ffi module. Verified: cargo fmt, cargo clippy (all/default/no-default features), cargo test --workspace --lib (--features legacy-nullifiers,v3), cargo build --no-default-features, cargo deny (bans/licenses/sources), cargo doc --all-features (RUSTDOCFLAGS=-Dwarnings) all pass. --- README.md | 2 +- .../src/storage/cache/maintenance.rs | 3 +- walletkit-core/src/storage/cache/merkle.rs | 2 +- walletkit-core/src/storage/cache/mod.rs | 2 +- .../src/storage/cache/nullifiers.rs | 2 +- walletkit-core/src/storage/cache/schema.rs | 3 +- walletkit-core/src/storage/cache/session.rs | 2 +- walletkit-core/src/storage/cache/util.rs | 11 +-- .../src/storage/credential_storage.rs | 4 +- walletkit-core/src/storage/keys.rs | 13 +++ walletkit-core/src/storage/types.rs | 6 ++ walletkit-core/src/storage/vault/helpers.rs | 8 +- walletkit-core/src/storage/vault/mod.rs | 21 ++-- walletkit-core/src/storage/vault/schema.rs | 30 ++++-- walletkit-core/src/storage/vault/tests.rs | 8 +- walletkit-db/src/lib.rs | 9 +- walletkit-db/src/sqlite/mod.rs | 2 +- walletkit-db/src/tests.rs | 97 +++++++++++++++++++ 18 files changed, 176 insertions(+), 49 deletions(-) diff --git a/README.md b/README.md index da30ed3c3..f2f480dbb 100644 --- a/README.md +++ b/README.md @@ -97,7 +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` - WalletKit's encrypted on-device storage crate. Houses the `SQLCipher` (`sqlite3mc`) wrapper plus opinionated vault primitives (`Vault::open` with integrity check, envelope-sealed key init, content-addressed blob table, cross-process lock). Used by `walletkit-core` for credential storage and consumed by sibling SDKs (e.g. OrbKit's planned `OrbPcpStore`) for their own typed stores. See its README for design rationale and a worked example of writing a consumer. +- `walletkit-db` - Generic encrypted `SQLite` (`sqlite3mc`) wrapper providing safe connection, transaction, and statement types plus encrypted-open and plaintext export/import helpers. Used by `walletkit-core` for credential storage and consumable by sibling SDKs that need an encrypted on-device store. ### World ID Secret diff --git a/walletkit-core/src/storage/cache/maintenance.rs b/walletkit-core/src/storage/cache/maintenance.rs index 5b46b9934..2dd24206e 100644 --- a/walletkit-core/src/storage/cache/maintenance.rs +++ b/walletkit-core/src/storage/cache/maintenance.rs @@ -6,8 +6,7 @@ use std::path::Path; use secrecy::SecretBox; use crate::storage::error::StorageResult; -use walletkit_db::sqlite::cipher; -use walletkit_db::sqlite::Connection; +use walletkit_db::{cipher, Connection}; use super::schema; use super::util::{map_db_err, map_io_err}; diff --git a/walletkit-core/src/storage/cache/merkle.rs b/walletkit-core/src/storage/cache/merkle.rs index a7cfccff9..3fd75b679 100644 --- a/walletkit-core/src/storage/cache/merkle.rs +++ b/walletkit-core/src/storage/cache/merkle.rs @@ -1,7 +1,7 @@ //! Merkle proof cache helpers. use crate::storage::{cache::util::CACHE_KEY_PREFIX_MERKLE, error::StorageResult}; -use walletkit_db::sqlite::Connection; +use walletkit_db::Connection; use super::util::{ cache_entry_times, get_cache_entry, prune_expired_entries, upsert_cache_entry, diff --git a/walletkit-core/src/storage/cache/mod.rs b/walletkit-core/src/storage/cache/mod.rs index 32f2e00be..71eb05e47 100644 --- a/walletkit-core/src/storage/cache/mod.rs +++ b/walletkit-core/src/storage/cache/mod.rs @@ -5,7 +5,7 @@ use std::path::Path; use crate::storage::error::StorageResult; use crate::storage::lock::StorageLockGuard; use secrecy::SecretBox; -use walletkit_db::sqlite::Connection; +use walletkit_db::Connection; mod maintenance; mod merkle; diff --git a/walletkit-core/src/storage/cache/nullifiers.rs b/walletkit-core/src/storage/cache/nullifiers.rs index 02d5d0c4f..b5e6bc7fb 100644 --- a/walletkit-core/src/storage/cache/nullifiers.rs +++ b/walletkit-core/src/storage/cache/nullifiers.rs @@ -4,7 +4,7 @@ //! remaining idempotent for retries within the TTL window. use crate::storage::error::StorageResult; -use walletkit_db::sqlite::Connection; +use walletkit_db::Connection; use super::util::{ cache_entry_times, get_cache_entry, get_cache_entry_tx, insert_cache_entry_tx, diff --git a/walletkit-core/src/storage/cache/schema.rs b/walletkit-core/src/storage/cache/schema.rs index 780e25b0b..c12b2f517 100644 --- a/walletkit-core/src/storage/cache/schema.rs +++ b/walletkit-core/src/storage/cache/schema.rs @@ -1,8 +1,7 @@ //! Cache database schema management. use crate::storage::error::StorageResult; -use walletkit_db::params; -use walletkit_db::sqlite::Connection; +use walletkit_db::{params, Connection}; use super::util::map_db_err; diff --git a/walletkit-core/src/storage/cache/session.rs b/walletkit-core/src/storage/cache/session.rs index 4a0cbf1ee..003c330b2 100644 --- a/walletkit-core/src/storage/cache/session.rs +++ b/walletkit-core/src/storage/cache/session.rs @@ -1,7 +1,7 @@ //! Session seed cache helpers. use crate::storage::error::StorageResult; -use walletkit_db::sqlite::Connection; +use walletkit_db::Connection; use super::util::{ cache_entry_times, get_cache_entry, parse_fixed_bytes, prune_expired_entries, diff --git a/walletkit-core/src/storage/cache/util.rs b/walletkit-core/src/storage/cache/util.rs index d532a0143..abc33f176 100644 --- a/walletkit-core/src/storage/cache/util.rs +++ b/walletkit-core/src/storage/cache/util.rs @@ -3,8 +3,7 @@ use std::io; use crate::storage::error::{StorageError, StorageResult}; -use walletkit_db::params; -use walletkit_db::sqlite::{Connection, Error as DbError, Transaction}; +use walletkit_db::{params, Connection, DbError, Transaction}; /// Maps a database error into a cache storage error. pub(super) fn map_db_err(err: &DbError) -> StorageError { @@ -190,8 +189,8 @@ pub(super) fn get_cache_entry_tx( stmt.bind_values(params![key, now, insertion_before]) .map_err(|err| map_db_err(&err))?; match stmt.step().map_err(|err| map_db_err(&err))? { - walletkit_db::sqlite::StepResult::Row(row) => Ok(Some(row.column_blob(0))), - walletkit_db::sqlite::StepResult::Done => Ok(None), + walletkit_db::StepResult::Row(row) => Ok(Some(row.column_blob(0))), + walletkit_db::StepResult::Done => Ok(None), } } else { let mut stmt = tx.prepare( @@ -200,8 +199,8 @@ pub(super) fn get_cache_entry_tx( stmt.bind_values(params![key, now]) .map_err(|err| map_db_err(&err))?; match stmt.step().map_err(|err| map_db_err(&err))? { - walletkit_db::sqlite::StepResult::Row(row) => Ok(Some(row.column_blob(0))), - walletkit_db::sqlite::StepResult::Done => Ok(None), + walletkit_db::StepResult::Row(row) => Ok(Some(row.column_blob(0))), + walletkit_db::StepResult::Done => Ok(None), } } } diff --git a/walletkit-core/src/storage/credential_storage.rs b/walletkit-core/src/storage/credential_storage.rs index b0bb16668..04ec1c206 100644 --- a/walletkit-core/src/storage/credential_storage.rs +++ b/walletkit-core/src/storage/credential_storage.rs @@ -128,7 +128,7 @@ impl CredentialStoreInner { } fn guard(&self) -> StorageResult { - Ok(self.lock.lock()?) + self.lock.lock() } fn state(&self) -> StorageResult<&StorageState> { @@ -1282,7 +1282,7 @@ mod tests { #[test] fn test_import_vault_backup_transaction_atomicity() { use crate::storage::vault::BACKUP_TABLES; - use walletkit_db::sqlite::Connection; + use walletkit_db::Connection; use world_id_core::Credential as CoreCredential; let src_root = temp_root_path(); diff --git a/walletkit-core/src/storage/keys.rs b/walletkit-core/src/storage/keys.rs index ee7fb9f7b..d43cce8c5 100644 --- a/walletkit-core/src/storage/keys.rs +++ b/walletkit-core/src/storage/keys.rs @@ -1,4 +1,17 @@ //! 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; diff --git a/walletkit-core/src/storage/types.rs b/walletkit-core/src/storage/types.rs index 3b15f3eff..edbea91cd 100644 --- a/walletkit-core/src/storage/types.rs +++ b/walletkit-core/src/storage/types.rs @@ -15,6 +15,12 @@ pub enum BlobKind { AssociatedData = 2, } +impl BlobKind { + pub(crate) const fn as_i64(self) -> i64 { + self as i64 + } +} + impl TryFrom for BlobKind { type Error = StorageError; diff --git a/walletkit-core/src/storage/vault/helpers.rs b/walletkit-core/src/storage/vault/helpers.rs index 1fc31bff6..75242beb5 100644 --- a/walletkit-core/src/storage/vault/helpers.rs +++ b/walletkit-core/src/storage/vault/helpers.rs @@ -3,15 +3,15 @@ use sha2::{Digest, Sha256}; use crate::storage::error::{StorageError, StorageResult}; -use crate::storage::types::{ContentId, CredentialRecord}; -use walletkit_db::sqlite::{Error as DbError, Row}; +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(kind_tag: u8, plaintext: &[u8]) -> ContentId { +pub(super) fn compute_content_id(kind: BlobKind, plaintext: &[u8]) -> ContentId { let mut hasher = Sha256::new(); hasher.update(CONTENT_ID_PREFIX); - hasher.update([kind_tag]); + hasher.update([kind as u8]); hasher.update(plaintext); let digest = hasher.finalize(); let mut out = [0u8; 32]; diff --git a/walletkit-core/src/storage/vault/mod.rs b/walletkit-core/src/storage/vault/mod.rs index 44fc1274b..8bb6d1994 100644 --- a/walletkit-core/src/storage/vault/mod.rs +++ b/walletkit-core/src/storage/vault/mod.rs @@ -13,8 +13,9 @@ 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::params; -use walletkit_db::sqlite::{cipher, Connection, StepResult, Value}; +use walletkit_db::{cipher, params, Connection, StepResult, Value}; + +pub(crate) use schema::BACKUP_TABLES; /// Encrypted vault database wrapper. #[derive(Debug)] @@ -22,8 +23,6 @@ pub struct VaultDb { conn: Connection, } -pub(crate) const BACKUP_TABLES: &[&str] = &["credential_records", "blob_objects"]; - impl VaultDb { /// Opens or creates the encrypted vault database at `path`. /// @@ -116,10 +115,10 @@ impl VaultDb { now: u64, ) -> StorageResult { let credential_blob_id = - compute_content_id(BlobKind::CredentialBlob as u8, &credential_blob); + compute_content_id(BlobKind::CredentialBlob, &credential_blob); let associated_data_id = associated_data .as_ref() - .map(|bytes| compute_content_id(BlobKind::AssociatedData as u8, bytes)); + .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")?; @@ -131,7 +130,7 @@ impl VaultDb { VALUES (?1, ?2, ?3, ?4)", params![ credential_blob_id.as_ref(), - i64::from(BlobKind::CredentialBlob as u8), + BlobKind::CredentialBlob.as_i64(), now_i64, credential_blob.as_slice(), ], @@ -147,7 +146,7 @@ impl VaultDb { VALUES (?1, ?2, ?3, ?4)", params![ cid.as_ref(), - i64::from(BlobKind::AssociatedData as u8), + BlobKind::AssociatedData.as_i64(), now_i64, data.as_slice(), ], @@ -268,7 +267,7 @@ impl VaultDb { FROM credential_records cr WHERE cr.credential_blob_cid = blob_objects.content_id )", - params![i64::from(BlobKind::CredentialBlob as u8)], + params![BlobKind::CredentialBlob.as_i64()], ) .map_err(|err| map_db_err(&err))?; @@ -281,7 +280,7 @@ impl VaultDb { FROM credential_records cr WHERE cr.associated_data_cid = blob_objects.content_id )", - params![i64::from(BlobKind::AssociatedData as u8)], + params![BlobKind::AssociatedData.as_i64()], ) .map_err(|err| map_db_err(&err))?; @@ -402,7 +401,7 @@ impl VaultDb { /// Borrows the underlying connection for direct SQL access. **Test-only.** #[cfg(test)] - pub(super) const fn raw_connection(&self) -> &walletkit_db::sqlite::Connection { + pub(super) const fn raw_connection(&self) -> &Connection { &self.conn } } diff --git a/walletkit-core/src/storage/vault/schema.rs b/walletkit-core/src/storage/vault/schema.rs index e40133a78..7389cc361 100644 --- a/walletkit-core/src/storage/vault/schema.rs +++ b/walletkit-core/src/storage/vault/schema.rs @@ -2,12 +2,22 @@ //! //! Owns the credential vault tables and backup-sensitive schema. -use walletkit_db::sqlite::{Connection, Result as DbResult}; +use walletkit_db::{Connection, DbResult}; pub(super) const VAULT_SCHEMA_VERSION: i64 = 1; +/// 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:** New tables added to the vault schema must be added here too. +pub const BACKUP_TABLES: &[&str] = &["credential_records", "blob_objects"]; + /// **Backup sensitivity:** Schema changes here affect vault backups made into the backup system. -/// - New tables must be added to [`super::BACKUP_TABLES`]. +/// - New tables must be added to [`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<()> { @@ -19,14 +29,6 @@ pub(super) fn ensure_schema(conn: &Connection) -> DbResult<()> { updated_at INTEGER NOT NULL ); - 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) - ); - CREATE UNIQUE INDEX IF NOT EXISTS idx_vault_meta_schema_version ON vault_meta (schema_version); @@ -55,6 +57,14 @@ pub(super) fn ensure_schema(conn: &Connection) -> DbResult<()> { 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) + ); ", ) } diff --git a/walletkit-core/src/storage/vault/tests.rs b/walletkit-core/src/storage/vault/tests.rs index 33c0e581a..a96572b80 100644 --- a/walletkit-core/src/storage/vault/tests.rs +++ b/walletkit-core/src/storage/vault/tests.rs @@ -162,8 +162,8 @@ fn test_store_credential_with_associated_data() { #[test] fn test_content_id_determinism() { - let a = compute_content_id(BlobKind::CredentialBlob as u8, b"data"); - let b = compute_content_id(BlobKind::CredentialBlob as u8, b"data"); + let a = compute_content_id(BlobKind::CredentialBlob, b"data"); + let b = compute_content_id(BlobKind::CredentialBlob, b"data"); assert_eq!(a, b); } @@ -432,7 +432,7 @@ fn test_delete_credential_cleans_up_orphaned_associated_data() { .raw_connection() .query_row( "SELECT COUNT(*) FROM blob_objects WHERE blob_kind = ?1", - params![i64::from(BlobKind::AssociatedData as u8)], + params![BlobKind::AssociatedData.as_i64()], |stmt| Ok(stmt.column_i64(0)), ) .map_err(|err| map_db_err(&err)) @@ -446,7 +446,7 @@ fn test_delete_credential_cleans_up_orphaned_associated_data() { .raw_connection() .query_row( "SELECT COUNT(*) FROM blob_objects WHERE blob_kind = ?1", - params![i64::from(BlobKind::AssociatedData as u8)], + params![BlobKind::AssociatedData.as_i64()], |stmt| Ok(stmt.column_i64(0)), ) .map_err(|err| map_db_err(&err)) diff --git a/walletkit-db/src/lib.rs b/walletkit-db/src/lib.rs index 3b4dc6736..19c1f25cb 100644 --- a/walletkit-db/src/lib.rs +++ b/walletkit-db/src/lib.rs @@ -1,15 +1,20 @@ //! Generic encrypted `SQLite` (`sqlite3mc`) wrapper. //! -//! The public API is exposed through [`sqlite`]: +//! The public API: //! //! - safe Rust connection / transaction / statement types //! - encrypted open helpers and integrity checks //! - plaintext export / import helpers parameterized by caller-owned tables //! -//! Raw FFI lives behind the `sqlite` module; consumer crates own their own +//! Raw FFI lives behind the [`sqlite`] module; consumer crates own their own //! schema, queries, and higher-level storage policy. pub mod sqlite; +pub use sqlite::{ + cipher, Connection, Error as DbError, Result as DbResult, Row, Statement, + StepResult, Transaction, Value, +}; + #[cfg(test)] mod tests; diff --git a/walletkit-db/src/sqlite/mod.rs b/walletkit-db/src/sqlite/mod.rs index 8a0bacd76..0e10c8c22 100644 --- a/walletkit-db/src/sqlite/mod.rs +++ b/walletkit-db/src/sqlite/mod.rs @@ -8,7 +8,7 @@ //! - **WASM** (`wasm32`): delegated to `sqlite-wasm-rs` (with the //! `sqlite3mc` feature) which ships its own `WASM`-compiled `sqlite3mc`. //! -//! The [`ffi`] module is the only file in this crate that contains +//! The internal `ffi` module is the only file in this crate that contains //! `unsafe` code or `C` types. mod ffi; diff --git a/walletkit-db/src/tests.rs b/walletkit-db/src/tests.rs index f51e82932..63abb52f2 100644 --- a/walletkit-db/src/tests.rs +++ b/walletkit-db/src/tests.rs @@ -182,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}" + ); +} From d75c37bdd5762cc7976ed0dafa9f3effe260aa6f Mon Sep 17 00:00:00 2001 From: danielle-tfh Date: Thu, 7 May 2026 18:37:37 +0200 Subject: [PATCH 03/31] feat(walletkit-db): add shared storage primitives Add Vault opener with consumer-supplied schema, content-addressed Blobs (with frozen content_id derivation), CBOR KeyEnvelope + init_or_open_envelope_key, cross-process Lock (native flock / WASM no-op), and plain-Rust Keystore + AtomicBlobStore traits for consumers that need their own FFI surface to adapt onto. --- Cargo.lock | 4 + walletkit-db/Cargo.toml | 13 +- walletkit-db/src/blobs.rs | 145 +++++++++++++++++++ walletkit-db/src/envelope.rs | 130 +++++++++++++++++ walletkit-db/src/error.rs | 42 ++++++ walletkit-db/src/lib.rs | 37 ++++- walletkit-db/src/lock.rs | 269 +++++++++++++++++++++++++++++++++++ walletkit-db/src/tests.rs | 209 +++++++++++++++++++++++++++ walletkit-db/src/traits.rs | 58 ++++++++ walletkit-db/src/vault.rs | 65 +++++++++ 10 files changed, 963 insertions(+), 9 deletions(-) create mode 100644 walletkit-db/src/blobs.rs create mode 100644 walletkit-db/src/envelope.rs create mode 100644 walletkit-db/src/error.rs create mode 100644 walletkit-db/src/lock.rs create mode 100644 walletkit-db/src/traits.rs create mode 100644 walletkit-db/src/vault.rs diff --git a/Cargo.lock b/Cargo.lock index 357f34d5c..971ad01d2 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -8247,11 +8247,15 @@ name = "walletkit-db" version = "0.16.1" 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/walletkit-db/Cargo.toml b/walletkit-db/Cargo.toml index 71368eddf..54a6aff25 100644 --- a/walletkit-db/Cargo.toml +++ b/walletkit-db/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "walletkit-db" -description = "Generic encrypted SQLite wrapper backed by sqlite3mc." +description = "Encrypted on-device storage primitives for WalletKit (SQLCipher wrapper, vault, content-addressed blobs, key envelope)." publish = true version.workspace = true edition.workspace = true @@ -15,11 +15,20 @@ 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/src/blobs.rs b/walletkit-db/src/blobs.rs new file mode 100644 index 000000000..cbdb49cd8 --- /dev/null +++ b/walletkit-db/src/blobs.rs @@ -0,0 +1,145 @@ +//! 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::params; +use crate::sqlite::{Connection, Result as DbResult}; + +const CONTENT_ID_PREFIX: &[u8] = b"worldid:blob"; + +/// 32-byte content identifier for a stored blob. +/// +/// Content ids are deterministic functions of `(kind, plaintext)` — see +/// [`compute_content_id`] for the exact derivation. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub struct ContentId([u8; 32]); + +impl ContentId { + /// Constructs a [`ContentId`] from raw bytes (no derivation). + #[must_use] + pub const fn new(bytes: [u8; 32]) -> Self { + Self(bytes) + } + + /// Borrows the underlying 32-byte buffer. + #[must_use] + pub const fn as_bytes(&self) -> &[u8; 32] { + &self.0 + } + + /// Consumes the [`ContentId`] and returns the underlying buffer. + #[must_use] + pub const fn into_bytes(self) -> [u8; 32] { + self.0 + } +} + +impl AsRef<[u8]> for ContentId { + fn as_ref(&self) -> &[u8] { + &self.0 + } +} + +impl From<[u8; 32]> for ContentId { + fn from(bytes: [u8; 32]) -> Self { + Self(bytes) + } +} + +/// 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); + ContentId(out) +} + +/// Content-addressed blob table operations. +/// +/// Type-only namespace; methods are associated functions taking a +/// [`Connection`] so callers can compose them into their own transactions. +pub struct Blobs; + +impl Blobs { + /// Creates the `blob_objects` table if it does not exist. + /// + /// Idempotent. The exact DDL is part of the on-disk format contract; + /// callers should not attempt to 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 database error if the insert fails. + pub fn put( + conn: &Connection, + kind: u8, + bytes: &[u8], + now: i64, + ) -> DbResult { + 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, bytes], + )?; + Ok(cid) + } + + /// Fetches blob bytes by content id, if present. + /// + /// # Errors + /// + /// Returns a database error if the query fails. + pub fn get(conn: &Connection, cid: &ContentId) -> DbResult>> { + conn.query_row_optional( + "SELECT bytes FROM blob_objects WHERE content_id = ?1", + params![cid.as_ref()], + |row| Ok(row.column_blob(0)), + ) + } +} diff --git a/walletkit-db/src/envelope.rs b/walletkit-db/src/envelope.rs new file mode 100644 index 000000000..ce24a2666 --- /dev/null +++ b/walletkit-db/src/envelope.rs @@ -0,0 +1,130 @@ +//! 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::LockGuard; +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 { + /// Envelope format version. Mismatch triggers + /// [`StoreError::UnsupportedEnvelopeVersion`]. + pub version: u32, + /// Output of [`Keystore::seal`] over the 32-byte intermediate key. + pub wrapped_k_intermediate: Vec, + /// Unix timestamp (seconds) recorded when the envelope was first written. + pub created_at: u64, + /// Unix timestamp (seconds) recorded on the most recent write. + pub 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. +/// +/// Holding `_lock` ensures the read-open / generate-write sequence is +/// serialized across processes. +/// +/// # Errors +/// +/// Propagates errors from the keystore, blob store, CBOR codec, or RNG. +pub fn init_or_open_envelope_key( + keystore: &dyn Keystore, + blob_store: &dyn AtomicBlobStore, + _lock: &LockGuard, + filename: &str, + ad: &[u8], + now: u64, +) -> StoreResult> { + if let Some(bytes) = blob_store.read(filename)? { + let envelope = KeyEnvelope::deserialize(&bytes)?; + let k_intermediate_bytes = + Zeroizing::new(keystore.open_sealed(ad, &envelope.wrapped_k_intermediate)?); + 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}")))?; + let wrapped = keystore.seal(ad, k_intermediate.as_ref())?; + let envelope = KeyEnvelope::new(wrapped, now); + let bytes = envelope.serialize()?; + blob_store.write_atomic(filename, &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) +} diff --git a/walletkit-db/src/error.rs b/walletkit-db/src/error.rs new file mode 100644 index 000000000..d8caba0df --- /dev/null +++ b/walletkit-db/src/error.rs @@ -0,0 +1,42 @@ +//! 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 [`crate::sqlite`]. + #[error("database error: {0}")] + Db(#[from] DbError), + /// `PRAGMA integrity_check` reported corruption. + #[error("integrity check failed: {0}")] + IntegrityCheckFailed(String), +} diff --git a/walletkit-db/src/lib.rs b/walletkit-db/src/lib.rs index 19c1f25cb..c8240490e 100644 --- a/walletkit-db/src/lib.rs +++ b/walletkit-db/src/lib.rs @@ -1,20 +1,43 @@ -//! Generic encrypted `SQLite` (`sqlite3mc`) wrapper. +//! Encrypted on-device storage primitives for `WalletKit`. //! -//! The public API: +//! The crate provides building blocks shared by `walletkit-core::storage` and +//! sibling SDKs (e.g. `OrbKit`'s `OrbPcpStore`): //! -//! - safe Rust connection / transaction / statement types -//! - encrypted open helpers and integrity checks -//! - plaintext export / import helpers parameterized by caller-owned tables +//! - [`sqlite`] — encrypted `SQLite` (`sqlite3mc`) wrapper with safe Rust +//! connection / transaction / statement types. +//! - [`Vault`] — encrypted-database opener with caller-supplied schema. +//! - [`Blobs`], [`ContentId`], [`compute_content_id`] — content-addressed +//! blob storage shared across consumer schemas. +//! - [`KeyEnvelope`] + [`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. //! -//! Raw FFI lives behind the [`sqlite`] module; consumer crates own their own -//! schema, queries, and higher-level storage policy. +//! Consumers own their schemas, FFI surfaces, and storage policy on top of +//! these primitives. pub mod sqlite; +mod blobs; +mod envelope; +mod error; +mod lock; +mod traits; +mod vault; + +pub use blobs::{compute_content_id, Blobs, ContentId}; +pub use envelope::{init_or_open_envelope_key, KeyEnvelope}; +pub use error::{StoreError, StoreResult}; +pub use lock::{Lock, LockGuard}; pub use sqlite::{ cipher, Connection, Error as DbError, Result as DbResult, Row, Statement, StepResult, Transaction, Value, }; +pub use traits::{AtomicBlobStore, Keystore}; +pub use vault::Vault; #[cfg(test)] mod tests; diff --git a/walletkit-db/src/lock.rs b/walletkit-db/src/lock.rs new file mode 100644 index 000000000..febdf2760 --- /dev/null +++ b/walletkit-db/src/lock.rs @@ -0,0 +1,269 @@ +//! Cross-process exclusive lock used to serialize writes. +//! +//! On native platforms (Unix, Windows) a file-based `flock` / `LockFileEx` +//! lock is used to serialize writes across processes. +//! +//! 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. + +use std::path::Path; + +use crate::error::StoreResult; + +#[cfg(target_arch = "wasm32")] +mod imp { + use super::*; + + /// No-op storage lock for WASM. + #[derive(Debug, Clone)] + pub struct Lock; + + /// No-op lock guard. + #[derive(Debug)] + pub struct LockGuard; + + impl Lock { + /// Opens a no-op lock (WASM is single-threaded). + pub fn open(_path: &Path) -> StoreResult { + Ok(Self) + } + + /// Acquires a no-op lock (always succeeds). + pub fn lock(&self) -> StoreResult { + Ok(LockGuard) + } + + /// Attempts to acquire a no-op lock (always succeeds). + pub fn try_lock(&self) -> StoreResult> { + Ok(Some(LockGuard)) + } + } +} + +#[cfg(not(target_arch = "wasm32"))] +mod imp { + 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. + #[derive(Debug, Clone)] + pub struct Lock { + file: Arc, + } + + /// Guard that holds an exclusive lock for its lifetime. + #[derive(Debug)] + pub struct LockGuard { + file: Arc, + } + + 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) -> StoreResult { + if let Some(parent) = path.parent() { + fs::create_dir_all(parent).map_err(|err| map_io_err(&err))?; + } + let file = OpenOptions::new() + .read(true) + .write(true) + .create(true) + .truncate(false) + .open(path) + .map_err(|err| map_io_err(&err))?; + Ok(Self { + file: Arc::new(file), + }) + } + + /// Acquires the exclusive lock. + /// + /// # Errors + /// + /// Returns an error if the lock cannot be acquired. + pub fn lock(&self) -> StoreResult { + lock_exclusive(&self.file).map_err(|err| map_io_err(&err))?; + Ok(LockGuard { + file: Arc::clone(&self.file), + }) + } + + /// Attempts to acquire the exclusive lock without blocking. + /// + /// # Errors + /// + /// Returns an error if the lock attempt fails for reasons other than + /// the lock being held by another process. + pub fn try_lock(&self) -> StoreResult> { + if try_lock_exclusive(&self.file).map_err(|err| map_io_err(&err))? { + Ok(Some(LockGuard { + file: Arc::clone(&self.file), + })) + } else { + Ok(None) + } + } + } + + impl Drop for LockGuard { + fn drop(&mut self) { + let _ = unlock(&self.file); + } + } + + fn map_io_err(err: &std::io::Error) -> StoreError { + StoreError::Lock(err.to_string()) + } + + #[cfg(unix)] + fn lock_exclusive(file: &File) -> std::io::Result<()> { + let fd = std::os::unix::io::AsRawFd::as_raw_fd(file); + let result = unsafe { flock(fd, LOCK_EX) }; + if result == 0 { + Ok(()) + } else { + Err(std::io::Error::last_os_error()) + } + } + + #[cfg(unix)] + fn try_lock_exclusive(file: &File) -> std::io::Result { + let fd = std::os::unix::io::AsRawFd::as_raw_fd(file); + let result = unsafe { flock(fd, LOCK_EX | LOCK_NB) }; + if result == 0 { + Ok(true) + } else { + let err = std::io::Error::last_os_error(); + if err.kind() == std::io::ErrorKind::WouldBlock { + Ok(false) + } else { + Err(err) + } + } + } + + #[cfg(unix)] + fn unlock(file: &File) -> std::io::Result<()> { + let fd = std::os::unix::io::AsRawFd::as_raw_fd(file); + let result = unsafe { flock(fd, LOCK_UN) }; + if result == 0 { + Ok(()) + } else { + Err(std::io::Error::last_os_error()) + } + } + + #[cfg(unix)] + use std::os::raw::c_int; + + #[cfg(unix)] + const LOCK_EX: c_int = 2; + #[cfg(unix)] + const LOCK_NB: c_int = 4; + #[cfg(unix)] + const LOCK_UN: c_int = 8; + + #[cfg(unix)] + extern "C" { + fn flock(fd: c_int, operation: c_int) -> c_int; + } + + #[cfg(windows)] + fn lock_exclusive(file: &File) -> std::io::Result<()> { + lock_file(file, 0) + } + + #[cfg(windows)] + fn try_lock_exclusive(file: &File) -> std::io::Result { + match lock_file(file, LOCKFILE_FAIL_IMMEDIATELY) { + Ok(()) => Ok(true), + Err(err) => { + if err.raw_os_error() == Some(ERROR_LOCK_VIOLATION) { + Ok(false) + } else { + Err(err) + } + } + } + } + + #[cfg(windows)] + fn unlock(file: &File) -> std::io::Result<()> { + let handle = std::os::windows::io::AsRawHandle::as_raw_handle(file) as HANDLE; + let mut overlapped: OVERLAPPED = unsafe { std::mem::zeroed() }; + let result = unsafe { UnlockFileEx(handle, 0, 1, 0, &mut overlapped) }; + if result != 0 { + Ok(()) + } else { + Err(std::io::Error::last_os_error()) + } + } + + #[cfg(windows)] + fn lock_file(file: &File, flags: u32) -> std::io::Result<()> { + let handle = std::os::windows::io::AsRawHandle::as_raw_handle(file) as HANDLE; + let mut overlapped: OVERLAPPED = unsafe { std::mem::zeroed() }; + let result = unsafe { + LockFileEx( + handle, + LOCKFILE_EXCLUSIVE_LOCK | flags, + 0, + 1, + 0, + &mut overlapped, + ) + }; + if result != 0 { + Ok(()) + } else { + Err(std::io::Error::last_os_error()) + } + } + + #[cfg(windows)] + type HANDLE = *mut std::ffi::c_void; + + #[cfg(windows)] + #[repr(C)] + struct OVERLAPPED { + internal: usize, + internal_high: usize, + offset: u32, + offset_high: u32, + h_event: HANDLE, + } + + #[cfg(windows)] + const LOCKFILE_EXCLUSIVE_LOCK: u32 = 0x2; + #[cfg(windows)] + const LOCKFILE_FAIL_IMMEDIATELY: u32 = 0x1; + #[cfg(windows)] + const ERROR_LOCK_VIOLATION: i32 = 33; + + #[cfg(windows)] + extern "system" { + fn LockFileEx( + h_file: HANDLE, + flags: u32, + reserved: u32, + bytes_to_lock_low: u32, + bytes_to_lock_high: u32, + overlapped: *mut OVERLAPPED, + ) -> i32; + fn UnlockFileEx( + h_file: HANDLE, + reserved: u32, + bytes_to_unlock_low: u32, + bytes_to_unlock_high: u32, + overlapped: *mut OVERLAPPED, + ) -> i32; + } +} + +pub use imp::{Lock, LockGuard}; diff --git a/walletkit-db/src/tests.rs b/walletkit-db/src/tests.rs index 63abb52f2..7adbc6d48 100644 --- a/walletkit-db/src/tests.rs +++ b/walletkit-db/src/tests.rs @@ -279,3 +279,212 @@ fn test_cipher_import_rejects_non_empty_destination() { "expected non-empty-table error, got: {err}" ); } + +// ------------------------------------------------------------------------- +// Storage primitives: blobs, envelope, lock, vault +// ------------------------------------------------------------------------- + +mod primitives { + use super::init_sqlite; + use crate::{ + compute_content_id, init_or_open_envelope_key, AtomicBlobStore, Blobs, + ContentId, KeyEnvelope, Keystore, Lock, StoreError, StoreResult, Vault, + }; + use secrecy::{ExposeSecret, SecretBox}; + use std::sync::Mutex; + + // ---- compute_content_id format guard -------------------------------- + // + // The hash domain is part of the on-disk format. Changing this test + // means breaking every existing user database. + + #[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 = hex::decode( + "ed4eba40f11beec64d0607586f09b7529418ef31bf2c46cf9b8b905615f2e7ca", + ) + .expect("decode hex"); + assert_eq!(cid.as_bytes(), expected.as_slice()); + + let cid2 = compute_content_id(2, b"hello"); + assert_ne!(cid, cid2, "kind tag must affect content id"); + } + + #[test] + fn test_content_id_round_trip() { + let bytes = [42u8; 32]; + let cid = ContentId::new(bytes); + assert_eq!(cid.as_bytes(), &bytes); + assert_eq!(cid.into_bytes(), bytes); + } + + // ---- KeyEnvelope CBOR format guard ---------------------------------- + + #[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_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"), + } + } + + // ---- Lock -------------------------------------------------------------- + + #[test] + #[cfg(not(target_arch = "wasm32"))] + 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 = 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()); + } + + // ---- Envelope helper end-to-end ---------------------------------------- + // + // Uses a 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: &[u8], plaintext: &[u8]) -> StoreResult> { + Ok(plaintext + .iter() + .enumerate() + .map(|(i, b)| b ^ self.pad[i % 32]) + .collect()) + } + fn open_sealed(&self, _ad: &[u8], ciphertext: &[u8]) -> 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: &str) -> StoreResult>> { + Ok(self.inner.lock().unwrap().get(path).cloned()) + } + fn write_atomic(&self, path: &str, bytes: &[u8]) -> StoreResult<()> { + self.inner + .lock() + .unwrap() + .insert(path.to_string(), bytes.to_vec()); + Ok(()) + } + fn delete(&self, path: &str) -> 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 guard = lock.lock().expect("acquire"); + + let keystore = XorKeystore { pad: [0xAA; 32] }; + let blobs = InMemoryBlobs::new(); + let key_a = init_or_open_envelope_key( + &keystore, &blobs, &guard, "k.bin", b"test-ad", 100, + ) + .expect("init"); + let key_b = init_or_open_envelope_key( + &keystore, &blobs, &guard, "k.bin", b"test-ad", 200, + ) + .expect("re-open"); + + assert_eq!(key_a.expose_secret(), key_b.expose_secret()); + } + + // ---- Vault::open -------------------------------------------------------- + + #[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 lock_path = dir.path().join("vault.lock"); + let lock = Lock::open(&lock_path).expect("open lock"); + let guard = lock.lock().expect("acquire"); + let key = SecretBox::init_with(|| [0x42u8; 32]); + + let vault = Vault::open(&db_path, &key, &guard, |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 lock_path = dir.path().join("vault.lock"); + let lock = Lock::open(&lock_path).expect("open lock"); + let guard = lock.lock().expect("acquire"); + let key = SecretBox::init_with(|| [0x11u8; 32]); + Vault::open(&db_path, &key, &guard, Blobs::ensure_schema) + .expect("create vault"); + drop(guard); + let guard = lock.lock().expect("re-acquire"); + let wrong = SecretBox::init_with(|| [0x22u8; 32]); + let err = + Vault::open(&db_path, &wrong, &guard, |_| Ok(())).expect_err("wrong key"); + assert!(matches!(err, StoreError::Db(_))); + } +} diff --git a/walletkit-db/src/traits.rs b/walletkit-db/src/traits.rs new file mode 100644 index 000000000..a7b04f422 --- /dev/null +++ b/walletkit-db/src/traits.rs @@ -0,0 +1,58 @@ +//! Plain-Rust trait surface for consumer-supplied platform integrations. +//! +//! Consumers that need FFI define their own annotated traits and adapt to +//! these via newtype wrappers. + +use crate::error::StoreResult; + +/// Device keystore for sealing and opening secrets under a device-bound key. +/// +/// Implementations must integrity-protect `associated_data` as part of the +/// seal: any mismatch when opening must fail. +pub trait Keystore: Send + Sync { + /// Seals plaintext under the device-bound key, authenticating + /// `associated_data`. + /// + /// # Errors + /// + /// Returns an error if the keystore refuses the operation or the seal + /// fails. + fn seal(&self, associated_data: &[u8], plaintext: &[u8]) -> StoreResult>; + + /// Opens ciphertext under the device-bound key, verifying + /// `associated_data`. The same associated data used during sealing must + /// be supplied or the open operation must fail. + /// + /// # Errors + /// + /// Returns an error if authentication fails or the keystore cannot open. + fn open_sealed( + &self, + associated_data: &[u8], + ciphertext: &[u8], + ) -> StoreResult>; +} + +/// Atomic blob store for small binary files (e.g. sealed key envelopes). +pub trait AtomicBlobStore: Send + Sync { + /// Reads the blob at `path`, if present. + /// + /// # Errors + /// + /// Returns an error if the read fails. + fn read(&self, path: &str) -> StoreResult>>; + + /// Writes bytes atomically to `path`. + /// + /// # Errors + /// + /// Returns an error if the write fails. + fn write_atomic(&self, path: &str, bytes: &[u8]) -> StoreResult<()>; + + /// Deletes the blob at `path`. + /// + /// # Errors + /// + /// Returns an error if the delete fails. + fn delete(&self, path: &str) -> StoreResult<()>; +} diff --git a/walletkit-db/src/vault.rs b/walletkit-db/src/vault.rs new file mode 100644 index 000000000..7107a4c23 --- /dev/null +++ b/walletkit-db/src/vault.rs @@ -0,0 +1,65 @@ +//! Encrypted vault opener with caller-supplied schema. +//! +//! [`Vault::open`] composes [`crate::sqlite::cipher::open_encrypted`], a +//! consumer-owned schema callback, and an integrity check into the standard +//! "open + key + ensure schema + verify" flow used by all `WalletKit` +//! storage consumers. + +use std::path::Path; + +use secrecy::SecretBox; + +use crate::error::{StoreError, StoreResult}; +use crate::lock::LockGuard; +use crate::sqlite::{cipher, Connection, Result as DbResult}; + +/// Encrypted-database wrapper holding the open `sqlite3mc` connection. +/// +/// Consumers compose schema-specific operations on top via +/// [`Vault::connection`]. +#[derive(Debug)] +pub struct Vault { + conn: Connection, +} + +impl Vault { + /// Opens (or creates) an encrypted database at `path`, runs + /// `ensure_schema`, then verifies integrity. + /// + /// `key` is the 32-byte intermediate key passed to `sqlite3mc`. `_lock` + /// is an in-scope [`LockGuard`] that proves the caller serialized writes + /// via [`crate::Lock`]. `ensure_schema` runs after the database is + /// opened and keyed but before the integrity check, and may create + /// tables, indexes, and triggers. + /// + /// # Errors + /// + /// Returns [`StoreError::Db`] if opening, keying, or schema setup fails, + /// or [`StoreError::IntegrityCheckFailed`] if `PRAGMA integrity_check` + /// reports corruption. + pub fn open( + path: &Path, + key: &SecretBox<[u8; 32]>, + _lock: &LockGuard, + ensure_schema: F, + ) -> StoreResult + where + F: FnOnce(&Connection) -> DbResult<()>, + { + let conn = cipher::open_encrypted(path, key, false)?; + ensure_schema(&conn)?; + let vault = Self { conn }; + if !cipher::integrity_check(&vault.conn)? { + return Err(StoreError::IntegrityCheckFailed( + "integrity_check failed".to_string(), + )); + } + Ok(vault) + } + + /// Borrows the underlying connection for direct SQL access. + #[must_use] + pub const fn connection(&self) -> &Connection { + &self.conn + } +} From 3a42c6b9439758a7bf8ba58454fa0b741439565e Mon Sep 17 00:00:00 2001 From: danielle-tfh Date: Thu, 7 May 2026 18:47:22 +0200 Subject: [PATCH 04/31] refactor(walletkit-core): adopt walletkit-db storage primitives Delete walletkit-core/src/storage/envelope.rs and lock.rs (whole-file moves into walletkit-db). Re-export StorageLock/StorageLockGuard from walletkit-db with the existing walletkit-core names so the public Rust API stays intact. Storage glue: - StorageKeys::init now wraps walletkit_db::init_or_open_envelope_key, with thin Keystore + AtomicBlobStore adapters that bridge the uniffi-annotated DeviceKeystore + AtomicBlobStore traits to the plain-Rust trait surface walletkit-db expects. - VaultDb is reshaped as a thin wrapper over walletkit_db::Vault::open with a credential schema callback. Blob writes go through walletkit_db::Blobs::put; orphan-blob cleanup queries stay schema-aware in walletkit-core. blob_objects DDL moves to Blobs::ensure_schema; vault/schema.rs keeps only vault_meta and credential_records. - ContentId is re-exported from walletkit-db (now a newtype). Internal call sites use cid.as_bytes() / AsRef<[u8]>; on-disk SHA-256 derivation is unchanged. - StorageError gains From mapping each generic variant; FFI-exposed StorageError surface is unchanged. - New on-disk-format guard test in vault/tests.rs: stores a credential with deterministic inputs, asserts the credential_blob content_id matches a frozen SHA-256, closes and reopens the vault, fetches back, and asserts byte-equality with the original payload. --- walletkit-core/src/storage/cache/mod.rs | 4 +- .../src/storage/credential_storage.rs | 4 +- walletkit-core/src/storage/envelope.rs | 73 ---- walletkit-core/src/storage/error.rs | 20 ++ walletkit-core/src/storage/keys.rs | 111 +++--- walletkit-core/src/storage/lock.rs | 331 ------------------ walletkit-core/src/storage/mod.rs | 4 +- walletkit-core/src/storage/types.rs | 3 +- walletkit-core/src/storage/vault/helpers.rs | 19 +- walletkit-core/src/storage/vault/mod.rs | 105 +++--- walletkit-core/src/storage/vault/schema.rs | 19 +- walletkit-core/src/storage/vault/tests.rs | 77 +++- 12 files changed, 221 insertions(+), 549 deletions(-) delete mode 100644 walletkit-core/src/storage/envelope.rs delete mode 100644 walletkit-core/src/storage/lock.rs diff --git a/walletkit-core/src/storage/cache/mod.rs b/walletkit-core/src/storage/cache/mod.rs index 71eb05e47..d7247936a 100644 --- a/walletkit-core/src/storage/cache/mod.rs +++ b/walletkit-core/src/storage/cache/mod.rs @@ -3,7 +3,7 @@ use std::path::Path; use crate::storage::error::StorageResult; -use crate::storage::lock::StorageLockGuard; +use crate::storage::StorageLockGuard; use secrecy::SecretBox; use walletkit_db::Connection; @@ -138,7 +138,7 @@ impl CacheDb { #[cfg(test)] mod tests { use super::*; - use crate::storage::lock::StorageLock; + use crate::storage::StorageLock; use secrecy::SecretBox; use std::fs; use std::path::PathBuf; diff --git a/walletkit-core/src/storage/credential_storage.rs b/walletkit-core/src/storage/credential_storage.rs index 04ec1c206..cb336e318 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"))] @@ -17,6 +16,7 @@ use super::traits::{AtomicBlobStore, DeviceKeystore}; use super::types::CredentialRecord; use super::ACCOUNT_KEYS_FILENAME; use super::{CacheDb, VaultDb}; +use super::{StorageLock, StorageLockGuard}; use crate::{Credential, FieldElement}; use world_id_core::primitives::merkle::AccountInclusionProof; use world_id_core::primitives::TREE_DEPTH; @@ -128,7 +128,7 @@ impl CredentialStoreInner { } fn guard(&self) -> StorageResult { - self.lock.lock() + Ok(self.lock.lock()?) } fn state(&self) -> StorageResult<&StorageState> { 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..945244c94 100644 --- a/walletkit-core/src/storage/error.rs +++ b/walletkit-core/src/storage/error.rs @@ -93,3 +93,23 @@ impl From for StorageError { Self::UnexpectedUniFFICallbackError(error.reason) } } + +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 d43cce8c5..ec6381334 100644 --- a/walletkit-core/src/storage/keys.rs +++ b/walletkit-core/src/storage/keys.rs @@ -13,17 +13,15 @@ //! - 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::LockGuard; /// In-memory account keys derived from the account key envelope. /// @@ -44,35 +42,18 @@ impl StorageKeys { pub fn init( keystore: &dyn DeviceKeystore, blob_store: &dyn AtomicBlobStore, - _lock: &StorageLockGuard, + lock: &LockGuard, 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( + &KeystoreAdapter { inner: keystore }, + &BlobStoreAdapter { inner: blob_store }, + lock, + ACCOUNT_KEYS_FILENAME, + ACCOUNT_KEY_ENVELOPE_AD, + now, + )?; + Ok(Self { intermediate_key }) } /// Returns a reference to the intermediate key's [`SecretBox`]. @@ -82,32 +63,64 @@ impl StorageKeys { } } -fn random_key() -> [u8; 32] { - let mut key = [0u8; 32]; - OsRng.fill_bytes(&mut key); - key +struct KeystoreAdapter<'a> { + inner: &'a dyn DeviceKeystore, +} + +impl walletkit_db::Keystore for KeystoreAdapter<'_> { + fn seal( + &self, + associated_data: &[u8], + plaintext: &[u8], + ) -> walletkit_db::StoreResult> { + self.inner + .seal(associated_data.to_vec(), plaintext.to_vec()) + .map_err(|err| walletkit_db::StoreError::Keystore(err.to_string())) + } + + fn open_sealed( + &self, + associated_data: &[u8], + ciphertext: &[u8], + ) -> walletkit_db::StoreResult> { + self.inner + .open_sealed(associated_data.to_vec(), ciphertext.to_vec()) + .map_err(|err| walletkit_db::StoreError::Keystore(err.to_string())) + } +} + +struct BlobStoreAdapter<'a> { + inner: &'a dyn AtomicBlobStore, } -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() - ))); +impl walletkit_db::AtomicBlobStore for BlobStoreAdapter<'_> { + fn read(&self, path: &str) -> walletkit_db::StoreResult>> { + self.inner + .read(path.to_string()) + .map_err(|err| walletkit_db::StoreError::BlobStore(err.to_string())) + } + + fn write_atomic(&self, path: &str, bytes: &[u8]) -> walletkit_db::StoreResult<()> { + self.inner + .write_atomic(path.to_string(), bytes.to_vec()) + .map_err(|err| walletkit_db::StoreError::BlobStore(err.to_string())) + } + + fn delete(&self, path: &str) -> walletkit_db::StoreResult<()> { + self.inner + .delete(path.to_string()) + .map_err(|err| walletkit_db::StoreError::BlobStore(err.to_string())) } - let mut out = [0u8; 32]; - out.copy_from_slice(bytes); - Ok(out) } #[cfg(test)] mod tests { use super::*; use crate::storage::error::StorageError; - use crate::storage::lock::StorageLock; 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(); @@ -120,7 +133,7 @@ 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 lock = Lock::open(&lock_path).expect("open lock"); let guard = lock.lock().expect("lock"); let keys_first = StorageKeys::init(&keystore, &blob_store, &guard, 100).expect("init"); @@ -139,7 +152,7 @@ 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 lock = Lock::open(&lock_path).expect("open lock"); let guard = lock.lock().expect("lock"); StorageKeys::init(&keystore, &blob_store, &guard, 123).expect("init"); @@ -161,7 +174,7 @@ 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 lock = Lock::open(&lock_path).expect("open lock"); let guard = lock.lock().expect("lock"); StorageKeys::init(&keystore, &blob_store, &guard, 123).expect("init"); diff --git a/walletkit-core/src/storage/lock.rs b/walletkit-core/src/storage/lock.rs deleted file mode 100644 index ae36c3af1..000000000 --- a/walletkit-core/src/storage/lock.rs +++ /dev/null @@ -1,331 +0,0 @@ -//! Storage lock for serializing writes. -//! -//! On native platforms (Unix, Windows) a file-based `flock`/`LockFileEx` lock -//! is used to serialize writes across processes. -//! -//! 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. - -use std::path::Path; - -use super::error::StorageResult; - -#[cfg(target_arch = "wasm32")] -mod imp { - use super::*; - - /// No-op storage lock for WASM. - #[derive(Debug, Clone)] - pub struct StorageLock; - - /// No-op lock guard. - #[derive(Debug)] - pub struct StorageLockGuard; - - impl StorageLock { - /// Opens a no-op lock (WASM is single-threaded). - pub fn open(_path: &Path) -> StorageResult { - Ok(Self) - } - - /// Acquires a no-op lock (always succeeds). - pub fn lock(&self) -> StorageResult { - Ok(StorageLockGuard) - } - - /// Attempts to acquire a no-op lock (always succeeds). - pub fn try_lock(&self) -> StorageResult> { - Ok(Some(StorageLockGuard)) - } - } -} - -#[cfg(not(target_arch = "wasm32"))] -mod imp { - use super::{Path, StorageResult}; - use crate::storage::error::StorageError; - use std::fs::{self, File, OpenOptions}; - use std::sync::Arc; - - /// A file-backed lock that serializes storage mutations across processes. - #[derive(Debug, Clone)] - pub struct StorageLock { - file: Arc, - } - - /// Guard that holds an exclusive lock for its lifetime. - #[derive(Debug)] - pub struct StorageLockGuard { - file: Arc, - } - - impl StorageLock { - /// 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 { - if let Some(parent) = path.parent() { - fs::create_dir_all(parent).map_err(|err| map_io_err(&err))?; - } - let file = OpenOptions::new() - .read(true) - .write(true) - .create(true) - .truncate(false) - .open(path) - .map_err(|err| map_io_err(&err))?; - Ok(Self { - file: Arc::new(file), - }) - } - - /// Acquires the exclusive lock. - /// - /// # Errors - /// - /// Returns an error if the lock cannot be acquired. - pub fn lock(&self) -> StorageResult { - lock_exclusive(&self.file).map_err(|err| map_io_err(&err))?; - Ok(StorageLockGuard { - file: Arc::clone(&self.file), - }) - } - - /// Attempts to acquire the exclusive lock without blocking. - /// - /// # Errors - /// - /// 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> { - if try_lock_exclusive(&self.file).map_err(|err| map_io_err(&err))? { - Ok(Some(StorageLockGuard { - file: Arc::clone(&self.file), - })) - } else { - Ok(None) - } - } - } - - impl Drop for StorageLockGuard { - fn drop(&mut self) { - let _ = unlock(&self.file); - } - } - - fn map_io_err(err: &std::io::Error) -> StorageError { - StorageError::Lock(err.to_string()) - } - - #[cfg(unix)] - fn lock_exclusive(file: &File) -> std::io::Result<()> { - let fd = std::os::unix::io::AsRawFd::as_raw_fd(file); - let result = unsafe { flock(fd, LOCK_EX) }; - if result == 0 { - Ok(()) - } else { - Err(std::io::Error::last_os_error()) - } - } - - #[cfg(unix)] - fn try_lock_exclusive(file: &File) -> std::io::Result { - let fd = std::os::unix::io::AsRawFd::as_raw_fd(file); - let result = unsafe { flock(fd, LOCK_EX | LOCK_NB) }; - if result == 0 { - Ok(true) - } else { - let err = std::io::Error::last_os_error(); - if err.kind() == std::io::ErrorKind::WouldBlock { - Ok(false) - } else { - Err(err) - } - } - } - - #[cfg(unix)] - fn unlock(file: &File) -> std::io::Result<()> { - let fd = std::os::unix::io::AsRawFd::as_raw_fd(file); - let result = unsafe { flock(fd, LOCK_UN) }; - if result == 0 { - Ok(()) - } else { - Err(std::io::Error::last_os_error()) - } - } - - #[cfg(unix)] - use std::os::raw::c_int; - - #[cfg(unix)] - const LOCK_EX: c_int = 2; - #[cfg(unix)] - const LOCK_NB: c_int = 4; - #[cfg(unix)] - const LOCK_UN: c_int = 8; - - #[cfg(unix)] - extern "C" { - fn flock(fd: c_int, operation: c_int) -> c_int; - } - - #[cfg(windows)] - fn lock_exclusive(file: &File) -> std::io::Result<()> { - lock_file(file, 0) - } - - #[cfg(windows)] - fn try_lock_exclusive(file: &File) -> std::io::Result { - match lock_file(file, LOCKFILE_FAIL_IMMEDIATELY) { - Ok(()) => Ok(true), - Err(err) => { - if err.raw_os_error() == Some(ERROR_LOCK_VIOLATION) { - Ok(false) - } else { - Err(err) - } - } - } - } - - #[cfg(windows)] - fn unlock(file: &File) -> std::io::Result<()> { - let handle = std::os::windows::io::AsRawHandle::as_raw_handle(file) as HANDLE; - let mut overlapped: OVERLAPPED = unsafe { std::mem::zeroed() }; - let result = unsafe { UnlockFileEx(handle, 0, 1, 0, &mut overlapped) }; - if result != 0 { - Ok(()) - } else { - Err(std::io::Error::last_os_error()) - } - } - - #[cfg(windows)] - fn lock_file(file: &File, flags: u32) -> std::io::Result<()> { - let handle = std::os::windows::io::AsRawHandle::as_raw_handle(file) as HANDLE; - let mut overlapped: OVERLAPPED = unsafe { std::mem::zeroed() }; - let result = unsafe { - LockFileEx( - handle, - LOCKFILE_EXCLUSIVE_LOCK | flags, - 0, - 1, - 0, - &mut overlapped, - ) - }; - if result != 0 { - Ok(()) - } else { - Err(std::io::Error::last_os_error()) - } - } - - #[cfg(windows)] - type HANDLE = *mut std::ffi::c_void; - - #[cfg(windows)] - #[repr(C)] - struct OVERLAPPED { - internal: usize, - internal_high: usize, - offset: u32, - offset_high: u32, - h_event: HANDLE, - } - - #[cfg(windows)] - const LOCKFILE_EXCLUSIVE_LOCK: u32 = 0x2; - #[cfg(windows)] - const LOCKFILE_FAIL_IMMEDIATELY: u32 = 0x1; - #[cfg(windows)] - const ERROR_LOCK_VIOLATION: i32 = 33; - - #[cfg(windows)] - extern "system" { - fn LockFileEx( - h_file: HANDLE, - flags: u32, - reserved: u32, - bytes_to_lock_low: u32, - bytes_to_lock_high: u32, - overlapped: *mut OVERLAPPED, - ) -> i32; - fn UnlockFileEx( - h_file: HANDLE, - reserved: u32, - bytes_to_unlock_low: u32, - bytes_to_unlock_high: u32, - 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 - } - - #[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"); - - let lock_b = StorageLock::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()); - - let _ = std::fs::remove_file(path); - } - - #[test] - fn test_lock_serializes_across_threads() { - let path = temp_lock_path(); - let lock = StorageLock::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 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); - }); - - 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()); - - release_tx.send(()).expect("release"); - released_rx.recv().expect("wait released"); - - let guard = lock_b.try_lock().expect("try lock"); - assert!(guard.is_some()); - - thread_a.join().expect("thread join"); - } -} diff --git a/walletkit-core/src/storage/mod.rs b/walletkit-core/src/storage/mod.rs index 16c705419..c66243230 100644 --- a/walletkit-core/src/storage/mod.rs +++ b/walletkit-core/src/storage/mod.rs @@ -2,12 +2,10 @@ pub mod cache; pub mod credential_storage; -pub mod envelope; 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; @@ -19,7 +17,6 @@ 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, @@ -29,6 +26,7 @@ pub use types::{ 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/types.rs b/walletkit-core/src/storage/types.rs index edbea91cd..d6194e58a 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 index 75242beb5..6c2576534 100644 --- a/walletkit-core/src/storage/vault/helpers.rs +++ b/walletkit-core/src/storage/vault/helpers.rs @@ -1,24 +1,9 @@ -//! Vault database helpers for content addressing and type conversion. - -use sha2::{Digest, Sha256}; +//! Credential-row mapping and small numeric conversion helpers. use crate::storage::error::{StorageError, StorageResult}; -use crate::storage::types::{BlobKind, ContentId, CredentialRecord}; +use crate::storage::types::CredentialRecord; use walletkit_db::{DbError, Row}; -const CONTENT_ID_PREFIX: &[u8] = b"worldid:blob"; - -pub(super) fn compute_content_id(kind: BlobKind, plaintext: &[u8]) -> ContentId { - let mut hasher = Sha256::new(); - hasher.update(CONTENT_ID_PREFIX); - hasher.update([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); diff --git a/walletkit-core/src/storage/vault/mod.rs b/walletkit-core/src/storage/vault/mod.rs index 8bb6d1994..5c63e6ac3 100644 --- a/walletkit-core/src/storage/vault/mod.rs +++ b/walletkit-core/src/storage/vault/mod.rs @@ -1,4 +1,9 @@ //! Encrypted vault database for credential storage. +//! +//! Thin wrapper around [`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; @@ -8,19 +13,19 @@ 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 crate::storage::StorageLockGuard; +use helpers::{map_db_err, map_record, to_i64, to_u64}; use schema::{ensure_schema, VAULT_SCHEMA_VERSION}; use secrecy::SecretBox; -use walletkit_db::{cipher, params, Connection, StepResult, Value}; +use walletkit_db::{cipher, params, Blobs, StepResult, Value, Vault}; pub(crate) use schema::BACKUP_TABLES; /// Encrypted vault database wrapper. #[derive(Debug)] pub struct VaultDb { - conn: Connection, + vault: Vault, } impl VaultDb { @@ -32,18 +37,13 @@ impl VaultDb { pub fn new( path: &Path, k_intermediate: &SecretBox<[u8; 32]>, - _lock: &StorageLockGuard, + lock: &StorageLockGuard, ) -> StorageResult { - let conn = cipher::open_encrypted(path, k_intermediate, false) - .map_err(|e| map_db_err(&e))?; - ensure_schema(&conn).map_err(|err| map_db_err(&err))?; - 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, lock, |conn| { + Blobs::ensure_schema(conn)?; + ensure_schema(conn) + })?; + Ok(Self { vault }) } /// Initializes or validates the leaf index for this vault. @@ -62,7 +62,8 @@ impl VaultDb { ) -> 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) @@ -114,49 +115,39 @@ 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(), - ], + let conn = self.vault.connection(); + let tx = conn.transaction().map_err(|err| map_db_err(&err))?; + + let credential_blob_id = Blobs::put( + conn, + BlobKind::CredentialBlob as u8, + credential_blob.as_slice(), + now_i64, ) .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, + let associated_data_id = if let Some(data) = associated_data.as_ref() { + Some( + Blobs::put( + conn, + BlobKind::AssociatedData as u8, data.as_slice(), - ], + now_i64, + ) + .map_err(|err| map_db_err(&err))?, ) - .map_err(|err| map_db_err(&err))?; - } + } else { + None + }; let ad_cid_value: Value = associated_data_id .as_ref() - .map_or(Value::Null, |cid| Value::Blob(cid.to_vec())); + .map_or(Value::Null, |cid| Value::Blob(cid.as_bytes().to_vec())); let credential_id = tx .query_row( @@ -220,7 +211,8 @@ 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 conn = self.vault.connection(); + let mut stmt = conn.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))? { @@ -245,7 +237,8 @@ impl VaultDb { 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( @@ -312,7 +305,8 @@ 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 conn = self.vault.connection(); + let mut stmt = conn.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))? { @@ -338,7 +332,8 @@ impl VaultDb { &mut self, _lock: &StorageLockGuard, ) -> StorageResult { - 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("DELETE FROM credential_records", &[]) @@ -357,7 +352,7 @@ 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`. @@ -377,7 +372,7 @@ impl VaultDb { StorageError::VaultDb(format!("failed to remove stale backup: {e}")) })?; } - cipher::export_plaintext_copy(&self.conn, dest, BACKUP_TABLES) + cipher::export_plaintext_copy(self.vault.connection(), dest, BACKUP_TABLES) .map_err(|e| map_db_err(&e)) } @@ -395,13 +390,13 @@ impl VaultDb { source: &Path, _lock: &StorageLockGuard, ) -> StorageResult<()> { - cipher::import_plaintext_copy(&self.conn, source, BACKUP_TABLES) + cipher::import_plaintext_copy(self.vault.connection(), source, BACKUP_TABLES) .map_err(|e| map_db_err(&e)) } /// Borrows the underlying connection for direct SQL access. **Test-only.** #[cfg(test)] - pub(super) const fn raw_connection(&self) -> &Connection { - &self.conn + pub(super) const fn raw_connection(&self) -> &walletkit_db::Connection { + self.vault.connection() } } diff --git a/walletkit-core/src/storage/vault/schema.rs b/walletkit-core/src/storage/vault/schema.rs index 7389cc361..196636715 100644 --- a/walletkit-core/src/storage/vault/schema.rs +++ b/walletkit-core/src/storage/vault/schema.rs @@ -1,6 +1,8 @@ -//! Vault database schema management. +//! Credential-vault schema (vault metadata + credential records). //! -//! Owns the credential vault tables and backup-sensitive schema. +//! The shared `blob_objects` table comes from +//! [`walletkit_db::Blobs::ensure_schema`]; this module owns only the +//! credential-specific tables. use walletkit_db::{Connection, DbResult}; @@ -16,7 +18,10 @@ pub(super) const VAULT_SCHEMA_VERSION: i64 = 1; /// **Note:** New tables added to the vault schema must be added here too. pub const BACKUP_TABLES: &[&str] = &["credential_records", "blob_objects"]; -/// **Backup sensitivity:** Schema changes here affect vault backups made into the backup system. +/// Creates the credential-vault tables, indexes, and triggers. +/// +/// **Backup sensitivity:** Schema changes here affect plaintext vault +/// backups. /// - New tables must be added to [`BACKUP_TABLES`]. /// - Column changes (especially new `NOT NULL` columns without defaults) can /// break restoring older backups into a newer schema. @@ -57,14 +62,6 @@ pub(super) fn ensure_schema(conn: &Connection) -> DbResult<()> { 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) - ); ", ) } diff --git a/walletkit-core/src/storage/vault/tests.rs b/walletkit-core/src/storage/vault/tests.rs index a96572b80..3ba8649e4 100644 --- a/walletkit-core/src/storage/vault/tests.rs +++ b/walletkit-core/src/storage/vault/tests.rs @@ -1,12 +1,13 @@ //! Vault database unit tests. -use super::helpers::{compute_content_id, map_db_err}; +use super::helpers::map_db_err; use super::*; -use crate::storage::lock::StorageLock; +use crate::storage::StorageLock; use secrecy::SecretBox; use std::fs; use std::path::{Path, PathBuf}; use uuid::Uuid; +use walletkit_db::compute_content_id; fn temp_vault_path() -> PathBuf { let mut path = std::env::temp_dir(); @@ -162,8 +163,8 @@ fn test_store_credential_with_associated_data() { #[test] fn test_content_id_determinism() { - let a = compute_content_id(BlobKind::CredentialBlob, b"data"); - let b = compute_content_id(BlobKind::CredentialBlob, b"data"); + let a = compute_content_id(BlobKind::CredentialBlob as u8, b"data"); + let b = compute_content_id(BlobKind::CredentialBlob as u8, b"data"); assert_eq!(a, b); } @@ -540,6 +541,74 @@ fn test_vault_integrity_check() { cleanup_lock_file(&lock_path); } +#[test] +fn test_credential_vault_on_disk_format_guard() { + // This is the on-disk-format guard required by the storage-primitives + // refactor: any change to schemas, CBOR layout, content_id derivation, + // or cipher configuration that breaks compatibility with vault files + // written by `main` must fail this test. + // + // The test: + // 1. Stores a credential with a deterministic plaintext payload. + // 2. Asserts the `content_id` written into `blob_objects` equals a + // hard-coded SHA-256 — locks the kind-tag + prefix derivation. + // 3. Closes the vault, reopens it under the same key, fetches the + // credential back and asserts byte-equality with the original. + // A schema or cipher mismatch would surface here. + 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(|| [0xA5u8; 32]); + let credential_bytes = b"on-disk-guard-credential-payload".to_vec(); + let blinding = sample_blinding_factor(); + + let credential_id = { + let mut db = VaultDb::new(&path, &key, &guard).expect("create vault"); + db.store_credential( + &guard, + 42, + blinding.clone(), + 1_700_000_000, + 1_800_000_000, + credential_bytes.clone(), + None, + 1_700_000_001, + ) + .expect("store credential") + }; + + // Frozen content_id for the credential blob. SHA-256(b"worldid:blob" + // || [BlobKind::CredentialBlob as u8 = 0x01] || credential_bytes). + let expected_cid_hex = + "9281febbd42d05857b399f8481d6842f1e3e4b78401081ca7f0d0fb3a80e9264"; + + let db = VaultDb::new(&path, &key, &guard).expect("reopen vault"); + let stored_cid_hex: String = db + .raw_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_vault_corruption_handling() { let path = temp_vault_path(); From 338490f575e6b26876457e615f2ff943ae37239b Mon Sep 17 00:00:00 2001 From: danielle-tfh Date: Tue, 12 May 2026 15:56:06 +0100 Subject: [PATCH 05/31] refactor: simplify storage primitives per review feedback MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - vault: drop Vault struct; expose open_vault(...) -> StoreResult as a free function. Consumers compose schema ops directly on the Connection. - blobs: drop Blobs namespace struct, ContentId newtype, and as_bytes/into_bytes methods. Surface is now blobs::{ensure_schema, put, get} module functions plus pub type ContentId = [u8; 32]. blobs::put / blobs::get return StoreResult; blobs::put takes now: u64 (no SQL i64 leak). - traits: Keystore/AtomicBlobStore shapes match walletkit-core's uniffi traits (Vec / String). walletkit-core bridges via small pass-through newtypes (Ks, Bs) — Rust's orphan rule blocks a blanket impl across crates. - error: From for StorageError collapses to Self::VaultDb(err.to_string()); the parallel-enum mirror is gone. keys.rs tests updated to match VaultDb. - envelope: KeyEnvelope fields back to pub(crate) — public methods are the surface. - lib: cipher canonical path is walletkit_db::cipher (sqlite module is now private inside the crate). params! macro updated to walk the public re-export. - lock.rs: comment dividers restored — pure move from walletkit-core. - vault/tests.rs: provenance comment on the frozen credential_blob content_id hex (verified to match main's compute_content_id; reproducible via shasum). - credential_storage.rs: drop the Ok(?) wrap on guard(). - New tests: test_lock_serializes_across_threads ported from the pre-PR walletkit-core/src/storage/lock.rs (don't delete tests). Net: ~−25 lines in walletkit-core and ~−25 lines on net in walletkit-db relative to the previous commit on this branch. Behavior and on-disk format unchanged: walletkit-core ships 123 lib tests, walletkit-db ships 18 lib tests, all green. --- .../src/storage/credential_storage.rs | 2 +- walletkit-core/src/storage/error.rs | 16 +- walletkit-core/src/storage/keys.rs | 88 +++++----- walletkit-core/src/storage/vault/mod.rs | 58 +++---- walletkit-core/src/storage/vault/tests.rs | 9 + walletkit-db/src/blobs.rs | 159 +++++++----------- walletkit-db/src/envelope.rs | 25 ++- walletkit-db/src/error.rs | 2 +- walletkit-db/src/lib.rs | 18 +- walletkit-db/src/lock.rs | 8 + walletkit-db/src/sqlite/value.rs | 2 +- walletkit-db/src/tests.rs | 117 ++++++++----- walletkit-db/src/traits.rs | 22 ++- walletkit-db/src/vault.rs | 80 ++++----- 14 files changed, 290 insertions(+), 316 deletions(-) diff --git a/walletkit-core/src/storage/credential_storage.rs b/walletkit-core/src/storage/credential_storage.rs index cb336e318..bec0336ec 100644 --- a/walletkit-core/src/storage/credential_storage.rs +++ b/walletkit-core/src/storage/credential_storage.rs @@ -128,7 +128,7 @@ impl CredentialStoreInner { } fn guard(&self) -> StorageResult { - Ok(self.lock.lock()?) + self.lock.lock().map_err(Into::into) } fn state(&self) -> StorageResult<&StorageState> { diff --git a/walletkit-core/src/storage/error.rs b/walletkit-core/src/storage/error.rs index 945244c94..50d2666b2 100644 --- a/walletkit-core/src/storage/error.rs +++ b/walletkit-core/src/storage/error.rs @@ -96,20 +96,6 @@ impl From for StorageError { 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) - } - } + Self::VaultDb(err.to_string()) } } diff --git a/walletkit-core/src/storage/keys.rs b/walletkit-core/src/storage/keys.rs index ec6381334..0650b229b 100644 --- a/walletkit-core/src/storage/keys.rs +++ b/walletkit-core/src/storage/keys.rs @@ -46,8 +46,8 @@ impl StorageKeys { now: u64, ) -> StorageResult { let intermediate_key = walletkit_db::init_or_open_envelope_key( - &KeystoreAdapter { inner: keystore }, - &BlobStoreAdapter { inner: blob_store }, + &Ks(keystore), + &Bs(blob_store), lock, ACCOUNT_KEYS_FILENAME, ACCOUNT_KEY_ENVELOPE_AD, @@ -63,53 +63,49 @@ impl StorageKeys { } } -struct KeystoreAdapter<'a> { - inner: &'a dyn DeviceKeystore, -} - -impl walletkit_db::Keystore for KeystoreAdapter<'_> { - fn seal( - &self, - associated_data: &[u8], - plaintext: &[u8], - ) -> walletkit_db::StoreResult> { - self.inner - .seal(associated_data.to_vec(), plaintext.to_vec()) - .map_err(|err| walletkit_db::StoreError::Keystore(err.to_string())) +// 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, ad: Vec, pt: Vec) -> walletkit_db::StoreResult> { + self.0 + .seal(ad, pt) + .map_err(|e| walletkit_db::StoreError::Keystore(e.to_string())) } - fn open_sealed( &self, - associated_data: &[u8], - ciphertext: &[u8], + ad: Vec, + ct: Vec, ) -> walletkit_db::StoreResult> { - self.inner - .open_sealed(associated_data.to_vec(), ciphertext.to_vec()) - .map_err(|err| walletkit_db::StoreError::Keystore(err.to_string())) + self.0 + .open_sealed(ad, ct) + .map_err(|e| walletkit_db::StoreError::Keystore(e.to_string())) } } -struct BlobStoreAdapter<'a> { - inner: &'a dyn AtomicBlobStore, -} - -impl walletkit_db::AtomicBlobStore for BlobStoreAdapter<'_> { - fn read(&self, path: &str) -> walletkit_db::StoreResult>> { - self.inner - .read(path.to_string()) - .map_err(|err| walletkit_db::StoreError::BlobStore(err.to_string())) +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: &str, bytes: &[u8]) -> walletkit_db::StoreResult<()> { - self.inner - .write_atomic(path.to_string(), bytes.to_vec()) - .map_err(|err| walletkit_db::StoreError::BlobStore(err.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: &str) -> walletkit_db::StoreResult<()> { - self.inner - .delete(path.to_string()) - .map_err(|err| walletkit_db::StoreError::BlobStore(err.to_string())) + fn delete(&self, path: String) -> walletkit_db::StoreResult<()> { + self.0 + .delete(path) + .map_err(|e| walletkit_db::StoreError::BlobStore(e.to_string())) } } @@ -158,11 +154,7 @@ mod tests { let other_keystore = InMemoryKeystore::new(); match StorageKeys::init(&other_keystore, &blob_store, &guard, 456) { - Err( - StorageError::Crypto(_) - | StorageError::InvalidEnvelope(_) - | StorageError::Keystore(_), - ) => {} + Err(StorageError::VaultDb(_)) => {} Err(err) => panic!("unexpected error: {err}"), Ok(_) => panic!("expected error"), } @@ -188,11 +180,7 @@ mod tests { .expect("write"); match StorageKeys::init(&keystore, &blob_store, &guard, 456) { - Err( - StorageError::Serialization(_) - | StorageError::Crypto(_) - | StorageError::UnsupportedEnvelopeVersion(_), - ) => {} + Err(StorageError::VaultDb(_)) => {} Err(err) => panic!("unexpected error: {err}"), Ok(_) => panic!("expected error"), } diff --git a/walletkit-core/src/storage/vault/mod.rs b/walletkit-core/src/storage/vault/mod.rs index 5c63e6ac3..43a9947bd 100644 --- a/walletkit-core/src/storage/vault/mod.rs +++ b/walletkit-core/src/storage/vault/mod.rs @@ -1,6 +1,6 @@ //! Encrypted vault database for credential storage. //! -//! Thin wrapper around [`walletkit_db::Vault`]: the credential-specific +//! Thin wrapper over [`walletkit_db::open_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`]. @@ -18,14 +18,14 @@ use crate::storage::StorageLockGuard; use helpers::{map_db_err, map_record, to_i64, to_u64}; use schema::{ensure_schema, VAULT_SCHEMA_VERSION}; use secrecy::SecretBox; -use walletkit_db::{cipher, params, Blobs, StepResult, Value, Vault}; +use walletkit_db::{blobs, cipher, open_vault, params, Connection, StepResult, Value}; pub(crate) use schema::BACKUP_TABLES; /// Encrypted vault database wrapper. #[derive(Debug)] pub struct VaultDb { - vault: Vault, + conn: Connection, } impl VaultDb { @@ -39,11 +39,11 @@ impl VaultDb { k_intermediate: &SecretBox<[u8; 32]>, lock: &StorageLockGuard, ) -> StorageResult { - let vault = Vault::open(path, k_intermediate, lock, |conn| { - Blobs::ensure_schema(conn)?; + let conn = open_vault(path, k_intermediate, lock, |conn| { + blobs::ensure_schema(conn)?; ensure_schema(conn) })?; - Ok(Self { vault }) + Ok(Self { conn }) } /// Initializes or validates the leaf index for this vault. @@ -62,7 +62,7 @@ impl VaultDb { ) -> StorageResult<()> { let leaf_index_i64 = to_i64(leaf_index, "leaf_index")?; let now_i64 = to_i64(now, "now")?; - let conn = self.vault.connection(); + let conn = &self.conn; let tx = conn.transaction().map_err(|err| map_db_err(&err))?; let stored = tx .query_row( @@ -120,34 +120,30 @@ impl VaultDb { let genesis_issued_at_i64 = to_i64(genesis_issued_at, "genesis_issued_at")?; let expires_at_i64 = to_i64(expires_at, "expires_at")?; - let conn = self.vault.connection(); + let conn = &self.conn; let tx = conn.transaction().map_err(|err| map_db_err(&err))?; - let credential_blob_id = Blobs::put( + let credential_blob_id = blobs::put( conn, BlobKind::CredentialBlob as u8, credential_blob.as_slice(), - now_i64, - ) - .map_err(|err| map_db_err(&err))?; + now, + )?; let associated_data_id = if let Some(data) = associated_data.as_ref() { - Some( - Blobs::put( - conn, - BlobKind::AssociatedData as u8, - data.as_slice(), - now_i64, - ) - .map_err(|err| map_db_err(&err))?, - ) + Some(blobs::put( + conn, + BlobKind::AssociatedData as u8, + data.as_slice(), + now, + )?) } else { None }; let ad_cid_value: Value = associated_data_id .as_ref() - .map_or(Value::Null, |cid| Value::Blob(cid.as_bytes().to_vec())); + .map_or(Value::Null, |cid| Value::Blob(cid.to_vec())); let credential_id = tx .query_row( @@ -167,7 +163,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)), @@ -211,7 +207,7 @@ impl VaultDb { WHERE (?2 IS NULL OR cr.issuer_schema_id = ?2) ORDER BY cr.updated_at DESC"; - let conn = self.vault.connection(); + let conn = &self.conn; let mut stmt = conn.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))?; @@ -237,7 +233,7 @@ impl VaultDb { credential_id: u64, ) -> StorageResult<()> { let credential_id_i64 = to_i64(credential_id, "credential_id")?; - let conn = self.vault.connection(); + let conn = &self.conn; let tx = conn.transaction().map_err(|err| map_db_err(&err))?; let deleted = tx @@ -305,7 +301,7 @@ impl VaultDb { ORDER BY cr.updated_at DESC LIMIT 1"; - let conn = self.vault.connection(); + let conn = &self.conn; let mut stmt = conn.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))?; @@ -332,7 +328,7 @@ impl VaultDb { &mut self, _lock: &StorageLockGuard, ) -> StorageResult { - let conn = self.vault.connection(); + let conn = &self.conn; let tx = conn.transaction().map_err(|err| map_db_err(&err))?; let deleted = tx @@ -352,7 +348,7 @@ impl VaultDb { /// /// Returns an error if the check cannot be executed. pub fn check_integrity(&self) -> StorageResult { - cipher::integrity_check(self.vault.connection()).map_err(|e| map_db_err(&e)) + cipher::integrity_check(&self.conn).map_err(|e| map_db_err(&e)) } /// Exports a plaintext (unencrypted) copy of the vault to `dest`. @@ -372,7 +368,7 @@ impl VaultDb { StorageError::VaultDb(format!("failed to remove stale backup: {e}")) })?; } - cipher::export_plaintext_copy(self.vault.connection(), dest, BACKUP_TABLES) + cipher::export_plaintext_copy(&self.conn, dest, BACKUP_TABLES) .map_err(|e| map_db_err(&e)) } @@ -390,13 +386,13 @@ impl VaultDb { source: &Path, _lock: &StorageLockGuard, ) -> StorageResult<()> { - cipher::import_plaintext_copy(self.vault.connection(), source, BACKUP_TABLES) + cipher::import_plaintext_copy(&self.conn, source, BACKUP_TABLES) .map_err(|e| map_db_err(&e)) } /// Borrows the underlying connection for direct SQL access. **Test-only.** #[cfg(test)] pub(super) const fn raw_connection(&self) -> &walletkit_db::Connection { - self.vault.connection() + &self.conn } } diff --git a/walletkit-core/src/storage/vault/tests.rs b/walletkit-core/src/storage/vault/tests.rs index 3ba8649e4..bf6966ec9 100644 --- a/walletkit-core/src/storage/vault/tests.rs +++ b/walletkit-core/src/storage/vault/tests.rs @@ -580,6 +580,15 @@ fn test_credential_vault_on_disk_format_guard() { // Frozen content_id for the credential blob. SHA-256(b"worldid:blob" // || [BlobKind::CredentialBlob as u8 = 0x01] || credential_bytes). + // + // Verified to match `main`: the SHA-256 derivation in + // `walletkit_db::compute_content_id` is a line-by-line move of the + // pre-refactor `compute_content_id` in walletkit-core/storage/vault/ + // helpers.rs (commit 9ff3b47), so the hex here equals what `main` + // would write for the same `(BlobKind::CredentialBlob, credential_bytes)`. + // Reproducible via: + // printf 'worldid:blob\x01on-disk-guard-credential-payload' \ + // | shasum -a 256 let expected_cid_hex = "9281febbd42d05857b399f8481d6842f1e3e4b78401081ca7f0d0fb3a80e9264"; diff --git a/walletkit-db/src/blobs.rs b/walletkit-db/src/blobs.rs index cbdb49cd8..3f1dc55ce 100644 --- a/walletkit-db/src/blobs.rs +++ b/walletkit-db/src/blobs.rs @@ -19,49 +19,14 @@ use sha2::{Digest, Sha256}; +use crate::error::{StoreError, StoreResult}; use crate::params; -use crate::sqlite::{Connection, Result as DbResult}; +use crate::sqlite::{Connection, Error as DbError, Result as DbResult}; const CONTENT_ID_PREFIX: &[u8] = b"worldid:blob"; /// 32-byte content identifier for a stored blob. -/// -/// Content ids are deterministic functions of `(kind, plaintext)` — see -/// [`compute_content_id`] for the exact derivation. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] -pub struct ContentId([u8; 32]); - -impl ContentId { - /// Constructs a [`ContentId`] from raw bytes (no derivation). - #[must_use] - pub const fn new(bytes: [u8; 32]) -> Self { - Self(bytes) - } - - /// Borrows the underlying 32-byte buffer. - #[must_use] - pub const fn as_bytes(&self) -> &[u8; 32] { - &self.0 - } - - /// Consumes the [`ContentId`] and returns the underlying buffer. - #[must_use] - pub const fn into_bytes(self) -> [u8; 32] { - self.0 - } -} - -impl AsRef<[u8]> for ContentId { - fn as_ref(&self) -> &[u8] { - &self.0 - } -} - -impl From<[u8; 32]> for ContentId { - fn from(bytes: [u8; 32]) -> Self { - Self(bytes) - } -} +pub type ContentId = [u8; 32]; /// Computes the content id for a blob. /// @@ -76,70 +41,66 @@ pub fn compute_content_id(kind: u8, plaintext: &[u8]) -> ContentId { let digest = hasher.finalize(); let mut out = [0u8; 32]; out.copy_from_slice(&digest); - ContentId(out) + out } -/// Content-addressed blob table operations. +/// Creates the `blob_objects` table if it does not exist. /// -/// Type-only namespace; methods are associated functions taking a -/// [`Connection`] so callers can compose them into their own transactions. -pub struct Blobs; - -impl Blobs { - /// Creates the `blob_objects` table if it does not exist. - /// - /// Idempotent. The exact DDL is part of the on-disk format contract; - /// callers should not attempt to 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) - );", - ) - } +/// 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 database error if the insert fails. - pub fn put( - conn: &Connection, - kind: u8, - bytes: &[u8], - now: i64, - ) -> DbResult { - 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, bytes], - )?; - Ok(cid) - } +/// 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. - /// - /// # Errors - /// - /// Returns a database error if the query fails. - pub fn get(conn: &Connection, cid: &ContentId) -> DbResult>> { - conn.query_row_optional( - "SELECT bytes FROM blob_objects WHERE content_id = ?1", - params![cid.as_ref()], - |row| Ok(row.column_blob(0)), - ) - } +/// Fetches blob bytes by content id, if present. +/// +/// # Errors +/// +/// Returns a [`StoreError`] if the query fails. +pub fn get(conn: &Connection, cid: &ContentId) -> StoreResult>> { + let bytes = conn.query_row_optional( + "SELECT bytes FROM blob_objects WHERE content_id = ?1", + params![cid.as_ref()], + |row| Ok(row.column_blob(0)), + )?; + Ok(bytes) } diff --git a/walletkit-db/src/envelope.rs b/walletkit-db/src/envelope.rs index ce24a2666..700267156 100644 --- a/walletkit-db/src/envelope.rs +++ b/walletkit-db/src/envelope.rs @@ -24,15 +24,10 @@ const ENVELOPE_VERSION: u32 = 1; /// breaks existing user databases. #[derive(Clone, Serialize, Deserialize, Zeroize, ZeroizeOnDrop)] pub struct KeyEnvelope { - /// Envelope format version. Mismatch triggers - /// [`StoreError::UnsupportedEnvelopeVersion`]. - pub version: u32, - /// Output of [`Keystore::seal`] over the 32-byte intermediate key. - pub wrapped_k_intermediate: Vec, - /// Unix timestamp (seconds) recorded when the envelope was first written. - pub created_at: u64, - /// Unix timestamp (seconds) recorded on the most recent write. - pub updated_at: u64, + pub(crate) version: u32, + pub(crate) wrapped_k_intermediate: Vec, + pub(crate) created_at: u64, + pub(crate) updated_at: u64, } impl KeyEnvelope { @@ -98,20 +93,22 @@ pub fn init_or_open_envelope_key( ad: &[u8], now: u64, ) -> StoreResult> { - if let Some(bytes) = blob_store.read(filename)? { + 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, &envelope.wrapped_k_intermediate)?); + 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}")))?; - let wrapped = keystore.seal(ad, k_intermediate.as_ref())?; + 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, &bytes)?; + blob_store.write_atomic(filename.to_string(), bytes)?; let key_copy = *k_intermediate; Ok(SecretBox::init_with(move || key_copy)) } diff --git a/walletkit-db/src/error.rs b/walletkit-db/src/error.rs index d8caba0df..f65925285 100644 --- a/walletkit-db/src/error.rs +++ b/walletkit-db/src/error.rs @@ -33,7 +33,7 @@ pub enum StoreError { /// Envelope written by an unsupported version. #[error("unsupported envelope version: {0}")] UnsupportedEnvelopeVersion(u32), - /// Underlying database error from [`crate::sqlite`]. + /// Underlying database error from the encrypted-`SQLite` wrapper. #[error("database error: {0}")] Db(#[from] DbError), /// `PRAGMA integrity_check` reported corruption. diff --git a/walletkit-db/src/lib.rs b/walletkit-db/src/lib.rs index c8240490e..df9b56c2b 100644 --- a/walletkit-db/src/lib.rs +++ b/walletkit-db/src/lib.rs @@ -3,11 +3,11 @@ //! The crate provides building blocks shared by `walletkit-core::storage` and //! sibling SDKs (e.g. `OrbKit`'s `OrbPcpStore`): //! -//! - [`sqlite`] — encrypted `SQLite` (`sqlite3mc`) wrapper with safe Rust -//! connection / transaction / statement types. -//! - [`Vault`] — encrypted-database opener with caller-supplied schema. -//! - [`Blobs`], [`ContentId`], [`compute_content_id`] — content-addressed -//! blob storage shared across consumer schemas. +//! - [`Connection`], [`Transaction`], [`Statement`], [`cipher`] — encrypted +//! `SQLite` (`sqlite3mc`) wrapper with safe Rust types. +//! - [`open_vault`] — encrypted-database opener with caller-supplied schema. +//! - [`blobs`] — content-addressed blob storage (`ensure_schema`, `put`, +//! `get`), [`ContentId`], and [`compute_content_id`]. //! - [`KeyEnvelope`] + [`init_or_open_envelope_key`] — sealed intermediate //! key persisted via [`AtomicBlobStore`]. //! - [`Lock`] / [`LockGuard`] — cross-process exclusive lock (`flock` / @@ -19,16 +19,16 @@ //! Consumers own their schemas, FFI surfaces, and storage policy on top of //! these primitives. -pub mod sqlite; +pub mod blobs; -mod blobs; mod envelope; mod error; mod lock; +mod sqlite; mod traits; mod vault; -pub use blobs::{compute_content_id, Blobs, ContentId}; +pub use blobs::{compute_content_id, ContentId}; pub use envelope::{init_or_open_envelope_key, KeyEnvelope}; pub use error::{StoreError, StoreResult}; pub use lock::{Lock, LockGuard}; @@ -37,7 +37,7 @@ pub use sqlite::{ StepResult, Transaction, Value, }; pub use traits::{AtomicBlobStore, Keystore}; -pub use vault::Vault; +pub use vault::open_vault; #[cfg(test)] mod tests; diff --git a/walletkit-db/src/lock.rs b/walletkit-db/src/lock.rs index febdf2760..cd2c687c5 100644 --- a/walletkit-db/src/lock.rs +++ b/walletkit-db/src/lock.rs @@ -11,6 +11,8 @@ use std::path::Path; use crate::error::StoreResult; +// WASM: no-op lock (single-threaded worker, SQLITE_THREADSAFE=0) + #[cfg(target_arch = "wasm32")] mod imp { use super::*; @@ -41,6 +43,8 @@ mod imp { } } +// Native: file-backed exclusive lock (flock on Unix, LockFileEx on Windows) + #[cfg(not(target_arch = "wasm32"))] mod imp { use super::{Path, StoreResult}; @@ -121,6 +125,8 @@ mod imp { StoreError::Lock(err.to_string()) } + // ── Unix flock ────────────────────────────────────────────────────── + #[cfg(unix)] fn lock_exclusive(file: &File) -> std::io::Result<()> { let fd = std::os::unix::io::AsRawFd::as_raw_fd(file); @@ -174,6 +180,8 @@ mod imp { fn flock(fd: c_int, operation: c_int) -> c_int; } + // ── Windows LockFileEx ────────────────────────────────────────────── + #[cfg(windows)] fn lock_exclusive(file: &File) -> std::io::Result<()> { lock_file(file, 0) diff --git a/walletkit-db/src/sqlite/value.rs b/walletkit-db/src/sqlite/value.rs index 8c0301d2e..57a253a2f 100644 --- a/walletkit-db/src/sqlite/value.rs +++ b/walletkit-db/src/sqlite/value.rs @@ -50,6 +50,6 @@ impl From<&str> for Value { #[macro_export] macro_rules! params { ($($val:expr),* $(,)?) => { - &[$($crate::sqlite::Value::from($val)),*][..] + &[$($crate::Value::from($val)),*][..] }; } diff --git a/walletkit-db/src/tests.rs b/walletkit-db/src/tests.rs index 7adbc6d48..b2bee010a 100644 --- a/walletkit-db/src/tests.rs +++ b/walletkit-db/src/tests.rs @@ -287,8 +287,8 @@ fn test_cipher_import_rejects_non_empty_destination() { mod primitives { use super::init_sqlite; use crate::{ - compute_content_id, init_or_open_envelope_key, AtomicBlobStore, Blobs, - ContentId, KeyEnvelope, Keystore, Lock, StoreError, StoreResult, Vault, + blobs, compute_content_id, init_or_open_envelope_key, open_vault, + AtomicBlobStore, KeyEnvelope, Keystore, Lock, StoreError, StoreResult, }; use secrecy::{ExposeSecret, SecretBox}; use std::sync::Mutex; @@ -300,27 +300,21 @@ mod primitives { #[test] fn test_compute_content_id_byte_stable() { - // SHA-256(b"worldid:blob" || [0x01] || b"hello"). Frozen value — + // 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 = hex::decode( + let expected: [u8; 32] = hex::decode( "ed4eba40f11beec64d0607586f09b7529418ef31bf2c46cf9b8b905615f2e7ca", ) - .expect("decode hex"); - assert_eq!(cid.as_bytes(), expected.as_slice()); + .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"); } - #[test] - fn test_content_id_round_trip() { - let bytes = [42u8; 32]; - let cid = ContentId::new(bytes); - assert_eq!(cid.as_bytes(), &bytes); - assert_eq!(cid.into_bytes(), bytes); - } - // ---- KeyEnvelope CBOR format guard ---------------------------------- #[test] @@ -365,6 +359,42 @@ mod primitives { assert!(guard.is_some()); } + #[test] + #[cfg(not(target_arch = "wasm32"))] + fn test_lock_serializes_across_threads() { + use std::sync::mpsc; + use std::thread; + + 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) = mpsc::channel(); + let (release_tx, release_rx) = mpsc::channel(); + let (released_tx, released_rx) = mpsc::channel(); + + 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 = 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"); + + let guard = lock_b.try_lock().expect("try lock"); + assert!(guard.is_some()); + + thread_a.join().expect("thread join"); + } + // ---- Envelope helper end-to-end ---------------------------------------- // // Uses a stub Keystore that XORs with a fixed pad. Good enough to verify @@ -375,14 +405,18 @@ mod primitives { } impl Keystore for XorKeystore { - fn seal(&self, _ad: &[u8], plaintext: &[u8]) -> StoreResult> { + 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: &[u8], ciphertext: &[u8]) -> StoreResult> { + fn open_sealed( + &self, + _ad: Vec, + ciphertext: Vec, + ) -> StoreResult> { Ok(ciphertext .iter() .enumerate() @@ -402,18 +436,15 @@ mod primitives { } } impl AtomicBlobStore for InMemoryBlobs { - fn read(&self, path: &str) -> StoreResult>> { - Ok(self.inner.lock().unwrap().get(path).cloned()) + fn read(&self, path: String) -> StoreResult>> { + Ok(self.inner.lock().unwrap().get(&path).cloned()) } - fn write_atomic(&self, path: &str, bytes: &[u8]) -> StoreResult<()> { - self.inner - .lock() - .unwrap() - .insert(path.to_string(), bytes.to_vec()); + fn write_atomic(&self, path: String, bytes: Vec) -> StoreResult<()> { + self.inner.lock().unwrap().insert(path, bytes); Ok(()) } - fn delete(&self, path: &str) -> StoreResult<()> { - self.inner.lock().unwrap().remove(path); + fn delete(&self, path: String) -> StoreResult<()> { + self.inner.lock().unwrap().remove(&path); Ok(()) } } @@ -427,24 +458,34 @@ mod primitives { let guard = lock.lock().expect("acquire"); let keystore = XorKeystore { pad: [0xAA; 32] }; - let blobs = InMemoryBlobs::new(); + let blob_store = InMemoryBlobs::new(); let key_a = init_or_open_envelope_key( - &keystore, &blobs, &guard, "k.bin", b"test-ad", 100, + &keystore, + &blob_store, + &guard, + "k.bin", + b"test-ad", + 100, ) .expect("init"); let key_b = init_or_open_envelope_key( - &keystore, &blobs, &guard, "k.bin", b"test-ad", 200, + &keystore, + &blob_store, + &guard, + "k.bin", + b"test-ad", + 200, ) .expect("re-open"); assert_eq!(key_a.expose_secret(), key_b.expose_secret()); } - // ---- Vault::open -------------------------------------------------------- + // ---- open_vault -------------------------------------------------------- #[test] #[cfg(not(target_arch = "wasm32"))] - fn test_vault_open_runs_schema_callback() { + fn test_open_vault_runs_schema_callback() { init_sqlite(); let dir = tempfile::tempdir().expect("create temp dir"); let db_path = dir.path().join("vault.sqlite"); @@ -453,24 +494,22 @@ mod primitives { let guard = lock.lock().expect("acquire"); let key = SecretBox::init_with(|| [0x42u8; 32]); - let vault = Vault::open(&db_path, &key, &guard, |conn| { - Blobs::ensure_schema(conn)?; + let conn = open_vault(&db_path, &key, &guard, |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"); + let cid = blobs::put(&conn, 7, b"payload", 1000).expect("put"); + let bytes = blobs::get(&conn, &cid).expect("get").expect("present"); assert_eq!(bytes, b"payload"); } #[test] #[cfg(not(target_arch = "wasm32"))] - fn test_vault_open_rejects_wrong_key() { + fn test_open_vault_rejects_wrong_key() { init_sqlite(); let dir = tempfile::tempdir().expect("create temp dir"); let db_path = dir.path().join("vault.sqlite"); @@ -478,13 +517,13 @@ mod primitives { let lock = Lock::open(&lock_path).expect("open lock"); let guard = lock.lock().expect("acquire"); let key = SecretBox::init_with(|| [0x11u8; 32]); - Vault::open(&db_path, &key, &guard, Blobs::ensure_schema) + let _ = open_vault(&db_path, &key, &guard, blobs::ensure_schema) .expect("create vault"); drop(guard); let guard = lock.lock().expect("re-acquire"); let wrong = SecretBox::init_with(|| [0x22u8; 32]); let err = - Vault::open(&db_path, &wrong, &guard, |_| Ok(())).expect_err("wrong key"); + open_vault(&db_path, &wrong, &guard, |_| Ok(())).expect_err("wrong key"); assert!(matches!(err, StoreError::Db(_))); } } diff --git a/walletkit-db/src/traits.rs b/walletkit-db/src/traits.rs index a7b04f422..350135390 100644 --- a/walletkit-db/src/traits.rs +++ b/walletkit-db/src/traits.rs @@ -1,7 +1,9 @@ //! Plain-Rust trait surface for consumer-supplied platform integrations. //! -//! Consumers that need FFI define their own annotated traits and adapt to -//! these via newtype wrappers. +//! Argument shapes mirror `WalletKit`'s existing uniffi-annotated traits +//! (`Vec` for byte buffers, owned `String` for paths) so downstream +//! consumers can blanket-impl these for their own annotated traits without +//! adapter newtypes. use crate::error::StoreResult; @@ -17,7 +19,11 @@ pub trait Keystore: Send + Sync { /// /// Returns an error if the keystore refuses the operation or the seal /// fails. - fn seal(&self, associated_data: &[u8], plaintext: &[u8]) -> StoreResult>; + fn seal( + &self, + associated_data: Vec, + plaintext: Vec, + ) -> StoreResult>; /// Opens ciphertext under the device-bound key, verifying /// `associated_data`. The same associated data used during sealing must @@ -28,8 +34,8 @@ pub trait Keystore: Send + Sync { /// Returns an error if authentication fails or the keystore cannot open. fn open_sealed( &self, - associated_data: &[u8], - ciphertext: &[u8], + associated_data: Vec, + ciphertext: Vec, ) -> StoreResult>; } @@ -40,19 +46,19 @@ pub trait AtomicBlobStore: Send + Sync { /// # Errors /// /// Returns an error if the read fails. - fn read(&self, path: &str) -> StoreResult>>; + fn read(&self, path: String) -> StoreResult>>; /// Writes bytes atomically to `path`. /// /// # Errors /// /// Returns an error if the write fails. - fn write_atomic(&self, path: &str, bytes: &[u8]) -> StoreResult<()>; + 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: &str) -> StoreResult<()>; + fn delete(&self, path: String) -> StoreResult<()>; } diff --git a/walletkit-db/src/vault.rs b/walletkit-db/src/vault.rs index 7107a4c23..438c766b5 100644 --- a/walletkit-db/src/vault.rs +++ b/walletkit-db/src/vault.rs @@ -1,6 +1,6 @@ //! Encrypted vault opener with caller-supplied schema. //! -//! [`Vault::open`] composes [`crate::sqlite::cipher::open_encrypted`], a +//! [`open_vault`] composes [`crate::sqlite::cipher::open_encrypted`], a //! consumer-owned schema callback, and an integrity check into the standard //! "open + key + ensure schema + verify" flow used by all `WalletKit` //! storage consumers. @@ -13,53 +13,37 @@ use crate::error::{StoreError, StoreResult}; use crate::lock::LockGuard; use crate::sqlite::{cipher, Connection, Result as DbResult}; -/// Encrypted-database wrapper holding the open `sqlite3mc` connection. +/// Opens (or creates) an encrypted database at `path`, runs `ensure_schema`, +/// then verifies integrity. Returns the open [`Connection`] for the caller +/// to compose schema-specific operations on top. /// -/// Consumers compose schema-specific operations on top via -/// [`Vault::connection`]. -#[derive(Debug)] -pub struct Vault { - conn: Connection, -} - -impl Vault { - /// Opens (or creates) an encrypted database at `path`, runs - /// `ensure_schema`, then verifies integrity. - /// - /// `key` is the 32-byte intermediate key passed to `sqlite3mc`. `_lock` - /// is an in-scope [`LockGuard`] that proves the caller serialized writes - /// via [`crate::Lock`]. `ensure_schema` runs after the database is - /// opened and keyed but before the integrity check, and may create - /// tables, indexes, and triggers. - /// - /// # Errors - /// - /// Returns [`StoreError::Db`] if opening, keying, or schema setup fails, - /// or [`StoreError::IntegrityCheckFailed`] if `PRAGMA integrity_check` - /// reports corruption. - pub fn open( - path: &Path, - key: &SecretBox<[u8; 32]>, - _lock: &LockGuard, - ensure_schema: F, - ) -> StoreResult - where - F: FnOnce(&Connection) -> DbResult<()>, - { - let conn = cipher::open_encrypted(path, key, false)?; - ensure_schema(&conn)?; - let vault = Self { conn }; - if !cipher::integrity_check(&vault.conn)? { - return Err(StoreError::IntegrityCheckFailed( - "integrity_check failed".to_string(), - )); - } - Ok(vault) - } - - /// Borrows the underlying connection for direct SQL access. - #[must_use] - pub const fn connection(&self) -> &Connection { - &self.conn +/// `key` is the 32-byte intermediate key passed to `sqlite3mc`. `_lock` is +/// an in-scope [`LockGuard`] that proves the caller serialized writes via +/// [`crate::Lock`]; the lock is required only for the open and the caller +/// re-acquires for each subsequent transaction. `ensure_schema` runs after +/// the database is opened and keyed but before the integrity check, and may +/// create tables, indexes, and triggers. +/// +/// # Errors +/// +/// Returns [`StoreError::Db`] if opening, keying, or schema setup fails, or +/// [`StoreError::IntegrityCheckFailed`] if `PRAGMA integrity_check` reports +/// corruption. +pub fn open_vault( + path: &Path, + key: &SecretBox<[u8; 32]>, + _lock: &LockGuard, + ensure_schema: F, +) -> StoreResult +where + F: FnOnce(&Connection) -> DbResult<()>, +{ + let conn = cipher::open_encrypted(path, key, false)?; + ensure_schema(&conn)?; + if !cipher::integrity_check(&conn)? { + return Err(StoreError::IntegrityCheckFailed( + "integrity_check failed".to_string(), + )); } + Ok(conn) } From e5ac6eb03ed9c2fe87a30992958b3aa16b51569e Mon Sep 17 00:00:00 2001 From: danielle-tfh Date: Wed, 13 May 2026 11:00:08 +0100 Subject: [PATCH 06/31] fix: address review punch list (items 1-8) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 1. error.rs: restore 1-1 From variant mapping. The previous Self::VaultDb(err.to_string()) collapse erased variant identity that hosts depend on for UX (Crypto vs InvalidEnvelope vs Keystore vs CorruptedVault, etc.). keys.rs tests restored to match specific variants. 2. blobs::get(conn, cid: &[u8]): lax input — accept any byte slice so callers reading content_id out of another table column (Vec from column_blob) don't need copy_from_slice into a [u8; 32]. 3. tests.rs: new test_key_envelope_cbor_bytes_frozen asserting the canonical KeyEnvelope serializes to a hard-coded hex string. Round-trip alone misses field-order or type drift; this catches it. 4. lib.rs: drop KeyEnvelope from public re-exports — fields are pub(crate), so external consumers couldn't read them anyway; only init_or_open_envelope_key is the surface. 5. blobs::delete(conn, cid: &[u8]): orphan-blob GC. Consumers handling status transitions (status: Enrolled → Unverified, etc.) call this instead of writing raw SQL. 6. README.md: walletkit-db description matches Cargo.toml ("Encrypted on-device storage primitives ...") — old wording was stale from the pre-refactor PR #396 framing. 7. vault/mod.rs: drop the redundant let conn = &self.conn aliasing introduced during the refactor. 8. traits.rs: tighten the doc note about consumer adapters — orphan rule blocks a blanket impl across crates, so consumers need a small newtype. Verified: cargo fmt, cargo clippy -D warnings (all/default/no-default-features), cargo test --workspace --lib --features walletkit-core/legacy-nullifiers --features walletkit-core/v3 (123 + 19 = 142 tests), cargo doc -Dwarnings. --- README.md | 2 +- walletkit-core/src/storage/error.rs | 16 +++++++++++++++- walletkit-core/src/storage/keys.rs | 12 ++++++++++-- walletkit-core/src/storage/vault/mod.rs | 22 ++++++++-------------- walletkit-db/src/blobs.rs | 24 ++++++++++++++++++++++-- walletkit-db/src/lib.rs | 6 +++--- walletkit-db/src/tests.rs | 23 ++++++++++++++++++++++- walletkit-db/src/traits.rs | 8 ++++---- 8 files changed, 85 insertions(+), 28 deletions(-) diff --git a/README.md b/README.md index f2f480dbb..3e5504ccd 100644 --- a/README.md +++ b/README.md @@ -97,7 +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` - Generic encrypted `SQLite` (`sqlite3mc`) wrapper providing safe connection, transaction, and statement types plus encrypted-open and plaintext export/import helpers. Used by `walletkit-core` for credential storage and consumable by sibling SDKs that need an encrypted on-device store. +- `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/error.rs b/walletkit-core/src/storage/error.rs index 50d2666b2..945244c94 100644 --- a/walletkit-core/src/storage/error.rs +++ b/walletkit-core/src/storage/error.rs @@ -96,6 +96,20 @@ impl From for StorageError { impl From for StorageError { fn from(err: walletkit_db::StoreError) -> Self { - Self::VaultDb(err.to_string()) + 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 0650b229b..fe8fd500e 100644 --- a/walletkit-core/src/storage/keys.rs +++ b/walletkit-core/src/storage/keys.rs @@ -154,7 +154,11 @@ mod tests { let other_keystore = InMemoryKeystore::new(); match StorageKeys::init(&other_keystore, &blob_store, &guard, 456) { - Err(StorageError::VaultDb(_)) => {} + Err( + StorageError::Crypto(_) + | StorageError::InvalidEnvelope(_) + | StorageError::Keystore(_), + ) => {} Err(err) => panic!("unexpected error: {err}"), Ok(_) => panic!("expected error"), } @@ -180,7 +184,11 @@ mod tests { .expect("write"); match StorageKeys::init(&keystore, &blob_store, &guard, 456) { - Err(StorageError::VaultDb(_)) => {} + Err( + StorageError::Serialization(_) + | StorageError::Crypto(_) + | StorageError::UnsupportedEnvelopeVersion(_), + ) => {} Err(err) => panic!("unexpected error: {err}"), Ok(_) => panic!("expected error"), } diff --git a/walletkit-core/src/storage/vault/mod.rs b/walletkit-core/src/storage/vault/mod.rs index 43a9947bd..b6a30e3ef 100644 --- a/walletkit-core/src/storage/vault/mod.rs +++ b/walletkit-core/src/storage/vault/mod.rs @@ -62,8 +62,7 @@ impl VaultDb { ) -> StorageResult<()> { let leaf_index_i64 = to_i64(leaf_index, "leaf_index")?; let now_i64 = to_i64(now, "now")?; - let conn = &self.conn; - let tx = conn.transaction().map_err(|err| map_db_err(&err))?; + let tx = self.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) @@ -120,11 +119,10 @@ impl VaultDb { let genesis_issued_at_i64 = to_i64(genesis_issued_at, "genesis_issued_at")?; let expires_at_i64 = to_i64(expires_at, "expires_at")?; - let conn = &self.conn; - let tx = conn.transaction().map_err(|err| map_db_err(&err))?; + let tx = self.conn.transaction().map_err(|err| map_db_err(&err))?; let credential_blob_id = blobs::put( - conn, + &self.conn, BlobKind::CredentialBlob as u8, credential_blob.as_slice(), now, @@ -132,7 +130,7 @@ impl VaultDb { let associated_data_id = if let Some(data) = associated_data.as_ref() { Some(blobs::put( - conn, + &self.conn, BlobKind::AssociatedData as u8, data.as_slice(), now, @@ -207,8 +205,7 @@ impl VaultDb { WHERE (?2 IS NULL OR cr.issuer_schema_id = ?2) ORDER BY cr.updated_at DESC"; - let conn = &self.conn; - let mut stmt = conn.prepare(sql).map_err(|err| map_db_err(&err))?; + let mut stmt = self.conn.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))? { @@ -233,8 +230,7 @@ impl VaultDb { credential_id: u64, ) -> StorageResult<()> { let credential_id_i64 = to_i64(credential_id, "credential_id")?; - let conn = &self.conn; - let tx = conn.transaction().map_err(|err| map_db_err(&err))?; + let tx = self.conn.transaction().map_err(|err| map_db_err(&err))?; let deleted = tx .execute( @@ -301,8 +297,7 @@ impl VaultDb { ORDER BY cr.updated_at DESC LIMIT 1"; - let conn = &self.conn; - let mut stmt = conn.prepare(sql).map_err(|err| map_db_err(&err))?; + let mut stmt = self.conn.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,8 +323,7 @@ impl VaultDb { &mut self, _lock: &StorageLockGuard, ) -> StorageResult { - let conn = &self.conn; - let tx = conn.transaction().map_err(|err| map_db_err(&err))?; + let tx = self.conn.transaction().map_err(|err| map_db_err(&err))?; let deleted = tx .execute("DELETE FROM credential_records", &[]) diff --git a/walletkit-db/src/blobs.rs b/walletkit-db/src/blobs.rs index 3f1dc55ce..ea2c47427 100644 --- a/walletkit-db/src/blobs.rs +++ b/walletkit-db/src/blobs.rs @@ -93,14 +93,34 @@ pub fn put( /// 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. +/// /// # Errors /// /// Returns a [`StoreError`] if the query fails. -pub fn get(conn: &Connection, cid: &ContentId) -> StoreResult>> { +pub fn get(conn: &Connection, cid: &[u8]) -> StoreResult>> { let bytes = conn.query_row_optional( "SELECT bytes FROM blob_objects WHERE content_id = ?1", - params![cid.as_ref()], + 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. Accepts any byte +/// slice for the same reason as [`get`]. +/// +/// # Errors +/// +/// Returns a [`StoreError`] if the delete fails. +pub fn delete(conn: &Connection, cid: &[u8]) -> StoreResult<()> { + conn.execute( + "DELETE FROM blob_objects WHERE content_id = ?1", + params![cid], + )?; + Ok(()) +} diff --git a/walletkit-db/src/lib.rs b/walletkit-db/src/lib.rs index df9b56c2b..ba64397b3 100644 --- a/walletkit-db/src/lib.rs +++ b/walletkit-db/src/lib.rs @@ -8,8 +8,8 @@ //! - [`open_vault`] — encrypted-database opener with caller-supplied schema. //! - [`blobs`] — content-addressed blob storage (`ensure_schema`, `put`, //! `get`), [`ContentId`], and [`compute_content_id`]. -//! - [`KeyEnvelope`] + [`init_or_open_envelope_key`] — sealed intermediate -//! key persisted via [`AtomicBlobStore`]. +//! - [`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 @@ -29,7 +29,7 @@ mod traits; mod vault; pub use blobs::{compute_content_id, ContentId}; -pub use envelope::{init_or_open_envelope_key, KeyEnvelope}; +pub use envelope::init_or_open_envelope_key; pub use error::{StoreError, StoreResult}; pub use lock::{Lock, LockGuard}; pub use sqlite::{ diff --git a/walletkit-db/src/tests.rs b/walletkit-db/src/tests.rs index b2bee010a..0d8f41163 100644 --- a/walletkit-db/src/tests.rs +++ b/walletkit-db/src/tests.rs @@ -286,9 +286,10 @@ fn test_cipher_import_rejects_non_empty_destination() { mod primitives { use super::init_sqlite; + use crate::envelope::KeyEnvelope; use crate::{ blobs, compute_content_id, init_or_open_envelope_key, open_vault, - AtomicBlobStore, KeyEnvelope, Keystore, Lock, StoreError, StoreResult, + AtomicBlobStore, Keystore, Lock, StoreError, StoreResult, }; use secrecy::{ExposeSecret, SecretBox}; use std::sync::Mutex; @@ -328,6 +329,26 @@ mod primitives { 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); diff --git a/walletkit-db/src/traits.rs b/walletkit-db/src/traits.rs index 350135390..b99c40e97 100644 --- a/walletkit-db/src/traits.rs +++ b/walletkit-db/src/traits.rs @@ -1,9 +1,9 @@ //! Plain-Rust trait surface for consumer-supplied platform integrations. //! -//! Argument shapes mirror `WalletKit`'s existing uniffi-annotated traits -//! (`Vec` for byte buffers, owned `String` for paths) so downstream -//! consumers can blanket-impl these for their own annotated traits without -//! adapter newtypes. +//! 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; From 810b608a122fb76ca9a80b0abe02c8dd195852b9 Mon Sep 17 00:00:00 2001 From: danielle-tfh Date: Wed, 13 May 2026 11:55:19 +0100 Subject: [PATCH 07/31] fix(walletkit-db): reject non-32-byte content_id in blobs::get / delete Lax &[u8] input was silently matching no row on the wrong length. 3-line guard up front turns it into an explicit StoreError so callers see the programmer error at the call site instead of a missed read or no-op delete. --- walletkit-db/src/blobs.rs | 25 ++++++++++++++++++++----- 1 file changed, 20 insertions(+), 5 deletions(-) diff --git a/walletkit-db/src/blobs.rs b/walletkit-db/src/blobs.rs index ea2c47427..09fbdcb9e 100644 --- a/walletkit-db/src/blobs.rs +++ b/walletkit-db/src/blobs.rs @@ -94,12 +94,15 @@ pub fn put( /// 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. +/// 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 the query fails. +/// 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], @@ -111,16 +114,28 @@ pub fn get(conn: &Connection, cid: &[u8]) -> StoreResult>> { /// 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. Accepts any byte -/// slice for the same reason as [`get`]. +/// or PCP becoming unreferenced) call this to GC the row. Same 32-byte +/// requirement as [`get`]. /// /// # Errors /// -/// Returns a [`StoreError`] if the delete fails. +/// 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()), + ))) + } +} From 9e0809cf05d2f0d80f308352fb56a4f2f47d3e1d Mon Sep 17 00:00:00 2001 From: danielle-tfh Date: Wed, 13 May 2026 12:02:07 +0100 Subject: [PATCH 08/31] docs(walletkit-db): add per-crate README Short, intended-usage-first README focused on the four-step pattern a new consumer follows (lock, envelope key, open_vault, blobs::*) plus the public surface and on-disk format guarantee. Cargo.toml points at the per-crate README so it surfaces on crates.io instead of the workspace top-level one. --- walletkit-db/Cargo.toml | 2 +- walletkit-db/README.md | 84 +++++++++++++++++++++++++++++++++++++++++ 2 files changed, 85 insertions(+), 1 deletion(-) create mode 100644 walletkit-db/README.md diff --git a/walletkit-db/Cargo.toml b/walletkit-db/Cargo.toml index 54a6aff25..f11101d1d 100644 --- a/walletkit-db/Cargo.toml +++ b/walletkit-db/Cargo.toml @@ -10,7 +10,7 @@ license.workspace = true homepage.workspace = true repository.workspace = true exclude.workspace = true -readme.workspace = true +readme = "README.md" keywords.workspace = true categories.workspace = true diff --git a/walletkit-db/README.md b/walletkit-db/README.md new file mode 100644 index 000000000..26459f798 --- /dev/null +++ b/walletkit-db/README.md @@ -0,0 +1,84 @@ +# walletkit-db + +Encrypted on-device storage primitives for WalletKit. SQLCipher (`sqlite3mc`) +wrapper, vault opener, content-addressed blobs, sealed key envelope, +cross-process lock. + +Consumed by `walletkit-core::storage` (credential vault) today and by +sibling SDKs that need an encrypted on-device store (e.g. OrbKit's planned +`OrbPcpStore`) next. Plain Rust, no `uniffi`. + +## Intended usage + +A new consumer wires up storage in four steps. Each consumer picks its own +paths, envelope filename, associated-data namespace, and SQL schema: + +```rust +use walletkit_db::{blobs, init_or_open_envelope_key, open_vault, Lock}; + +// 1. Cross-process lock. One file per consumer. +let lock = Lock::open(&paths.lock_path())?; +let guard = lock.lock()?; + +// 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, + &guard, + "orb_pcp_keys.bin", + b"orbkit:pcp-key-envelope", + now, +)?; + +// 3. Open the encrypted SQLite database with the consumer's own schema. +let conn = open_vault(&paths.db_path(), &k_intermediate, &guard, |conn| { + blobs::ensure_schema(conn)?; // shared blob_objects table + my_schema::ensure_schema(conn) // consumer's own tables +})?; + +// 4. Store and fetch blobs by content id; insert consumer-specific rows +// referencing those ids. +let cid = blobs::put(&conn, MY_KIND_TAG, &payload_bytes, now)?; +let bytes = blobs::get(&conn, &cid)?.expect("present"); +blobs::delete(&conn, &cid)?; // GC orphaned bytes on status change +``` + +The consumer brings: + +- A type implementing `Keystore` (seal/open under a device-bound key) +- A type implementing `AtomicBlobStore` (small-blob persistence — e.g. the + sealed envelope file) +- A `kind: u8` tag space for blob payloads +- Its own SQL schema and queries + +The crate handles cipher setup, schema dispatch, integrity check, content +hashing (`SHA-256(b"worldid:blob" || [kind] || plaintext)`), CBOR-encoded +envelope persistence, and the lock. + +## Public surface + +- `open_vault(...) -> StoreResult` — open + key + schema + + integrity check. Returns the bare `Connection`; consumers compose on top. +- `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. Consumers that expose + FFI define their own annotated traits and bridge with a small newtype. +- `Connection`, `Transaction`, `Statement`, `Row`, `StepResult`, `Value`, + `cipher::*`, `DbError`, `DbResult`, `StoreError`, `StoreResult` — the + underlying SQLite wrapper and error types. + +## 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 in `src/tests.rs` guard the format. + +## 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 (single-threaded Web Worker runtime). From c17c7360bef28cf048fd5f6d09120db326c379e2 Mon Sep 17 00:00:00 2001 From: danielle-tfh Date: Wed, 13 May 2026 12:11:58 +0100 Subject: [PATCH 09/31] docs(walletkit-db): drop OrbKit references from README README should describe the crate for any consumer, not name a specific future one. Replaces the OrbKit mention + envelope filename/AD example with generic placeholders. --- walletkit-db/README.md | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/walletkit-db/README.md b/walletkit-db/README.md index 26459f798..faad666bb 100644 --- a/walletkit-db/README.md +++ b/walletkit-db/README.md @@ -4,9 +4,9 @@ Encrypted on-device storage primitives for WalletKit. SQLCipher (`sqlite3mc`) wrapper, vault opener, content-addressed blobs, sealed key envelope, cross-process lock. -Consumed by `walletkit-core::storage` (credential vault) today and by -sibling SDKs that need an encrypted on-device store (e.g. OrbKit's planned -`OrbPcpStore`) next. 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. +Plain Rust, no `uniffi`. ## Intended usage @@ -26,8 +26,8 @@ let k_intermediate = init_or_open_envelope_key( &my_keystore_adapter, &my_blob_store_adapter, &guard, - "orb_pcp_keys.bin", - b"orbkit:pcp-key-envelope", + "my_consumer_keys.bin", + b"my-consumer:key-envelope", now, )?; From 7859f8cff12e1dfa576e2579ef590fd344e596fe Mon Sep 17 00:00:00 2001 From: danielle-tfh Date: Wed, 13 May 2026 12:24:41 +0100 Subject: [PATCH 10/31] refactor(walletkit-core): rename vault module to credential_vault, VaultDb to CredentialVault MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Makes the credential-specific wrapper distinct from walletkit-db's generic vault primitive. The directory used to read like "the vault"; now it reads like "the credential vault" — same shape, clearer intent. StorageError::VaultDb variant kept under its existing name (FFI-exposed; host bindings depend on it). --- .../src/storage/credential_storage.rs | 9 ++-- .../{vault => credential_vault}/helpers.rs | 0 .../{vault => credential_vault}/mod.rs | 4 +- .../{vault => credential_vault}/schema.rs | 0 .../{vault => credential_vault}/tests.rs | 42 +++++++++---------- walletkit-core/src/storage/mod.rs | 4 +- 6 files changed, 30 insertions(+), 29 deletions(-) rename walletkit-core/src/storage/{vault => credential_vault}/helpers.rs (100%) rename walletkit-core/src/storage/{vault => credential_vault}/mod.rs (99%) rename walletkit-core/src/storage/{vault => credential_vault}/schema.rs (100%) rename walletkit-core/src/storage/{vault => credential_vault}/tests.rs (91%) diff --git a/walletkit-core/src/storage/credential_storage.rs b/walletkit-core/src/storage/credential_storage.rs index bec0336ec..13ec93b70 100644 --- a/walletkit-core/src/storage/credential_storage.rs +++ b/walletkit-core/src/storage/credential_storage.rs @@ -15,7 +15,7 @@ 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; @@ -87,7 +87,7 @@ struct CredentialStoreInner { struct StorageState { #[allow(dead_code)] keys: StorageKeys, - vault: VaultDb, + vault: CredentialVault, cache: CacheDb, leaf_index: u64, } @@ -526,7 +526,8 @@ impl CredentialStoreInner { now, )?; let k_intermediate = keys.intermediate_key(); - let vault = VaultDb::new(&self.paths.vault_db_path(), k_intermediate, &guard)?; + let vault = + CredentialVault::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 { keys, @@ -1281,7 +1282,7 @@ mod tests { #[test] fn test_import_vault_backup_transaction_atomicity() { - use crate::storage::vault::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/helpers.rs b/walletkit-core/src/storage/credential_vault/helpers.rs similarity index 100% rename from walletkit-core/src/storage/vault/helpers.rs rename to walletkit-core/src/storage/credential_vault/helpers.rs diff --git a/walletkit-core/src/storage/vault/mod.rs b/walletkit-core/src/storage/credential_vault/mod.rs similarity index 99% rename from walletkit-core/src/storage/vault/mod.rs rename to walletkit-core/src/storage/credential_vault/mod.rs index b6a30e3ef..b81d6a90a 100644 --- a/walletkit-core/src/storage/vault/mod.rs +++ b/walletkit-core/src/storage/credential_vault/mod.rs @@ -24,11 +24,11 @@ pub(crate) use schema::BACKUP_TABLES; /// Encrypted vault database wrapper. #[derive(Debug)] -pub struct VaultDb { +pub struct CredentialVault { conn: Connection, } -impl VaultDb { +impl CredentialVault { /// Opens or creates the encrypted vault database at `path`. /// /// # Errors diff --git a/walletkit-core/src/storage/vault/schema.rs b/walletkit-core/src/storage/credential_vault/schema.rs similarity index 100% rename from walletkit-core/src/storage/vault/schema.rs rename to walletkit-core/src/storage/credential_vault/schema.rs diff --git a/walletkit-core/src/storage/vault/tests.rs b/walletkit-core/src/storage/credential_vault/tests.rs similarity index 91% rename from walletkit-core/src/storage/vault/tests.rs rename to walletkit-core/src/storage/credential_vault/tests.rs index bf6966ec9..81286882d 100644 --- a/walletkit-core/src/storage/vault/tests.rs +++ b/walletkit-core/src/storage/credential_vault/tests.rs @@ -44,9 +44,9 @@ fn test_vault_create_and_open() { 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, &guard).expect("create vault"); drop(db); - VaultDb::new(&path, &key, &guard).expect("open vault"); + CredentialVault::new(&path, &key, &guard).expect("open vault"); cleanup_vault_files(&path); cleanup_lock_file(&lock_path); } @@ -58,9 +58,9 @@ fn test_vault_wrong_key_fails() { 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, &guard).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, &guard).expect_err("wrong key"); match err { StorageError::VaultDb(_) | StorageError::CorruptedVault(_) => {} _ => panic!("unexpected error: {err}"), @@ -76,7 +76,7 @@ fn test_leaf_index_set_once() { 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"); + let mut db = CredentialVault::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) @@ -92,7 +92,7 @@ fn test_leaf_index_immutable() { 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"); + let mut db = CredentialVault::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"); match err { @@ -110,7 +110,7 @@ fn test_store_credential_without_associated_data() { 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 mut db = CredentialVault::new(&path, &key, &guard).expect("create vault"); let credential_id = db .store_credential( &guard, @@ -140,7 +140,7 @@ fn test_store_credential_with_associated_data() { 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 mut db = CredentialVault::new(&path, &key, &guard).expect("create vault"); db.store_credential( &guard, 11, @@ -175,7 +175,7 @@ fn test_content_id_deduplication() { 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 mut db = CredentialVault::new(&path, &key, &guard).expect("create vault"); let first_id = db .store_credential( &guard, @@ -244,7 +244,7 @@ fn test_list_credentials_by_issuer() { 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 mut db = CredentialVault::new(&path, &key, &guard).expect("create vault"); db.store_credential( &guard, 100, @@ -283,7 +283,7 @@ fn test_list_credentials_marks_expired() { 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 mut db = CredentialVault::new(&path, &key, &guard).expect("create vault"); db.store_credential( &guard, 300, @@ -323,7 +323,7 @@ fn test_list_credentials_by_issuer_includes_expired() { 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 mut db = CredentialVault::new(&path, &key, &guard).expect("create vault"); db.store_credential( &guard, 500, @@ -353,7 +353,7 @@ fn test_delete_credential_by_id() { 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 mut db = CredentialVault::new(&path, &key, &guard).expect("create vault"); let credential_id = db .store_credential( &guard, @@ -414,7 +414,7 @@ fn test_delete_credential_cleans_up_orphaned_associated_data() { 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 mut db = CredentialVault::new(&path, &key, &guard).expect("create vault"); let credential_id = db .store_credential( @@ -465,7 +465,7 @@ fn test_danger_delete_all_credentials() { 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 mut db = CredentialVault::new(&path, &key, &guard).expect("create vault"); db.store_credential( &guard, 100, @@ -517,7 +517,7 @@ fn test_danger_delete_all_credentials_empty() { 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 mut db = CredentialVault::new(&path, &key, &guard).expect("create vault"); let deleted = db .danger_delete_all_credentials(&guard) @@ -535,7 +535,7 @@ fn test_vault_integrity_check() { 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, &guard).expect("create vault"); assert!(db.check_integrity().expect("integrity")); cleanup_vault_files(&path); cleanup_lock_file(&lock_path); @@ -564,7 +564,7 @@ fn test_credential_vault_on_disk_format_guard() { let blinding = sample_blinding_factor(); let credential_id = { - let mut db = VaultDb::new(&path, &key, &guard).expect("create vault"); + let mut db = CredentialVault::new(&path, &key, &guard).expect("create vault"); db.store_credential( &guard, 42, @@ -592,7 +592,7 @@ fn test_credential_vault_on_disk_format_guard() { let expected_cid_hex = "9281febbd42d05857b399f8481d6842f1e3e4b78401081ca7f0d0fb3a80e9264"; - let db = VaultDb::new(&path, &key, &guard).expect("reopen vault"); + let db = CredentialVault::new(&path, &key, &guard).expect("reopen vault"); let stored_cid_hex: String = db .raw_connection() .query_row( @@ -625,9 +625,9 @@ fn test_vault_corruption_handling() { 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, &guard).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, &guard).expect_err("corrupt vault"); match err { StorageError::VaultDb(_) | StorageError::CorruptedVault(_) => {} _ => panic!("unexpected error: {err}"), diff --git a/walletkit-core/src/storage/mod.rs b/walletkit-core/src/storage/mod.rs index c66243230..56803ed60 100644 --- a/walletkit-core/src/storage/mod.rs +++ b/walletkit-core/src/storage/mod.rs @@ -2,6 +2,7 @@ pub mod cache; pub mod credential_storage; +pub mod credential_vault; pub mod error; #[cfg(all(not(target_arch = "wasm32"), feature = "embed-zkeys"))] pub mod groth16_cache; @@ -9,10 +10,10 @@ pub mod keys; 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; @@ -25,7 +26,6 @@ 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"; From e5aba7d10836d549add996d1360cee3f2eac0402 Mon Sep 17 00:00:00 2001 From: danielle-tfh Date: Wed, 13 May 2026 14:00:12 +0100 Subject: [PATCH 11/31] docs(walletkit-db): expand README with architecture, key hierarchy, startup, encryption, threat model MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds the substantive context a consumer needs to understand what the crate is doing under the four-step pattern: the key hierarchy (K_device / K_intermediate / AD), the cold and warm startup sequences, what sqlite3mc actually does after PRAGMA key, and the threat model with its defense-in-depth levers. Content distilled from the OrbKit storage layer notes. OrbKit-specific material (write points, OrbPcpStore mapping) deliberately left out — README describes the crate for any consumer. --- walletkit-db/README.md | 133 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 133 insertions(+) diff --git a/walletkit-db/README.md b/walletkit-db/README.md index faad666bb..27718b509 100644 --- a/walletkit-db/README.md +++ b/walletkit-db/README.md @@ -8,6 +8,139 @@ Consumed by `walletkit-core::storage` (credential vault) and by sibling SDKs in the WalletKit workspace that need an encrypted on-device store. Plain Rust, no `uniffi`. +## Architecture + +```mermaid +flowchart TB + subgraph Host["Host platform (Kotlin / Swift)"] + KS["DeviceKeystore (uniffi)"] + BS["AtomicBlobStore (uniffi)"] + end + + subgraph WKDB["walletkit-db (this crate)"] + OV["open_vault()"] + Blobs["blobs::{ensure_schema, put, get, delete}"] + Env["init_or_open_envelope_key()"] + Lock["Lock / LockGuard"] + Cipher["sqlite3mc (encrypted SQLite)"] + OV --> Cipher + Blobs --> Cipher + end + + subgraph Consumer["Consumer crate (e.g. walletkit-core)"] + Wrapper["Domain-specific 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 +``` + +The 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; the crate +gives them an encrypted database with the safety machinery wired up. + +## Key hierarchy + +- **`K_device`** — hardware keystore root key (iOS Secure Enclave / Android + Keystore). Seal/unseal small blobs only; cannot be extracted from the + device. Provided by the consumer via the `Keystore` trait. +- **`K_intermediate`** — 32-byte random key per consumer-vault. Generated + once on first run with `getrandom`, sealed under `K_device`, persisted as + a CBOR `KeyEnvelope`. Used as the SQLite page-encryption key by sqlite3mc. +- **AD (associated data)** — non-secret label bound into the AEAD seal + (e.g. `worldid:account-key-envelope`). Per-consumer so envelopes can't be + swapped between vaults; not a password. + +Each consumer picks its own envelope filename + AD so independent vaults +never share intermediate keys, and so the host can apply different keystore +access policies (Face ID on one vault, none on another) per consumer. + +## Startup sequence + +**Cold start (first run, brand-new install):** + +1. Open `Lock`, acquire `LockGuard`. +2. `init_or_open_envelope_key` calls `AtomicBlobStore.read(filename)` → `None`. +3. `getrandom::fill` → 32 random bytes = `K_intermediate`. +4. `K_device.seal(AD, K_intermediate)` → opaque ciphertext. +5. Wrap in `KeyEnvelope` (version, ciphertext, timestamps), CBOR-encode, + `AtomicBlobStore.write_atomic` to disk. +6. `open_vault` opens the SQLite file via `sqlite3_open_v2`. +7. `PRAGMA key = "x''"` — sqlite3mc installs its encryption codec. +8. Schema callback: `blobs::ensure_schema(conn)` + consumer's schema DDL. +9. `PRAGMA integrity_check`. +10. Return open `Connection`. + +**Warm start (every subsequent run):** + +1. Open `Lock`, acquire `LockGuard`. +2. `AtomicBlobStore.read(filename)` → envelope bytes. +3. CBOR-decode, verify version, extract sealed ciphertext. +4. `K_device.open_sealed(AD, sealed_ciphertext)` → recover the **bit-for-bit + original** `K_intermediate`. Encryption is reversible; nothing is + re-derived. +5. `sqlite3_open_v2` + `PRAGMA key`. Wrong key returns `SQLITE_NOTADB` on + the first page read. +6. Schema callback runs idempotently (`CREATE TABLE IF NOT EXISTS`). +7. Integrity check; return. + +**Device wipe / app uninstall:** `K_device` is destroyed. The envelope on +disk becomes permanently unsealable. Recovery has to come from a separate +backup path that re-wraps `K_intermediate` (or the data) under a +non-device-bound key. + +## Encryption mechanism (sqlite3mc) + +After `PRAGMA key`, walletkit-db is out of the crypto loop. sqlite3mc takes +over inside SQLite's pager: + +- **Cipher**: ChaCha20-Poly1305 AEAD. Default for sqlite3mc; no external + crypto library. +- **KDF**: PBKDF2-SHA256 derives per-page subkeys from `K_intermediate` and + the page number. Same plaintext on two pages does not produce identical + ciphertext. +- **Pager hook**: every page read decrypts; every page write encrypts; SQL + engine sees only plaintext. +- **What's encrypted**: every page in the file — tables, indexes, freelist, + WAL. Only the first 16 bytes (`SQLite format 3\0` magic + header) stay + plaintext so sqlite3mc can recognize the file before keying. +- **Tamper detection**: Poly1305 MAC per page. Bit-flip → `SQLITE_CORRUPT`. + Wrong key → first decrypted page header is garbage → `SQLITE_NOTADB`. +- **Performance**: single-digit µs per 4KB page on modern ARM. This is why + `K_intermediate` lives in main RAM — sqlite3mc invokes the codec + hundreds of times per transaction and the secure enclave can't service + that load. + +## Threat model + +| Tier | Status | What protects you | +|------|--------|-------------------| +| Disk copy / lost device / unencrypted backup | **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. Separate `K_intermediate` per consumer does not change this. | +| File corruption / envelope swap between vaults | **Safe** | Per-page MAC fails on corrupted pages; AD binding fails AEAD auth on swapped envelopes. | +| Hardware keystore compromise | Out of scope | — | + +**Defense-in-depth lever** against in-app attackers: host policy on the +keystore entry (iOS `kSecAccessControlBiometryCurrentSet`, Android +`setUserAuthenticationRequired(true)`, etc.). walletkit-db is neutral; the +policy lives in the Kotlin/Swift code that creates `K_device`. + +**Why per-consumer `K_intermediate` exists** (not for in-app isolation): + +1. sqlite3mc needs a key in main RAM anyway — the enclave doesn't expose + bulk encryption, so we cannot use `K_device` directly. +2. **Per-keystore-entry policy.** Host can require Face ID on one vault's + unseal but not another — only possible with separate envelopes. +3. Independent rotation, recovery, and file lifecycle per consumer. +4. AEAD tamper-evidence on each envelope. + ## Intended usage A new consumer wires up storage in four steps. Each consumer picks its own From ff0b3877417cc98efc5881fc89eb0af305c37e08 Mon Sep 17 00:00:00 2001 From: danielle-tfh Date: Wed, 13 May 2026 14:48:09 +0100 Subject: [PATCH 12/31] docs(walletkit-db): add Concepts primer to README MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Up-front explanation of what each piece is — Vault, Envelope, Lock, blob_objects table, Keystore + AtomicBlobStore traits — and how they interact at open / store / read / delete time. Reading the rest of the README without this primer was harder than it needed to be. --- walletkit-db/README.md | 71 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 71 insertions(+) diff --git a/walletkit-db/README.md b/walletkit-db/README.md index 27718b509..b4c2d94e6 100644 --- a/walletkit-db/README.md +++ b/walletkit-db/README.md @@ -8,6 +8,77 @@ Consumed by `walletkit-core::storage` (credential vault) and by sibling SDKs in the WalletKit workspace that need an encrypted on-device store. Plain Rust, no `uniffi`. +## Concepts + +There are five physical pieces. Knowing what each one is and isn't makes the +rest of the README straightforward. + +- **Vault** — the encrypted SQLite database file on disk (e.g. + `account.vault.sqlite`). Holds every row the consumer cares about, + including the shared `blob_objects` table. Encrypted with sqlite3mc. + Opened via `open_vault`. +- **Envelope** — a small CBOR-encoded file on disk (e.g. + `account_keys.bin`) that holds the *sealed* 32-byte `K_intermediate`. + Not a vault, not encrypted by sqlite3mc — it's the wrapper around the + key that opens the vault. The seal is done by the host's hardware + keystore. Managed by `init_or_open_envelope_key` + `KeyEnvelope`. +- **Lock** — a separate empty file (e.g. `account.lock`) used as a + cross-process mutex via `flock` / `LockFileEx`. Not encrypted, not part + of the vault. Prevents two processes (e.g. extension + main app) from + racing on the open-and-mutate path. Managed by `Lock` / `LockGuard`. +- **blob_objects table** — one shared table inside the vault for + content-addressed bytes. Consumer-specific tables (`credential_records`, + `pcp_records`, etc.) reference rows here by `content_id` (SHA-256). Big + payloads live here once, deduplicated by hash. Managed by `blobs::*`. +- **Keystore + AtomicBlobStore** — two traits the consumer (and + ultimately the host platform) 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; it goes through these traits. + +### How they interact + +**Opening from scratch:** + +1. Acquire the **Lock** so no other process is mid-open. +2. Ask `AtomicBlobStore` for the **Envelope** file. +3. If no envelope: generate `K_intermediate`, seal it with the host's + **Keystore**, write the envelope. If envelope exists: read it, unseal + with the keystore. +4. Open the **Vault** SQLite file, key it with `K_intermediate` (sqlite3mc + installs its encryption codec). +5. Run the consumer's schema callback — typically + `blobs::ensure_schema(conn)` plus the consumer's domain tables. +6. Integrity-check the vault, return the open `Connection`. + +**Storing a payload:** + +1. `blobs::put(conn, kind, bytes, now)` hashes the bytes + (`SHA-256("worldid:blob" || [kind] || bytes)`), `INSERT OR IGNORE`s + into `blob_objects`, returns the `ContentId`. +2. The consumer inserts its own row (e.g. `credential_records`) + referencing that `content_id`. +3. The host's `LockGuard` is held throughout the mutation; the consumer's + table and `blob_objects` are written in one SQLite transaction. + +**Reading a payload:** + +1. The consumer queries its own table for the row + the `content_id`. +2. `blobs::get(conn, &content_id)` returns the bytes from `blob_objects`. +3. No keystore call; the database is already keyed for the session. + +**Deleting:** + +1. The consumer deletes from its own table. +2. If no other row references the same `content_id`, call + `blobs::delete(conn, &content_id)` to GC the orphan from + `blob_objects`. (walletkit-db doesn't track references; consumers + decide when a blob has become unreferenced.) + +The four files (vault, envelope, lock, and the consumer-chosen blob-store +backing) all live under paths the consumer picks. walletkit-db doesn't +prescribe where; it just expects them to stay together. + ## Architecture ```mermaid From 556b7daae077d0090222102c904743c91cf0d0f1 Mon Sep 17 00:00:00 2001 From: danielle-tfh Date: Wed, 13 May 2026 15:40:19 +0100 Subject: [PATCH 13/31] feat(walletkit-db): encode lock discipline in Vault::{open, read, mutate} Replace the open_vault free function with a Vault struct that owns its Lock. Mutations run under a freshly-acquired guard via Vault::mutate(closure); reads bypass the lock via Vault::read(). The type system now enforces "mutations only under lock" instead of relying on documented caller discipline. - walletkit-db: open_vault free fn dropped; Vault::{open, read, mutate}. init_or_open_envelope_key takes &Lock and acquires internally rather than requiring a witness &LockGuard. - walletkit-core: every CredentialVault mutator drops its _lock: &StorageLockGuard parameter. CredentialStore stops plumbing a guard into vault.* (cache still takes &guard until the corruption-policy fix). The Vault is opened by passing a Lock::clone() from CredentialStoreInner. Behavior unchanged: the same flock file is acquired around the same SQL operations. The lock is now acquired per-mutation rather than held across multi-call sequences, but each mutation is already its own SQL transaction so SQLite's own serialization covers the gap. The bootstrap race (two processes generating competing envelopes on first install) is still covered by the lock acquisition inside init_or_open_envelope_key and Vault::open. On-disk format and host FFI surface unchanged. 123 walletkit-core lib tests pass, 20 walletkit-db lib tests pass. --- .../src/storage/credential_storage.rs | 29 +- .../src/storage/credential_vault/mod.rs | 369 +++++++++--------- .../src/storage/credential_vault/tests.rs | 103 ++--- walletkit-core/src/storage/keys.rs | 19 +- walletkit-db/README.md | 84 ++-- walletkit-db/src/envelope.rs | 12 +- walletkit-db/src/lib.rs | 5 +- walletkit-db/src/tests.rs | 67 +++- walletkit-db/src/vault.rs | 125 ++++-- 9 files changed, 441 insertions(+), 372 deletions(-) diff --git a/walletkit-core/src/storage/credential_storage.rs b/walletkit-core/src/storage/credential_storage.rs index 13ec93b70..91705115d 100644 --- a/walletkit-core/src/storage/credential_storage.rs +++ b/walletkit-core/src/storage/credential_storage.rs @@ -514,7 +514,7 @@ 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(()); } @@ -522,20 +522,23 @@ 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 = - CredentialVault::new(&self.paths.vault_db_path(), k_intermediate, &guard)?; + let vault = CredentialVault::new( + &self.paths.vault_db_path(), + k_intermediate, + self.lock.clone(), + )?; let cache = CacheDb::new(&self.paths.cache_db_path(), k_intermediate, &guard)?; - let mut state = StorageState { + 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(()) } @@ -550,9 +553,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( @@ -603,10 +605,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, @@ -728,10 +728,9 @@ impl CredentialStoreInner { /// Returns the path to the file. The caller is responsible for cleanup. #[cfg(not(target_arch = "wasm32"))] fn export_vault_for_backup_to_file(&self) -> StorageResult { - 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()) } @@ -754,10 +753,9 @@ impl CredentialStoreInner { /// Imports from a plaintext vault file on disk. #[cfg(not(target_arch = "wasm32"))] fn import_vault_from_file(&self, backup_path: &str) -> StorageResult<()> { - 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 @@ -799,9 +797,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. diff --git a/walletkit-core/src/storage/credential_vault/mod.rs b/walletkit-core/src/storage/credential_vault/mod.rs index b81d6a90a..214bdb288 100644 --- a/walletkit-core/src/storage/credential_vault/mod.rs +++ b/walletkit-core/src/storage/credential_vault/mod.rs @@ -1,9 +1,9 @@ //! Encrypted vault database for credential storage. //! -//! Thin wrapper over [`walletkit_db::open_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`]. +//! 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; @@ -14,83 +14,83 @@ use std::path::Path; use crate::storage::error::{StorageError, StorageResult}; use crate::storage::types::{BlobKind, CredentialRecord}; -use crate::storage::StorageLockGuard; use helpers::{map_db_err, map_record, to_i64, to_u64}; use schema::{ensure_schema, VAULT_SCHEMA_VERSION}; use secrecy::SecretBox; -use walletkit_db::{blobs, cipher, open_vault, params, Connection, StepResult, Value}; +use walletkit_db::{blobs, cipher, params, Lock, StepResult, Value, Vault}; pub(crate) use schema::BACKUP_TABLES; /// Encrypted vault database wrapper. +/// +/// Wraps [`walletkit_db::Vault`]: mutating methods acquire the vault's lock +/// internally and run inside a SQL transaction; read methods bypass the +/// lock (`SQLite` WAL handles concurrent readers). #[derive(Debug)] pub struct CredentialVault { - conn: Connection, + vault: Vault, } impl CredentialVault { - /// Opens or creates the encrypted vault database at `path`. + /// Opens or creates the encrypted vault database at `path`. Takes + /// ownership of `lock`; subsequent mutations re-acquire it through + /// [`walletkit_db::Vault::mutate`]. /// /// # 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, + lock: Lock, ) -> StorageResult { - let conn = open_vault(path, k_intermediate, lock, |conn| { + let vault = Vault::open(path, k_intermediate, lock, |conn| { blobs::ensure_schema(conn)?; ensure_schema(conn) })?; - Ok(Self { 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 stored = tx - .query_row( - "INSERT INTO vault_meta (schema_version, leaf_index, created_at, updated_at) - VALUES (?1, ?2, ?3, ?3) - ON CONFLICT(schema_version) DO UPDATE SET - leaf_index = CASE - WHEN vault_meta.leaf_index IS NULL - THEN excluded.leaf_index - ELSE vault_meta.leaf_index - END - RETURNING leaf_index", - params![ - VAULT_SCHEMA_VERSION, - leaf_index_i64, - now_i64, - ], - |stmt| Ok(stmt.column_i64(0)), - ) - .map_err(|err| map_db_err(&err))?; - if stored != leaf_index_i64 { - let expected = to_u64(stored, "leaf_index")?; - return Err(StorageError::InvalidLeafIndex { - expected, - provided: leaf_index, - }); - } - tx.commit().map_err(|err| map_db_err(&err))?; - Ok(()) + self.vault.mutate(|conn| { + 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) + VALUES (?1, ?2, ?3, ?3) + ON CONFLICT(schema_version) DO UPDATE SET + leaf_index = CASE + WHEN vault_meta.leaf_index IS NULL + THEN excluded.leaf_index + ELSE vault_meta.leaf_index + END + RETURNING leaf_index", + params![VAULT_SCHEMA_VERSION, leaf_index_i64, now_i64], + |stmt| Ok(stmt.column_i64(0)), + ) + .map_err(|err| map_db_err(&err))?; + if stored != leaf_index_i64 { + let expected = to_u64(stored, "leaf_index")?; + return Err(StorageError::InvalidLeafIndex { + expected, + provided: leaf_index, + }); + } + tx.commit().map_err(|err| map_db_err(&err))?; + Ok(()) + }) } /// Stores a credential and optional associated data. @@ -104,8 +104,7 @@ impl CredentialVault { #[allow(clippy::too_many_arguments)] #[allow(clippy::needless_pass_by_value)] pub fn store_credential( - &mut self, - _lock: &StorageLockGuard, + &self, issuer_schema_id: u64, subject_blinding_factor: Vec, genesis_issued_at: u64, @@ -119,57 +118,60 @@ impl CredentialVault { 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))?; + self.vault.mutate(|conn| { + let tx = conn.transaction().map_err(|err| map_db_err(&err))?; - let credential_blob_id = blobs::put( - &self.conn, - BlobKind::CredentialBlob as u8, - credential_blob.as_slice(), - now, - )?; - - let associated_data_id = if let Some(data) = associated_data.as_ref() { - Some(blobs::put( - &self.conn, - BlobKind::AssociatedData as u8, - data.as_slice(), + let credential_blob_id = blobs::put( + conn, + BlobKind::CredentialBlob as u8, + credential_blob.as_slice(), now, - )?) - } else { - None - }; - - let ad_cid_value: Value = associated_data_id - .as_ref() - .map_or(Value::Null, |cid| Value::Blob(cid.to_vec())); - - let credential_id = tx - .query_row( - "INSERT INTO credential_records ( - issuer_schema_id, - subject_blinding_factor, - genesis_issued_at, - expires_at, - updated_at, - credential_blob_cid, - associated_data_cid - ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7) - RETURNING credential_id", - params![ - issuer_schema_id_i64, - subject_blinding_factor, - genesis_issued_at_i64, - expires_at_i64, - now_i64, - credential_blob_id.as_slice(), - ad_cid_value, - ], - |stmt| Ok(stmt.column_i64(0)), - ) - .map_err(|err| map_db_err(&err))?; - - tx.commit().map_err(|err| map_db_err(&err))?; - to_u64(credential_id, "credential_id") + )?; + + 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() + .map_or(Value::Null, |cid| Value::Blob(cid.to_vec())); + + let credential_id = tx + .query_row( + "INSERT INTO credential_records ( + issuer_schema_id, + subject_blinding_factor, + genesis_issued_at, + expires_at, + updated_at, + credential_blob_cid, + associated_data_cid + ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7) + RETURNING credential_id", + params![ + issuer_schema_id_i64, + subject_blinding_factor, + genesis_issued_at_i64, + expires_at_i64, + now_i64, + credential_blob_id.as_slice(), + ad_cid_value, + ], + |stmt| Ok(stmt.column_i64(0)), + ) + .map_err(|err| map_db_err(&err))?; + + tx.commit().map_err(|err| map_db_err(&err))?; + to_u64(credential_id, "credential_id") + }) } /// Lists credential metadata, optionally filtered by issuer schema. @@ -205,7 +207,11 @@ impl CredentialVault { 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 + .read() + .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))? { @@ -222,60 +228,59 @@ impl CredentialVault { /// /// # 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))?; + self.vault.mutate(|conn| { + let tx = conn.transaction().map_err(|err| map_db_err(&err))?; + + let deleted = tx + .execute( + "DELETE FROM credential_records WHERE credential_id = ?1", + params![credential_id_i64], + ) + .map_err(|err| map_db_err(&err))?; + + if deleted == 0 { + return Err(StorageError::CredentialIdNotFound { credential_id }); + } - let deleted = tx - .execute( - "DELETE FROM credential_records WHERE credential_id = ?1", - params![credential_id_i64], + // Delete orphaned credential blobs + tx.execute( + "DELETE FROM blob_objects + WHERE blob_kind = ?1 + AND NOT EXISTS ( + SELECT 1 + FROM credential_records cr + WHERE cr.credential_blob_cid = blob_objects.content_id + )", + params![BlobKind::CredentialBlob.as_i64()], ) .map_err(|err| map_db_err(&err))?; - if deleted == 0 { - return Err(StorageError::CredentialIdNotFound { credential_id }); - } + // Delete orphaned associated data blobs + tx.execute( + "DELETE FROM blob_objects + WHERE blob_kind = ?1 + AND NOT EXISTS ( + SELECT 1 + FROM credential_records cr + WHERE cr.associated_data_cid = blob_objects.content_id + )", + params![BlobKind::AssociatedData.as_i64()], + ) + .map_err(|err| map_db_err(&err))?; - // Delete orphaned credential blobs - tx.execute( - "DELETE FROM blob_objects - WHERE blob_kind = ?1 - AND NOT EXISTS ( - SELECT 1 - FROM credential_records cr - WHERE cr.credential_blob_cid = blob_objects.content_id - )", - params![BlobKind::CredentialBlob.as_i64()], - ) - .map_err(|err| map_db_err(&err))?; - - // Delete orphaned associated data blobs - tx.execute( - "DELETE FROM blob_objects - WHERE blob_kind = ?1 - AND NOT EXISTS ( - SELECT 1 - FROM credential_records cr - WHERE cr.associated_data_cid = blob_objects.content_id - )", - params![BlobKind::AssociatedData.as_i64()], - ) - .map_err(|err| map_db_err(&err))?; - - tx.commit().map_err(|err| map_db_err(&err))?; - Ok(()) + tx.commit().map_err(|err| map_db_err(&err))?; + Ok(()) + }) } /// 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 /// @@ -297,7 +302,11 @@ impl CredentialVault { 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 + .read() + .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))? { @@ -313,27 +322,26 @@ impl CredentialVault { /// **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 { + self.vault.mutate(|conn| { + let tx = conn.transaction().map_err(|err| map_db_err(&err))?; - let deleted = tx - .execute("DELETE FROM credential_records", &[]) - .map_err(|err| map_db_err(&err))?; + let deleted = tx + .execute("DELETE FROM credential_records", &[]) + .map_err(|err| map_db_err(&err))?; - tx.execute("DELETE FROM blob_objects", &[]) - .map_err(|err| map_db_err(&err))?; + tx.execute("DELETE FROM blob_objects", &[]) + .map_err(|err| map_db_err(&err))?; - tx.commit().map_err(|err| map_db_err(&err))?; - Ok(deleted as u64) + tx.commit().map_err(|err| map_db_err(&err))?; + Ok(deleted as u64) + }) } /// Runs an integrity check on the vault database. @@ -342,28 +350,27 @@ impl CredentialVault { /// /// 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.read()).map_err(|e| map_db_err(&e)) } - /// Exports a plaintext (unencrypted) copy of the vault to `dest`. + /// Exports a plaintext (unencrypted) copy of the vault to `dest`. Runs + /// under the vault's lock so concurrent writers cannot interleave. /// /// The caller is 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<()> { - 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, BACKUP_TABLES) - .map_err(|e| map_db_err(&e)) + pub fn export_plaintext(&self, dest: &Path) -> StorageResult<()> { + self.vault.mutate(|conn| { + if dest.exists() { + std::fs::remove_file(dest).map_err(|e| { + StorageError::VaultDb(format!("failed to remove stale backup: {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 @@ -375,18 +382,16 @@ impl CredentialVault { /// # 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, BACKUP_TABLES) - .map_err(|e| map_db_err(&e)) + pub fn import_plaintext(&self, source: &Path) -> StorageResult<()> { + self.vault.mutate(|conn| { + cipher::import_plaintext_copy(conn, source, BACKUP_TABLES) + .map_err(|e| map_db_err(&e)) + }) } /// Borrows the underlying connection for direct SQL access. **Test-only.** #[cfg(test)] pub(super) const fn raw_connection(&self) -> &walletkit_db::Connection { - &self.conn + self.vault.read() } } diff --git a/walletkit-core/src/storage/credential_vault/tests.rs b/walletkit-core/src/storage/credential_vault/tests.rs index 81286882d..d1d957fa7 100644 --- a/walletkit-core/src/storage/credential_vault/tests.rs +++ b/walletkit-core/src/storage/credential_vault/tests.rs @@ -1,5 +1,7 @@ //! Vault database unit tests. +#![allow(clippy::redundant_clone)] + use super::helpers::map_db_err; use super::*; use crate::storage::StorageLock; @@ -43,10 +45,9 @@ fn test_vault_create_and_open() { 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 = CredentialVault::new(&path, &key, &guard).expect("create vault"); + let db = CredentialVault::new(&path, &key, lock.clone()).expect("create vault"); drop(db); - CredentialVault::new(&path, &key, &guard).expect("open vault"); + CredentialVault::new(&path, &key, lock.clone()).expect("open vault"); cleanup_vault_files(&path); cleanup_lock_file(&lock_path); } @@ -57,10 +58,10 @@ fn test_vault_wrong_key_fails() { 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"); - CredentialVault::new(&path, &key, &guard).expect("create vault"); + CredentialVault::new(&path, &key, lock.clone()).expect("create vault"); let wrong_key = SecretBox::init_with(|| [0x02u8; 32]); - let err = CredentialVault::new(&path, &wrong_key, &guard).expect_err("wrong key"); + let err = + CredentialVault::new(&path, &wrong_key, lock.clone()).expect_err("wrong key"); match err { StorageError::VaultDb(_) | StorageError::CorruptedVault(_) => {} _ => panic!("unexpected error: {err}"), @@ -74,13 +75,10 @@ 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 = CredentialVault::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, lock.clone()).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); } @@ -90,11 +88,10 @@ 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 = CredentialVault::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, lock.clone()).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}"), @@ -108,12 +105,10 @@ 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 = CredentialVault::new(&path, &key, &guard).expect("create vault"); + let db = CredentialVault::new(&path, &key, lock.clone()).expect("create vault"); let credential_id = db .store_credential( - &guard, 10, sample_blinding_factor(), 123, @@ -138,11 +133,9 @@ 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 = CredentialVault::new(&path, &key, &guard).expect("create vault"); + let db = CredentialVault::new(&path, &key, lock.clone()).expect("create vault"); db.store_credential( - &guard, 11, sample_blinding_factor(), 456, @@ -173,12 +166,10 @@ 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 = CredentialVault::new(&path, &key, &guard).expect("create vault"); + let db = CredentialVault::new(&path, &key, lock.clone()).expect("create vault"); let first_id = db .store_credential( - &guard, 12, sample_blinding_factor(), 1, @@ -190,7 +181,6 @@ fn test_content_id_deduplication() { .expect("store credential"); let second_id = db .store_credential( - &guard, 12, sample_blinding_factor(), 1, @@ -209,7 +199,7 @@ 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 @@ -221,7 +211,7 @@ 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 @@ -242,11 +232,9 @@ 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 = CredentialVault::new(&path, &key, &guard).expect("create vault"); + let db = CredentialVault::new(&path, &key, lock.clone()).expect("create vault"); db.store_credential( - &guard, 100, sample_blinding_factor(), 1, @@ -257,7 +245,6 @@ fn test_list_credentials_by_issuer() { ) .expect("store credential"); db.store_credential( - &guard, 200, sample_blinding_factor(), 1, @@ -281,11 +268,9 @@ 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 = CredentialVault::new(&path, &key, &guard).expect("create vault"); + let db = CredentialVault::new(&path, &key, lock.clone()).expect("create vault"); db.store_credential( - &guard, 300, sample_blinding_factor(), 1, @@ -296,7 +281,6 @@ fn test_list_credentials_marks_expired() { ) .expect("store expired credential"); db.store_credential( - &guard, 301, sample_blinding_factor(), 1, @@ -321,11 +305,9 @@ 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 = CredentialVault::new(&path, &key, &guard).expect("create vault"); + let db = CredentialVault::new(&path, &key, lock.clone()).expect("create vault"); db.store_credential( - &guard, 500, sample_blinding_factor(), 1, @@ -351,12 +333,10 @@ 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 = CredentialVault::new(&path, &key, &guard).expect("create vault"); + let db = CredentialVault::new(&path, &key, lock.clone()).expect("create vault"); let credential_id = db .store_credential( - &guard, 400, sample_blinding_factor(), 1, @@ -376,7 +356,7 @@ 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"); @@ -392,7 +372,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 { @@ -412,13 +392,11 @@ 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 = CredentialVault::new(&path, &key, &guard).expect("create vault"); + let db = CredentialVault::new(&path, &key, lock.clone()).expect("create vault"); let credential_id = db .store_credential( - &guard, 401, sample_blinding_factor(), 1, @@ -440,7 +418,7 @@ 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 @@ -463,11 +441,9 @@ 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 = CredentialVault::new(&path, &key, &guard).expect("create vault"); + let db = CredentialVault::new(&path, &key, lock.clone()).expect("create vault"); db.store_credential( - &guard, 100, sample_blinding_factor(), 1, @@ -478,7 +454,6 @@ fn test_danger_delete_all_credentials() { ) .expect("store credential 1"); db.store_credential( - &guard, 200, sample_blinding_factor(), 2, @@ -489,9 +464,7 @@ 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"); @@ -515,12 +488,11 @@ 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 = CredentialVault::new(&path, &key, &guard).expect("create vault"); + let db = CredentialVault::new(&path, &key, lock.clone()).expect("create vault"); let deleted = db - .danger_delete_all_credentials(&guard) + .danger_delete_all_credentials() .expect("delete all on empty"); assert_eq!(deleted, 0); @@ -533,9 +505,8 @@ 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 = CredentialVault::new(&path, &key, &guard).expect("create vault"); + let db = CredentialVault::new(&path, &key, lock.clone()).expect("create vault"); assert!(db.check_integrity().expect("integrity")); cleanup_vault_files(&path); cleanup_lock_file(&lock_path); @@ -558,15 +529,13 @@ fn test_credential_vault_on_disk_format_guard() { 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(|| [0xA5u8; 32]); let credential_bytes = b"on-disk-guard-credential-payload".to_vec(); let blinding = sample_blinding_factor(); let credential_id = { - let mut db = CredentialVault::new(&path, &key, &guard).expect("create vault"); + let db = CredentialVault::new(&path, &key, lock.clone()).expect("create vault"); db.store_credential( - &guard, 42, blinding.clone(), 1_700_000_000, @@ -592,7 +561,7 @@ fn test_credential_vault_on_disk_format_guard() { let expected_cid_hex = "9281febbd42d05857b399f8481d6842f1e3e4b78401081ca7f0d0fb3a80e9264"; - let db = CredentialVault::new(&path, &key, &guard).expect("reopen vault"); + let db = CredentialVault::new(&path, &key, lock.clone()).expect("reopen vault"); let stored_cid_hex: String = db .raw_connection() .query_row( @@ -624,10 +593,10 @@ fn test_vault_corruption_handling() { 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"); - CredentialVault::new(&path, &key, &guard).expect("create vault"); + CredentialVault::new(&path, &key, lock.clone()).expect("create vault"); fs::write(&path, b"corrupt").expect("corrupt file"); - let err = CredentialVault::new(&path, &key, &guard).expect_err("corrupt vault"); + let err = + CredentialVault::new(&path, &key, lock.clone()).expect_err("corrupt vault"); match err { StorageError::VaultDb(_) | StorageError::CorruptedVault(_) => {} _ => panic!("unexpected error: {err}"), diff --git a/walletkit-core/src/storage/keys.rs b/walletkit-core/src/storage/keys.rs index fe8fd500e..97275a9c2 100644 --- a/walletkit-core/src/storage/keys.rs +++ b/walletkit-core/src/storage/keys.rs @@ -21,7 +21,7 @@ use super::{ traits::{AtomicBlobStore, DeviceKeystore}, ACCOUNT_KEYS_FILENAME, ACCOUNT_KEY_ENVELOPE_AD, }; -use walletkit_db::LockGuard; +use walletkit_db::Lock; /// In-memory account keys derived from the account key envelope. /// @@ -42,7 +42,7 @@ impl StorageKeys { pub fn init( keystore: &dyn DeviceKeystore, blob_store: &dyn AtomicBlobStore, - lock: &LockGuard, + lock: &Lock, now: u64, ) -> StorageResult { let intermediate_key = walletkit_db::init_or_open_envelope_key( @@ -130,11 +130,10 @@ mod tests { let blob_store = InMemoryBlobStore::new(); let lock_path = temp_lock_path(); let lock = Lock::open(&lock_path).expect("open lock"); - let guard = lock.lock().expect("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(), @@ -149,11 +148,10 @@ mod tests { let blob_store = InMemoryBlobStore::new(); let lock_path = temp_lock_path(); let lock = Lock::open(&lock_path).expect("open lock"); - let guard = lock.lock().expect("lock"); - StorageKeys::init(&keystore, &blob_store, &guard, 123).expect("init"); + 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(_) @@ -171,8 +169,7 @@ mod tests { let blob_store = InMemoryBlobStore::new(); let lock_path = temp_lock_path(); let lock = Lock::open(&lock_path).expect("open lock"); - let guard = lock.lock().expect("lock"); - StorageKeys::init(&keystore, &blob_store, &guard, 123).expect("init"); + StorageKeys::init(&keystore, &blob_store, &lock, 123).expect("init"); let mut bytes = blob_store .read(ACCOUNT_KEYS_FILENAME.to_string()) @@ -183,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-db/README.md b/walletkit-db/README.md index b4c2d94e6..337998763 100644 --- a/walletkit-db/README.md +++ b/walletkit-db/README.md @@ -16,7 +16,8 @@ rest of the README straightforward. - **Vault** — the encrypted SQLite database file on disk (e.g. `account.vault.sqlite`). Holds every row the consumer cares about, including the shared `blob_objects` table. Encrypted with sqlite3mc. - Opened via `open_vault`. + Opened via `Vault::open`; mutated via `Vault::mutate` (which acquires + the lock); read via `Vault::read` (which bypasses the lock). - **Envelope** — a small CBOR-encoded file on disk (e.g. `account_keys.bin`) that holds the *sealed* 32-byte `K_intermediate`. Not a vault, not encrypted by sqlite3mc — it's the wrapper around the @@ -25,7 +26,9 @@ rest of the README straightforward. - **Lock** — a separate empty file (e.g. `account.lock`) used as a cross-process mutex via `flock` / `LockFileEx`. Not encrypted, not part of the vault. Prevents two processes (e.g. extension + main app) from - racing on the open-and-mutate path. Managed by `Lock` / `LockGuard`. + racing on the open or mutate path. Owned by the `Vault` after `open`; + `Vault::mutate` acquires it internally, so consumers never plumb a + `LockGuard` through their method signatures. - **blob_objects table** — one shared table inside the vault for content-addressed bytes. Consumer-specific tables (`credential_records`, `pcp_records`, etc.) reference rows here by `content_id` (SHA-256). Big @@ -40,36 +43,38 @@ rest of the README straightforward. **Opening from scratch:** -1. Acquire the **Lock** so no other process is mid-open. -2. Ask `AtomicBlobStore` for the **Envelope** file. -3. If no envelope: generate `K_intermediate`, seal it with the host's - **Keystore**, write the envelope. If envelope exists: read it, unseal - with the keystore. -4. Open the **Vault** SQLite file, key it with `K_intermediate` (sqlite3mc - installs its encryption codec). -5. Run the consumer's schema callback — typically - `blobs::ensure_schema(conn)` plus the consumer's domain tables. -6. Integrity-check the vault, return the open `Connection`. +1. Open a `Lock` on the lock file path. +2. `init_or_open_envelope_key(...)` acquires the lock, asks + `AtomicBlobStore` for the envelope file. If absent, generates + `K_intermediate`, seals it with the host's `Keystore`, writes the + envelope. If present, reads it and unseals with the keystore. Releases + the lock. +3. `Vault::open(path, key, lock, ensure_schema)` acquires the lock, opens + the SQLite file via sqlite3mc, runs the consumer's schema callback + (typically `blobs::ensure_schema(conn)` plus the consumer's domain + tables), runs `PRAGMA integrity_check`, releases the lock. Returns + a `Vault`. **Storing a payload:** -1. `blobs::put(conn, kind, bytes, now)` hashes the bytes - (`SHA-256("worldid:blob" || [kind] || bytes)`), `INSERT OR IGNORE`s - into `blob_objects`, returns the `ContentId`. -2. The consumer inserts its own row (e.g. `credential_records`) - referencing that `content_id`. -3. The host's `LockGuard` is held throughout the mutation; the consumer's - table and `blob_objects` are written in one SQLite transaction. +1. Inside `vault.mutate(|conn| { ... })`: `blobs::put(conn, kind, bytes, + now)` hashes the bytes (`SHA-256("worldid:blob" || [kind] || bytes)`), + `INSERT OR IGNORE`s into `blob_objects`, returns the `ContentId`. +2. The closure inserts the consumer's own row referencing that + `content_id`, then commits its transaction. +3. The lock is held for the entire closure and released on return. **Reading a payload:** -1. The consumer queries its own table for the row + the `content_id`. -2. `blobs::get(conn, &content_id)` returns the bytes from `blob_objects`. -3. No keystore call; the database is already keyed for the session. +1. `vault.read()` returns `&Connection` directly — no lock acquisition. +2. The consumer queries its own table for the row + the `content_id`. +3. `blobs::get(vault.read(), &content_id)` returns the bytes from + `blob_objects`. **Deleting:** -1. The consumer deletes from its own table. +1. Inside `vault.mutate(|conn| { ... })`: the consumer deletes from its + own table. 2. If no other row references the same `content_id`, call `blobs::delete(conn, &content_id)` to GC the orphan from `blob_objects`. (walletkit-db doesn't track references; consumers @@ -218,34 +223,40 @@ A new consumer wires up storage in four steps. Each consumer picks its own paths, envelope filename, associated-data namespace, and SQL schema: ```rust -use walletkit_db::{blobs, init_or_open_envelope_key, open_vault, Lock}; +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())?; -let guard = lock.lock()?; // 2. Unseal or generate the consumer's intermediate key. // Filename + AD are per-consumer so different vaults never share keys. +// Acquires `lock` internally, releases on return. let k_intermediate = init_or_open_envelope_key( &my_keystore_adapter, &my_blob_store_adapter, - &guard, + &lock, "my_consumer_keys.bin", b"my-consumer:key-envelope", now, )?; // 3. Open the encrypted SQLite database with the consumer's own schema. -let conn = open_vault(&paths.db_path(), &k_intermediate, &guard, |conn| { +// Vault takes ownership of `lock` and re-acquires it for each mutation. +let vault = Vault::open(&paths.db_path(), &k_intermediate, lock, |conn| { blobs::ensure_schema(conn)?; // shared blob_objects table my_schema::ensure_schema(conn) // consumer's own tables })?; -// 4. Store and fetch blobs by content id; insert consumer-specific rows -// referencing those ids. -let cid = blobs::put(&conn, MY_KIND_TAG, &payload_bytes, now)?; -let bytes = blobs::get(&conn, &cid)?.expect("present"); -blobs::delete(&conn, &cid)?; // GC orphaned bytes on status change +// 4. Mutations run under a freshly-acquired lock. +let cid = vault.mutate(|conn| { + blobs::put(conn, MY_KIND_TAG, &payload_bytes, now) +})?; + +// Reads bypass the lock (SQLite WAL handles concurrent readers). +let bytes = blobs::get(vault.read(), &cid)?.expect("present"); + +// Deletes are mutations. +vault.mutate(|conn| blobs::delete(conn, &cid))?; ``` The consumer brings: @@ -262,12 +273,15 @@ envelope persistence, and the lock. ## Public surface -- `open_vault(...) -> StoreResult` — open + key + schema + - integrity check. Returns the bare `Connection`; consumers compose on top. +- `Vault::open(path, key, lock, ensure_schema) -> StoreResult` — + open + key + schema + integrity check. +- `Vault::read(&self) -> &Connection` — read-only access, no lock. +- `Vault::mutate(&self, f) -> Result` — runs `f` under a + freshly-acquired lock; `E: From`. - `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. +- `Lock` — native `flock` / `LockFileEx`, no-op on WASM. - `Keystore` / `AtomicBlobStore` traits — plain Rust. Consumers that expose FFI define their own annotated traits and bridge with a small newtype. - `Connection`, `Transaction`, `Statement`, `Row`, `StepResult`, `Value`, diff --git a/walletkit-db/src/envelope.rs b/walletkit-db/src/envelope.rs index 700267156..669d7e790 100644 --- a/walletkit-db/src/envelope.rs +++ b/walletkit-db/src/envelope.rs @@ -13,7 +13,7 @@ use serde::{Deserialize, Serialize}; use zeroize::{Zeroize, ZeroizeOnDrop, Zeroizing}; use crate::error::{StoreError, StoreResult}; -use crate::lock::LockGuard; +use crate::lock::Lock; use crate::traits::{AtomicBlobStore, Keystore}; const ENVELOPE_VERSION: u32 = 1; @@ -79,20 +79,22 @@ impl KeyEnvelope { /// On subsequent runs, reads the envelope at `filename`, opens it under /// `keystore` authenticated by `ad`, and returns the unsealed key. /// -/// Holding `_lock` ensures the read-open / generate-write sequence is -/// serialized across processes. +/// `lock` is acquired internally to serialize the read-open / generate-write +/// sequence across processes, and released before this returns. /// /// # Errors /// -/// Propagates errors from the keystore, blob store, CBOR codec, or RNG. +/// 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: &LockGuard, + 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( diff --git a/walletkit-db/src/lib.rs b/walletkit-db/src/lib.rs index ba64397b3..54d2486e4 100644 --- a/walletkit-db/src/lib.rs +++ b/walletkit-db/src/lib.rs @@ -5,7 +5,8 @@ //! //! - [`Connection`], [`Transaction`], [`Statement`], [`cipher`] — encrypted //! `SQLite` (`sqlite3mc`) wrapper with safe Rust types. -//! - [`open_vault`] — encrypted-database opener with caller-supplied schema. +//! - [`Vault`] — encrypted-database wrapper with caller-supplied schema and +//! lock-enforced mutation. //! - [`blobs`] — content-addressed blob storage (`ensure_schema`, `put`, //! `get`), [`ContentId`], and [`compute_content_id`]. //! - [`init_or_open_envelope_key`] — sealed intermediate key persisted via @@ -37,7 +38,7 @@ pub use sqlite::{ StepResult, Transaction, Value, }; pub use traits::{AtomicBlobStore, Keystore}; -pub use vault::open_vault; +pub use vault::Vault; #[cfg(test)] mod tests; diff --git a/walletkit-db/src/tests.rs b/walletkit-db/src/tests.rs index 0d8f41163..524a1135f 100644 --- a/walletkit-db/src/tests.rs +++ b/walletkit-db/src/tests.rs @@ -288,8 +288,8 @@ mod primitives { use super::init_sqlite; use crate::envelope::KeyEnvelope; use crate::{ - blobs, compute_content_id, init_or_open_envelope_key, open_vault, - AtomicBlobStore, Keystore, Lock, StoreError, StoreResult, + blobs, compute_content_id, init_or_open_envelope_key, AtomicBlobStore, + Keystore, Lock, StoreError, StoreResult, Vault, }; use secrecy::{ExposeSecret, SecretBox}; use std::sync::Mutex; @@ -476,14 +476,13 @@ mod primitives { 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 guard = lock.lock().expect("acquire"); let keystore = XorKeystore { pad: [0xAA; 32] }; let blob_store = InMemoryBlobs::new(); let key_a = init_or_open_envelope_key( &keystore, &blob_store, - &guard, + &lock, "k.bin", b"test-ad", 100, @@ -492,7 +491,7 @@ mod primitives { let key_b = init_or_open_envelope_key( &keystore, &blob_store, - &guard, + &lock, "k.bin", b"test-ad", 200, @@ -502,20 +501,19 @@ mod primitives { assert_eq!(key_a.expose_secret(), key_b.expose_secret()); } - // ---- open_vault -------------------------------------------------------- + // ---- Vault ------------------------------------------------------------- #[test] #[cfg(not(target_arch = "wasm32"))] - fn test_open_vault_runs_schema_callback() { + 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 lock_path = dir.path().join("vault.lock"); let lock = Lock::open(&lock_path).expect("open lock"); - let guard = lock.lock().expect("acquire"); let key = SecretBox::init_with(|| [0x42u8; 32]); - let conn = open_vault(&db_path, &key, &guard, |conn| { + let vault = Vault::open(&db_path, &key, lock, |conn| { blobs::ensure_schema(conn)?; conn.execute_batch( "CREATE TABLE IF NOT EXISTS items (id INTEGER PRIMARY KEY);", @@ -523,28 +521,63 @@ mod primitives { }) .expect("open vault"); - let cid = blobs::put(&conn, 7, b"payload", 1000).expect("put"); - let bytes = blobs::get(&conn, &cid).expect("get").expect("present"); + let cid: crate::ContentId = vault + .mutate::<_, StoreError, _>(|conn| blobs::put(conn, 7, b"payload", 1000)) + .expect("put"); + let bytes = blobs::get(vault.read(), &cid) + .expect("get") + .expect("present"); assert_eq!(bytes, b"payload"); } #[test] #[cfg(not(target_arch = "wasm32"))] - fn test_open_vault_rejects_wrong_key() { + 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 lock_path = dir.path().join("vault.lock"); let lock = Lock::open(&lock_path).expect("open lock"); - let guard = lock.lock().expect("acquire"); let key = SecretBox::init_with(|| [0x11u8; 32]); - let _ = open_vault(&db_path, &key, &guard, blobs::ensure_schema) + let _ = Vault::open(&db_path, &key, lock.clone(), blobs::ensure_schema) .expect("create vault"); - drop(guard); - let guard = lock.lock().expect("re-acquire"); let wrong = SecretBox::init_with(|| [0x22u8; 32]); let err = - open_vault(&db_path, &wrong, &guard, |_| Ok(())).expect_err("wrong key"); + Vault::open(&db_path, &wrong, lock, |_| Ok(())).expect_err("wrong key"); assert!(matches!(err, StoreError::Db(_))); } + + #[test] + #[cfg(not(target_arch = "wasm32"))] + fn test_vault_mutate_serializes_writes() { + init_sqlite(); + let dir = tempfile::tempdir().expect("create temp dir"); + let db_path = dir.path().join("vault.sqlite"); + let lock_path = dir.path().join("vault.lock"); + let lock = Lock::open(&lock_path).expect("open lock"); + let key = SecretBox::init_with(|| [0x55u8; 32]); + + let vault = Vault::open(&db_path, &key, lock, blobs::ensure_schema) + .expect("open vault"); + + // Two mutations in sequence; lock acquired/released around each. + vault + .mutate::<_, StoreError, _>(|conn| { + blobs::put(conn, 1, b"a", 100)?; + Ok(()) + }) + .expect("first mutate"); + vault + .mutate::<_, StoreError, _>(|conn| { + blobs::put(conn, 1, b"b", 200)?; + Ok(()) + }) + .expect("second mutate"); + + // Reads after the lock is released. + let cid_a = blobs::compute_content_id(1, b"a"); + let cid_b = blobs::compute_content_id(1, b"b"); + assert_eq!(blobs::get(vault.read(), &cid_a).unwrap().unwrap(), b"a"); + assert_eq!(blobs::get(vault.read(), &cid_b).unwrap().unwrap(), b"b"); + } } diff --git a/walletkit-db/src/vault.rs b/walletkit-db/src/vault.rs index 438c766b5..b98196ded 100644 --- a/walletkit-db/src/vault.rs +++ b/walletkit-db/src/vault.rs @@ -1,49 +1,100 @@ -//! Encrypted vault opener with caller-supplied schema. +//! Encrypted vault: opens an encrypted database with a caller-supplied +//! schema and exposes read / mutate handles. //! -//! [`open_vault`] composes [`crate::sqlite::cipher::open_encrypted`], a -//! consumer-owned schema callback, and an integrity check into the standard -//! "open + key + ensure schema + verify" flow used by all `WalletKit` -//! storage consumers. +//! The vault owns its own [`Lock`] so the type system can enforce "no +//! mutation without holding the lock." Reads bypass the lock — `sqlite3mc` +//! is opened in WAL mode and `SQLite`'s own reader/writer serialization +//! handles concurrent readers. +//! +//! The lock is acquired around three things: +//! +//! - the open sequence (open + key + `ensure_schema` + integrity check), +//! preventing two processes from racing on first-install envelope init, +//! - every closure passed to [`Vault::mutate`], serializing multi-statement +//! mutations across processes, +//! - implicitly nothing else: callers can read freely via [`Vault::read`]. use std::path::Path; use secrecy::SecretBox; use crate::error::{StoreError, StoreResult}; -use crate::lock::LockGuard; +use crate::lock::Lock; use crate::sqlite::{cipher, Connection, Result as DbResult}; -/// Opens (or creates) an encrypted database at `path`, runs `ensure_schema`, -/// then verifies integrity. Returns the open [`Connection`] for the caller -/// to compose schema-specific operations on top. -/// -/// `key` is the 32-byte intermediate key passed to `sqlite3mc`. `_lock` is -/// an in-scope [`LockGuard`] that proves the caller serialized writes via -/// [`crate::Lock`]; the lock is required only for the open and the caller -/// re-acquires for each subsequent transaction. `ensure_schema` runs after -/// the database is opened and keyed but before the integrity check, and may -/// create tables, indexes, and triggers. +/// Open encrypted database paired with the lock that serializes its +/// mutations. /// -/// # Errors -/// -/// Returns [`StoreError::Db`] if opening, keying, or schema setup fails, or -/// [`StoreError::IntegrityCheckFailed`] if `PRAGMA integrity_check` reports -/// corruption. -pub fn open_vault( - path: &Path, - key: &SecretBox<[u8; 32]>, - _lock: &LockGuard, - ensure_schema: F, -) -> StoreResult -where - F: FnOnce(&Connection) -> DbResult<()>, -{ - let conn = cipher::open_encrypted(path, key, false)?; - ensure_schema(&conn)?; - if !cipher::integrity_check(&conn)? { - return Err(StoreError::IntegrityCheckFailed( - "integrity_check failed".to_string(), - )); +/// Read access via [`Vault::read`]; mutations via [`Vault::mutate`]. +#[derive(Debug)] +pub struct Vault { + conn: Connection, + lock: Lock, +} + +impl Vault { + /// Opens (or creates) the encrypted database at `db_path`, holding + /// `lock` for the duration of the open + key + schema + integrity-check + /// sequence. The lock is released before this returns; subsequent + /// mutations re-acquire it via [`Vault::mutate`]. + /// + /// `ensure_schema` runs after the database is opened and keyed but + /// before the integrity check. + /// + /// # Errors + /// + /// Returns [`StoreError::Db`] if open / key / schema fails, + /// [`StoreError::IntegrityCheckFailed`] on corruption, or + /// [`StoreError::Lock`] if the lock cannot be acquired. + pub fn open( + db_path: &Path, + key: &SecretBox<[u8; 32]>, + lock: Lock, + ensure_schema: F, + ) -> StoreResult + where + F: FnOnce(&Connection) -> DbResult<()>, + { + let guard = lock.lock()?; + 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(), + )); + } + drop(guard); + Ok(Self { conn, lock }) + } + + /// Borrows the underlying connection for read-only SQL. `SQLite` handles + /// concurrent readers in WAL mode; no lock is acquired. + /// + /// Do not mutate via this handle. Mutations belong inside + /// [`Vault::mutate`]. + #[must_use] + pub const fn read(&self) -> &Connection { + &self.conn + } + + /// Runs `f` under a freshly-acquired lock guard. The guard is held for + /// the entire closure (any number of SQL transactions) and released on + /// return. + /// + /// The closure's error type must convert from [`StoreError`] so the + /// lock-acquisition failure flows through. Most consumers use the + /// crate's [`StoreResult`] directly. + /// + /// # Errors + /// + /// Propagates the closure's error, plus [`StoreError::Lock`] if the + /// lock cannot be acquired. + pub fn mutate(&self, f: F) -> Result + where + F: FnOnce(&Connection) -> Result, + E: From, + { + let _guard = self.lock.lock().map_err(E::from)?; + f(&self.conn) } - Ok(conn) } From 491a7f43576b57fa662b282910120877a6605057 Mon Sep 17 00:00:00 2001 From: danielle-tfh Date: Wed, 13 May 2026 15:48:33 +0100 Subject: [PATCH 14/31] refactor(walletkit-core): cache wraps Vault, drops lock-witness pattern CacheDb now wraps walletkit_db::Vault instead of opening Connection directly. Mutators (merkle_cache_put, session_seed_put, replay_guard_set) drop the _lock: &StorageLockGuard witness parameter and run their bodies inside self.vault.mutate(|conn| { ... }). Readers use self.vault.read(). open_or_rebuild moves to using Vault::open with the cache-specific rebuild-on-corruption policy in walletkit-core (one level above the generic vault opener). The corruption-handling policy where it belongs: in the consumer that knows cache contents are regenerable. walletkit-db stays policy-neutral; no CorruptionPolicy enum on Vault::open. CredentialStore drops the dead let guard = self.guard()? bindings from init, merkle_cache_put, replay_guard_set, and the export/import paths. destroy_storage keeps self.guard() because it's the one place we still need an externally-held lock (filesystem deletes, not SQL transactions). The CredentialStoreInner.lock field stays for that case and to clone into Vault/Cache at construction. Behavior unchanged: same flock around the same SQL operations; same SQL ran in the same order. On-disk format byte-stable. 123 walletkit-core + 20 walletkit-db lib tests pass; clippy clean on all/default/no-default-features; doc clean with -Dwarnings. --- .../src/storage/cache/maintenance.rs | 68 +++-------- walletkit-core/src/storage/cache/mod.rs | 107 +++++++++--------- walletkit-core/src/storage/cache/schema.rs | 54 +++------ .../src/storage/credential_storage.rs | 17 ++- 4 files changed, 88 insertions(+), 158 deletions(-) diff --git a/walletkit-core/src/storage/cache/maintenance.rs b/walletkit-core/src/storage/cache/maintenance.rs index 2dd24206e..89cb31db8 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,12 +6,17 @@ use std::path::Path; use secrecy::SecretBox; use crate::storage::error::StorageResult; -use walletkit_db::{cipher, Connection}; +use walletkit_db::{Lock, 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 /// @@ -19,55 +24,18 @@ 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), + lock: Lock, +) -> StorageResult { + if let Ok(vault) = + Vault::open(path, k_intermediate, lock.clone(), 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, lock, 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"))?; @@ -76,10 +44,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 d7247936a..19e176e7e 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::StorageLockGuard; use secrecy::SecretBox; -use walletkit_db::Connection; +use walletkit_db::{Lock, Vault}; mod maintenance; mod merkle; @@ -16,18 +15,25 @@ 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`]: mutations acquire the +/// vault's lock via `Vault::mutate`; reads bypass the lock. +/// +/// 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`. Takes + /// ownership of `lock`; subsequent mutations re-acquire it through + /// [`walletkit_db::Vault::mutate`]. /// - /// 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 +41,13 @@ impl CacheDb { pub fn new( path: &Path, k_intermediate: &SecretBox<[u8; 32]>, - _lock: &StorageLockGuard, + lock: Lock, ) -> StorageResult { - let conn = maintenance::open_or_rebuild(path, k_intermediate)?; - Ok(Self { conn }) + let vault = maintenance::open_or_rebuild(path, k_intermediate, lock)?; + 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 +56,23 @@ 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.read(), 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) + self.vault + .mutate(|conn| merkle::put(conn, proof_bytes, now, ttl_seconds)) } /// Fetches a cached `session_id_r_seed` for the given `oprf_seed`. @@ -84,7 +87,7 @@ impl CacheDb { oprf_seed: [u8; 32], now: u64, ) -> StorageResult> { - session::get(&self.conn, oprf_seed, now) + session::get(self.vault.read(), oprf_seed, now) } /// Stores a `session_id_r_seed` keyed by `oprf_seed` with a TTL. @@ -93,20 +96,23 @@ 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) + self.vault.mutate(|conn| { + session::put(conn, 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,22 +122,18 @@ impl CacheDb { nullifier: [u8; 32], now: u64, ) -> StorageResult { - nullifiers::is_nullifier_replay(&self.conn, nullifier, now) + nullifiers::is_nullifier_replay(self.vault.read(), 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<()> { + self.vault + .mutate(|conn| nullifiers::replay_guard_set(conn, nullifier, now)) } } @@ -152,10 +154,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 { @@ -174,10 +174,9 @@ mod tests { 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, lock.clone()).expect("create cache"); drop(db); - CacheDb::new(&path, &key, &guard).expect("open cache"); + CacheDb::new(&path, &key, lock).expect("open cache"); cleanup_cache_files(&path); cleanup_lock_file(&lock_path); } @@ -188,18 +187,17 @@ mod tests { 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, lock.clone()).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, lock).expect("rebuild cache"); let value = db .session_seed_get(oprf_seed, now) .expect("get session seed"); @@ -214,12 +212,10 @@ mod tests { 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, lock).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()); @@ -233,12 +229,11 @@ mod tests { 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, lock).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 91705115d..6c8c97b54 100644 --- a/walletkit-core/src/storage/credential_storage.rs +++ b/walletkit-core/src/storage/credential_storage.rs @@ -512,7 +512,6 @@ 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(leaf_index, now)?; state.leaf_index = leaf_index; @@ -531,7 +530,11 @@ impl CredentialStoreInner { k_intermediate, self.lock.clone(), )?; - let cache = CacheDb::new(&self.paths.cache_db_path(), k_intermediate, &guard)?; + let cache = CacheDb::new( + &self.paths.cache_db_path(), + k_intermediate, + self.lock.clone(), + )?; let state = StorageState { keys, vault, @@ -623,10 +626,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, @@ -674,7 +675,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 @@ -684,9 +684,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. @@ -718,10 +716,9 @@ 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. From b748c492a55b5cc2c2ad0001536de720218ff5a5 Mon Sep 17 00:00:00 2001 From: danielle-tfh Date: Wed, 13 May 2026 15:51:13 +0100 Subject: [PATCH 15/31] docs(walletkit-core): document intentional StoreError mirror in From impl Hosts pattern-match on StorageError variants for UX. Collapsing the From impl into a single VaultDb(String) variant erases that variant identity at the FFI boundary; that path was tried in an earlier review and reverted. The mirror is intentional; future readers should not try to flatten it without a coordinated host change. The doc comment names the contract so the next reviewer doesn't relitigate it. --- walletkit-core/src/storage/error.rs | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/walletkit-core/src/storage/error.rs b/walletkit-core/src/storage/error.rs index 945244c94..c1ef7d4d8 100644 --- a/walletkit-core/src/storage/error.rs +++ b/walletkit-core/src/storage/error.rs @@ -94,6 +94,19 @@ impl From for StorageError { } } +/// Maps `walletkit_db::StoreError` variants 1-1 onto `StorageError`. +/// +/// The two enums look like mirrors and they are, on purpose. `StorageError` +/// is `#[derive(uniffi::Error)]` and hosts (Kotlin / Swift) pattern-match on +/// the variant to drive UX (keystore failure means re-authenticate; lock +/// failure means retry; etc.). Collapsing this to a single `VaultDb(String)` +/// would erase that variant identity at the FFI boundary. +/// +/// The "two enums kept in lockstep forever" cost is real but small: nine +/// mapping lines, and the set of possible `StoreError` variants is bounded +/// by what encrypted `SQLite` + envelope + lock + blobs can fail at. Adding a +/// new variant means adding one line here. If you're tempted to flatten: +/// don't, without a coordinated host change first. impl From for StorageError { fn from(err: walletkit_db::StoreError) -> Self { match err { From b497443998fbb1529fd89771ed441b7d8191b8a4 Mon Sep 17 00:00:00 2001 From: danielle-tfh Date: Wed, 13 May 2026 16:12:31 +0100 Subject: [PATCH 16/31] docs(walletkit-db): drop lingering open_vault refs in README --- walletkit-db/README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/walletkit-db/README.md b/walletkit-db/README.md index 337998763..ce72a71fc 100644 --- a/walletkit-db/README.md +++ b/walletkit-db/README.md @@ -94,7 +94,7 @@ flowchart TB end subgraph WKDB["walletkit-db (this crate)"] - OV["open_vault()"] + OV["Vault::open / mutate / read"] Blobs["blobs::{ensure_schema, put, get, delete}"] Env["init_or_open_envelope_key()"] Lock["Lock / LockGuard"] @@ -148,7 +148,7 @@ access policies (Face ID on one vault, none on another) per consumer. 4. `K_device.seal(AD, K_intermediate)` → opaque ciphertext. 5. Wrap in `KeyEnvelope` (version, ciphertext, timestamps), CBOR-encode, `AtomicBlobStore.write_atomic` to disk. -6. `open_vault` opens the SQLite file via `sqlite3_open_v2`. +6. `Vault::open` opens the SQLite file via `sqlite3_open_v2`. 7. `PRAGMA key = "x''"` — sqlite3mc installs its encryption codec. 8. Schema callback: `blobs::ensure_schema(conn)` + consumer's schema DDL. 9. `PRAGMA integrity_check`. From 685dd7b4e8653875f6d0d61677b68385fe3f1b3a Mon Sep 17 00:00:00 2001 From: danielle-tfh Date: Wed, 13 May 2026 16:13:34 +0100 Subject: [PATCH 17/31] docs(walletkit-db): drop duplicated open sequence from Concepts section The Concepts section described opening at a high level; the Startup sequence section repeats the same flow in more detail. Removed the duplicate from Concepts and link forward to Startup sequence. Concepts now covers runtime ops only (store / read / delete). --- walletkit-db/README.md | 19 ++++--------------- 1 file changed, 4 insertions(+), 15 deletions(-) diff --git a/walletkit-db/README.md b/walletkit-db/README.md index ce72a71fc..8775fc784 100644 --- a/walletkit-db/README.md +++ b/walletkit-db/README.md @@ -39,21 +39,10 @@ rest of the README straightforward. file. walletkit-db never touches the OS keystore or the filesystem directly; it goes through these traits. -### How they interact - -**Opening from scratch:** - -1. Open a `Lock` on the lock file path. -2. `init_or_open_envelope_key(...)` acquires the lock, asks - `AtomicBlobStore` for the envelope file. If absent, generates - `K_intermediate`, seals it with the host's `Keystore`, writes the - envelope. If present, reads it and unseals with the keystore. Releases - the lock. -3. `Vault::open(path, key, lock, ensure_schema)` acquires the lock, opens - the SQLite file via sqlite3mc, runs the consumer's schema callback - (typically `blobs::ensure_schema(conn)` plus the consumer's domain - tables), runs `PRAGMA integrity_check`, releases the lock. Returns - a `Vault`. +### How they interact at runtime + +(For the open-and-initialize sequence, see [Startup sequence](#startup-sequence) +below.) **Storing a payload:** From 7c1abdc73ba8fb36164a06b9a89763deae07e5bc Mon Sep 17 00:00:00 2001 From: danielle-tfh Date: Mon, 18 May 2026 14:07:13 +0100 Subject: [PATCH 18/31] docs(walletkit-db): tighten README, add explicit host-side isolation contract Two changes: - Add a 'Host-side contract for multi-consumer isolation' section spelling out what hosts MUST do (distinct keystore entry / AD / file paths per consumer) and the enforcement split (walletkit-db cryptographically enforces AD binding; everything else is host discipline). Names the IssuerKit layer as the long-term home for code-level enforcement. - Tighten the whole README: drop the redundant 'how they interact at runtime' walkthrough (the usage code example is the same content), collapse the encryption deep-dive to one paragraph, compress the startup sequence from numbered lists to two short paragraphs. Net ~40% shorter. --- walletkit-db/README.md | 276 ++++++++++------------------------------- 1 file changed, 67 insertions(+), 209 deletions(-) diff --git a/walletkit-db/README.md b/walletkit-db/README.md index 8775fc784..09bedda93 100644 --- a/walletkit-db/README.md +++ b/walletkit-db/README.md @@ -1,77 +1,18 @@ # walletkit-db -Encrypted on-device storage primitives for WalletKit. SQLCipher (`sqlite3mc`) -wrapper, vault opener, content-addressed blobs, sealed key envelope, -cross-process lock. +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. -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 -There are five physical pieces. Knowing what each one is and isn't makes the -rest of the README straightforward. - -- **Vault** — the encrypted SQLite database file on disk (e.g. - `account.vault.sqlite`). Holds every row the consumer cares about, - including the shared `blob_objects` table. Encrypted with sqlite3mc. - Opened via `Vault::open`; mutated via `Vault::mutate` (which acquires - the lock); read via `Vault::read` (which bypasses the lock). -- **Envelope** — a small CBOR-encoded file on disk (e.g. - `account_keys.bin`) that holds the *sealed* 32-byte `K_intermediate`. - Not a vault, not encrypted by sqlite3mc — it's the wrapper around the - key that opens the vault. The seal is done by the host's hardware - keystore. Managed by `init_or_open_envelope_key` + `KeyEnvelope`. -- **Lock** — a separate empty file (e.g. `account.lock`) used as a - cross-process mutex via `flock` / `LockFileEx`. Not encrypted, not part - of the vault. Prevents two processes (e.g. extension + main app) from - racing on the open or mutate path. Owned by the `Vault` after `open`; - `Vault::mutate` acquires it internally, so consumers never plumb a - `LockGuard` through their method signatures. -- **blob_objects table** — one shared table inside the vault for - content-addressed bytes. Consumer-specific tables (`credential_records`, - `pcp_records`, etc.) reference rows here by `content_id` (SHA-256). Big - payloads live here once, deduplicated by hash. Managed by `blobs::*`. -- **Keystore + AtomicBlobStore** — two traits the consumer (and - ultimately the host platform) 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; it goes through these traits. - -### How they interact at runtime - -(For the open-and-initialize sequence, see [Startup sequence](#startup-sequence) -below.) - -**Storing a payload:** - -1. Inside `vault.mutate(|conn| { ... })`: `blobs::put(conn, kind, bytes, - now)` hashes the bytes (`SHA-256("worldid:blob" || [kind] || bytes)`), - `INSERT OR IGNORE`s into `blob_objects`, returns the `ContentId`. -2. The closure inserts the consumer's own row referencing that - `content_id`, then commits its transaction. -3. The lock is held for the entire closure and released on return. - -**Reading a payload:** - -1. `vault.read()` returns `&Connection` directly — no lock acquisition. -2. The consumer queries its own table for the row + the `content_id`. -3. `blobs::get(vault.read(), &content_id)` returns the bytes from - `blob_objects`. - -**Deleting:** - -1. Inside `vault.mutate(|conn| { ... })`: the consumer deletes from its - own table. -2. If no other row references the same `content_id`, call - `blobs::delete(conn, &content_id)` to GC the orphan from - `blob_objects`. (walletkit-db doesn't track references; consumers - decide when a blob has become unreferenced.) - -The four files (vault, envelope, lock, and the consumer-chosen blob-store -backing) all live under paths the consumer picks. walletkit-db doesn't -prescribe where; it just expects them to stay together. +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`; mutated via `Vault::mutate` (which holds the lock for the closure); read via `Vault::read` (which bypasses it). +- **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`. Owned by the `Vault` after open; consumers never plumb a `LockGuard` through their method signatures. +- **`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 @@ -81,135 +22,81 @@ flowchart TB KS["DeviceKeystore (uniffi)"] BS["AtomicBlobStore (uniffi)"] end - subgraph WKDB["walletkit-db (this crate)"] OV["Vault::open / mutate / read"] Blobs["blobs::{ensure_schema, put, get, delete}"] - Env["init_or_open_envelope_key()"] + Env["init_or_open_envelope_key"] Lock["Lock / LockGuard"] - Cipher["sqlite3mc (encrypted SQLite)"] + Cipher["sqlite3mc"] OV --> Cipher Blobs --> Cipher end - - subgraph Consumer["Consumer crate (e.g. walletkit-core)"] - Wrapper["Domain-specific wrapper
(e.g. CredentialVault)"] - Tables["domain tables
blob_objects (shared)"] + 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 ``` -The 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; the crate -gives them an encrypted database with the safety machinery wired up. +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). Seal/unseal small blobs only; cannot be extracted from the - device. Provided by the consumer via the `Keystore` trait. -- **`K_intermediate`** — 32-byte random key per consumer-vault. Generated - once on first run with `getrandom`, sealed under `K_device`, persisted as - a CBOR `KeyEnvelope`. Used as the SQLite page-encryption key by sqlite3mc. -- **AD (associated data)** — non-secret label bound into the AEAD seal - (e.g. `worldid:account-key-envelope`). Per-consumer so envelopes can't be - swapped between vaults; not a password. - -Each consumer picks its own envelope filename + AD so independent vaults -never share intermediate keys, and so the host can apply different keystore -access policies (Face ID on one vault, none on another) per consumer. - -## Startup sequence - -**Cold start (first run, brand-new install):** - -1. Open `Lock`, acquire `LockGuard`. -2. `init_or_open_envelope_key` calls `AtomicBlobStore.read(filename)` → `None`. -3. `getrandom::fill` → 32 random bytes = `K_intermediate`. -4. `K_device.seal(AD, K_intermediate)` → opaque ciphertext. -5. Wrap in `KeyEnvelope` (version, ciphertext, timestamps), CBOR-encode, - `AtomicBlobStore.write_atomic` to disk. -6. `Vault::open` opens the SQLite file via `sqlite3_open_v2`. -7. `PRAGMA key = "x''"` — sqlite3mc installs its encryption codec. -8. Schema callback: `blobs::ensure_schema(conn)` + consumer's schema DDL. -9. `PRAGMA integrity_check`. -10. Return open `Connection`. - -**Warm start (every subsequent run):** - -1. Open `Lock`, acquire `LockGuard`. -2. `AtomicBlobStore.read(filename)` → envelope bytes. -3. CBOR-decode, verify version, extract sealed ciphertext. -4. `K_device.open_sealed(AD, sealed_ciphertext)` → recover the **bit-for-bit - original** `K_intermediate`. Encryption is reversible; nothing is - re-derived. -5. `sqlite3_open_v2` + `PRAGMA key`. Wrong key returns `SQLITE_NOTADB` on - the first page read. -6. Schema callback runs idempotently (`CREATE TABLE IF NOT EXISTS`). -7. Integrity check; return. - -**Device wipe / app uninstall:** `K_device` is destroyed. The envelope on -disk becomes permanently unsealable. Recovery has to come from a separate -backup path that re-wraps `K_intermediate` (or the data) under a -non-device-bound key. - -## Encryption mechanism (sqlite3mc) - -After `PRAGMA key`, walletkit-db is out of the crypto loop. sqlite3mc takes -over inside SQLite's pager: - -- **Cipher**: ChaCha20-Poly1305 AEAD. Default for sqlite3mc; no external - crypto library. -- **KDF**: PBKDF2-SHA256 derives per-page subkeys from `K_intermediate` and - the page number. Same plaintext on two pages does not produce identical - ciphertext. -- **Pager hook**: every page read decrypts; every page write encrypts; SQL - engine sees only plaintext. -- **What's encrypted**: every page in the file — tables, indexes, freelist, - WAL. Only the first 16 bytes (`SQLite format 3\0` magic + header) stay - plaintext so sqlite3mc can recognize the file before keying. -- **Tamper detection**: Poly1305 MAC per page. Bit-flip → `SQLITE_CORRUPT`. - Wrong key → first decrypted page header is garbage → `SQLITE_NOTADB`. -- **Performance**: single-digit µs per 4KB page on modern ARM. This is why - `K_intermediate` lives in main RAM — sqlite3mc invokes the codec - hundreds of times per transaction and the secure enclave can't service - that load. +- **`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 / unencrypted backup | **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. Separate `K_intermediate` per consumer does not change this. | -| File corruption / envelope swap between vaults | **Safe** | Per-page MAC fails on corrupted pages; AD binding fails AEAD auth on swapped envelopes. | +|---|---|---| +| 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** against in-app attackers: host policy on the -keystore entry (iOS `kSecAccessControlBiometryCurrentSet`, Android -`setUserAuthenticationRequired(true)`, etc.). walletkit-db is neutral; the -policy lives in the Kotlin/Swift code that creates `K_device`. +**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`. + +## Host-side contract for multi-consumer isolation + +When multiple consumers share the device (credential vault, future PCP store, future NFC issuer, etc.), trust isolation between them depends on the **host** wiring each consumer to its own resources. walletkit-db cryptographically enforces only the AD binding; the rest is host discipline. + +**Hosts MUST, per consumer:** + +1. **A distinct hardware keystore entry.** Each consumer's `Keystore` impl must point at a separate Secure Enclave key / Android Keystore alias. Sharing one entry across consumers means a compromised consumer can ask the OS to unseal another's envelope. +2. **A distinct AD** passed to `init_or_open_envelope_key`. AD binding makes envelopes non-fungible — sealed under one AD, will not open under another. +3. **A distinct envelope filename, vault file, and lock file.** No shared paths. -**Why per-consumer `K_intermediate` exists** (not for in-app isolation): +**What walletkit-db enforces vs what it can't:** -1. sqlite3mc needs a key in main RAM anyway — the enclave doesn't expose - bulk encryption, so we cannot use `K_device` directly. -2. **Per-keystore-entry policy.** Host can require Face ID on one vault's - unseal but not another — only possible with separate envelopes. -3. Independent rotation, recovery, and file lifecycle per consumer. -4. AEAD tamper-evidence on each envelope. +| Guarantee | Enforced by | +|---|---| +| Envelope sealed under AD `X` won't open under AD `Y` | walletkit-db (AEAD on `Keystore::open_sealed`) | +| Opening vault `A` with vault `B`'s `K_intermediate` fails | walletkit-db + sqlite3mc (`SQLITE_NOTADB`) | +| Consumer `A` cannot ask the keystore for consumer `B`'s `K_device` | **Host.** Requires distinct keystore entries with distinct identities. | +| Consumer `A` cannot read consumer `B`'s envelope from disk | **Host.** Requires per-consumer file paths inside the app sandbox. | -## Intended usage +A future IssuerKit layer is the long-term home for enforcing one-keystore-entry-per-issuer in code. Until then this contract lives in the host integration. -A new consumer wires up storage in four steps. Each consumer picks its own -paths, envelope filename, associated-data namespace, and SQL schema: +## Usage + +A consumer wires up storage in four steps: ```rust use walletkit_db::{blobs, init_or_open_envelope_key, Lock, Vault}; @@ -219,7 +106,6 @@ 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. -// Acquires `lock` internally, releases on return. let k_intermediate = init_or_open_envelope_key( &my_keystore_adapter, &my_blob_store_adapter, @@ -230,62 +116,34 @@ let k_intermediate = init_or_open_envelope_key( )?; // 3. Open the encrypted SQLite database with the consumer's own schema. -// Vault takes ownership of `lock` and re-acquires it for each mutation. let vault = Vault::open(&paths.db_path(), &k_intermediate, lock, |conn| { - blobs::ensure_schema(conn)?; // shared blob_objects table - my_schema::ensure_schema(conn) // consumer's own tables + blobs::ensure_schema(conn)?; + my_schema::ensure_schema(conn) })?; -// 4. Mutations run under a freshly-acquired lock. +// 4. Store / read / delete. let cid = vault.mutate(|conn| { blobs::put(conn, MY_KIND_TAG, &payload_bytes, now) })?; - -// Reads bypass the lock (SQLite WAL handles concurrent readers). let bytes = blobs::get(vault.read(), &cid)?.expect("present"); - -// Deletes are mutations. vault.mutate(|conn| blobs::delete(conn, &cid))?; ``` -The consumer brings: - -- A type implementing `Keystore` (seal/open under a device-bound key) -- A type implementing `AtomicBlobStore` (small-blob persistence — e.g. the - sealed envelope file) -- A `kind: u8` tag space for blob payloads -- Its own SQL schema and queries - -The crate handles cipher setup, schema dispatch, integrity check, content -hashing (`SHA-256(b"worldid:blob" || [kind] || plaintext)`), CBOR-encoded -envelope persistence, and the lock. +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, lock, ensure_schema) -> StoreResult` — - open + key + schema + integrity check. -- `Vault::read(&self) -> &Connection` — read-only access, no lock. -- `Vault::mutate(&self, f) -> Result` — runs `f` under a - freshly-acquired lock; `E: From`. -- `blobs::{ensure_schema, put, get, delete, compute_content_id}` plus - `pub type ContentId = [u8; 32]`. +- `Vault::open(path, key, lock, ensure_schema) -> StoreResult`, `Vault::read(&self) -> &Connection`, `Vault::mutate(&self, f) -> Result`. +- `blobs::{ensure_schema, put, get, delete, compute_content_id}` plus `pub type ContentId = [u8; 32]`. - `init_or_open_envelope_key(...) -> StoreResult>`. -- `Lock` — native `flock` / `LockFileEx`, no-op on WASM. -- `Keystore` / `AtomicBlobStore` traits — plain Rust. Consumers that expose - FFI define their own annotated traits and bridge with a small newtype. -- `Connection`, `Transaction`, `Statement`, `Row`, `StepResult`, `Value`, - `cipher::*`, `DbError`, `DbResult`, `StoreError`, `StoreResult` — the - underlying SQLite wrapper and error types. +- `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 in `src/tests.rs` guard the 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 in `src/tests.rs` guard the format. ## 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 (single-threaded Web Worker runtime). +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. From 4bc5e3236e09f83081ed4a6f3619e57e55ae3965 Mon Sep 17 00:00:00 2001 From: danielle-tfh Date: Mon, 18 May 2026 14:13:01 +0100 Subject: [PATCH 19/31] docs(walletkit-db): tighten per-consumer isolation section Drop the 'what walletkit-db enforces vs what it can't' table from the public README. It enumerates the host-side gap and reads like CVE-shopping documentation. The three host requirements (distinct keystore entry, distinct AD, distinct files) stay; the positive guarantee (AD binding) stays. Section renamed to 'Per-consumer isolation' and shrunk from ~25 lines to ~10. --- walletkit-db/README.md | 23 ++++++----------------- 1 file changed, 6 insertions(+), 17 deletions(-) diff --git a/walletkit-db/README.md b/walletkit-db/README.md index 09bedda93..6f76faba2 100644 --- a/walletkit-db/README.md +++ b/walletkit-db/README.md @@ -73,26 +73,15 @@ After `PRAGMA key`, sqlite3mc takes over inside SQLite's pager. ChaCha20-Poly130 **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`. -## Host-side contract for multi-consumer isolation +## Per-consumer isolation -When multiple consumers share the device (credential vault, future PCP store, future NFC issuer, etc.), trust isolation between them depends on the **host** wiring each consumer to its own resources. walletkit-db cryptographically enforces only the AD binding; the rest is host discipline. +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: -**Hosts MUST, per consumer:** +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. -1. **A distinct hardware keystore entry.** Each consumer's `Keystore` impl must point at a separate Secure Enclave key / Android Keystore alias. Sharing one entry across consumers means a compromised consumer can ask the OS to unseal another's envelope. -2. **A distinct AD** passed to `init_or_open_envelope_key`. AD binding makes envelopes non-fungible — sealed under one AD, will not open under another. -3. **A distinct envelope filename, vault file, and lock file.** No shared paths. - -**What walletkit-db enforces vs what it can't:** - -| Guarantee | Enforced by | -|---|---| -| Envelope sealed under AD `X` won't open under AD `Y` | walletkit-db (AEAD on `Keystore::open_sealed`) | -| Opening vault `A` with vault `B`'s `K_intermediate` fails | walletkit-db + sqlite3mc (`SQLITE_NOTADB`) | -| Consumer `A` cannot ask the keystore for consumer `B`'s `K_device` | **Host.** Requires distinct keystore entries with distinct identities. | -| Consumer `A` cannot read consumer `B`'s envelope from disk | **Host.** Requires per-consumer file paths inside the app sandbox. | - -A future IssuerKit layer is the long-term home for enforcing one-keystore-entry-per-issuer in code. Until then this contract lives in the host integration. +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 From 53e95ff61f90c08249c25f72c11f75b0367a10ee Mon Sep 17 00:00:00 2001 From: danielle-tfh Date: Mon, 18 May 2026 14:16:41 +0100 Subject: [PATCH 20/31] docs(walletkit-core): tighten StoreError mirror comment, add TODO Old comment was 13 lines of defensive explanation. Replaces with 6: states the intent (don't flatten, hosts depend on variants) and names the future move (extract to walletkit-ffi-shared when a second uniffi-exporting consumer ships). --- walletkit-core/src/storage/error.rs | 18 ++++++------------ 1 file changed, 6 insertions(+), 12 deletions(-) diff --git a/walletkit-core/src/storage/error.rs b/walletkit-core/src/storage/error.rs index c1ef7d4d8..8871c7bde 100644 --- a/walletkit-core/src/storage/error.rs +++ b/walletkit-core/src/storage/error.rs @@ -94,19 +94,13 @@ impl From for StorageError { } } -/// Maps `walletkit_db::StoreError` variants 1-1 onto `StorageError`. +/// 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. /// -/// The two enums look like mirrors and they are, on purpose. `StorageError` -/// is `#[derive(uniffi::Error)]` and hosts (Kotlin / Swift) pattern-match on -/// the variant to drive UX (keystore failure means re-authenticate; lock -/// failure means retry; etc.). Collapsing this to a single `VaultDb(String)` -/// would erase that variant identity at the FFI boundary. -/// -/// The "two enums kept in lockstep forever" cost is real but small: nine -/// mapping lines, and the set of possible `StoreError` variants is bounded -/// by what encrypted `SQLite` + envelope + lock + blobs can fail at. Adding a -/// new variant means adding one line here. If you're tempted to flatten: -/// don't, without a coordinated host change first. +/// 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 { From d65f34e3188bc902f1a869054dbfda966ba886a5 Mon Sep 17 00:00:00 2001 From: danielle-tfh Date: Mon, 18 May 2026 14:30:50 +0100 Subject: [PATCH 21/31] docs(walletkit-db): address Paolo's PR review nits Five inline review comments on PR #400: - traits.rs: make Keystore AEAD requirement explicit (name AES-GCM / ChaCha20-Poly1305 in the doc instead of "integrity-protect"). - traits.rs: rename Keystore::seal/open_sealed param from associated_data to aad (standard AEAD term). Avoids collision with the credential-domain BlobKind::AssociatedData. - traits.rs: document why AtomicBlobStore is a trait the host implements rather than walletkit-db calling std::fs directly (no std::fs on wasm32; hosts own where data lives). - tests.rs: replace ASCII-art // ---- xxx ---- separators inside mod primitives with a //! module-level doc; promote the meaningful prose (XorKeystore explainer) to a /// doc on the struct. - sqlite/error.rs: rename the internal alias from Result to DbResult so it no longer shadows std::result::Result. Drops the "Result as DbResult" indirection at the crate root and at internal callers; the canonical name is now just DbResult everywhere. --- walletkit-core/src/storage/keys.rs | 8 +++--- walletkit-db/src/blobs.rs | 2 +- walletkit-db/src/lib.rs | 4 +-- walletkit-db/src/sqlite/cipher.rs | 14 +++++----- walletkit-db/src/sqlite/connection.rs | 26 +++++++++--------- walletkit-db/src/sqlite/error.rs | 2 +- walletkit-db/src/sqlite/ffi.rs | 28 +++++++++---------- walletkit-db/src/sqlite/mod.rs | 2 +- walletkit-db/src/sqlite/statement.rs | 6 ++--- walletkit-db/src/sqlite/transaction.rs | 16 +++++------ walletkit-db/src/tests.rs | 23 +++++----------- walletkit-db/src/traits.rs | 37 ++++++++++++++------------ walletkit-db/src/vault.rs | 2 +- 13 files changed, 82 insertions(+), 88 deletions(-) diff --git a/walletkit-core/src/storage/keys.rs b/walletkit-core/src/storage/keys.rs index 97275a9c2..21d94d525 100644 --- a/walletkit-core/src/storage/keys.rs +++ b/walletkit-core/src/storage/keys.rs @@ -70,18 +70,18 @@ impl StorageKeys { struct Ks<'a>(&'a dyn DeviceKeystore); impl walletkit_db::Keystore for Ks<'_> { - fn seal(&self, ad: Vec, pt: Vec) -> walletkit_db::StoreResult> { + fn seal(&self, aad: Vec, pt: Vec) -> walletkit_db::StoreResult> { self.0 - .seal(ad, pt) + .seal(aad, pt) .map_err(|e| walletkit_db::StoreError::Keystore(e.to_string())) } fn open_sealed( &self, - ad: Vec, + aad: Vec, ct: Vec, ) -> walletkit_db::StoreResult> { self.0 - .open_sealed(ad, ct) + .open_sealed(aad, ct) .map_err(|e| walletkit_db::StoreError::Keystore(e.to_string())) } } diff --git a/walletkit-db/src/blobs.rs b/walletkit-db/src/blobs.rs index 09fbdcb9e..c58a87b0f 100644 --- a/walletkit-db/src/blobs.rs +++ b/walletkit-db/src/blobs.rs @@ -21,7 +21,7 @@ use sha2::{Digest, Sha256}; use crate::error::{StoreError, StoreResult}; use crate::params; -use crate::sqlite::{Connection, Error as DbError, Result as DbResult}; +use crate::sqlite::{Connection, DbResult, Error as DbError}; const CONTENT_ID_PREFIX: &[u8] = b"worldid:blob"; diff --git a/walletkit-db/src/lib.rs b/walletkit-db/src/lib.rs index 54d2486e4..ccc0ac036 100644 --- a/walletkit-db/src/lib.rs +++ b/walletkit-db/src/lib.rs @@ -34,8 +34,8 @@ pub use envelope::init_or_open_envelope_key; pub use error::{StoreError, StoreResult}; pub use lock::{Lock, LockGuard}; pub use sqlite::{ - cipher, Connection, Error as DbError, Result as DbResult, Row, Statement, - StepResult, Transaction, Value, + cipher, Connection, DbResult, Error as DbError, Row, Statement, StepResult, + Transaction, Value, }; pub use traits::{AtomicBlobStore, Keystore}; pub use vault::Vault; diff --git a/walletkit-db/src/sqlite/cipher.rs b/walletkit-db/src/sqlite/cipher.rs index 26333d550..b95f50258 100644 --- a/walletkit-db/src/sqlite/cipher.rs +++ b/walletkit-db/src/sqlite/cipher.rs @@ -37,7 +37,7 @@ use secrecy::{ExposeSecret, SecretBox}; use zeroize::Zeroizing; use super::connection::Connection; -use super::error::{Error, Result}; +use super::error::{DbResult, Error}; /// Opens a database, applies the encryption key, and configures the connection. /// @@ -53,7 +53,7 @@ pub fn open_encrypted( path: &Path, k_intermediate: &SecretBox<[u8; 32]>, read_only: bool, -) -> Result { +) -> DbResult { let conn = Connection::open(path, read_only)?; apply_key(&conn, k_intermediate)?; configure_connection(&conn)?; @@ -70,7 +70,7 @@ pub fn open_encrypted( /// After keying, a lightweight read (`SELECT count(*) FROM sqlite_master`) /// verifies the key is correct. If it's wrong, `sqlite3mc` fails with /// `SQLITE_NOTADB` on the first page read. -fn apply_key(conn: &Connection, k_intermediate: &SecretBox<[u8; 32]>) -> Result<()> { +fn apply_key(conn: &Connection, k_intermediate: &SecretBox<[u8; 32]>) -> DbResult<()> { // Hex-encode the key and build the PRAGMA. Both are zeroized on drop. let key_hex = Zeroizing::new(hex::encode(k_intermediate.expose_secret())); let pragma = Zeroizing::new(format!("PRAGMA key = \"x'{}'\";", key_hex.as_str())); @@ -105,7 +105,7 @@ fn apply_key(conn: &Connection, k_intermediate: &SecretBox<[u8; 32]>) -> Result< /// - `foreign_keys = ON` -- enforces referential integrity constraints. /// - `secure_delete = ON` -- overwrites deleted content with zeroes so /// sensitive data does not linger in free pages. -fn configure_connection(conn: &Connection) -> Result<()> { +fn configure_connection(conn: &Connection) -> DbResult<()> { conn.execute_batch( "PRAGMA foreign_keys = ON; PRAGMA journal_mode = WAL; @@ -132,7 +132,7 @@ pub fn export_plaintext_copy( conn: &Connection, dest_path: &Path, tables: &[&str], -) -> Result<()> { +) -> DbResult<()> { let dest_str = dest_path.to_string_lossy(); let attach_sql = format!( "ATTACH DATABASE '{}' AS backup KEY '';", @@ -180,7 +180,7 @@ pub fn import_plaintext_copy( conn: &Connection, source_path: &Path, tables: &[&str], -) -> Result<()> { +) -> DbResult<()> { if !source_path.exists() { return Err(Error::new( -1, @@ -237,7 +237,7 @@ pub fn import_plaintext_copy( /// # Errors /// /// Returns `Error` if the integrity check query fails. -pub fn integrity_check(conn: &Connection) -> Result { +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/sqlite/connection.rs b/walletkit-db/src/sqlite/connection.rs index c7dff985f..970f15a63 100644 --- a/walletkit-db/src/sqlite/connection.rs +++ b/walletkit-db/src/sqlite/connection.rs @@ -5,7 +5,7 @@ use std::path::Path; -use super::error::{Error, Result}; +use super::error::{DbResult, Error}; use super::ffi::{self, RawDb}; use super::statement::{Row, Statement, StepResult}; use super::transaction::Transaction; @@ -26,7 +26,7 @@ impl Connection { /// # Errors /// /// Returns `Error` if `SQLite` cannot open the file. - pub fn open(path: &Path, read_only: bool) -> Result { + pub fn open(path: &Path, read_only: bool) -> DbResult { let path_str = path.to_string_lossy(); let flags = if read_only { ffi::SQLITE_OPEN_READONLY | ffi::SQLITE_OPEN_FULLMUTEX @@ -47,7 +47,7 @@ impl Connection { /// # Errors /// /// Returns `Error` if any statement fails. - pub fn execute_batch(&self, sql: &str) -> Result<()> { + pub fn execute_batch(&self, sql: &str) -> DbResult<()> { self.db.exec(sql) } @@ -58,7 +58,7 @@ impl Connection { /// # Errors /// /// Returns `Error` if the statement fails. - pub fn execute_batch_zeroized(&self, sql: &str) -> Result<()> { + pub fn execute_batch_zeroized(&self, sql: &str) -> DbResult<()> { self.db.exec_zeroized(sql) } @@ -67,7 +67,7 @@ impl Connection { /// # Errors /// /// Returns `Error` if the SQL is invalid. - pub fn prepare(&self, sql: &str) -> Result> { + pub fn prepare(&self, sql: &str) -> DbResult> { let raw_stmt = self.db.prepare(sql)?; Ok(Statement::new(raw_stmt)) } @@ -79,7 +79,7 @@ impl Connection { /// # Errors /// /// Returns `Error` if preparation or execution fails. - pub fn execute(&self, sql: &str, params: &[Value]) -> Result { + pub fn execute(&self, sql: &str, params: &[Value]) -> DbResult { let mut stmt = self.prepare(sql)?; stmt.bind_values(params)?; stmt.step()?; @@ -98,8 +98,8 @@ impl Connection { &self, sql: &str, params: &[Value], - mapper: impl FnOnce(&Row<'_, '_>) -> Result, - ) -> Result { + mapper: impl FnOnce(&Row<'_, '_>) -> DbResult, + ) -> DbResult { let mut stmt = self.prepare(sql)?; stmt.bind_values(params)?; match stmt.step()? { @@ -120,8 +120,8 @@ impl Connection { &self, sql: &str, params: &[Value], - mapper: impl FnOnce(&Row<'_, '_>) -> Result, - ) -> Result> { + mapper: impl FnOnce(&Row<'_, '_>) -> DbResult, + ) -> DbResult> { let mut stmt = self.prepare(sql)?; stmt.bind_values(params)?; match stmt.step()? { @@ -135,7 +135,7 @@ impl Connection { /// # Errors /// /// Returns `Error` if `BEGIN DEFERRED` fails. - pub fn transaction(&self) -> Result> { + pub fn transaction(&self) -> DbResult> { Transaction::begin(self, false) } @@ -144,7 +144,7 @@ impl Connection { /// # Errors /// /// Returns `Error` if `BEGIN IMMEDIATE` fails. - pub fn transaction_immediate(&self) -> Result> { + pub fn transaction_immediate(&self) -> DbResult> { Transaction::begin(self, true) } @@ -176,7 +176,7 @@ impl Connection { /// # Errors /// /// Returns `Error` if the in-memory database cannot be opened. - pub fn open_in_memory() -> Result { + 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 index b342d594c..65ea4ef08 100644 --- a/walletkit-db/src/sqlite/error.rs +++ b/walletkit-db/src/sqlite/error.rs @@ -40,4 +40,4 @@ impl fmt::Display for Error { impl std::error::Error for Error {} /// Result alias for [`Error`]. -pub type Result = std::result::Result; +pub type DbResult = std::result::Result; diff --git a/walletkit-db/src/sqlite/ffi.rs b/walletkit-db/src/sqlite/ffi.rs index b5d0b7bfe..d1fce0b1f 100644 --- a/walletkit-db/src/sqlite/ffi.rs +++ b/walletkit-db/src/sqlite/ffi.rs @@ -3,7 +3,7 @@ //! This module is the **only** place in the codebase that contains `unsafe` code //! or C types (`*mut c_void`, `CString`, etc.). It exposes two safe handle types //! -- [`RawDb`] and [`RawStmt`] -- whose methods perform the underlying FFI calls -//! and translate results into idiomatic Rust ([`Result`], `String`, `Vec`). +//! and translate results into idiomatic Rust ([`DbResult`], `String`, `Vec`). //! //! Why `unsafe` is required: `SQLite` is a C library. Calling any C function from //! Rust is `unsafe` by definition because the Rust compiler cannot verify memory @@ -26,7 +26,7 @@ use std::os::raw::{c_char, c_int, c_void}; use zeroize::Zeroize; -use super::error::{Error, Result}; +use super::error::{DbResult, Error}; // -- SQLite constants (plain `i32`, no C types leaked to callers) ------------- @@ -75,7 +75,7 @@ unsafe impl Send for RawDb {} impl RawDb { /// Opens (or creates) a database at the given `path`. - pub fn open(path: &str, flags: i32) -> Result { + pub fn open(path: &str, flags: i32) -> DbResult { let c_path = to_cstring(path)?; let mut ptr: *mut c_void = std::ptr::null_mut(); @@ -109,7 +109,7 @@ impl RawDb { } /// Executes one or more semicolon-separated SQL statements. No results. - pub fn exec(&self, sql: &str) -> Result<()> { + pub fn exec(&self, sql: &str) -> DbResult<()> { let c_sql = to_cstring(sql)?; let mut errmsg: *mut c_char = std::ptr::null_mut(); @@ -147,7 +147,7 @@ impl RawDb { /// Like [`exec`](Self::exec) but zeroizes the internal `CString` buffer /// after the FFI call. Use for SQL that contains sensitive material (e.g. /// `PRAGMA key`). - pub fn exec_zeroized(&self, sql: &str) -> Result<()> { + pub fn exec_zeroized(&self, sql: &str) -> DbResult<()> { let c_sql = to_cstring(sql)?; let mut errmsg: *mut c_char = std::ptr::null_mut(); @@ -187,7 +187,7 @@ impl RawDb { } /// Prepares a single SQL statement for execution. - pub fn prepare(&self, sql: &str) -> Result> { + pub fn prepare(&self, sql: &str) -> DbResult> { let c_sql = to_cstring(sql)?; let mut stmt_ptr: *mut c_void = std::ptr::null_mut(); @@ -252,7 +252,7 @@ impl std::fmt::Debug for RawDb { impl RawStmt<'_> { /// Executes a single step. Returns `SQLITE_ROW` or `SQLITE_DONE`. - pub fn step(&mut self) -> Result { + pub fn step(&mut self) -> DbResult { // Safety: self.ptr is a valid prepared statement. let rc = unsafe { raw::sqlite3_step(self.ptr) }; match rc { @@ -264,7 +264,7 @@ impl RawStmt<'_> { /// Resets the statement so it can be stepped again. #[allow(dead_code)] - pub fn reset(&mut self) -> Result<()> { + pub fn reset(&mut self) -> DbResult<()> { // Safety: self.ptr is valid. let rc = unsafe { raw::sqlite3_reset(self.ptr) }; if rc == SQLITE_OK as c_int { @@ -276,13 +276,13 @@ impl RawStmt<'_> { // -- Binding -------------------------------------------------------------- - pub fn bind_i64(&mut self, idx: i32, value: i64) -> Result<()> { + pub fn bind_i64(&mut self, idx: i32, value: i64) -> DbResult<()> { // Safety: self.ptr is valid; idx is a 1-based parameter index. let rc = unsafe { raw::sqlite3_bind_int64(self.ptr, idx as c_int, value) }; check(rc, self) } - pub fn bind_blob(&mut self, idx: i32, value: &[u8]) -> Result<()> { + pub fn bind_blob(&mut self, idx: i32, value: &[u8]) -> DbResult<()> { // Safety: value.as_ptr() is valid for value.len() bytes. // SQLITE_TRANSIENT tells SQLite to copy the data immediately. let rc = unsafe { @@ -297,7 +297,7 @@ impl RawStmt<'_> { check(rc, self) } - pub fn bind_text(&mut self, idx: i32, value: &str) -> Result<()> { + pub fn bind_text(&mut self, idx: i32, value: &str) -> DbResult<()> { // Safety: value.as_ptr() is valid for value.len() bytes. // SQLITE_TRANSIENT tells SQLite to copy the data immediately. let rc = unsafe { @@ -312,7 +312,7 @@ impl RawStmt<'_> { check(rc, self) } - pub fn bind_null(&mut self, idx: i32) -> Result<()> { + pub fn bind_null(&mut self, idx: i32) -> DbResult<()> { // Safety: self.ptr is valid. let rc = unsafe { raw::sqlite3_bind_null(self.ptr, idx as c_int) }; check(rc, self) @@ -385,7 +385,7 @@ impl Drop for RawStmt<'_> { // -- Helpers (private) -------------------------------------------------------- -fn to_cstring(s: &str) -> Result { +fn to_cstring(s: &str) -> DbResult { CString::new(s) .map_err(|e| Error::new(SQLITE_ERROR, format!("nul byte in string: {e}"))) } @@ -404,7 +404,7 @@ fn errmsg_from_ptr(db: *mut c_void) -> String { } } -fn check(rc: c_int, stmt: &RawStmt) -> Result<()> { +fn check(rc: c_int, stmt: &RawStmt) -> DbResult<()> { if rc == SQLITE_OK as c_int { Ok(()) } else { diff --git a/walletkit-db/src/sqlite/mod.rs b/walletkit-db/src/sqlite/mod.rs index 0e10c8c22..9df5649ec 100644 --- a/walletkit-db/src/sqlite/mod.rs +++ b/walletkit-db/src/sqlite/mod.rs @@ -22,7 +22,7 @@ mod transaction; mod value; pub use connection::Connection; -pub use error::{Error, Result}; +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/sqlite/statement.rs b/walletkit-db/src/sqlite/statement.rs index ee5f5118f..7dd24ae6a 100644 --- a/walletkit-db/src/sqlite/statement.rs +++ b/walletkit-db/src/sqlite/statement.rs @@ -3,7 +3,7 @@ //! This file contains **no `unsafe` code**. All FFI interaction is delegated to //! [`ffi::RawStmt`] which encapsulates the raw pointers and C type conversions. -use super::error::Result; +use super::error::DbResult; use super::ffi::{self, RawStmt}; use super::value::Value; @@ -100,7 +100,7 @@ impl<'conn> Statement<'conn> { /// # Panics /// /// Panics if the number of values exceeds `i32::MAX`. - pub fn bind_values(&mut self, values: &[Value]) -> Result<()> { + pub fn bind_values(&mut self, values: &[Value]) -> DbResult<()> { for (i, val) in values.iter().enumerate() { let idx = i32::try_from(i + 1).expect("parameter index overflow"); match val { @@ -118,7 +118,7 @@ impl<'conn> Statement<'conn> { /// # Errors /// /// Returns `Error` if the step fails. - pub fn step<'stmt>(&'stmt mut self) -> Result> { + pub fn step<'stmt>(&'stmt mut self) -> DbResult> { let rc = self.raw.step()?; if rc == ffi::SQLITE_ROW { Ok(StepResult::Row(Row { stmt: self })) diff --git a/walletkit-db/src/sqlite/transaction.rs b/walletkit-db/src/sqlite/transaction.rs index 0cb2fb483..2579f65bb 100644 --- a/walletkit-db/src/sqlite/transaction.rs +++ b/walletkit-db/src/sqlite/transaction.rs @@ -3,7 +3,7 @@ //! Automatically rolls back on drop unless explicitly committed. use super::connection::Connection; -use super::error::Result; +use super::error::DbResult; use super::statement::{Row, Statement}; use super::value::Value; @@ -22,7 +22,7 @@ impl<'conn> Transaction<'conn> { /// /// When `immediate` is true, the transaction acquires a RESERVED lock /// immediately (`BEGIN IMMEDIATE`) rather than deferring it. - pub(super) fn begin(conn: &'conn Connection, immediate: bool) -> Result { + pub(super) fn begin(conn: &'conn Connection, immediate: bool) -> DbResult { let sql = if immediate { "BEGIN IMMEDIATE" } else { @@ -40,7 +40,7 @@ impl<'conn> Transaction<'conn> { /// # Errors /// /// Returns `Error` if the COMMIT statement fails. - pub fn commit(mut self) -> Result<()> { + pub fn commit(mut self) -> DbResult<()> { self.conn.execute_batch("COMMIT")?; self.committed = true; Ok(()) @@ -54,7 +54,7 @@ impl<'conn> Transaction<'conn> { /// /// Returns `Error` if any statement fails. #[allow(dead_code)] - pub fn execute_batch(&self, sql: &str) -> Result<()> { + pub fn execute_batch(&self, sql: &str) -> DbResult<()> { self.conn.execute_batch(sql) } @@ -63,7 +63,7 @@ impl<'conn> Transaction<'conn> { /// # Errors /// /// Returns `Error` if preparation or execution fails. - pub fn execute(&self, sql: &str, params: &[Value]) -> Result { + pub fn execute(&self, sql: &str, params: &[Value]) -> DbResult { self.conn.execute(sql, params) } @@ -76,8 +76,8 @@ impl<'conn> Transaction<'conn> { &self, sql: &str, params: &[Value], - mapper: impl FnOnce(&Row<'_, '_>) -> Result, - ) -> Result { + mapper: impl FnOnce(&Row<'_, '_>) -> DbResult, + ) -> DbResult { self.conn.query_row(sql, params, mapper) } @@ -86,7 +86,7 @@ impl<'conn> Transaction<'conn> { /// # Errors /// /// Returns `Error` if the SQL is invalid. - pub fn prepare(&self, sql: &str) -> Result> { + pub fn prepare(&self, sql: &str) -> DbResult> { self.conn.prepare(sql) } } diff --git a/walletkit-db/src/tests.rs b/walletkit-db/src/tests.rs index 524a1135f..f06e40e85 100644 --- a/walletkit-db/src/tests.rs +++ b/walletkit-db/src/tests.rs @@ -285,6 +285,11 @@ fn test_cipher_import_rejects_non_empty_destination() { // ------------------------------------------------------------------------- mod primitives { + //! Storage-primitive tests: `compute_content_id` and `KeyEnvelope` + //! frozen-byte format guards, `Lock` exclusivity and cross-thread + //! serialization, `init_or_open_envelope_key` round-trip, and + //! `Vault::open` schema-callback / wrong-key behavior. + use super::init_sqlite; use crate::envelope::KeyEnvelope; use crate::{ @@ -294,11 +299,6 @@ mod primitives { use secrecy::{ExposeSecret, SecretBox}; use std::sync::Mutex; - // ---- compute_content_id format guard -------------------------------- - // - // The hash domain is part of the on-disk format. Changing this test - // means breaking every existing user database. - #[test] fn test_compute_content_id_byte_stable() { // SHA-256(b"worldid:blob" || [0x01] || b"hello"). Frozen value; @@ -316,8 +316,6 @@ mod primitives { assert_ne!(cid, cid2, "kind tag must affect content id"); } - // ---- KeyEnvelope CBOR format guard ---------------------------------- - #[test] fn test_key_envelope_round_trip() { let envelope = KeyEnvelope::new(vec![1, 2, 3], 123); @@ -361,8 +359,6 @@ mod primitives { } } - // ---- Lock -------------------------------------------------------------- - #[test] #[cfg(not(target_arch = "wasm32"))] fn test_lock_is_exclusive() { @@ -416,11 +412,8 @@ mod primitives { thread_a.join().expect("thread join"); } - // ---- Envelope helper end-to-end ---------------------------------------- - // - // Uses a stub Keystore that XORs with a fixed pad. Good enough to verify - // the seal -> persist -> open round-trip on the envelope wiring. - + /// 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], } @@ -501,8 +494,6 @@ mod primitives { assert_eq!(key_a.expose_secret(), key_b.expose_secret()); } - // ---- Vault ------------------------------------------------------------- - #[test] #[cfg(not(target_arch = "wasm32"))] fn test_vault_open_runs_schema_callback() { diff --git a/walletkit-db/src/traits.rs b/walletkit-db/src/traits.rs index b99c40e97..cf8f4ddba 100644 --- a/walletkit-db/src/traits.rs +++ b/walletkit-db/src/traits.rs @@ -9,37 +9,40 @@ use crate::error::StoreResult; /// Device keystore for sealing and opening secrets under a device-bound key. /// -/// Implementations must integrity-protect `associated_data` as part of the -/// seal: any mismatch when opening must fail. +/// 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 - /// `associated_data`. + /// 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, - associated_data: Vec, - plaintext: Vec, - ) -> StoreResult>; + fn seal(&self, aad: Vec, plaintext: Vec) -> StoreResult>; - /// Opens ciphertext under the device-bound key, verifying - /// `associated_data`. The same associated data used during sealing must - /// be supplied or the open operation must fail. + /// 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, - associated_data: Vec, - ciphertext: Vec, - ) -> StoreResult>; + 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. /// diff --git a/walletkit-db/src/vault.rs b/walletkit-db/src/vault.rs index b98196ded..50b874b7f 100644 --- a/walletkit-db/src/vault.rs +++ b/walletkit-db/src/vault.rs @@ -20,7 +20,7 @@ use secrecy::SecretBox; use crate::error::{StoreError, StoreResult}; use crate::lock::Lock; -use crate::sqlite::{cipher, Connection, Result as DbResult}; +use crate::sqlite::{cipher, Connection, DbResult}; /// Open encrypted database paired with the lock that serializes its /// mutations. From 75d736921652dde27efdb5b28b8b8fa10252da31 Mon Sep 17 00:00:00 2001 From: danielle-tfh Date: Mon, 18 May 2026 14:38:41 +0100 Subject: [PATCH 22/31] docs(walletkit-db): restore lost TODO on heap-allocated key bytes The pre-PR walletkit-core/src/storage/keys.rs had a TODO flagging that k_intermediate.to_vec() leaves key material in a non-zeroized Vec. The comment got dropped when the cold-start path moved into walletkit-db::envelope. The underlying issue still exists: the trait shape (Vec) is what walletkit-core's uniffi DeviceKeystore requires, and the allocator doesn't zero on drop. Restore the comment, updated for the new location, naming both possible fixes (change the trait shape, or wrap the Vec in Zeroizing). --- walletkit-db/src/envelope.rs | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/walletkit-db/src/envelope.rs b/walletkit-db/src/envelope.rs index 669d7e790..a91167780 100644 --- a/walletkit-db/src/envelope.rs +++ b/walletkit-db/src/envelope.rs @@ -107,6 +107,14 @@ pub fn init_or_open_envelope_key( 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()?; From 5c3031a10bf3b5e799a520f5b13de6ee1bcc9152 Mon Sep 17 00:00:00 2001 From: danielle-tfh Date: Tue, 19 May 2026 12:23:59 +0100 Subject: [PATCH 23/31] refactor(walletkit-db): drop Vault::mutate; rely on SQLite's own writer serialization MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Per Paolo's review on vault.rs:73-74: SQLite handles cross-process writer serialization itself in WAL mode (cipher::configure_connection sets journal_mode = WAL). Vault::mutate's flock acquisition was redundant with that for normal mutations. Vault collapses to a single accessor: - pub struct Vault { conn: Connection } — no lock field. - Vault::open(path, key, ensure_schema) — no Lock parameter. - Vault::connection(&self) -> &Connection — replaces both read() and mutate(). Lock stays in walletkit-db as a primitive. init_or_open_envelope_key still acquires it internally for the bootstrap race (two processes generating competing envelopes on first install — SQLite isn't involved in envelope writes). CredentialStore acquires self.guard() explicitly around export_plaintext / import_plaintext for file-op atomicity at the ATTACH boundaries. Removes self.vault.mutate(|conn| { ... }) wrapping across credential_vault and cache (~50 LOC). Method bodies become flat. CredentialVault::new and CacheDb::new no longer take a Lock argument. On-disk format and host FFI unchanged. 141 walletkit-core + 19 walletkit-db lib tests pass. --- .../src/storage/cache/maintenance.rs | 9 +-- walletkit-core/src/storage/cache/mod.rs | 51 ++++++------- .../src/storage/credential_storage.rs | 21 ++--- .../src/storage/credential_vault/mod.rs | 59 +++++++------- .../src/storage/credential_vault/tests.rs | 65 ++++++---------- walletkit-db/src/tests.rs | 53 ++----------- walletkit-db/src/vault.rs | 76 +++++-------------- 7 files changed, 117 insertions(+), 217 deletions(-) diff --git a/walletkit-core/src/storage/cache/maintenance.rs b/walletkit-core/src/storage/cache/maintenance.rs index 89cb31db8..d81030fee 100644 --- a/walletkit-core/src/storage/cache/maintenance.rs +++ b/walletkit-core/src/storage/cache/maintenance.rs @@ -6,7 +6,7 @@ use std::path::Path; use secrecy::SecretBox; use crate::storage::error::StorageResult; -use walletkit_db::{Lock, Vault}; +use walletkit_db::Vault; use super::schema; use super::util::map_io_err; @@ -24,15 +24,12 @@ use super::util::map_io_err; pub(super) fn open_or_rebuild( path: &Path, k_intermediate: &SecretBox<[u8; 32]>, - lock: Lock, ) -> StorageResult { - if let Ok(vault) = - Vault::open(path, k_intermediate, lock.clone(), schema::ensure_schema) - { + if let Ok(vault) = Vault::open(path, k_intermediate, schema::ensure_schema) { return Ok(vault); } delete_cache_files(path)?; - Vault::open(path, k_intermediate, lock, schema::ensure_schema).map_err(Into::into) + Vault::open(path, k_intermediate, schema::ensure_schema).map_err(Into::into) } /// Deletes the cache DB and its WAL/SHM sidecar files if present. diff --git a/walletkit-core/src/storage/cache/mod.rs b/walletkit-core/src/storage/cache/mod.rs index 19e176e7e..64956a485 100644 --- a/walletkit-core/src/storage/cache/mod.rs +++ b/walletkit-core/src/storage/cache/mod.rs @@ -4,7 +4,7 @@ use std::path::Path; use crate::storage::error::StorageResult; use secrecy::SecretBox; -use walletkit_db::{Lock, Vault}; +use walletkit_db::Vault; mod maintenance; mod merkle; @@ -16,8 +16,7 @@ mod util; /// Encrypted cache database wrapper. /// /// Stores non-authoritative, regenerable data (proof cache, session keys, -/// replay guard). Wraps [`walletkit_db::Vault`]: mutations acquire the -/// vault's lock via `Vault::mutate`; reads bypass the lock. +/// 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 @@ -28,9 +27,7 @@ pub struct CacheDb { } impl CacheDb { - /// Opens or rebuilds the encrypted cache database at `path`. Takes - /// ownership of `lock`; subsequent mutations re-acquire it through - /// [`walletkit_db::Vault::mutate`]. + /// Opens or rebuilds the encrypted cache database at `path`. /// /// If the database is corrupted or unreadable, the file is deleted /// and a fresh empty cache is created. @@ -41,9 +38,8 @@ impl CacheDb { pub fn new( path: &Path, k_intermediate: &SecretBox<[u8; 32]>, - lock: Lock, ) -> StorageResult { - let vault = maintenance::open_or_rebuild(path, k_intermediate, lock)?; + let vault = maintenance::open_or_rebuild(path, k_intermediate)?; Ok(Self { vault }) } @@ -56,7 +52,7 @@ impl CacheDb { /// /// Returns an error if the query fails. pub fn merkle_cache_get(&self, valid_until: u64) -> StorageResult>> { - merkle::get(self.vault.read(), valid_until) + merkle::get(self.vault.connection(), valid_until) } /// Inserts a cached Merkle proof with a TTL. Existing entries for the @@ -71,8 +67,7 @@ impl CacheDb { now: u64, ttl_seconds: u64, ) -> StorageResult<()> { - self.vault - .mutate(|conn| merkle::put(conn, proof_bytes, 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`. @@ -87,7 +82,7 @@ impl CacheDb { oprf_seed: [u8; 32], now: u64, ) -> StorageResult> { - session::get(self.vault.read(), oprf_seed, now) + session::get(self.vault.connection(), oprf_seed, now) } /// Stores a `session_id_r_seed` keyed by `oprf_seed` with a TTL. @@ -102,9 +97,13 @@ impl CacheDb { now: u64, ttl_seconds: u64, ) -> StorageResult<()> { - self.vault.mutate(|conn| { - session::put(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. @@ -122,7 +121,7 @@ impl CacheDb { nullifier: [u8; 32], now: u64, ) -> StorageResult { - nullifiers::is_nullifier_replay(self.vault.read(), nullifier, now) + nullifiers::is_nullifier_replay(self.vault.connection(), nullifier, now) } /// After a proof has been successfully generated, creates a replay guard @@ -132,15 +131,13 @@ impl CacheDb { /// /// Returns an error if the query to the cache unexpectedly fails. pub fn replay_guard_set(&self, nullifier: [u8; 32], now: u64) -> StorageResult<()> { - self.vault - .mutate(|conn| nullifiers::replay_guard_set(conn, nullifier, now)) + nullifiers::replay_guard_set(self.vault.connection(), nullifier, now) } } #[cfg(test)] mod tests { use super::*; - use crate::storage::StorageLock; use secrecy::SecretBox; use std::fs; use std::path::PathBuf; @@ -173,10 +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 db = CacheDb::new(&path, &key, lock.clone()).expect("create cache"); + let db = CacheDb::new(&path, &key).expect("create cache"); drop(db); - CacheDb::new(&path, &key, lock).expect("open cache"); + CacheDb::new(&path, &key).expect("open cache"); cleanup_cache_files(&path); cleanup_lock_file(&lock_path); } @@ -186,8 +182,7 @@ 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 db = CacheDb::new(&path, &key, lock.clone()).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; @@ -197,7 +192,7 @@ mod tests { fs::write(&path, b"corrupt").expect("corrupt cache file"); - let db = CacheDb::new(&path, &key, lock).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"); @@ -211,8 +206,7 @@ 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 db = CacheDb::new(&path, &key, lock).expect("create cache"); + let db = CacheDb::new(&path, &key).expect("create cache"); db.merkle_cache_put(&[1, 2, 3], 100, 10) .expect("put merkle proof"); let hit = db.merkle_cache_get(105).expect("get merkle proof"); @@ -228,8 +222,7 @@ 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 db = CacheDb::new(&path, &key, lock).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; diff --git a/walletkit-core/src/storage/credential_storage.rs b/walletkit-core/src/storage/credential_storage.rs index 31172527a..df34539aa 100644 --- a/walletkit-core/src/storage/credential_storage.rs +++ b/walletkit-core/src/storage/credential_storage.rs @@ -521,16 +521,8 @@ impl CredentialStoreInner { now, )?; let k_intermediate = keys.intermediate_key(); - let vault = CredentialVault::new( - &self.paths.vault_db_path(), - k_intermediate, - self.lock.clone(), - )?; - let cache = CacheDb::new( - &self.paths.cache_db_path(), - k_intermediate, - self.lock.clone(), - )?; + 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, @@ -719,8 +711,13 @@ impl CredentialStoreInner { /// 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 state = self.state()?; let dest = self.temp_backup_path(); state.vault.export_plaintext(&dest)?; @@ -744,8 +741,12 @@ 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 state = self.state()?; let source = std::path::Path::new(backup_path); state.vault.import_plaintext(source) diff --git a/walletkit-core/src/storage/credential_vault/mod.rs b/walletkit-core/src/storage/credential_vault/mod.rs index 1a0766936..feb97c095 100644 --- a/walletkit-core/src/storage/credential_vault/mod.rs +++ b/walletkit-core/src/storage/credential_vault/mod.rs @@ -4,6 +4,12 @@ //! queries, and backup-table list live here; the underlying open / key / //! integrity-check machinery and the shared `blob_objects` table come from //! [`walletkit_db`]. +//! +//! No flock around mutations — `SQLite`'s own WAL-mode locking serializes +//! cross-process writers. `CredentialStore` acquires the shared +//! [`crate::storage::StorageLock`] only for operations that mix `SQLite` +//! with filesystem state (`export_plaintext` / `import_plaintext`, +//! `destroy_storage`) or for the envelope-init bootstrap race. mod helpers; mod schema; @@ -17,24 +23,18 @@ use crate::storage::types::{BlobKind, CredentialRecord}; use helpers::{map_db_err, map_record, to_i64, to_u64}; use schema::{ensure_schema, VAULT_SCHEMA_VERSION}; use secrecy::SecretBox; -use walletkit_db::{blobs, cipher, params, Lock, StepResult, Value, Vault}; +use walletkit_db::{blobs, cipher, params, StepResult, Value, Vault}; pub(crate) use schema::BACKUP_TABLES; -/// Encrypted vault database wrapper. -/// -/// Wraps [`walletkit_db::Vault`]: mutating methods acquire the vault's lock -/// internally and run inside a SQL transaction; read methods bypass the -/// lock (`SQLite` WAL handles concurrent readers). +/// Encrypted vault database wrapper around [`walletkit_db::Vault`]. #[derive(Debug)] pub struct CredentialVault { vault: Vault, } impl CredentialVault { - /// Opens or creates the encrypted vault database at `path`. Takes - /// ownership of `lock`; subsequent mutations re-acquire it through - /// [`walletkit_db::Vault::mutate`]. + /// Opens or creates the encrypted vault database at `path`. /// /// # Errors /// @@ -43,9 +43,8 @@ impl CredentialVault { pub fn new( path: &Path, k_intermediate: &SecretBox<[u8; 32]>, - lock: Lock, ) -> StorageResult { - let vault = Vault::open(path, k_intermediate, lock, |conn| { + let vault = Vault::open(path, k_intermediate, |conn| { blobs::ensure_schema(conn)?; ensure_schema(conn) })?; @@ -64,7 +63,8 @@ impl CredentialVault { 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")?; - self.vault.mutate(|conn| { + { + let conn = self.vault.connection(); let tx = conn.transaction().map_err(|err| map_db_err(&err))?; let stored = tx .query_row( @@ -90,7 +90,7 @@ impl CredentialVault { } tx.commit().map_err(|err| map_db_err(&err))?; Ok(()) - }) + } } /// Stores a credential and optional associated data. @@ -118,7 +118,8 @@ impl CredentialVault { let genesis_issued_at_i64 = to_i64(genesis_issued_at, "genesis_issued_at")?; let expires_at_i64 = to_i64(expires_at, "expires_at")?; - self.vault.mutate(|conn| { + { + let conn = self.vault.connection(); let tx = conn.transaction().map_err(|err| map_db_err(&err))?; let credential_blob_id = blobs::put( @@ -171,7 +172,7 @@ impl CredentialVault { tx.commit().map_err(|err| map_db_err(&err))?; to_u64(credential_id, "credential_id") - }) + } } /// Lists credential metadata, optionally filtered by issuer schema. @@ -210,7 +211,7 @@ impl CredentialVault { let mut stmt = self .vault - .read() + .connection() .prepare(sql) .map_err(|err| map_db_err(&err))?; stmt.bind_values(&[Value::Integer(now_i64), issuer_filter]) @@ -233,7 +234,8 @@ impl CredentialVault { /// not exist. pub fn delete_credential(&self, credential_id: u64) -> StorageResult<()> { let credential_id_i64 = to_i64(credential_id, "credential_id")?; - self.vault.mutate(|conn| { + { + let conn = self.vault.connection(); let tx = conn.transaction().map_err(|err| map_db_err(&err))?; let deleted = tx @@ -275,7 +277,7 @@ impl CredentialVault { tx.commit().map_err(|err| map_db_err(&err))?; Ok(()) - }) + } } /// Retrieves the credential bytes and blinding factor by issuer schema ID. @@ -305,7 +307,7 @@ impl CredentialVault { let mut stmt = self .vault - .read() + .connection() .prepare(sql) .map_err(|err| map_db_err(&err))?; stmt.bind_values(params![expires, issuer_schema_id_i64]) @@ -330,7 +332,8 @@ impl CredentialVault { /// /// Returns an error if the delete operation fails. pub fn danger_delete_all_credentials(&self) -> StorageResult { - self.vault.mutate(|conn| { + { + let conn = self.vault.connection(); let tx = conn.transaction().map_err(|err| map_db_err(&err))?; let deleted = tx @@ -342,7 +345,7 @@ impl CredentialVault { tx.commit().map_err(|err| map_db_err(&err))?; Ok(deleted as u64) - }) + } } /// Runs an integrity check on the vault database. @@ -351,7 +354,7 @@ impl CredentialVault { /// /// Returns an error if the check cannot be executed. pub fn check_integrity(&self) -> StorageResult { - cipher::integrity_check(self.vault.read()).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`. Runs @@ -363,7 +366,8 @@ impl CredentialVault { /// /// Returns an error if the export fails. pub fn export_plaintext(&self, dest: &Path) -> StorageResult<()> { - self.vault.mutate(|conn| { + { + 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}")) @@ -371,7 +375,7 @@ impl CredentialVault { } cipher::export_plaintext_copy(conn, dest, BACKUP_TABLES) .map_err(|e| map_db_err(&e)) - }) + } } /// Imports credentials from a plaintext (unencrypted) vault backup into @@ -384,15 +388,16 @@ impl CredentialVault { /// /// Returns an error if the import fails. pub fn import_plaintext(&self, source: &Path) -> StorageResult<()> { - self.vault.mutate(|conn| { + { + let conn = self.vault.connection(); cipher::import_plaintext_copy(conn, source, BACKUP_TABLES) .map_err(|e| map_db_err(&e)) - }) + } } /// Borrows the underlying connection for direct SQL access. **Test-only.** #[cfg(test)] pub(super) const fn raw_connection(&self) -> &walletkit_db::Connection { - self.vault.read() + self.vault.connection() } } diff --git a/walletkit-core/src/storage/credential_vault/tests.rs b/walletkit-core/src/storage/credential_vault/tests.rs index 4e459f153..150804bb1 100644 --- a/walletkit-core/src/storage/credential_vault/tests.rs +++ b/walletkit-core/src/storage/credential_vault/tests.rs @@ -4,7 +4,6 @@ use super::helpers::map_db_err; use super::*; -use crate::storage::StorageLock; use secrecy::SecretBox; use std::fs; use std::path::{Path, PathBuf}; @@ -44,10 +43,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 db = CredentialVault::new(&path, &key, lock.clone()).expect("create vault"); + let db = CredentialVault::new(&path, &key).expect("create vault"); drop(db); - CredentialVault::new(&path, &key, lock.clone()).expect("open vault"); + CredentialVault::new(&path, &key).expect("open vault"); cleanup_vault_files(&path); cleanup_lock_file(&lock_path); } @@ -57,11 +55,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"); - CredentialVault::new(&path, &key, lock.clone()).expect("create vault"); + CredentialVault::new(&path, &key).expect("create vault"); let wrong_key = SecretBox::init_with(|| [0x02u8; 32]); - let err = - CredentialVault::new(&path, &wrong_key, lock.clone()).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}"), @@ -74,9 +70,8 @@ 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 key = SecretBox::init_with(|| [0x03u8; 32]); - let db = CredentialVault::new(&path, &key, lock.clone()).expect("create vault"); + 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); @@ -87,9 +82,8 @@ 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 key = SecretBox::init_with(|| [0x04u8; 32]); - let db = CredentialVault::new(&path, &key, lock.clone()).expect("create vault"); + 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 { @@ -104,9 +98,8 @@ 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 key = SecretBox::init_with(|| [0x05u8; 32]); - let db = CredentialVault::new(&path, &key, lock.clone()).expect("create vault"); + let db = CredentialVault::new(&path, &key).expect("create vault"); let credential_id = db .store_credential( 10, @@ -132,9 +125,8 @@ 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 key = SecretBox::init_with(|| [0x06u8; 32]); - let db = CredentialVault::new(&path, &key, lock.clone()).expect("create vault"); + let db = CredentialVault::new(&path, &key).expect("create vault"); db.store_credential( 11, sample_blinding_factor(), @@ -165,9 +157,8 @@ fn test_content_id_determinism() { 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 key = SecretBox::init_with(|| [0x07u8; 32]); - let db = CredentialVault::new(&path, &key, lock.clone()).expect("create vault"); + let db = CredentialVault::new(&path, &key).expect("create vault"); let first_id = db .store_credential( 12, @@ -231,9 +222,8 @@ 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 key = SecretBox::init_with(|| [0x08u8; 32]); - let db = CredentialVault::new(&path, &key, lock.clone()).expect("create vault"); + let db = CredentialVault::new(&path, &key).expect("create vault"); db.store_credential( 100, sample_blinding_factor(), @@ -267,9 +257,8 @@ 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 key = SecretBox::init_with(|| [0x09u8; 32]); - let db = CredentialVault::new(&path, &key, lock.clone()).expect("create vault"); + let db = CredentialVault::new(&path, &key).expect("create vault"); db.store_credential( 300, sample_blinding_factor(), @@ -304,9 +293,8 @@ 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 key = SecretBox::init_with(|| [0x0Au8; 32]); - let db = CredentialVault::new(&path, &key, lock.clone()).expect("create vault"); + let db = CredentialVault::new(&path, &key).expect("create vault"); db.store_credential( 500, sample_blinding_factor(), @@ -332,9 +320,8 @@ 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 key = SecretBox::init_with(|| [0x0Bu8; 32]); - let db = CredentialVault::new(&path, &key, lock.clone()).expect("create vault"); + let db = CredentialVault::new(&path, &key).expect("create vault"); let credential_id = db .store_credential( 400, @@ -391,9 +378,8 @@ 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 key = SecretBox::init_with(|| [0x0Cu8; 32]); - let db = CredentialVault::new(&path, &key, lock.clone()).expect("create vault"); + let db = CredentialVault::new(&path, &key).expect("create vault"); let credential_id = db .store_credential( @@ -440,9 +426,8 @@ 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 key = SecretBox::init_with(|| [0x0Cu8; 32]); - let db = CredentialVault::new(&path, &key, lock.clone()).expect("create vault"); + let db = CredentialVault::new(&path, &key).expect("create vault"); db.store_credential( 100, sample_blinding_factor(), @@ -487,9 +472,8 @@ 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 key = SecretBox::init_with(|| [0x0Du8; 32]); - let db = CredentialVault::new(&path, &key, lock.clone()).expect("create vault"); + let db = CredentialVault::new(&path, &key).expect("create vault"); let deleted = db .danger_delete_all_credentials() @@ -504,9 +488,8 @@ 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 key = SecretBox::init_with(|| [0x0Au8; 32]); - let db = CredentialVault::new(&path, &key, lock.clone()).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); @@ -528,13 +511,12 @@ fn test_credential_vault_on_disk_format_guard() { // A schema or cipher mismatch would surface here. let path = temp_vault_path(); let lock_path = temp_lock_path(); - let lock = StorageLock::open(&lock_path).expect("open lock"); 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, lock.clone()).expect("create vault"); + let db = CredentialVault::new(&path, &key).expect("create vault"); db.store_credential( 42, blinding.clone(), @@ -561,7 +543,7 @@ fn test_credential_vault_on_disk_format_guard() { let expected_cid_hex = "9281febbd42d05857b399f8481d6842f1e3e4b78401081ca7f0d0fb3a80e9264"; - let db = CredentialVault::new(&path, &key, lock.clone()).expect("reopen vault"); + let db = CredentialVault::new(&path, &key).expect("reopen vault"); let stored_cid_hex: String = db .raw_connection() .query_row( @@ -591,9 +573,8 @@ fn test_credential_vault_on_disk_format_guard() { 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 key = SecretBox::init_with(|| [0x0Cu8; 32]); - let db = CredentialVault::new(&path, &key, lock).expect("create vault"); + let db = CredentialVault::new(&path, &key).expect("create vault"); let genesis_issued_at = 123_456_789u64; db.store_credential( @@ -619,11 +600,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"); - CredentialVault::new(&path, &key, lock.clone()).expect("create vault"); + CredentialVault::new(&path, &key).expect("create vault"); fs::write(&path, b"corrupt").expect("corrupt file"); - let err = - CredentialVault::new(&path, &key, lock.clone()).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-db/src/tests.rs b/walletkit-db/src/tests.rs index f06e40e85..cbe69f74d 100644 --- a/walletkit-db/src/tests.rs +++ b/walletkit-db/src/tests.rs @@ -500,11 +500,9 @@ mod primitives { init_sqlite(); let dir = tempfile::tempdir().expect("create temp dir"); let db_path = dir.path().join("vault.sqlite"); - let lock_path = dir.path().join("vault.lock"); - let lock = Lock::open(&lock_path).expect("open lock"); let key = SecretBox::init_with(|| [0x42u8; 32]); - let vault = Vault::open(&db_path, &key, lock, |conn| { + 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);", @@ -512,10 +510,8 @@ mod primitives { }) .expect("open vault"); - let cid: crate::ContentId = vault - .mutate::<_, StoreError, _>(|conn| blobs::put(conn, 7, b"payload", 1000)) - .expect("put"); - let bytes = blobs::get(vault.read(), &cid) + 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"); @@ -527,48 +523,11 @@ mod primitives { init_sqlite(); let dir = tempfile::tempdir().expect("create temp dir"); let db_path = dir.path().join("vault.sqlite"); - let lock_path = dir.path().join("vault.lock"); - let lock = Lock::open(&lock_path).expect("open lock"); let key = SecretBox::init_with(|| [0x11u8; 32]); - let _ = Vault::open(&db_path, &key, lock.clone(), blobs::ensure_schema) - .expect("create vault"); + 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, lock, |_| Ok(())).expect_err("wrong key"); + let err = Vault::open(&db_path, &wrong, |_| Ok(())).expect_err("wrong key"); assert!(matches!(err, StoreError::Db(_))); } - - #[test] - #[cfg(not(target_arch = "wasm32"))] - fn test_vault_mutate_serializes_writes() { - init_sqlite(); - let dir = tempfile::tempdir().expect("create temp dir"); - let db_path = dir.path().join("vault.sqlite"); - let lock_path = dir.path().join("vault.lock"); - let lock = Lock::open(&lock_path).expect("open lock"); - let key = SecretBox::init_with(|| [0x55u8; 32]); - - let vault = Vault::open(&db_path, &key, lock, blobs::ensure_schema) - .expect("open vault"); - - // Two mutations in sequence; lock acquired/released around each. - vault - .mutate::<_, StoreError, _>(|conn| { - blobs::put(conn, 1, b"a", 100)?; - Ok(()) - }) - .expect("first mutate"); - vault - .mutate::<_, StoreError, _>(|conn| { - blobs::put(conn, 1, b"b", 200)?; - Ok(()) - }) - .expect("second mutate"); - - // Reads after the lock is released. - let cid_a = blobs::compute_content_id(1, b"a"); - let cid_b = blobs::compute_content_id(1, b"b"); - assert_eq!(blobs::get(vault.read(), &cid_a).unwrap().unwrap(), b"a"); - assert_eq!(blobs::get(vault.read(), &cid_b).unwrap().unwrap(), b"b"); - } } diff --git a/walletkit-db/src/vault.rs b/walletkit-db/src/vault.rs index 50b874b7f..2d7d7e547 100644 --- a/walletkit-db/src/vault.rs +++ b/walletkit-db/src/vault.rs @@ -1,61 +1,49 @@ //! Encrypted vault: opens an encrypted database with a caller-supplied -//! schema and exposes read / mutate handles. +//! schema and hands out the underlying [`Connection`]. //! -//! The vault owns its own [`Lock`] so the type system can enforce "no -//! mutation without holding the lock." Reads bypass the lock — `sqlite3mc` -//! is opened in WAL mode and `SQLite`'s own reader/writer serialization -//! handles concurrent readers. -//! -//! The lock is acquired around three things: -//! -//! - the open sequence (open + key + `ensure_schema` + integrity check), -//! preventing two processes from racing on first-install envelope init, -//! - every closure passed to [`Vault::mutate`], serializing multi-statement -//! mutations across processes, -//! - implicitly nothing else: callers can read freely via [`Vault::read`]. +//! `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::lock::Lock; use crate::sqlite::{cipher, Connection, DbResult}; -/// Open encrypted database paired with the lock that serializes its -/// mutations. +/// Open encrypted database wrapper. /// -/// Read access via [`Vault::read`]; mutations via [`Vault::mutate`]. +/// Exposes the underlying [`Connection`] via [`Vault::connection`]. #[derive(Debug)] pub struct Vault { conn: Connection, - lock: Lock, } impl Vault { - /// Opens (or creates) the encrypted database at `db_path`, holding - /// `lock` for the duration of the open + key + schema + integrity-check - /// sequence. The lock is released before this returns; subsequent - /// mutations re-acquire it via [`Vault::mutate`]. + /// 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, - /// [`StoreError::IntegrityCheckFailed`] on corruption, or - /// [`StoreError::Lock`] if the lock cannot be acquired. + /// Returns [`StoreError::Db`] if open / key / schema fails or + /// [`StoreError::IntegrityCheckFailed`] on corruption. pub fn open( db_path: &Path, key: &SecretBox<[u8; 32]>, - lock: Lock, ensure_schema: F, ) -> StoreResult where F: FnOnce(&Connection) -> DbResult<()>, { - let guard = lock.lock()?; let conn = cipher::open_encrypted(db_path, key, false)?; ensure_schema(&conn)?; if !cipher::integrity_check(&conn)? { @@ -63,38 +51,16 @@ impl Vault { "integrity_check failed".to_string(), )); } - drop(guard); - Ok(Self { conn, lock }) + Ok(Self { conn }) } - /// Borrows the underlying connection for read-only SQL. `SQLite` handles - /// concurrent readers in WAL mode; no lock is acquired. + /// Borrows the underlying connection. /// - /// Do not mutate via this handle. Mutations belong inside - /// [`Vault::mutate`]. + /// `SQLite` (in WAL mode, which `cipher::open_encrypted` configures) + /// serializes cross-process writers via its own file locks. Callers + /// don't need to acquire anything to mutate. #[must_use] - pub const fn read(&self) -> &Connection { + pub const fn connection(&self) -> &Connection { &self.conn } - - /// Runs `f` under a freshly-acquired lock guard. The guard is held for - /// the entire closure (any number of SQL transactions) and released on - /// return. - /// - /// The closure's error type must convert from [`StoreError`] so the - /// lock-acquisition failure flows through. Most consumers use the - /// crate's [`StoreResult`] directly. - /// - /// # Errors - /// - /// Propagates the closure's error, plus [`StoreError::Lock`] if the - /// lock cannot be acquired. - pub fn mutate(&self, f: F) -> Result - where - F: FnOnce(&Connection) -> Result, - E: From, - { - let _guard = self.lock.lock().map_err(E::from)?; - f(&self.conn) - } } From b7164a2a7855036259ec60c3c5357d2f111ba702 Mon Sep 17 00:00:00 2001 From: danielle-tfh Date: Wed, 20 May 2026 10:57:54 +0100 Subject: [PATCH 24/31] address Dzejkop's walletkit-db PR review feedback - Test relocation: walletkit-db primitive tests move next to their modules (blobs/envelope/lock/vault). Cross-crate format guard in credential_vault is reframed as a permanent on-disk test, not a refactor-time consistency check. - raw_connection() escape hatch dropped; tests use vault.connection() via child-module visibility. - #[allow] -> #[expect(..., reason = ...)] on store_credential. - Stale Vault::mutate / lock-enforced-mutation references swept from README, lib.rs, lock.rs module doc, vault.rs connection() doc, and credential_vault export/import method docs (which claimed lock acquisition that actually lives one layer up in CredentialStore). - #![allow(clippy::redundant_clone)] dropped (no actual hits). - test_content_id_determinism deleted; superseded by the frozen-byte test now living in walletkit-db/src/blobs.rs. --- .../src/storage/credential_vault/mod.rs | 36 ++- .../src/storage/credential_vault/tests.rs | 58 ++-- walletkit-db/README.md | 21 +- walletkit-db/src/blobs.rs | 22 ++ walletkit-db/src/envelope.rs | 133 +++++++++ walletkit-db/src/lib.rs | 4 +- walletkit-db/src/lock.rs | 72 ++++- walletkit-db/src/tests.rs | 253 +----------------- walletkit-db/src/traits.rs | 2 +- walletkit-db/src/vault.rs | 50 +++- 10 files changed, 316 insertions(+), 335 deletions(-) diff --git a/walletkit-core/src/storage/credential_vault/mod.rs b/walletkit-core/src/storage/credential_vault/mod.rs index feb97c095..de49b8461 100644 --- a/walletkit-core/src/storage/credential_vault/mod.rs +++ b/walletkit-core/src/storage/credential_vault/mod.rs @@ -4,12 +4,6 @@ //! queries, and backup-table list live here; the underlying open / key / //! integrity-check machinery and the shared `blob_objects` table come from //! [`walletkit_db`]. -//! -//! No flock around mutations — `SQLite`'s own WAL-mode locking serializes -//! cross-process writers. `CredentialStore` acquires the shared -//! [`crate::storage::StorageLock`] only for operations that mix `SQLite` -//! with filesystem state (`export_plaintext` / `import_plaintext`, -//! `destroy_storage`) or for the envelope-init bootstrap race. mod helpers; mod schema; @@ -101,8 +95,14 @@ impl CredentialVault { /// # 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( &self, issuer_schema_id: u64, @@ -357,10 +357,13 @@ impl CredentialVault { cipher::integrity_check(self.vault.connection()).map_err(|e| map_db_err(&e)) } - /// Exports a plaintext (unencrypted) copy of the vault to `dest`. Runs - /// under the vault's lock so concurrent writers cannot interleave. + /// 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 /// @@ -381,8 +384,9 @@ impl CredentialVault { /// 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 /// @@ -394,10 +398,4 @@ impl CredentialVault { .map_err(|e| map_db_err(&e)) } } - - /// Borrows the underlying connection for direct SQL access. **Test-only.** - #[cfg(test)] - pub(super) const fn raw_connection(&self) -> &walletkit_db::Connection { - self.vault.connection() - } } diff --git a/walletkit-core/src/storage/credential_vault/tests.rs b/walletkit-core/src/storage/credential_vault/tests.rs index 150804bb1..4d5eb28b1 100644 --- a/walletkit-core/src/storage/credential_vault/tests.rs +++ b/walletkit-core/src/storage/credential_vault/tests.rs @@ -1,14 +1,11 @@ //! Vault database unit tests. -#![allow(clippy::redundant_clone)] - use super::helpers::map_db_err; use super::*; use secrecy::SecretBox; use std::fs; use std::path::{Path, PathBuf}; use uuid::Uuid; -use walletkit_db::compute_content_id; fn temp_vault_path() -> PathBuf { let mut path = std::env::temp_dir(); @@ -146,13 +143,6 @@ 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 as u8, b"data"); - let b = compute_content_id(BlobKind::CredentialBlob as u8, b"data"); - assert_eq!(a, b); -} - #[test] fn test_content_id_deduplication() { let path = temp_vault_path(); @@ -182,7 +172,7 @@ fn test_content_id_deduplication() { ) .expect("store credential"); let count = db - .raw_connection() + .vault.connection() .query_row("SELECT COUNT(*) FROM blob_objects", &[], |stmt| { Ok(stmt.column_i64(0)) }) @@ -194,7 +184,7 @@ fn test_content_id_deduplication() { .expect("delete first credential"); let count_after_first_delete = db - .raw_connection() + .vault.connection() .query_row("SELECT COUNT(*) FROM blob_objects", &[], |stmt| { Ok(stmt.column_i64(0)) }) @@ -206,7 +196,7 @@ fn test_content_id_deduplication() { .expect("delete second credential"); let count_after_second_delete = db - .raw_connection() + .vault.connection() .query_row("SELECT COUNT(*) FROM blob_objects", &[], |stmt| { Ok(stmt.column_i64(0)) }) @@ -335,7 +325,7 @@ fn test_delete_credential_by_id() { .expect("store credential"); let blob_count_before = db - .raw_connection() + .vault.connection() .query_row("SELECT COUNT(*) FROM blob_objects", &[], |stmt| { Ok(stmt.column_i64(0)) }) @@ -350,7 +340,7 @@ fn test_delete_credential_by_id() { assert!(records.is_empty()); let blob_count_after = db - .raw_connection() + .vault.connection() .query_row("SELECT COUNT(*) FROM blob_objects", &[], |stmt| { Ok(stmt.column_i64(0)) }) @@ -394,7 +384,7 @@ fn test_delete_credential_cleans_up_orphaned_associated_data() { .expect("store credential"); let associated_before = db - .raw_connection() + .vault.connection() .query_row( "SELECT COUNT(*) FROM blob_objects WHERE blob_kind = ?1", params![BlobKind::AssociatedData.as_i64()], @@ -408,7 +398,7 @@ fn test_delete_credential_cleans_up_orphaned_associated_data() { .expect("delete credential"); let associated_after = db - .raw_connection() + .vault.connection() .query_row( "SELECT COUNT(*) FROM blob_objects WHERE blob_kind = ?1", params![BlobKind::AssociatedData.as_i64()], @@ -456,7 +446,7 @@ fn test_danger_delete_all_credentials() { assert!(records.is_empty()); let blob_count = db - .raw_connection() + .vault.connection() .query_row("SELECT COUNT(*) FROM blob_objects", &[], |stmt| { Ok(stmt.column_i64(0)) }) @@ -497,18 +487,13 @@ fn test_vault_integrity_check() { #[test] fn test_credential_vault_on_disk_format_guard() { - // This is the on-disk-format guard required by the storage-primitives - // refactor: any change to schemas, CBOR layout, content_id derivation, - // or cipher configuration that breaks compatibility with vault files - // written by `main` must fail this test. - // - // The test: - // 1. Stores a credential with a deterministic plaintext payload. - // 2. Asserts the `content_id` written into `blob_objects` equals a - // hard-coded SHA-256 — locks the kind-tag + prefix derivation. - // 3. Closes the vault, reopens it under the same key, fetches the - // credential back and asserts byte-equality with the original. - // A schema or cipher mismatch would surface here. + // 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]); @@ -529,15 +514,8 @@ fn test_credential_vault_on_disk_format_guard() { .expect("store credential") }; - // Frozen content_id for the credential blob. SHA-256(b"worldid:blob" - // || [BlobKind::CredentialBlob as u8 = 0x01] || credential_bytes). - // - // Verified to match `main`: the SHA-256 derivation in - // `walletkit_db::compute_content_id` is a line-by-line move of the - // pre-refactor `compute_content_id` in walletkit-core/storage/vault/ - // helpers.rs (commit 9ff3b47), so the hex here equals what `main` - // would write for the same `(BlobKind::CredentialBlob, credential_bytes)`. - // Reproducible via: + // 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 = @@ -545,7 +523,7 @@ fn test_credential_vault_on_disk_format_guard() { let db = CredentialVault::new(&path, &key).expect("reopen vault"); let stored_cid_hex: String = db - .raw_connection() + .vault.connection() .query_row( "SELECT lower(hex(credential_blob_cid)) FROM credential_records WHERE credential_id = ?1", params![i64::try_from(credential_id).unwrap()], diff --git a/walletkit-db/README.md b/walletkit-db/README.md index 6f76faba2..f10149347 100644 --- a/walletkit-db/README.md +++ b/walletkit-db/README.md @@ -8,9 +8,9 @@ Consumed by `walletkit-core::storage` (credential vault) and by sibling SDKs in 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`; mutated via `Vault::mutate` (which holds the lock for the closure); read via `Vault::read` (which bypasses it). +- **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`. Owned by the `Vault` after open; consumers never plumb a `LockGuard` through their method signatures. +- **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. @@ -23,7 +23,7 @@ flowchart TB BS["AtomicBlobStore (uniffi)"] end subgraph WKDB["walletkit-db (this crate)"] - OV["Vault::open / mutate / read"] + OV["Vault::open / connection"] Blobs["blobs::{ensure_schema, put, get, delete}"] Env["init_or_open_envelope_key"] Lock["Lock / LockGuard"] @@ -105,24 +105,23 @@ let k_intermediate = init_or_open_envelope_key( )?; // 3. Open the encrypted SQLite database with the consumer's own schema. -let vault = Vault::open(&paths.db_path(), &k_intermediate, lock, |conn| { +let vault = Vault::open(&paths.db_path(), &k_intermediate, |conn| { blobs::ensure_schema(conn)?; my_schema::ensure_schema(conn) })?; // 4. Store / read / delete. -let cid = vault.mutate(|conn| { - blobs::put(conn, MY_KIND_TAG, &payload_bytes, now) -})?; -let bytes = blobs::get(vault.read(), &cid)?.expect("present"); -vault.mutate(|conn| blobs::delete(conn, &cid))?; +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, lock, ensure_schema) -> StoreResult`, `Vault::read(&self) -> &Connection`, `Vault::mutate(&self, f) -> Result`. +- `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. @@ -131,7 +130,7 @@ The consumer brings a `Keystore` impl, an `AtomicBlobStore` impl, a `kind: u8` t ## 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 in `src/tests.rs` guard the 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 diff --git a/walletkit-db/src/blobs.rs b/walletkit-db/src/blobs.rs index c58a87b0f..48525a40b 100644 --- a/walletkit-db/src/blobs.rs +++ b/walletkit-db/src/blobs.rs @@ -139,3 +139,25 @@ fn check_cid_len(cid: &[u8]) -> StoreResult<()> { ))) } } + +#[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 index a91167780..4cbd50318 100644 --- a/walletkit-db/src/envelope.rs +++ b/walletkit-db/src/envelope.rs @@ -135,3 +135,136 @@ fn parse_key_32(bytes: &[u8], label: &str) -> StoreResult<[u8; 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/lib.rs b/walletkit-db/src/lib.rs index ccc0ac036..314e9a93d 100644 --- a/walletkit-db/src/lib.rs +++ b/walletkit-db/src/lib.rs @@ -5,8 +5,8 @@ //! //! - [`Connection`], [`Transaction`], [`Statement`], [`cipher`] — encrypted //! `SQLite` (`sqlite3mc`) wrapper with safe Rust types. -//! - [`Vault`] — encrypted-database wrapper with caller-supplied schema and -//! lock-enforced mutation. +//! - [`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 diff --git a/walletkit-db/src/lock.rs b/walletkit-db/src/lock.rs index cd2c687c5..47a83ee43 100644 --- a/walletkit-db/src/lock.rs +++ b/walletkit-db/src/lock.rs @@ -1,11 +1,15 @@ -//! Cross-process exclusive lock used to serialize 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; @@ -272,6 +276,62 @@ mod imp { overlapped: *mut OVERLAPPED, ) -> i32; } + + #[cfg(test)] + mod tests { + use super::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 = 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()); + } + + #[test] + fn test_lock_serializes_across_threads() { + use std::sync::mpsc; + use std::thread; + + 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) = mpsc::channel(); + let (release_tx, release_rx) = mpsc::channel(); + let (released_tx, released_rx) = mpsc::channel(); + + 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 = 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"); + + let guard = lock_b.try_lock().expect("try lock"); + assert!(guard.is_some()); + + thread_a.join().expect("thread join"); + } + } } pub use imp::{Lock, LockGuard}; diff --git a/walletkit-db/src/tests.rs b/walletkit-db/src/tests.rs index cbe69f74d..b2e43a8fc 100644 --- a/walletkit-db/src/tests.rs +++ b/walletkit-db/src/tests.rs @@ -18,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")); @@ -280,254 +280,3 @@ fn test_cipher_import_rejects_non_empty_destination() { ); } -// ------------------------------------------------------------------------- -// Storage primitives: blobs, envelope, lock, vault -// ------------------------------------------------------------------------- - -mod primitives { - //! Storage-primitive tests: `compute_content_id` and `KeyEnvelope` - //! frozen-byte format guards, `Lock` exclusivity and cross-thread - //! serialization, `init_or_open_envelope_key` round-trip, and - //! `Vault::open` schema-callback / wrong-key behavior. - - use super::init_sqlite; - use crate::envelope::KeyEnvelope; - use crate::{ - blobs, compute_content_id, init_or_open_envelope_key, AtomicBlobStore, - Keystore, Lock, StoreError, StoreResult, Vault, - }; - use secrecy::{ExposeSecret, SecretBox}; - use std::sync::Mutex; - - #[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"); - } - - #[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"), - } - } - - #[test] - #[cfg(not(target_arch = "wasm32"))] - 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 = 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()); - } - - #[test] - #[cfg(not(target_arch = "wasm32"))] - fn test_lock_serializes_across_threads() { - use std::sync::mpsc; - use std::thread; - - 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) = mpsc::channel(); - let (release_tx, release_rx) = mpsc::channel(); - let (released_tx, released_rx) = mpsc::channel(); - - 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 = 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"); - - let guard = lock_b.try_lock().expect("try lock"); - assert!(guard.is_some()); - - thread_a.join().expect("thread join"); - } - - /// 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()); - } - - #[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(_))); - } -} diff --git a/walletkit-db/src/traits.rs b/walletkit-db/src/traits.rs index cf8f4ddba..e988c94b2 100644 --- a/walletkit-db/src/traits.rs +++ b/walletkit-db/src/traits.rs @@ -1,4 +1,4 @@ -//! Plain-Rust trait surface for consumer-supplied platform integrations. +//! 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 diff --git a/walletkit-db/src/vault.rs b/walletkit-db/src/vault.rs index 2d7d7e547..b1188c915 100644 --- a/walletkit-db/src/vault.rs +++ b/walletkit-db/src/vault.rs @@ -55,12 +55,54 @@ impl Vault { } /// Borrows the underlying connection. - /// - /// `SQLite` (in WAL mode, which `cipher::open_encrypted` configures) - /// serializes cross-process writers via its own file locks. Callers - /// don't need to acquire anything to mutate. #[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(_))); + } +} From 071ec5ff092ef6261a87703970095087203c182f Mon Sep 17 00:00:00 2001 From: danielle-tfh Date: Wed, 20 May 2026 11:09:23 +0100 Subject: [PATCH 25/31] refactor(walletkit-core): colocate BACKUP_TABLES with its consumers Move the constant from credential_vault/schema.rs to credential_vault/mod.rs so it lives next to export_plaintext / import_plaintext, the only call sites. schema.rs keeps the maintenance reminder via [`super::BACKUP_TABLES`]. --- walletkit-core/src/storage/credential_vault/mod.rs | 10 +++++++++- .../src/storage/credential_vault/schema.rs | 12 +----------- 2 files changed, 10 insertions(+), 12 deletions(-) diff --git a/walletkit-core/src/storage/credential_vault/mod.rs b/walletkit-core/src/storage/credential_vault/mod.rs index de49b8461..d8993df5e 100644 --- a/walletkit-core/src/storage/credential_vault/mod.rs +++ b/walletkit-core/src/storage/credential_vault/mod.rs @@ -19,7 +19,15 @@ use schema::{ensure_schema, VAULT_SCHEMA_VERSION}; use secrecy::SecretBox; use walletkit_db::{blobs, cipher, params, StepResult, Value, Vault}; -pub(crate) use schema::BACKUP_TABLES; +/// 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)] diff --git a/walletkit-core/src/storage/credential_vault/schema.rs b/walletkit-core/src/storage/credential_vault/schema.rs index 196636715..db5b1896e 100644 --- a/walletkit-core/src/storage/credential_vault/schema.rs +++ b/walletkit-core/src/storage/credential_vault/schema.rs @@ -8,21 +8,11 @@ use walletkit_db::{Connection, DbResult}; pub(super) const VAULT_SCHEMA_VERSION: i64 = 1; -/// 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:** New tables added to the vault schema must be added here too. -pub const BACKUP_TABLES: &[&str] = &["credential_records", "blob_objects"]; - /// Creates the credential-vault tables, indexes, and triggers. /// /// **Backup sensitivity:** Schema changes here affect plaintext vault /// backups. -/// - New tables must be added to [`BACKUP_TABLES`]. +/// - 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<()> { From e3d0f690cd13ab9b4e79ee7b53bd3424afe28ab2 Mon Sep 17 00:00:00 2001 From: danielle-tfh Date: Wed, 20 May 2026 11:10:33 +0100 Subject: [PATCH 26/31] style: rustfmt walletkit-db/walletkit-core tests after raw_connection drop The earlier sed-based rewrite from .raw_connection() to .vault.connection() left chained calls that rustfmt wants split across lines. --- .../src/storage/credential_vault/tests.rs | 24 ++++++++++++------- walletkit-db/src/tests.rs | 1 - 2 files changed, 16 insertions(+), 9 deletions(-) diff --git a/walletkit-core/src/storage/credential_vault/tests.rs b/walletkit-core/src/storage/credential_vault/tests.rs index 4d5eb28b1..d9083c4e7 100644 --- a/walletkit-core/src/storage/credential_vault/tests.rs +++ b/walletkit-core/src/storage/credential_vault/tests.rs @@ -172,7 +172,8 @@ fn test_content_id_deduplication() { ) .expect("store credential"); let count = db - .vault.connection() + .vault + .connection() .query_row("SELECT COUNT(*) FROM blob_objects", &[], |stmt| { Ok(stmt.column_i64(0)) }) @@ -184,7 +185,8 @@ fn test_content_id_deduplication() { .expect("delete first credential"); let count_after_first_delete = db - .vault.connection() + .vault + .connection() .query_row("SELECT COUNT(*) FROM blob_objects", &[], |stmt| { Ok(stmt.column_i64(0)) }) @@ -196,7 +198,8 @@ fn test_content_id_deduplication() { .expect("delete second credential"); let count_after_second_delete = db - .vault.connection() + .vault + .connection() .query_row("SELECT COUNT(*) FROM blob_objects", &[], |stmt| { Ok(stmt.column_i64(0)) }) @@ -325,7 +328,8 @@ fn test_delete_credential_by_id() { .expect("store credential"); let blob_count_before = db - .vault.connection() + .vault + .connection() .query_row("SELECT COUNT(*) FROM blob_objects", &[], |stmt| { Ok(stmt.column_i64(0)) }) @@ -340,7 +344,8 @@ fn test_delete_credential_by_id() { assert!(records.is_empty()); let blob_count_after = db - .vault.connection() + .vault + .connection() .query_row("SELECT COUNT(*) FROM blob_objects", &[], |stmt| { Ok(stmt.column_i64(0)) }) @@ -384,7 +389,8 @@ fn test_delete_credential_cleans_up_orphaned_associated_data() { .expect("store credential"); let associated_before = db - .vault.connection() + .vault + .connection() .query_row( "SELECT COUNT(*) FROM blob_objects WHERE blob_kind = ?1", params![BlobKind::AssociatedData.as_i64()], @@ -398,7 +404,8 @@ fn test_delete_credential_cleans_up_orphaned_associated_data() { .expect("delete credential"); let associated_after = db - .vault.connection() + .vault + .connection() .query_row( "SELECT COUNT(*) FROM blob_objects WHERE blob_kind = ?1", params![BlobKind::AssociatedData.as_i64()], @@ -446,7 +453,8 @@ fn test_danger_delete_all_credentials() { assert!(records.is_empty()); let blob_count = db - .vault.connection() + .vault + .connection() .query_row("SELECT COUNT(*) FROM blob_objects", &[], |stmt| { Ok(stmt.column_i64(0)) }) diff --git a/walletkit-db/src/tests.rs b/walletkit-db/src/tests.rs index b2e43a8fc..431a5b4fb 100644 --- a/walletkit-db/src/tests.rs +++ b/walletkit-db/src/tests.rs @@ -279,4 +279,3 @@ fn test_cipher_import_rejects_non_empty_destination() { "expected non-empty-table error, got: {err}" ); } - From 55f664814e71b3523b84b0ac808e1e722b2b79b0 Mon Sep 17 00:00:00 2001 From: danielle-tfh Date: Wed, 20 May 2026 11:11:58 +0100 Subject: [PATCH 27/31] refactor(walletkit-core): inline credential_vault helpers into mod.rs The four-function helpers module had a single consumer (mod.rs) and added a layer with no encapsulation payoff. Moves map_record, to_i64, to_u64, and map_db_err to the bottom of mod.rs as private free functions and deletes the file. --- .../src/storage/credential_vault/helpers.rs | 36 ------------------- .../src/storage/credential_vault/mod.rs | 35 ++++++++++++++++-- .../src/storage/credential_vault/tests.rs | 1 - 3 files changed, 32 insertions(+), 40 deletions(-) delete mode 100644 walletkit-core/src/storage/credential_vault/helpers.rs diff --git a/walletkit-core/src/storage/credential_vault/helpers.rs b/walletkit-core/src/storage/credential_vault/helpers.rs deleted file mode 100644 index b97e22c1a..000000000 --- a/walletkit-core/src/storage/credential_vault/helpers.rs +++ /dev/null @@ -1,36 +0,0 @@ -//! Credential-row mapping and small numeric conversion helpers. - -use crate::storage::error::{StorageError, StorageResult}; -use crate::storage::types::CredentialRecord; -use walletkit_db::{DbError, Row}; - -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-core/src/storage/credential_vault/mod.rs b/walletkit-core/src/storage/credential_vault/mod.rs index d8993df5e..c291dab10 100644 --- a/walletkit-core/src/storage/credential_vault/mod.rs +++ b/walletkit-core/src/storage/credential_vault/mod.rs @@ -5,7 +5,6 @@ //! integrity-check machinery and the shared `blob_objects` table come from //! [`walletkit_db`]. -mod helpers; mod schema; #[cfg(test)] mod tests; @@ -14,10 +13,9 @@ use std::path::Path; use crate::storage::error::{StorageError, StorageResult}; use crate::storage::types::{BlobKind, CredentialRecord}; -use helpers::{map_db_err, map_record, to_i64, to_u64}; use schema::{ensure_schema, VAULT_SCHEMA_VERSION}; use secrecy::SecretBox; -use walletkit_db::{blobs, cipher, params, StepResult, Value, Vault}; +use walletkit_db::{blobs, cipher, params, DbError, Row, StepResult, Value, Vault}; /// Tables included in plaintext vault backups, in order. /// @@ -407,3 +405,34 @@ impl CredentialVault { } } } + +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/credential_vault/tests.rs b/walletkit-core/src/storage/credential_vault/tests.rs index d9083c4e7..13317191f 100644 --- a/walletkit-core/src/storage/credential_vault/tests.rs +++ b/walletkit-core/src/storage/credential_vault/tests.rs @@ -1,6 +1,5 @@ //! Vault database unit tests. -use super::helpers::map_db_err; use super::*; use secrecy::SecretBox; use std::fs; From 71a07d2aeb7a5df66e9badb14067d865bcae56b3 Mon Sep 17 00:00:00 2001 From: danielle-tfh Date: Wed, 20 May 2026 11:17:52 +0100 Subject: [PATCH 28/31] docs: capture walletkit-specific invariants in AGENTS.md Five sections distilled from the storage-primitives review: - On-disk format is byte-stable (with the frozen-byte test locations) - AEAD terminology (name the primitive, not "encrypt") - aad vs associated_data name overload - SQLite WAL handles writer serialization; don't layer flock - Per-consumer isolation lives in host wiring, not walletkit-db --- AGENTS.md | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/AGENTS.md b/AGENTS.md index de955d6eb..7af4eb121 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -3,3 +3,23 @@ ## 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`). + +## On-disk format is byte-stable + +Schemas, CBOR envelope layout, the `compute_content_id` derivation (`SHA-256(b"worldid:blob" || [kind] || plaintext)`), and the `account_keys.bin` filename / `worldid:account-key-envelope` AD tag are part of the on-disk contract. Existing user databases must keep opening without migration. Frozen-byte tests guard these: `walletkit-db/src/blobs.rs` (content_id), `walletkit-db/src/envelope.rs` (CBOR), `walletkit-core/src/storage/credential_vault/tests.rs::test_credential_vault_on_disk_format_guard` (credential schema + kind tag). Any change that updates one of these hex strings needs an on-disk format review, not a fresh hex commit. + +## AEAD terminology + +When documenting envelope or page encryption, name the AEAD primitive (`ChaCha20-Poly1305`, `AES-GCM`) rather than writing "encrypt" or "authenticate". The host `Keystore` trait requires an AEAD construction; the contract dies if a non-AEAD impl is plugged in. + +## `aad` not `associated_data` + +The AEAD term clashes with `BlobKind::AssociatedData` (the credential-vault blob kind). Use `aad` for AEAD-parameter names and doc text; reserve `associated_data` for the unrelated credential-vault concept. + +## SQLite WAL serializes writers; don't layer flock + +`Vault::connection()` exposes `&Connection`; `walletkit-db` does NOT wrap mutations in a cross-process lock. SQLite in WAL mode handles writer serialization itself. The `Lock` primitive is only for (a) the envelope-init bootstrap race inside `init_or_open_envelope_key` and (b) operations that mix SQL with filesystem state (plaintext export / import, `destroy_storage`). Don't add a flock around ordinary writes. + +## Per-consumer isolation lives in host wiring + +If a second consumer (e.g. OrbKit's PCP store) shares the device with `walletkit-core`'s credential vault, the host must provide: a separate hardware keystore entry, a separate AD label, and separate envelope / vault / lock filenames. `walletkit-db` enforces only the AEAD-AD binding; sharing a keystore entry across consumers breaks isolation. From 71cfeb2c56a5f275c8c43a8a8c51bb17fbe33684 Mon Sep 17 00:00:00 2001 From: danielle-tfh Date: Wed, 20 May 2026 11:20:25 +0100 Subject: [PATCH 29/31] docs: tighten walletkit AGENTS.md into a short code-style list Collapse the five expanded sections into one Code style block with bullet points. Same invariants, less detail, broader strokes. --- AGENTS.md | 24 ++++++------------------ 1 file changed, 6 insertions(+), 18 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 7af4eb121..f4fb2baf4 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -4,22 +4,10 @@ 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`). -## On-disk format is byte-stable +## Code style -Schemas, CBOR envelope layout, the `compute_content_id` derivation (`SHA-256(b"worldid:blob" || [kind] || plaintext)`), and the `account_keys.bin` filename / `worldid:account-key-envelope` AD tag are part of the on-disk contract. Existing user databases must keep opening without migration. Frozen-byte tests guard these: `walletkit-db/src/blobs.rs` (content_id), `walletkit-db/src/envelope.rs` (CBOR), `walletkit-core/src/storage/credential_vault/tests.rs::test_credential_vault_on_disk_format_guard` (credential schema + kind tag). Any change that updates one of these hex strings needs an on-disk format review, not a fresh hex commit. - -## AEAD terminology - -When documenting envelope or page encryption, name the AEAD primitive (`ChaCha20-Poly1305`, `AES-GCM`) rather than writing "encrypt" or "authenticate". The host `Keystore` trait requires an AEAD construction; the contract dies if a non-AEAD impl is plugged in. - -## `aad` not `associated_data` - -The AEAD term clashes with `BlobKind::AssociatedData` (the credential-vault blob kind). Use `aad` for AEAD-parameter names and doc text; reserve `associated_data` for the unrelated credential-vault concept. - -## SQLite WAL serializes writers; don't layer flock - -`Vault::connection()` exposes `&Connection`; `walletkit-db` does NOT wrap mutations in a cross-process lock. SQLite in WAL mode handles writer serialization itself. The `Lock` primitive is only for (a) the envelope-init bootstrap race inside `init_or_open_envelope_key` and (b) operations that mix SQL with filesystem state (plaintext export / import, `destroy_storage`). Don't add a flock around ordinary writes. - -## Per-consumer isolation lives in host wiring - -If a second consumer (e.g. OrbKit's PCP store) shares the device with `walletkit-core`'s credential vault, the host must provide: a separate hardware keystore entry, a separate AD label, and separate envelope / vault / lock filenames. `walletkit-db` enforces only the AEAD-AD binding; sharing a keystore entry across consumers breaks isolation. +- **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. +- **Name the AEAD primitive** (`ChaCha20-Poly1305`, `AES-GCM`) in crypto docs instead of "encrypt" / "authenticate". The `Keystore` trait requires AEAD. +- **Use `aad` for AEAD parameter names.** `associated_data` is taken by `BlobKind::AssociatedData`. +- **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. From d8579e4f4963682af74caa8c6312bc7ea01d7394 Mon Sep 17 00:00:00 2001 From: danielle-tfh Date: Wed, 20 May 2026 11:39:36 +0100 Subject: [PATCH 30/31] docs: pin walletkit coding-style invariants in AGENTS.md Captures the patterns the storage-primitives review surfaced so they're visible to any future contributor (Claude or human) opening this repo: crate boundary, on-disk format invariance, no-flock-around-SQLite, per- consumer host wiring, #[expect] over #[allow], inline single-consumer helpers, colocate constants with consumers, tests next to code. --- AGENTS.md | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index f4fb2baf4..714955a91 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -4,10 +4,13 @@ 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`). -## Code style +## 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. -- **Name the AEAD primitive** (`ChaCha20-Poly1305`, `AES-GCM`) in crypto docs instead of "encrypt" / "authenticate". The `Keystore` trait requires AEAD. -- **Use `aad` for AEAD parameter names.** `associated_data` is taken by `BlobKind::AssociatedData`. - **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. From 7269f5e1f77dae45ba5b983d2ae39b3e0885b197 Mon Sep 17 00:00:00 2001 From: danielle-tfh Date: Wed, 20 May 2026 11:43:46 +0100 Subject: [PATCH 31/31] refactor(walletkit-core): drop vestigial lock-guard scopes; fix stale Lock doc The {} blocks in CredentialVault methods existed to scope a lock guard that no longer exists (dropped with Vault::mutate in 5c3031a). Removing them flattens the bodies and ?-propagation reads naturally now. Also updates the inner Lock struct doc to stop describing itself as serializing mutations; that framing was already corrected at the module level but missed inside the imp module. --- .../src/storage/credential_vault/mod.rs | 275 ++++++++---------- walletkit-db/src/lock.rs | 3 +- 2 files changed, 131 insertions(+), 147 deletions(-) diff --git a/walletkit-core/src/storage/credential_vault/mod.rs b/walletkit-core/src/storage/credential_vault/mod.rs index c291dab10..bc52172db 100644 --- a/walletkit-core/src/storage/credential_vault/mod.rs +++ b/walletkit-core/src/storage/credential_vault/mod.rs @@ -63,34 +63,32 @@ impl CredentialVault { 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 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) - VALUES (?1, ?2, ?3, ?3) - ON CONFLICT(schema_version) DO UPDATE SET - leaf_index = CASE - WHEN vault_meta.leaf_index IS NULL - THEN excluded.leaf_index - ELSE vault_meta.leaf_index - END - RETURNING leaf_index", - params![VAULT_SCHEMA_VERSION, leaf_index_i64, now_i64], - |stmt| Ok(stmt.column_i64(0)), - ) - .map_err(|err| map_db_err(&err))?; - if stored != leaf_index_i64 { - let expected = to_u64(stored, "leaf_index")?; - return Err(StorageError::InvalidLeafIndex { - expected, - provided: leaf_index, - }); - } - tx.commit().map_err(|err| map_db_err(&err))?; - Ok(()) + 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) + VALUES (?1, ?2, ?3, ?3) + ON CONFLICT(schema_version) DO UPDATE SET + leaf_index = CASE + WHEN vault_meta.leaf_index IS NULL + THEN excluded.leaf_index + ELSE vault_meta.leaf_index + END + RETURNING leaf_index", + params![VAULT_SCHEMA_VERSION, leaf_index_i64, now_i64], + |stmt| Ok(stmt.column_i64(0)), + ) + .map_err(|err| map_db_err(&err))?; + if stored != leaf_index_i64 { + let expected = to_u64(stored, "leaf_index")?; + return Err(StorageError::InvalidLeafIndex { + expected, + provided: leaf_index, + }); } + tx.commit().map_err(|err| map_db_err(&err))?; + Ok(()) } /// Stores a credential and optional associated data. @@ -124,61 +122,54 @@ impl CredentialVault { let genesis_issued_at_i64 = to_i64(genesis_issued_at, "genesis_issued_at")?; let expires_at_i64 = to_i64(expires_at, "expires_at")?; - { - let conn = self.vault.connection(); - let tx = conn.transaction().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() - .map_or(Value::Null, |cid| Value::Blob(cid.to_vec())); - - let credential_id = tx - .query_row( - "INSERT INTO credential_records ( - issuer_schema_id, - subject_blinding_factor, - genesis_issued_at, - expires_at, - updated_at, - credential_blob_cid, - associated_data_cid - ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7) - RETURNING credential_id", - params![ - issuer_schema_id_i64, - subject_blinding_factor, - genesis_issued_at_i64, - expires_at_i64, - now_i64, - credential_blob_id.as_slice(), - ad_cid_value, - ], - |stmt| Ok(stmt.column_i64(0)), - ) - .map_err(|err| map_db_err(&err))?; - - tx.commit().map_err(|err| map_db_err(&err))?; - to_u64(credential_id, "credential_id") - } + let conn = self.vault.connection(); + let tx = conn.transaction().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() + .map_or(Value::Null, |cid| Value::Blob(cid.to_vec())); + + let credential_id = tx + .query_row( + "INSERT INTO credential_records ( + issuer_schema_id, + subject_blinding_factor, + genesis_issued_at, + expires_at, + updated_at, + credential_blob_cid, + associated_data_cid + ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7) + RETURNING credential_id", + params![ + issuer_schema_id_i64, + subject_blinding_factor, + genesis_issued_at_i64, + expires_at_i64, + now_i64, + credential_blob_id.as_slice(), + ad_cid_value, + ], + |stmt| Ok(stmt.column_i64(0)), + ) + .map_err(|err| map_db_err(&err))?; + + tx.commit().map_err(|err| map_db_err(&err))?; + to_u64(credential_id, "credential_id") } /// Lists credential metadata, optionally filtered by issuer schema. @@ -240,50 +231,48 @@ impl CredentialVault { /// not exist. pub fn delete_credential(&self, credential_id: u64) -> StorageResult<()> { let credential_id_i64 = to_i64(credential_id, "credential_id")?; - { - let conn = self.vault.connection(); - let tx = conn.transaction().map_err(|err| map_db_err(&err))?; - - let deleted = tx - .execute( - "DELETE FROM credential_records WHERE credential_id = ?1", - params![credential_id_i64], - ) - .map_err(|err| map_db_err(&err))?; - - if deleted == 0 { - return Err(StorageError::CredentialIdNotFound { credential_id }); - } + let conn = self.vault.connection(); + let tx = conn.transaction().map_err(|err| map_db_err(&err))?; - // Delete orphaned credential blobs - tx.execute( - "DELETE FROM blob_objects - WHERE blob_kind = ?1 - AND NOT EXISTS ( - SELECT 1 - FROM credential_records cr - WHERE cr.credential_blob_cid = blob_objects.content_id - )", - params![BlobKind::CredentialBlob.as_i64()], + let deleted = tx + .execute( + "DELETE FROM credential_records WHERE credential_id = ?1", + params![credential_id_i64], ) .map_err(|err| map_db_err(&err))?; - // Delete orphaned associated data blobs - tx.execute( - "DELETE FROM blob_objects - WHERE blob_kind = ?1 - AND NOT EXISTS ( - SELECT 1 - FROM credential_records cr - WHERE cr.associated_data_cid = blob_objects.content_id - )", - params![BlobKind::AssociatedData.as_i64()], - ) - .map_err(|err| map_db_err(&err))?; - - tx.commit().map_err(|err| map_db_err(&err))?; - Ok(()) + if deleted == 0 { + return Err(StorageError::CredentialIdNotFound { credential_id }); } + + // Delete orphaned credential blobs + tx.execute( + "DELETE FROM blob_objects + WHERE blob_kind = ?1 + AND NOT EXISTS ( + SELECT 1 + FROM credential_records cr + WHERE cr.credential_blob_cid = blob_objects.content_id + )", + params![BlobKind::CredentialBlob.as_i64()], + ) + .map_err(|err| map_db_err(&err))?; + + // Delete orphaned associated data blobs + tx.execute( + "DELETE FROM blob_objects + WHERE blob_kind = ?1 + AND NOT EXISTS ( + SELECT 1 + FROM credential_records cr + WHERE cr.associated_data_cid = blob_objects.content_id + )", + params![BlobKind::AssociatedData.as_i64()], + ) + .map_err(|err| map_db_err(&err))?; + + tx.commit().map_err(|err| map_db_err(&err))?; + Ok(()) } /// Retrieves the credential bytes and blinding factor by issuer schema ID. @@ -338,20 +327,18 @@ impl CredentialVault { /// /// Returns an error if the delete operation fails. 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 conn = self.vault.connection(); + let tx = conn.transaction().map_err(|err| map_db_err(&err))?; - let deleted = tx - .execute("DELETE FROM credential_records", &[]) - .map_err(|err| map_db_err(&err))?; + let deleted = tx + .execute("DELETE FROM credential_records", &[]) + .map_err(|err| map_db_err(&err))?; - tx.execute("DELETE FROM blob_objects", &[]) - .map_err(|err| map_db_err(&err))?; + tx.execute("DELETE FROM blob_objects", &[]) + .map_err(|err| map_db_err(&err))?; - tx.commit().map_err(|err| map_db_err(&err))?; - Ok(deleted as u64) - } + tx.commit().map_err(|err| map_db_err(&err))?; + Ok(deleted as u64) } /// Runs an integrity check on the vault database. @@ -375,16 +362,14 @@ impl CredentialVault { /// /// Returns an error if the export fails. 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(conn, dest, BACKUP_TABLES) - .map_err(|e| map_db_err(&e)) + 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(conn, dest, BACKUP_TABLES) + .map_err(|e| map_db_err(&e)) } /// Imports credentials from a plaintext (unencrypted) vault backup into @@ -398,11 +383,9 @@ impl CredentialVault { /// /// Returns an error if the import fails. 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)) - } + let conn = self.vault.connection(); + cipher::import_plaintext_copy(conn, source, BACKUP_TABLES) + .map_err(|e| map_db_err(&e)) } } diff --git a/walletkit-db/src/lock.rs b/walletkit-db/src/lock.rs index 47a83ee43..15807daa5 100644 --- a/walletkit-db/src/lock.rs +++ b/walletkit-db/src/lock.rs @@ -56,7 +56,8 @@ mod imp { 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 Lock { file: Arc,