From 34cf1a040897eccc1907f3b4b6fea7b97a0f867e Mon Sep 17 00:00:00 2001 From: danielle-tfh Date: Thu, 7 May 2026 15:35:06 +0200 Subject: [PATCH 1/2] 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 2/2] 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}" + ); +}