diff --git a/Cargo.lock b/Cargo.lock index 357f34d5..3c6d4e23 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -8256,6 +8256,22 @@ dependencies = [ "zip", ] +[[package]] +name = "walletkit-secure-store" +version = "0.16.1" +dependencies = [ + "ciborium", + "rand 0.8.6", + "secrecy", + "serde", + "sha2 0.10.9", + "tempfile", + "thiserror 2.0.18", + "uuid", + "walletkit-db", + "zeroize", +] + [[package]] name = "want" version = "0.3.1" diff --git a/Cargo.toml b/Cargo.toml index 82db93d4..4c70f905 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -4,6 +4,7 @@ members = [ "walletkit-core", "walletkit", "walletkit-db", + "walletkit-secure-store", "walletkit-cli", ] resolver = "2" @@ -44,6 +45,7 @@ world-id-proof = { version = "0.10.2", default-features = false } # internal walletkit-core = { version = "0.16.1", path = "walletkit-core", default-features = false } walletkit-db = { version = "0.16.1", path = "walletkit-db" } +walletkit-secure-store = { version = "0.16.1", path = "walletkit-secure-store" } [workspace.lints.clippy] all = { level = "deny", priority = -1 } diff --git a/walletkit-secure-store/Cargo.toml b/walletkit-secure-store/Cargo.toml new file mode 100644 index 00000000..742106bf --- /dev/null +++ b/walletkit-secure-store/Cargo.toml @@ -0,0 +1,36 @@ +[package] +name = "walletkit-secure-store" +description = "Reusable encrypted on-device storage primitives for WalletKit consumers (CredentialStore, OrbKit, NFC, etc.)." +publish = true + +version.workspace = true +edition.workspace = true +rust-version.workspace = true +authors.workspace = true +license.workspace = true +homepage.workspace = true +repository.workspace = true +exclude.workspace = true +readme.workspace = true +keywords.workspace = true +categories.workspace = true + +[dependencies] +ciborium = "0.2.2" +rand = "0.8" +secrecy = "0.10" +serde = { version = "1", features = ["derive"] } +sha2 = "0.10" +thiserror = "2" +walletkit-db = { workspace = true } +zeroize = { version = "1", features = ["derive"] } + +[target.'cfg(target_os = "android")'.dependencies] +sha2 = { version = "0.10", features = ["force-soft"] } + +[dev-dependencies] +tempfile = "3" +uuid = { version = "1.10", features = ["v4"] } + +[lints] +workspace = true diff --git a/walletkit-secure-store/src/blobs.rs b/walletkit-secure-store/src/blobs.rs new file mode 100644 index 00000000..7ea03df5 --- /dev/null +++ b/walletkit-secure-store/src/blobs.rs @@ -0,0 +1,99 @@ +//! Content-addressed blob table shared across consumer schemas. +//! +//! [`Blobs::ensure_schema`] creates the `blob_objects` table; [`Blobs::put`] +//! and [`Blobs::get`] insert and read rows by [`ContentId`]. Consumers +//! reference blob rows from their own tables via a `BLOB NOT NULL` column +//! holding the content id (no foreign-key constraint — matches existing +//! `walletkit-core` behaviour). + +use walletkit_db::{params, Connection, StepResult, Transaction, Value}; + +use crate::content_id::{compute_content_id, ContentId}; +use crate::error::{StoreError, StoreResult}; + +/// Helper functions for the shared `blob_objects` table. +/// +/// `Blobs` is a zero-sized namespace, not a stateful struct. +pub struct Blobs; + +impl Blobs { + /// Idempotently creates the `blob_objects` table. + /// + /// **Backup sensitivity:** the `blob_objects` table participates in + /// `walletkit-db`'s plaintext export/import. Schema changes here flow + /// through to existing backups. + /// + /// # Errors + /// + /// Returns an error if the `CREATE TABLE` statement fails. + pub fn ensure_schema(conn: &Connection) -> StoreResult<()> { + conn.execute_batch( + "CREATE TABLE IF NOT EXISTS blob_objects ( + content_id BLOB NOT NULL, + blob_kind INTEGER NOT NULL, + created_at INTEGER NOT NULL, + bytes BLOB NOT NULL, + PRIMARY KEY (content_id) + );", + ) + .map_err(StoreError::from) + } + + /// Inserts `bytes` into the `blob_objects` table (idempotent on + /// `content_id`) and returns the computed [`ContentId`]. + /// + /// `kind_tag` is a consumer-defined `u8` identifier. It is stored as + /// `INTEGER` and folded into the content id via [`compute_content_id`]. + /// + /// # Errors + /// + /// Returns an error if the insert fails or `now` cannot be represented + /// as `i64`. + pub fn put( + tx: &Transaction, + kind_tag: u8, + bytes: &[u8], + now: u64, + ) -> StoreResult { + let content_id = compute_content_id(kind_tag, bytes); + let now_i64 = u64_to_i64(now, "now")?; + tx.execute( + "INSERT OR IGNORE INTO blob_objects (content_id, blob_kind, created_at, bytes) + VALUES (?1, ?2, ?3, ?4)", + params![ + content_id.as_ref(), + i64::from(kind_tag), + now_i64, + bytes, + ], + ) + .map_err(StoreError::from)?; + Ok(content_id) + } + + /// Reads the bytes for `content_id`, returning `None` if absent. + /// + /// # Errors + /// + /// Returns an error if the query fails. + pub fn get( + conn: &Connection, + content_id: &ContentId, + ) -> StoreResult>> { + let mut stmt = conn + .prepare("SELECT bytes FROM blob_objects WHERE content_id = ?1") + .map_err(StoreError::from)?; + stmt.bind_values(&[Value::Blob(content_id.to_vec())]) + .map_err(StoreError::from)?; + match stmt.step().map_err(StoreError::from)? { + StepResult::Row(row) => Ok(Some(row.column_blob(0))), + StepResult::Done => Ok(None), + } + } +} + +fn u64_to_i64(value: u64, label: &str) -> StoreResult { + i64::try_from(value).map_err(|_| { + StoreError::Db(format!("{label} out of range for i64: {value}")) + }) +} diff --git a/walletkit-secure-store/src/content_id.rs b/walletkit-secure-store/src/content_id.rs new file mode 100644 index 00000000..66b95049 --- /dev/null +++ b/walletkit-secure-store/src/content_id.rs @@ -0,0 +1,33 @@ +//! Content-addressed identifier for stored blobs. + +use sha2::{Digest, Sha256}; + +/// Length in bytes of a [`ContentId`]. +pub const CONTENT_ID_LEN: usize = 32; + +/// Content identifier for a stored blob — `SHA-256` over the kind tag and +/// plaintext bytes. +pub type ContentId = [u8; CONTENT_ID_LEN]; + +const CONTENT_ID_PREFIX: &[u8] = b"worldid:blob"; + +/// Computes a [`ContentId`] for `plaintext` namespaced by `kind_tag`. +/// +/// The kind tag namespace is owned by the caller (each consumer defines its +/// own `u8` constants). Tags do not collide across consumers because each +/// consumer keeps its own database file. +/// +/// **On-disk compatibility:** the byte layout fed into `SHA-256` is +/// `CONTENT_ID_PREFIX || [kind_tag] || plaintext`. Changing this layout would +/// invalidate existing content IDs on every device. +#[must_use] +pub fn compute_content_id(kind_tag: u8, plaintext: &[u8]) -> ContentId { + let mut hasher = Sha256::new(); + hasher.update(CONTENT_ID_PREFIX); + hasher.update([kind_tag]); + hasher.update(plaintext); + let digest = hasher.finalize(); + let mut out = [0u8; CONTENT_ID_LEN]; + out.copy_from_slice(&digest); + out +} diff --git a/walletkit-secure-store/src/envelope.rs b/walletkit-secure-store/src/envelope.rs new file mode 100644 index 00000000..0f98c179 --- /dev/null +++ b/walletkit-secure-store/src/envelope.rs @@ -0,0 +1,96 @@ +//! Persistent envelope holding a `Keystore`-sealed intermediate key. + +use serde::{Deserialize, Serialize}; +use zeroize::{Zeroize, ZeroizeOnDrop}; + +use crate::error::{StoreError, StoreResult}; + +const ENVELOPE_VERSION: u32 = 1; + +/// Persisted envelope produced by [`crate::init_or_open_envelope_key`]. +/// +/// `wrapped_k_intermediate` is the intermediate key sealed by the +/// [`Keystore`](crate::Keystore). The envelope is serialised as `CBOR` and +/// written via an [`AtomicBlobStore`](crate::AtomicBlobStore). +#[derive(Clone, Serialize, Deserialize, Zeroize, ZeroizeOnDrop)] +pub struct KeyEnvelope { + /// On-disk format version. Currently always `1`. + pub version: u32, + /// Keystore-sealed intermediate key bytes. + pub wrapped_k_intermediate: Vec, + /// Creation timestamp (seconds). + pub created_at: u64, + /// Last update timestamp (seconds). + pub updated_at: u64, +} + +impl KeyEnvelope { + /// Creates a new envelope at version `1`. + #[must_use] + pub const fn new(wrapped_k_intermediate: Vec, now: u64) -> Self { + Self { + version: ENVELOPE_VERSION, + wrapped_k_intermediate, + created_at: now, + updated_at: now, + } + } + + /// Serialises the envelope to `CBOR`. + /// + /// # Errors + /// + /// Returns an error if `CBOR` encoding fails. + pub fn serialize(&self) -> StoreResult> { + let mut bytes = Vec::new(); + ciborium::ser::into_writer(self, &mut bytes) + .map_err(|err| StoreError::Serialization(err.to_string()))?; + Ok(bytes) + } + + /// Deserialises an envelope from `CBOR` bytes. + /// + /// # Errors + /// + /// Returns [`StoreError::UnsupportedEnvelopeVersion`] if the version is + /// unrecognised, or [`StoreError::Serialization`] if `CBOR` decoding + /// fails. + pub fn deserialize(bytes: &[u8]) -> StoreResult { + let envelope: Self = ciborium::de::from_reader(bytes) + .map_err(|err| StoreError::Serialization(err.to_string()))?; + if envelope.version != ENVELOPE_VERSION { + return Err(StoreError::UnsupportedEnvelopeVersion(envelope.version)); + } + Ok(envelope) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn round_trip() { + let envelope = KeyEnvelope::new(vec![1, 2, 3], 123); + let bytes = envelope.serialize().expect("serialize"); + let decoded = KeyEnvelope::deserialize(&bytes).expect("deserialize"); + assert_eq!(decoded.version, 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 version_mismatch() { + let mut envelope = KeyEnvelope::new(vec![1, 2, 3], 123); + envelope.version = ENVELOPE_VERSION + 1; + let bytes = envelope.serialize().expect("serialize"); + match KeyEnvelope::deserialize(&bytes) { + Err(StoreError::UnsupportedEnvelopeVersion(version)) => { + assert_eq!(version, ENVELOPE_VERSION + 1); + } + Err(err) => panic!("unexpected error: {err}"), + Ok(_) => panic!("expected error"), + } + } +} diff --git a/walletkit-secure-store/src/error.rs b/walletkit-secure-store/src/error.rs new file mode 100644 index 00000000..8126631e --- /dev/null +++ b/walletkit-secure-store/src/error.rs @@ -0,0 +1,57 @@ +//! Error type for `walletkit-secure-store` primitives. + +use thiserror::Error; +use walletkit_db::DbError; + +/// Result alias for [`StoreError`]. +pub type StoreResult = Result; + +/// Errors produced by the primitives in this crate. +/// +/// Consumers typically wrap this in a richer error enum at their boundary +/// (e.g. `walletkit-core`'s `StorageError`) so the `uniffi` surface stays +/// under their control. +#[derive(Debug, Error)] +pub enum StoreError { + /// Errors coming from the device keystore. + #[error("keystore error: {0}")] + Keystore(String), + + /// Errors coming from the atomic blob store. + #[error("blob store error: {0}")] + BlobStore(String), + + /// Errors coming from the cross-process lock. + #[error("lock error: {0}")] + Lock(String), + + /// Serialization or deserialization failures (e.g. CBOR envelope). + #[error("serialization error: {0}")] + Serialization(String), + + /// Cryptographic failures (AEAD, HKDF, etc.). + #[error("crypto error: {0}")] + Crypto(String), + + /// Invalid or malformed envelope. + #[error("invalid envelope: {0}")] + InvalidEnvelope(String), + + /// Unsupported envelope version. + #[error("unsupported envelope version: {0}")] + UnsupportedEnvelopeVersion(u32), + + /// Errors coming from the underlying database. + #[error("db error: {0}")] + Db(String), + + /// Database integrity check failed. + #[error("integrity check failed: {0}")] + IntegrityCheckFailed(String), +} + +impl From for StoreError { + fn from(err: DbError) -> Self { + Self::Db(err.to_string()) + } +} diff --git a/walletkit-secure-store/src/key_init.rs b/walletkit-secure-store/src/key_init.rs new file mode 100644 index 00000000..0eeba5f1 --- /dev/null +++ b/walletkit-secure-store/src/key_init.rs @@ -0,0 +1,78 @@ +//! Key envelope initialization helpers. +//! +//! Each consumer (`CredentialStore`, `OrbPcpStore`, …) calls +//! [`init_or_open_envelope_key`] once at startup with its own envelope +//! filename and associated-data namespace, producing an in-memory +//! intermediate key bound to that consumer's vault. +//! +//! Consumers MUST use distinct `(envelope_filename, associated_data)` pairs +//! so a compromise of one envelope does not leak another consumer's key. + +use rand::{rngs::OsRng, RngCore}; +use secrecy::SecretBox; +use zeroize::Zeroizing; + +use crate::envelope::KeyEnvelope; +use crate::error::{StoreError, StoreResult}; +use crate::lock::LockGuard; +use crate::traits::{AtomicBlobStore, Keystore}; + +/// Opens (or creates) a [`KeyEnvelope`] at `envelope_filename` and returns +/// the unsealed 32-byte intermediate key wrapped in a [`SecretBox`]. +/// +/// On first call the function generates a random key, seals it with +/// `keystore` (under `associated_data`), and persists the envelope via +/// `blob_store`. On subsequent calls it loads and unseals the existing +/// envelope. +/// +/// # Errors +/// +/// Returns an error if the envelope cannot be read, decrypted, parsed, or +/// persisted. +pub fn init_or_open_envelope_key( + keystore: &dyn Keystore, + blob_store: &dyn AtomicBlobStore, + envelope_filename: &str, + associated_data: &[u8], + _lock: &LockGuard, + now: u64, +) -> StoreResult> { + if let Some(bytes) = blob_store.read(envelope_filename.to_string())? { + let envelope = KeyEnvelope::deserialize(&bytes)?; + let wrapped_k_intermediate = envelope.wrapped_k_intermediate.clone(); + let k_intermediate_bytes = Zeroizing::new( + keystore.open_sealed(associated_data.to_vec(), wrapped_k_intermediate)?, + ); + let k_intermediate = + parse_key_32(k_intermediate_bytes.as_slice(), "K_intermediate")?; + Ok(SecretBox::init_with(|| k_intermediate)) + } else { + let k_intermediate = random_key(); + // The key needs to be temporarily heap-allocated to bridge through + // the keystore trait. The temporary copy is dropped immediately. + let wrapped_k_intermediate = + keystore.seal(associated_data.to_vec(), k_intermediate.to_vec())?; + let envelope = KeyEnvelope::new(wrapped_k_intermediate, now); + let bytes = envelope.serialize()?; + blob_store.write_atomic(envelope_filename.to_string(), bytes)?; + Ok(SecretBox::init_with(|| k_intermediate)) + } +} + +fn random_key() -> [u8; 32] { + let mut key = [0u8; 32]; + OsRng.fill_bytes(&mut key); + key +} + +fn parse_key_32(bytes: &[u8], label: &str) -> StoreResult<[u8; 32]> { + if bytes.len() != 32 { + return Err(StoreError::InvalidEnvelope(format!( + "{label} length mismatch: expected 32, got {}", + bytes.len() + ))); + } + let mut out = [0u8; 32]; + out.copy_from_slice(bytes); + Ok(out) +} diff --git a/walletkit-secure-store/src/lib.rs b/walletkit-secure-store/src/lib.rs new file mode 100644 index 00000000..b6612362 --- /dev/null +++ b/walletkit-secure-store/src/lib.rs @@ -0,0 +1,39 @@ +//! Encrypted on-device storage primitives shared across `WalletKit` consumers. +//! +//! This crate exposes the building blocks that any consumer needs to maintain +//! an encrypted, integrity-checked, content-addressed local store: +//! +//! - [`Vault`] — opens an `SQLCipher` database with a caller-provided schema +//! callback and runs an integrity check. +//! - [`Blobs`] — content-addressed blob table with `put`/`get` helpers. +//! Consumers use it to deduplicate and reference encrypted payloads. +//! - [`KeyEnvelope`] + [`init_or_open_envelope_key`] — `DeviceKeystore`-sealed +//! envelope holding a 32-byte intermediate key, persisted via an +//! [`AtomicBlobStore`]. +//! - [`Lock`] — cross-process exclusive lock (file-backed on native targets, +//! no-op on `wasm32`). +//! +//! Consumers (e.g. `walletkit-core`'s `CredentialStore`, `OrbKit`'s +//! `OrbPcpStore`) own their own SQL schemas and FFI surface; this crate only +//! provides the primitives. It is plain Rust and does not depend on +//! `uniffi`. + +#![cfg_attr(docsrs, feature(doc_cfg))] + +pub mod blobs; +pub mod content_id; +pub mod envelope; +pub mod error; +pub mod key_init; +pub mod lock; +pub mod traits; +pub mod vault; + +pub use blobs::Blobs; +pub use content_id::{compute_content_id, ContentId, CONTENT_ID_LEN}; +pub use envelope::KeyEnvelope; +pub use error::{StoreError, StoreResult}; +pub use key_init::init_or_open_envelope_key; +pub use lock::{Lock, LockGuard}; +pub use traits::{AtomicBlobStore, Keystore}; +pub use vault::Vault; diff --git a/walletkit-secure-store/src/lock.rs b/walletkit-secure-store/src/lock.rs new file mode 100644 index 00000000..9331ab06 --- /dev/null +++ b/walletkit-secure-store/src/lock.rs @@ -0,0 +1,337 @@ +//! Cross-process exclusive lock for serializing storage writes. +//! +//! On native platforms (Unix, Windows) a file-based `flock` / `LockFileEx` +//! lock is used to serialize writes across processes. +//! +//! On `wasm32` targets the lock is a no-op because the runtime is +//! single-threaded (sqlite-wasm-rs is compiled with `SQLITE_THREADSAFE=0`) +//! and runs in a dedicated Web Worker. + +use std::path::Path; + +use crate::error::StoreResult; + +#[cfg(target_arch = "wasm32")] +mod imp { + use super::{Path, StoreResult}; + + /// No-op lock for WASM. + #[derive(Debug, Clone)] + pub struct Lock; + + /// No-op guard. + #[derive(Debug)] + pub struct LockGuard; + + impl Lock { + /// Opens a no-op lock. + pub fn open(_path: &Path) -> StoreResult { + Ok(Self) + } + + /// Acquires a no-op lock. + pub fn lock(&self) -> StoreResult { + Ok(LockGuard) + } + + /// Attempts to acquire a no-op lock. + pub fn try_lock(&self) -> StoreResult> { + Ok(Some(LockGuard)) + } + } +} + +#[cfg(not(target_arch = "wasm32"))] +mod imp { + use super::{Path, StoreResult}; + use crate::error::StoreError; + use std::fs::{self, File, OpenOptions}; + use std::sync::Arc; + + /// File-backed exclusive lock that serializes mutations across processes. + #[derive(Debug, Clone)] + pub struct Lock { + file: Arc, + } + + /// Guard holding the exclusive lock for its lifetime. + #[derive(Debug)] + pub struct LockGuard { + file: Arc, + } + + impl Lock { + /// Opens or creates the lock file at `path`. + /// + /// # Errors + /// + /// Returns an error if the file cannot be opened or created. + pub fn open(path: &Path) -> StoreResult { + if let Some(parent) = path.parent() { + fs::create_dir_all(parent).map_err(|err| map_io_err(&err))?; + } + let file = OpenOptions::new() + .read(true) + .write(true) + .create(true) + .truncate(false) + .open(path) + .map_err(|err| map_io_err(&err))?; + Ok(Self { + file: Arc::new(file), + }) + } + + /// Acquires the exclusive lock, blocking until available. + /// + /// # Errors + /// + /// Returns an error if the lock cannot be acquired. + pub fn lock(&self) -> StoreResult { + lock_exclusive(&self.file).map_err(|err| map_io_err(&err))?; + Ok(LockGuard { + file: Arc::clone(&self.file), + }) + } + + /// Attempts to acquire the exclusive lock without blocking. + /// + /// Returns `Ok(None)` if the lock is held by another process. + /// + /// # Errors + /// + /// Returns an error if the lock attempt fails for reasons other than + /// the lock being held. + pub fn try_lock(&self) -> StoreResult> { + if try_lock_exclusive(&self.file).map_err(|err| map_io_err(&err))? { + Ok(Some(LockGuard { + file: Arc::clone(&self.file), + })) + } else { + Ok(None) + } + } + } + + impl Drop for LockGuard { + fn drop(&mut self) { + let _ = unlock(&self.file); + } + } + + fn map_io_err(err: &std::io::Error) -> StoreError { + StoreError::Lock(err.to_string()) + } + + // ── 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::{Lock, LockGuard}; + +#[cfg(all(test, not(target_arch = "wasm32")))] +mod tests { + use super::Lock; + use uuid::Uuid; + + fn temp_lock_path() -> std::path::PathBuf { + let mut path = std::env::temp_dir(); + path.push(format!("walletkit-secure-store-lock-{}.lock", Uuid::new_v4())); + path + } + + #[test] + fn lock_is_exclusive() { + let path = temp_lock_path(); + let lock_a = Lock::open(&path).expect("open lock"); + let guard = lock_a.lock().expect("acquire lock"); + + let lock_b = Lock::open(&path).expect("open lock"); + let blocked = lock_b.try_lock().expect("try lock"); + assert!(blocked.is_none()); + + drop(guard); + let guard = lock_b.try_lock().expect("try lock"); + assert!(guard.is_some()); + + let _ = std::fs::remove_file(path); + } + + #[test] + fn lock_serializes_across_threads() { + let path = temp_lock_path(); + let lock = Lock::open(&path).expect("open lock"); + + let (locked_tx, locked_rx) = std::sync::mpsc::channel(); + let (release_tx, release_rx) = std::sync::mpsc::channel(); + let (released_tx, released_rx) = std::sync::mpsc::channel(); + + let 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 = Lock::open(&path).expect("open lock"); + let blocked = lock_b.try_lock().expect("try lock"); + assert!(blocked.is_none()); + + release_tx.send(()).expect("release"); + released_rx.recv().expect("wait released"); + + let guard = lock_b.try_lock().expect("try lock"); + assert!(guard.is_some()); + + thread_a.join().expect("thread join"); + } +} diff --git a/walletkit-secure-store/src/traits.rs b/walletkit-secure-store/src/traits.rs new file mode 100644 index 00000000..0f960c7c --- /dev/null +++ b/walletkit-secure-store/src/traits.rs @@ -0,0 +1,69 @@ +//! Internal platform interfaces consumed by the primitives in this crate. +//! +//! These traits are deliberately plain Rust (no `uniffi` annotations). +//! Consumers that need to expose them across an FFI boundary define their +//! own annotated traits and provide thin adapters to these. + +use crate::error::StoreResult; + +/// Device-bound keystore that seals and opens small key material. +/// +/// Implementations are typically backed by the platform secure enclave +/// (iOS Keychain / Android Keystore) and ensure the wrapped material can +/// only be decrypted on the same device. +/// +/// `associated_data` is integrity-protected but not encrypted — callers must +/// supply identical bytes when sealing and opening. +pub trait Keystore: Send + Sync { + /// Seals `plaintext` under the device-bound key, authenticating + /// `associated_data`. + /// + /// # Errors + /// + /// Returns an error if the keystore refuses the operation or the seal + /// fails. + fn seal( + &self, + associated_data: Vec, + plaintext: Vec, + ) -> StoreResult>; + + /// Opens `ciphertext` under the device-bound key, verifying + /// `associated_data`. + /// + /// # Errors + /// + /// Returns an error if authentication fails or the keystore cannot open. + fn open_sealed( + &self, + associated_data: Vec, + ciphertext: Vec, + ) -> StoreResult>; +} + +/// Atomic key-value blob store for small files (e.g. envelope payloads). +/// +/// Writes must be atomic (write-then-rename or equivalent) so a crash never +/// leaves a half-written blob on disk. +pub trait AtomicBlobStore: Send + Sync { + /// Reads the blob at `path`, returning `None` if absent. + /// + /// # Errors + /// + /// Returns an error if the read fails. + fn read(&self, path: String) -> StoreResult>>; + + /// Writes `bytes` atomically to `path`. + /// + /// # Errors + /// + /// Returns an error if the write fails. + fn write_atomic(&self, path: String, bytes: Vec) -> StoreResult<()>; + + /// Deletes the blob at `path`. + /// + /// # Errors + /// + /// Returns an error if the delete fails. + fn delete(&self, path: String) -> StoreResult<()>; +} diff --git a/walletkit-secure-store/src/vault.rs b/walletkit-secure-store/src/vault.rs new file mode 100644 index 00000000..80f8bcf7 --- /dev/null +++ b/walletkit-secure-store/src/vault.rs @@ -0,0 +1,118 @@ +//! Encrypted `SQLCipher` database opener. +//! +//! [`Vault`] handles the three things every consumer needs to do correctly +//! when opening an encrypted local database: open with the intermediate +//! key, run the consumer's schema callback (idempotent `CREATE TABLE IF NOT +//! EXISTS …`), and run an integrity check. Consumers wrap [`Vault`] in +//! their own typed facade and add domain-specific queries. + +use std::path::Path; + +use secrecy::SecretBox; +use walletkit_db::{cipher, Connection, Transaction}; + +use crate::error::{StoreError, StoreResult}; +use crate::lock::LockGuard; + +/// Encrypted database wrapper produced by [`Vault::open`]. +/// +/// Wraps a [`walletkit_db::Connection`] and exposes the underlying connection +/// + transaction APIs for consumer-defined queries. +#[derive(Debug)] +pub struct Vault { + conn: Connection, +} + +impl Vault { + /// Opens (or creates) an encrypted `SQLCipher` database at `path`, + /// runs `ensure_schema`, then runs an integrity check. + /// + /// `ensure_schema` is invoked exactly once per call and must be + /// idempotent (use `CREATE TABLE IF NOT EXISTS …` etc.). The integrity + /// check runs after `ensure_schema` so any schema-time failures surface + /// before integrity issues do. + /// + /// # Errors + /// + /// Returns an error if the database cannot be opened, keyed, + /// schema-initialised, or if the integrity check fails. + pub fn open( + path: &Path, + k_intermediate: &SecretBox<[u8; 32]>, + _lock: &LockGuard, + ensure_schema: F, + ) -> StoreResult + where + F: FnOnce(&Connection) -> StoreResult<()>, + { + let conn = cipher::open_encrypted(path, k_intermediate, false) + .map_err(StoreError::from)?; + ensure_schema(&conn)?; + let vault = Self { conn }; + if !vault.check_integrity()? { + return Err(StoreError::IntegrityCheckFailed( + "integrity_check failed".to_string(), + )); + } + Ok(vault) + } + + /// Borrows the underlying connection for read-only queries. + #[must_use] + pub const fn connection(&self) -> &Connection { + &self.conn + } + + /// Begins a new transaction. + /// + /// # Errors + /// + /// Returns an error if the transaction cannot be started. + pub fn transaction(&mut self) -> StoreResult> { + self.conn.transaction().map_err(StoreError::from) + } + + /// Runs the `SQLCipher` integrity check. + /// + /// # Errors + /// + /// Returns an error if the check cannot be executed. + pub fn check_integrity(&self) -> StoreResult { + cipher::integrity_check(&self.conn).map_err(StoreError::from) + } + + /// Exports a plaintext (unencrypted) copy of the database to `dest`. + /// + /// Stale copies at `dest` are removed first. The caller is responsible + /// for deleting the exported file after use. + /// + /// # Errors + /// + /// Returns an error if the export fails. + pub fn export_plaintext( + &self, + dest: &Path, + _lock: &LockGuard, + ) -> StoreResult<()> { + if dest.exists() { + std::fs::remove_file(dest).map_err(|e| { + StoreError::Db(format!("failed to remove stale backup: {e}")) + })?; + } + cipher::export_plaintext_copy(&self.conn, dest).map_err(StoreError::from) + } + + /// Imports rows from a plaintext (unencrypted) database backup at + /// `source` into this (empty) vault. + /// + /// # Errors + /// + /// Returns an error if the import fails. + pub fn import_plaintext( + &self, + source: &Path, + _lock: &LockGuard, + ) -> StoreResult<()> { + cipher::import_plaintext_copy(&self.conn, source).map_err(StoreError::from) + } +}