From 11af7fb64afd4ab9dccc813784adbaefe2435d2b Mon Sep 17 00:00:00 2001 From: danielle-tfh Date: Tue, 5 May 2026 18:03:47 +0200 Subject: [PATCH] refactor: walletkit-core uses walletkit-secure-store Stacked on the walletkit-secure-store crate. Migrates walletkit-core's storage layer to consume the shared primitives: - vault/ uses Vault::open + Blobs::put for SQLCipher open + blob inserts. blob_objects table moves to Blobs::ensure_schema; credential-specific tables (credential_records, vault_meta) stay in vault/schema.rs. - keys.rs delegates to init_or_open_envelope_key with the existing ACCOUNT_KEYS_FILENAME and ACCOUNT_KEY_ENVELOPE_AD constants. - lock.rs collapses to a re-export aliased to StorageLock / StorageLockGuard for in-tree stability. - envelope.rs deleted (replaced by walletkit_secure_store::KeyEnvelope). - traits.rs adds DeviceKeystoreAdapter / AtomicBlobStoreAdapter that bridge the uniffi-annotated FFI traits to the new crate's plain-Rust Keystore / AtomicBlobStore. Hosts (Kotlin / Swift) see no change. - error.rs adds From for StorageError so ? converts cleanly at the boundary. - types.rs drops BlobKind::as_i64 (consumers now pass `as u8`). On-disk format unchanged: same envelope CBOR layout, same content_id byte layout (SHA-256(prefix || [kind_byte] || plaintext)), same SQL schemas. Existing user databases keep working without migration. 95 walletkit-core lib tests + 17 vault tests pass; clippy clean. --- Cargo.lock | 1 + walletkit-core/Cargo.toml | 1 + .../src/storage/credential_storage.rs | 2 +- walletkit-core/src/storage/envelope.rs | 73 ---- walletkit-core/src/storage/error.rs | 19 + walletkit-core/src/storage/keys.rs | 74 ++-- walletkit-core/src/storage/lock.rs | 340 +----------------- walletkit-core/src/storage/mod.rs | 1 - walletkit-core/src/storage/traits.rs | 77 ++++ walletkit-core/src/storage/types.rs | 6 - walletkit-core/src/storage/vault/helpers.rs | 19 +- walletkit-core/src/storage/vault/mod.rs | 117 +++--- walletkit-core/src/storage/vault/schema.rs | 21 +- walletkit-core/src/storage/vault/tests.rs | 35 +- 14 files changed, 198 insertions(+), 588 deletions(-) delete mode 100644 walletkit-core/src/storage/envelope.rs diff --git a/Cargo.lock b/Cargo.lock index 3c6d4e23d..a1ecd8577 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -8237,6 +8237,7 @@ dependencies = [ "uniffi", "uuid", "walletkit-db", + "walletkit-secure-store", "world-id-core", "world-id-proof", "zeroize", diff --git a/walletkit-core/Cargo.toml b/walletkit-core/Cargo.toml index 15e170c2f..c9d6dcd08 100644 --- a/walletkit-core/Cargo.toml +++ b/walletkit-core/Cargo.toml @@ -51,6 +51,7 @@ world-id-core = { workspace = true, features = ["authenticator"] } world-id-proof = { workspace = true } ciborium = "0.2.2" walletkit-db = { workspace = true } +walletkit-secure-store = { workspace = true } [target.'cfg(target_arch = "wasm32")'.dependencies] getrandom = { version = "0.3", features = ["wasm_js"] } diff --git a/walletkit-core/src/storage/credential_storage.rs b/walletkit-core/src/storage/credential_storage.rs index cbb2e37d0..a8ed5408d 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> { 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..1cae7a302 100644 --- a/walletkit-core/src/storage/error.rs +++ b/walletkit-core/src/storage/error.rs @@ -1,6 +1,7 @@ //! Error types for credential storage components. use thiserror::Error; +use walletkit_secure_store::StoreError; /// Result type for storage operations. pub type StorageResult = Result; @@ -93,3 +94,21 @@ impl From for StorageError { Self::UnexpectedUniFFICallbackError(error.reason) } } + +impl From for StorageError { + fn from(err: StoreError) -> Self { + match err { + StoreError::Keystore(msg) => Self::Keystore(msg), + StoreError::BlobStore(msg) => Self::BlobStore(msg), + StoreError::Lock(msg) => Self::Lock(msg), + StoreError::Serialization(msg) => Self::Serialization(msg), + StoreError::Crypto(msg) => Self::Crypto(msg), + StoreError::InvalidEnvelope(msg) => Self::InvalidEnvelope(msg), + StoreError::UnsupportedEnvelopeVersion(version) => { + Self::UnsupportedEnvelopeVersion(version) + } + StoreError::Db(msg) => Self::VaultDb(msg), + StoreError::IntegrityCheckFailed(msg) => Self::CorruptedVault(msg), + } + } +} diff --git a/walletkit-core/src/storage/keys.rs b/walletkit-core/src/storage/keys.rs index e1b20af37..3cc295549 100644 --- a/walletkit-core/src/storage/keys.rs +++ b/walletkit-core/src/storage/keys.rs @@ -1,14 +1,20 @@ //! Key hierarchy management for credential storage. +//! +//! Delegates the actual envelope load/create logic to +//! [`walletkit_secure_store::init_or_open_envelope_key`], passing the +//! credential-store-specific envelope filename and associated data so the +//! intermediate key is bound to this consumer's vault. -use rand::{rngs::OsRng, RngCore}; use secrecy::SecretBox; -use zeroize::{Zeroize, ZeroizeOnDrop, Zeroizing}; +use walletkit_secure_store::init_or_open_envelope_key; +use zeroize::{Zeroize, ZeroizeOnDrop}; use super::{ - envelope::AccountKeyEnvelope, - error::{StorageError, StorageResult}, + error::StorageResult, lock::StorageLockGuard, - traits::{AtomicBlobStore, DeviceKeystore}, + traits::{ + AtomicBlobStore, AtomicBlobStoreAdapter, DeviceKeystore, DeviceKeystoreAdapter, + }, ACCOUNT_KEYS_FILENAME, ACCOUNT_KEY_ENVELOPE_AD, }; @@ -31,35 +37,20 @@ impl StorageKeys { pub fn init( keystore: &dyn DeviceKeystore, blob_store: &dyn AtomicBlobStore, - _lock: &StorageLockGuard, + lock: &StorageLockGuard, 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 keystore_adapter = DeviceKeystoreAdapter::new(keystore); + let blob_store_adapter = AtomicBlobStoreAdapter::new(blob_store); + let intermediate_key = init_or_open_envelope_key( + &keystore_adapter, + &blob_store_adapter, + ACCOUNT_KEYS_FILENAME, + ACCOUNT_KEY_ENVELOPE_AD, + lock, + now, + )?; + Ok(Self { intermediate_key }) } /// Returns a reference to the intermediate key's [`SecretBox`]. @@ -69,29 +60,12 @@ impl StorageKeys { } } -fn random_key() -> [u8; 32] { - let mut key = [0u8; 32]; - OsRng.fill_bytes(&mut key); - key -} - -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() - ))); - } - let mut out = [0u8; 32]; - out.copy_from_slice(bytes); - Ok(out) -} - #[cfg(test)] mod tests { use super::*; use crate::storage::lock::StorageLock; use crate::storage::tests_utils::{InMemoryBlobStore, InMemoryKeystore}; + use crate::storage::error::StorageError; use secrecy::ExposeSecret; use uuid::Uuid; diff --git a/walletkit-core/src/storage/lock.rs b/walletkit-core/src/storage/lock.rs index b69f25761..d6fbcfff3 100644 --- a/walletkit-core/src/storage/lock.rs +++ b/walletkit-core/src/storage/lock.rs @@ -1,339 +1,7 @@ //! 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; - -// WASM: no-op lock (single-threaded worker, SQLITE_THREADSAFE=0) - -#[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)) - } - } -} - -// Native: file-backed exclusive lock (flock on Unix, LockFileEx on Windows) - -#[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()) - } - - // ── Unix flock ────────────────────────────────────────────────────── - - #[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; - } - - // ── Windows LockFileEx ────────────────────────────────────────────── - - #[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()); +//! Re-export of the generic primitive provided by [`walletkit_secure_store`]. +//! Kept under the `StorageLock` / `StorageLockGuard` names for stability +//! within `walletkit-core`. - thread_a.join().expect("thread join"); - } -} +pub use walletkit_secure_store::{Lock as StorageLock, LockGuard as StorageLockGuard}; diff --git a/walletkit-core/src/storage/mod.rs b/walletkit-core/src/storage/mod.rs index 16c705419..55a18a170 100644 --- a/walletkit-core/src/storage/mod.rs +++ b/walletkit-core/src/storage/mod.rs @@ -2,7 +2,6 @@ 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; diff --git a/walletkit-core/src/storage/traits.rs b/walletkit-core/src/storage/traits.rs index 2d0304af6..6d4d8c8fe 100644 --- a/walletkit-core/src/storage/traits.rs +++ b/walletkit-core/src/storage/traits.rs @@ -16,6 +16,11 @@ use std::sync::Arc; +use walletkit_secure_store::{ + AtomicBlobStore as SecureAtomicBlobStore, Keystore as SecureKeystore, + StoreError, StoreResult, +}; + use super::error::StorageResult; use super::paths::StoragePaths; @@ -89,6 +94,78 @@ pub trait StorageProvider: Send + Sync { fn paths(&self) -> Arc; } +/// Adapter that lets a [`DeviceKeystore`] satisfy the +/// [`walletkit_secure_store::Keystore`](SecureKeystore) trait. +/// +/// `walletkit-secure-store` is a plain-Rust crate with no FFI awareness; the +/// adapter bridges its trait surface to the `uniffi`-annotated traits exposed +/// here. Errors are converted via the `String` payload — variant identity is +/// preserved by `walletkit-core`'s [`From for +/// StorageError`](super::error::StorageError). +pub(crate) struct DeviceKeystoreAdapter<'a> { + inner: &'a dyn DeviceKeystore, +} + +impl<'a> DeviceKeystoreAdapter<'a> { + pub(crate) const fn new(inner: &'a dyn DeviceKeystore) -> Self { + Self { inner } + } +} + +impl SecureKeystore for DeviceKeystoreAdapter<'_> { + fn seal( + &self, + associated_data: Vec, + plaintext: Vec, + ) -> StoreResult> { + self.inner + .seal(associated_data, plaintext) + .map_err(|err| StoreError::Keystore(err.to_string())) + } + + fn open_sealed( + &self, + associated_data: Vec, + ciphertext: Vec, + ) -> StoreResult> { + self.inner + .open_sealed(associated_data, ciphertext) + .map_err(|err| StoreError::Keystore(err.to_string())) + } +} + +/// Adapter that lets an [`AtomicBlobStore`] satisfy the +/// [`walletkit_secure_store::AtomicBlobStore`](SecureAtomicBlobStore) trait. +pub(crate) struct AtomicBlobStoreAdapter<'a> { + inner: &'a dyn AtomicBlobStore, +} + +impl<'a> AtomicBlobStoreAdapter<'a> { + pub(crate) const fn new(inner: &'a dyn AtomicBlobStore) -> Self { + Self { inner } + } +} + +impl SecureAtomicBlobStore for AtomicBlobStoreAdapter<'_> { + fn read(&self, path: String) -> StoreResult>> { + self.inner + .read(path) + .map_err(|err| StoreError::BlobStore(err.to_string())) + } + + fn write_atomic(&self, path: String, bytes: Vec) -> StoreResult<()> { + self.inner + .write_atomic(path, bytes) + .map_err(|err| StoreError::BlobStore(err.to_string())) + } + + fn delete(&self, path: String) -> StoreResult<()> { + self.inner + .delete(path) + .map_err(|err| StoreError::BlobStore(err.to_string())) + } +} + /// Listener notified when the credential vault contents change and a new /// backup is needed. /// 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..a32884d63 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}; +//! Vault database helpers for type conversion and row mapping. 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(blob_kind: BlobKind, plaintext: &[u8]) -> ContentId { - let mut hasher = Sha256::new(); - hasher.update(CONTENT_ID_PREFIX); - hasher.update([blob_kind as u8]); - hasher.update(plaintext); - let digest = hasher.finalize(); - let mut out = [0u8; 32]; - out.copy_from_slice(&digest); - out -} - pub(super) fn map_record(row: &Row<'_, '_>) -> StorageResult { let credential_id = row.column_i64(0); let issuer_schema_id = row.column_i64(1); diff --git a/walletkit-core/src/storage/vault/mod.rs b/walletkit-core/src/storage/vault/mod.rs index c22c572b5..12b6a8d2e 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. +//! +//! Wraps [`walletkit_secure_store::Vault`] (the generic `SQLCipher` opener + +//! integrity check) with credential-specific tables and queries. The shared +//! `blob_objects` table is owned by +//! [`walletkit_secure_store::Blobs`]. mod helpers; mod schema; @@ -7,19 +12,21 @@ mod tests; use std::path::Path; -use crate::storage::error::{StorageError, StorageResult}; +use crate::storage::error::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 helpers::{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, StepResult, Value}; +use walletkit_secure_store::{Blobs, Vault}; + +use crate::storage::error::StorageError; /// Encrypted vault database wrapper. #[derive(Debug)] pub struct VaultDb { - conn: Connection, + inner: Vault, } impl VaultDb { @@ -31,18 +38,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)?; - let db = Self { conn }; - if !db.check_integrity()? { - return Err(StorageError::CorruptedVault( - "integrity_check failed".to_string(), - )); - } - Ok(db) + let inner = Vault::open(path, k_intermediate, lock, |conn| { + Blobs::ensure_schema(conn)?; + ensure_schema(conn) + })?; + Ok(Self { inner }) } /// Initializes or validates the leaf index for this vault. @@ -61,7 +63,7 @@ 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 tx = self.inner.transaction()?; let stored = tx .query_row( "INSERT INTO vault_meta (schema_version, leaf_index, created_at, updated_at) @@ -113,47 +115,22 @@ impl VaultDb { associated_data: Option>, now: u64, ) -> StorageResult { - let credential_blob_id = - compute_content_id(BlobKind::CredentialBlob, &credential_blob); - let associated_data_id = associated_data - .as_ref() - .map(|bytes| compute_content_id(BlobKind::AssociatedData, bytes)); let now_i64 = to_i64(now, "now")?; let issuer_schema_id_i64 = to_i64(issuer_schema_id, "issuer_schema_id")?; let genesis_issued_at_i64 = to_i64(genesis_issued_at, "genesis_issued_at")?; let expires_at_i64 = to_i64(expires_at, "expires_at")?; - let tx = self.conn.transaction().map_err(|err| map_db_err(&err))?; - tx.execute( - "INSERT OR IGNORE INTO blob_objects (content_id, blob_kind, created_at, bytes) - VALUES (?1, ?2, ?3, ?4)", - params![ - credential_blob_id.as_ref(), - BlobKind::CredentialBlob.as_i64(), - now_i64, - credential_blob.as_slice(), - ], - ) - .map_err(|err| map_db_err(&err))?; + let tx = self.inner.transaction()?; - if let Some(data) = associated_data { - let cid = associated_data_id.as_ref().ok_or_else(|| { - StorageError::VaultDb("associated data CID must be present".to_string()) - })?; - tx.execute( - "INSERT OR IGNORE INTO blob_objects (content_id, blob_kind, created_at, bytes) - VALUES (?1, ?2, ?3, ?4)", - params![ - cid.as_ref(), - BlobKind::AssociatedData.as_i64(), - now_i64, - data.as_slice(), - ], - ) - .map_err(|err| map_db_err(&err))?; - } + let credential_blob_cid = + Blobs::put(&tx, BlobKind::CredentialBlob as u8, &credential_blob, now)?; + + let associated_data_cid = associated_data + .as_deref() + .map(|bytes| Blobs::put(&tx, BlobKind::AssociatedData as u8, bytes, now)) + .transpose()?; - let ad_cid_value: Value = associated_data_id + let ad_cid_value: Value = associated_data_cid .as_ref() .map_or(Value::Null, |cid| Value::Blob(cid.to_vec())); @@ -175,7 +152,7 @@ impl VaultDb { genesis_issued_at_i64, expires_at_i64, now_i64, - credential_blob_id.as_ref(), + credential_blob_cid.as_ref(), ad_cid_value, ], |stmt| Ok(stmt.column_i64(0)), @@ -219,7 +196,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.inner.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))? { @@ -244,7 +222,7 @@ 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 tx = self.inner.transaction()?; let deleted = tx .execute( @@ -266,7 +244,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 +257,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))?; @@ -311,7 +289,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.inner.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))? { @@ -337,7 +316,7 @@ impl VaultDb { &mut self, _lock: &StorageLockGuard, ) -> StorageResult { - let tx = self.conn.transaction().map_err(|err| map_db_err(&err))?; + let tx = self.inner.transaction()?; let deleted = tx .execute("DELETE FROM credential_records", &[]) @@ -356,7 +335,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)) + Ok(self.inner.check_integrity()?) } /// Exports a plaintext (unencrypted) copy of the vault to `dest`. @@ -369,15 +348,9 @@ impl VaultDb { pub fn export_plaintext( &self, dest: &Path, - _lock: &StorageLockGuard, + 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)) + Ok(self.inner.export_plaintext(dest, lock)?) } /// Imports credentials from a plaintext (unencrypted) vault backup into @@ -392,8 +365,14 @@ impl VaultDb { pub fn import_plaintext( &self, source: &Path, - _lock: &StorageLockGuard, + lock: &StorageLockGuard, ) -> StorageResult<()> { - cipher::import_plaintext_copy(&self.conn, source).map_err(|e| map_db_err(&e)) + Ok(self.inner.import_plaintext(source, lock)?) + } + + /// Borrows the underlying connection for direct SQL access. **Test-only.** + #[cfg(test)] + pub(super) const fn raw_connection(&self) -> &walletkit_db::Connection { + self.inner.connection() } } diff --git a/walletkit-core/src/storage/vault/schema.rs b/walletkit-core/src/storage/vault/schema.rs index 2602fb6f5..fee355ee1 100644 --- a/walletkit-core/src/storage/vault/schema.rs +++ b/walletkit-core/src/storage/vault/schema.rs @@ -1,9 +1,11 @@ //! Vault database schema management. +//! +//! Owns only the credential-specific tables. The shared `blob_objects` table +//! is created via [`walletkit_secure_store::Blobs::ensure_schema`] from +//! [`super::VaultDb::new`]. -use crate::storage::error::StorageResult; use walletkit_db::Connection; - -use super::helpers::map_db_err; +use walletkit_secure_store::{StoreError, StoreResult}; pub(super) const VAULT_SCHEMA_VERSION: i64 = 1; @@ -12,7 +14,7 @@ pub(super) const VAULT_SCHEMA_VERSION: i64 = 1; /// - 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<()> { +pub(super) fn ensure_schema(conn: &Connection) -> StoreResult<()> { conn.execute_batch( "CREATE TABLE IF NOT EXISTS vault_meta ( schema_version INTEGER NOT NULL, @@ -49,17 +51,8 @@ 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))?; + .map_err(StoreError::from)?; Ok(()) } diff --git a/walletkit-core/src/storage/vault/tests.rs b/walletkit-core/src/storage/vault/tests.rs index 77de8a010..3e453a664 100644 --- a/walletkit-core/src/storage/vault/tests.rs +++ b/walletkit-core/src/storage/vault/tests.rs @@ -1,7 +1,8 @@ //! Vault database unit tests. -use super::helpers::{compute_content_id, map_db_err}; +use super::helpers::map_db_err; use super::*; +use walletkit_secure_store::compute_content_id; use crate::storage::lock::StorageLock; use secrecy::SecretBox; use std::fs; @@ -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); } @@ -199,8 +200,7 @@ fn test_content_id_deduplication() { 1001, ) .expect("store credential"); - let count = db - .conn + let count = db.raw_connection() .query_row("SELECT COUNT(*) FROM blob_objects", &[], |stmt| { Ok(stmt.column_i64(0)) }) @@ -211,8 +211,7 @@ fn test_content_id_deduplication() { db.delete_credential(&guard, first_id) .expect("delete first credential"); - let count_after_first_delete = db - .conn + let count_after_first_delete = db.raw_connection() .query_row("SELECT COUNT(*) FROM blob_objects", &[], |stmt| { Ok(stmt.column_i64(0)) }) @@ -223,8 +222,7 @@ fn test_content_id_deduplication() { db.delete_credential(&guard, second_id) .expect("delete second credential"); - let count_after_second_delete = db - .conn + let count_after_second_delete = db.raw_connection() .query_row("SELECT COUNT(*) FROM blob_objects", &[], |stmt| { Ok(stmt.column_i64(0)) }) @@ -366,8 +364,7 @@ fn test_delete_credential_by_id() { ) .expect("store credential"); - let blob_count_before = db - .conn + let blob_count_before = db.raw_connection() .query_row("SELECT COUNT(*) FROM blob_objects", &[], |stmt| { Ok(stmt.column_i64(0)) }) @@ -381,8 +378,7 @@ fn test_delete_credential_by_id() { let records = db.list_credentials(None, 1000).expect("list credentials"); assert!(records.is_empty()); - let blob_count_after = db - .conn + let blob_count_after = db.raw_connection() .query_row("SELECT COUNT(*) FROM blob_objects", &[], |stmt| { Ok(stmt.column_i64(0)) }) @@ -428,11 +424,10 @@ fn test_delete_credential_cleans_up_orphaned_associated_data() { ) .expect("store credential"); - let associated_before = db - .conn + let associated_before = db.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)) @@ -442,11 +437,10 @@ fn test_delete_credential_cleans_up_orphaned_associated_data() { db.delete_credential(&guard, credential_id) .expect("delete credential"); - let associated_after = db - .conn + let associated_after = db.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)) @@ -496,8 +490,7 @@ fn test_danger_delete_all_credentials() { let records = db.list_credentials(None, 1000).expect("list credentials"); assert!(records.is_empty()); - let blob_count = db - .conn + let blob_count = db.raw_connection() .query_row("SELECT COUNT(*) FROM blob_objects", &[], |stmt| { Ok(stmt.column_i64(0)) })