Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
32 commits
Select commit Hold shift + click to select a range
34cf1a0
refactor: make walletkit-db a generic encrypted sqlite crate
danielle-tfh May 7, 2026
5b47d2f
fix: address review feedback for PR #396
danielle-tfh May 7, 2026
d75c37b
feat(walletkit-db): add shared storage primitives
danielle-tfh May 7, 2026
3a42c6b
refactor(walletkit-core): adopt walletkit-db storage primitives
danielle-tfh May 7, 2026
338490f
refactor: simplify storage primitives per review feedback
danielle-tfh May 12, 2026
e5ac6eb
fix: address review punch list (items 1-8)
danielle-tfh May 13, 2026
810b608
fix(walletkit-db): reject non-32-byte content_id in blobs::get / delete
danielle-tfh May 13, 2026
9e0809c
docs(walletkit-db): add per-crate README
danielle-tfh May 13, 2026
c17c736
docs(walletkit-db): drop OrbKit references from README
danielle-tfh May 13, 2026
7859f8c
refactor(walletkit-core): rename vault module to credential_vault, Va…
danielle-tfh May 13, 2026
e5aba7d
docs(walletkit-db): expand README with architecture, key hierarchy, s…
danielle-tfh May 13, 2026
ff0b387
docs(walletkit-db): add Concepts primer to README
danielle-tfh May 13, 2026
556b7da
feat(walletkit-db): encode lock discipline in Vault::{open, read, mut…
danielle-tfh May 13, 2026
491a7f4
refactor(walletkit-core): cache wraps Vault, drops lock-witness pattern
danielle-tfh May 13, 2026
b748c49
docs(walletkit-core): document intentional StoreError mirror in From …
danielle-tfh May 13, 2026
b497443
docs(walletkit-db): drop lingering open_vault refs in README
danielle-tfh May 13, 2026
685dd7b
docs(walletkit-db): drop duplicated open sequence from Concepts section
danielle-tfh May 13, 2026
5b64a6e
Merge remote-tracking branch 'origin/main' into walletkit-db-storage-…
danielle-tfh May 18, 2026
7c1abdc
docs(walletkit-db): tighten README, add explicit host-side isolation …
danielle-tfh May 18, 2026
4bc5e32
docs(walletkit-db): tighten per-consumer isolation section
danielle-tfh May 18, 2026
53e95ff
docs(walletkit-core): tighten StoreError mirror comment, add TODO
danielle-tfh May 18, 2026
d65f34e
docs(walletkit-db): address Paolo's PR review nits
danielle-tfh May 18, 2026
75d7369
docs(walletkit-db): restore lost TODO on heap-allocated key bytes
danielle-tfh May 18, 2026
5c3031a
refactor(walletkit-db): drop Vault::mutate; rely on SQLite's own writ…
danielle-tfh May 19, 2026
b7164a2
address Dzejkop's walletkit-db PR review feedback
danielle-tfh May 20, 2026
071ec5f
refactor(walletkit-core): colocate BACKUP_TABLES with its consumers
danielle-tfh May 20, 2026
e3d0f69
style: rustfmt walletkit-db/walletkit-core tests after raw_connection…
danielle-tfh May 20, 2026
55f6648
refactor(walletkit-core): inline credential_vault helpers into mod.rs
danielle-tfh May 20, 2026
71a07d2
docs: capture walletkit-specific invariants in AGENTS.md
danielle-tfh May 20, 2026
71cfeb2
docs: tighten walletkit AGENTS.md into a short code-style list
danielle-tfh May 20, 2026
d8579e4
docs: pin walletkit coding-style invariants in AGENTS.md
danielle-tfh May 20, 2026
7269f5e
refactor(walletkit-core): drop vestigial lock-guard scopes; fix stale…
danielle-tfh May 20, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,3 +3,14 @@
## UniFFI Naming

Never name a UniFFI-exported method `to_string`. UniFFI maps Rust's `to_string` to Kotlin's `toString`, which conflicts with `Any.toString()` and causes a compilation error (`'toString' hides member of supertype 'Any' and needs 'override' modifier`). Use a descriptive name instead (e.g., `to_hex_string`, `to_decimal_string`, `to_json`).

## Coding style

- **On-disk format is byte-stable.** Schemas, CBOR layouts, and `compute_content_id` derivations are part of the contract. Existing user databases must keep opening without migration; guard format-sensitive code with frozen-byte tests next to it.
- **Don't layer a flock around SQLite writes.** WAL mode serializes writers itself. The `Lock` primitive is only for the envelope-init bootstrap and operations that mix SQL with filesystem state.
- **Per-consumer isolation is host wiring.** Separate keystore entry, AD label, and envelope/vault/lock files. `walletkit-db` enforces only the AEAD-AD binding.
- **`walletkit-db` is consumer-agnostic.** It owns `blob_objects` and the storage primitives (vault, envelope, lock, traits). Credential-specific tables, schemas, and APIs live in `walletkit-core/storage/credential_vault`. Don't put consumer logic in `walletkit-db`, and don't put primitives in consumer crates.
- **`#[expect(lint, reason = "...")]` over `#[allow(lint)]`.** `#[expect]` fails to compile when the suppression is no longer needed, so dead suppressions don't accumulate.
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

👍 👍 👍 👍

- **Inline single-consumer helper modules.** A `mod foo;` file with `pub(super) fn`s used only by its parent adds boundary without payoff; put the functions as private free `fn`s at the bottom of the parent module.
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I guess I agree but it feels weird to write it down

- **Colocate constants with the code that reads them, not their conceptual home.** A `const` used only by `mod.rs` belongs in `mod.rs`, not in a sibling `schema.rs`.
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't agree with this at all 😅

my earlier comment about BACKUP_TABLES I think argued exactly for the opposite - i.e. collocating backup specific tables alongside backup code

- **Tests live next to the code they test.** One `#[cfg(test)] mod tests` per source file, including inside `cfg`-gated blocks. For code gated by `#[cfg(not(target_arch = "wasm32"))]` (e.g. `Lock`'s native `imp` module), tests go inside that same block so they only compile for the platform they cover.
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

too perscriptive - we don't need to colocate test code within the same cfg gated module

4 changes: 4 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,7 @@ cargo fmt -- --check
WalletKit is broken down into separate crates, offering the following functionality.

- `walletkit-core` - Enables basic usage of a World ID to generate ZKPs using different credentials.
- `walletkit-db` - Encrypted on-device storage primitives for WalletKit: `SQLCipher` (`sqlite3mc`) wrapper, vault opener, content-addressed blob storage, sealed key envelope, and cross-process lock. Used by `walletkit-core` for credential storage and consumable by sibling SDKs (e.g. OrbKit's planned `OrbPcpStore`).

### World ID Secret

Expand Down
66 changes: 13 additions & 53 deletions walletkit-core/src/storage/cache/maintenance.rs
Original file line number Diff line number Diff line change
@@ -1,74 +1,38 @@
//! Cache DB maintenance helpers (integrity checks, rebuilds).
//! Cache DB maintenance helpers (open with rebuild-on-corruption).

use std::fs;
use std::path::Path;

use secrecy::SecretBox;

use crate::storage::error::StorageResult;
use walletkit_db::cipher;
use walletkit_db::Connection;
use walletkit_db::Vault;

use super::schema;
use super::util::{map_db_err, map_io_err};
use super::util::map_io_err;

/// Opens the cache DB or rebuilds it if integrity checks fail.
/// Opens the cache DB through `Vault`, rebuilding on any open / key /
/// integrity failure.
///
/// Cache contents are non-authoritative and regenerable, so the policy
/// here is "blow it away and retry" rather than the credential vault's
/// fatal-on-integrity contract.
///
/// # Errors
///
/// Returns an error if the database cannot be opened or rebuilt.
pub(super) fn open_or_rebuild(
path: &Path,
k_intermediate: &SecretBox<[u8; 32]>,
) -> StorageResult<Connection> {
match open_prepared(path, k_intermediate) {
Ok(conn) => {
let integrity_ok =
cipher::integrity_check(&conn).map_err(|e| map_db_err(&e))?;
if integrity_ok {
Ok(conn)
} else {
drop(conn);
rebuild(path, k_intermediate)
}
}
Err(err) => rebuild(path, k_intermediate).map_or_else(|_| Err(err), Ok),
) -> StorageResult<Vault> {
if let Ok(vault) = Vault::open(path, k_intermediate, schema::ensure_schema) {
return Ok(vault);
}
}

/// Opens the cache DB, applies encryption settings, and ensures schema.
///
/// # Errors
///
/// Returns an error if the DB cannot be opened or configured.
fn open_prepared(
path: &Path,
k_intermediate: &SecretBox<[u8; 32]>,
) -> StorageResult<Connection> {
let conn = cipher::open_encrypted(path, k_intermediate, false)
.map_err(|e| map_db_err(&e))?;
schema::ensure_schema(&conn)?;
Ok(conn)
}

/// Rebuilds the cache database by deleting and recreating it.
///
/// # Errors
///
/// Returns an error if deletion or re-open fails.
fn rebuild(
path: &Path,
k_intermediate: &SecretBox<[u8; 32]>,
) -> StorageResult<Connection> {
delete_cache_files(path)?;
open_prepared(path, k_intermediate)
Vault::open(path, k_intermediate, schema::ensure_schema).map_err(Into::into)
}

/// Deletes the cache DB and its WAL/SHM sidecar files if present.
///
/// # Errors
///
/// Returns an error for IO failures other than missing files.
fn delete_cache_files(path: &Path) -> StorageResult<()> {
delete_if_exists(path)?;
delete_if_exists(&path.with_extension("sqlite-wal"))?;
Expand All @@ -77,10 +41,6 @@ fn delete_cache_files(path: &Path) -> StorageResult<()> {
}

/// Deletes the file at `path` if it exists.
///
/// # Errors
///
/// Returns an error for IO failures other than missing files.
fn delete_if_exists(path: &Path) -> StorageResult<()> {
match fs::remove_file(path) {
Ok(()) => Ok(()),
Expand Down
110 changes: 49 additions & 61 deletions walletkit-core/src/storage/cache/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,8 @@
use std::path::Path;

use crate::storage::error::StorageResult;
use crate::storage::lock::StorageLockGuard;
use secrecy::SecretBox;
use walletkit_db::Connection;
use walletkit_db::Vault;

mod maintenance;
mod merkle;
Expand All @@ -16,32 +15,35 @@ mod util;

/// Encrypted cache database wrapper.
///
/// Stores non-authoritative, regenerable data (proof cache, session keys, replay guard)
/// to improve performance without affecting correctness if rebuilt.
/// Stores non-authoritative, regenerable data (proof cache, session keys,
/// replay guard). Wraps [`walletkit_db::Vault`].
///
/// Unlike the credential vault, cache corruption is recoverable: open
/// failures or integrity failures trigger a wipe-and-rebuild rather than
/// a fatal error.
#[derive(Debug)]
pub struct CacheDb {
conn: Connection,
vault: Vault,
}

impl CacheDb {
/// Opens or creates the encrypted cache database at `path`.
/// Opens or rebuilds the encrypted cache database at `path`.
///
/// If integrity checks fail, the cache is rebuilt since its contents can be
/// regenerated from authoritative sources.
/// If the database is corrupted or unreadable, the file is deleted
/// and a fresh empty cache is created.
///
/// # Errors
///
/// Returns an error if the database cannot be opened or rebuilt.
pub fn new(
path: &Path,
k_intermediate: &SecretBox<[u8; 32]>,
_lock: &StorageLockGuard,
) -> StorageResult<Self> {
let conn = maintenance::open_or_rebuild(path, k_intermediate)?;
Ok(Self { conn })
let vault = maintenance::open_or_rebuild(path, k_intermediate)?;
Ok(Self { vault })
}

/// Fetches a cached Merkle proof if it remains valid beyond `valid_before`.
/// Fetches a cached Merkle proof if it remains valid beyond `valid_until`.
///
/// Returns `None` when missing or expired so callers can refetch from the
/// indexer without relying on stale proofs.
Expand All @@ -50,26 +52,22 @@ impl CacheDb {
///
/// Returns an error if the query fails.
pub fn merkle_cache_get(&self, valid_until: u64) -> StorageResult<Option<Vec<u8>>> {
merkle::get(&self.conn, valid_until)
merkle::get(self.vault.connection(), valid_until)
}

/// Inserts a cached Merkle proof with a TTL.
/// Uses the database current time for `inserted_at`.
///
/// Existing entries for the same (registry, root, leaf index) are replaced.
/// Inserts a cached Merkle proof with a TTL. Existing entries for the
/// same key are replaced.
///
/// # Errors
///
/// Returns an error if the insert fails.
#[allow(clippy::needless_pass_by_value)]
pub fn merkle_cache_put(
&mut self,
_lock: &StorageLockGuard,
proof_bytes: Vec<u8>,
&self,
proof_bytes: &[u8],
now: u64,
ttl_seconds: u64,
) -> StorageResult<()> {
merkle::put(&self.conn, proof_bytes.as_ref(), now, ttl_seconds)
merkle::put(self.vault.connection(), proof_bytes, now, ttl_seconds)
}

/// Fetches a cached `session_id_r_seed` for the given `oprf_seed`.
Expand All @@ -84,7 +82,7 @@ impl CacheDb {
oprf_seed: [u8; 32],
now: u64,
) -> StorageResult<Option<[u8; 32]>> {
session::get(&self.conn, oprf_seed, now)
session::get(self.vault.connection(), oprf_seed, now)
}

/// Stores a `session_id_r_seed` keyed by `oprf_seed` with a TTL.
Expand All @@ -93,20 +91,27 @@ impl CacheDb {
///
/// Returns an error if the insert fails.
pub fn session_seed_put(
&mut self,
_lock: &StorageLockGuard,
&self,
oprf_seed: [u8; 32],
session_id_r_seed: [u8; 32],
now: u64,
ttl_seconds: u64,
) -> StorageResult<()> {
session::put(&self.conn, oprf_seed, session_id_r_seed, now, ttl_seconds)
session::put(
self.vault.connection(),
oprf_seed,
session_id_r_seed,
now,
ttl_seconds,
)
}

/// Checks whether a replay guard entry exists for the given nullifier.
///
/// # Returns
/// - bool: true if a replay guard entry exists (hence signalling a nullifier replay), false otherwise.
///
/// - `true` if a replay guard entry exists (nullifier replay).
/// - `false` otherwise.
///
/// # Errors
///
Expand All @@ -116,29 +121,23 @@ impl CacheDb {
nullifier: [u8; 32],
now: u64,
) -> StorageResult<bool> {
nullifiers::is_nullifier_replay(&self.conn, nullifier, now)
nullifiers::is_nullifier_replay(self.vault.connection(), nullifier, now)
}

/// After a proof has been successfully generated, creates a replay guard entry
/// locally to avoid future replays of the same nullifier.
/// After a proof has been successfully generated, creates a replay guard
/// entry locally to avoid future replays of the same nullifier.
///
/// # Errors
///
/// Returns an error if the query to the cache unexpectedly fails.
pub fn replay_guard_set(
&mut self,
_lock: &StorageLockGuard,
nullifier: [u8; 32],
now: u64,
) -> StorageResult<()> {
nullifiers::replay_guard_set(&self.conn, nullifier, now)
pub fn replay_guard_set(&self, nullifier: [u8; 32], now: u64) -> StorageResult<()> {
nullifiers::replay_guard_set(self.vault.connection(), nullifier, now)
}
}

#[cfg(test)]
mod tests {
use super::*;
use crate::storage::lock::StorageLock;
use secrecy::SecretBox;
use std::fs;
use std::path::PathBuf;
Expand All @@ -152,10 +151,8 @@ mod tests {

fn cleanup_cache_files(path: &Path) {
let _ = fs::remove_file(path);
let wal_path = path.with_extension("sqlite-wal");
let shm_path = path.with_extension("sqlite-shm");
let _ = fs::remove_file(wal_path);
let _ = fs::remove_file(shm_path);
let _ = fs::remove_file(path.with_extension("sqlite-wal"));
let _ = fs::remove_file(path.with_extension("sqlite-shm"));
}

fn temp_lock_path() -> PathBuf {
Expand All @@ -173,11 +170,9 @@ mod tests {
let path = temp_cache_path();
let key = SecretBox::init_with(|| [0x11u8; 32]);
let lock_path = temp_lock_path();
let lock = StorageLock::open(&lock_path).expect("open lock");
let guard = lock.lock().expect("lock");
let db = CacheDb::new(&path, &key, &guard).expect("create cache");
let db = CacheDb::new(&path, &key).expect("create cache");
drop(db);
CacheDb::new(&path, &key, &guard).expect("open cache");
CacheDb::new(&path, &key).expect("open cache");
cleanup_cache_files(&path);
cleanup_lock_file(&lock_path);
}
Expand All @@ -187,19 +182,17 @@ mod tests {
let path = temp_cache_path();
let key = SecretBox::init_with(|| [0x22u8; 32]);
let lock_path = temp_lock_path();
let lock = StorageLock::open(&lock_path).expect("open lock");
let guard = lock.lock().expect("lock");
let mut db = CacheDb::new(&path, &key, &guard).expect("create cache");
let db = CacheDb::new(&path, &key).expect("create cache");
let oprf_seed = [0x01u8; 32];
let r_seed = [0x02u8; 32];
let now = 1_000;
db.session_seed_put(&guard, oprf_seed, r_seed, now, 1000)
db.session_seed_put(oprf_seed, r_seed, now, 1000)
.expect("put session seed");
drop(db);

fs::write(&path, b"corrupt").expect("corrupt cache file");

let db = CacheDb::new(&path, &key, &guard).expect("rebuild cache");
let db = CacheDb::new(&path, &key).expect("rebuild cache");
let value = db
.session_seed_get(oprf_seed, now)
.expect("get session seed");
Expand All @@ -213,13 +206,10 @@ mod tests {
let path = temp_cache_path();
let key = SecretBox::init_with(|| [0x33u8; 32]);
let lock_path = temp_lock_path();
let lock = StorageLock::open(&lock_path).expect("open lock");
let guard = lock.lock().expect("lock");
let mut db = CacheDb::new(&path, &key, &guard).expect("create cache");
db.merkle_cache_put(&guard, vec![1, 2, 3], 100, 10)
let db = CacheDb::new(&path, &key).expect("create cache");
db.merkle_cache_put(&[1, 2, 3], 100, 10)
.expect("put merkle proof");
let valid_until = 105;
let hit = db.merkle_cache_get(valid_until).expect("get merkle proof");
let hit = db.merkle_cache_get(105).expect("get merkle proof");
assert!(hit.is_some());
let miss = db.merkle_cache_get(111).expect("get merkle proof");
assert!(miss.is_none());
Expand All @@ -232,13 +222,11 @@ mod tests {
let path = temp_cache_path();
let key = SecretBox::init_with(|| [0x44u8; 32]);
let lock_path = temp_lock_path();
let lock = StorageLock::open(&lock_path).expect("open lock");
let guard = lock.lock().expect("lock");
let mut db = CacheDb::new(&path, &key, &guard).expect("create cache");
let db = CacheDb::new(&path, &key).expect("create cache");
let oprf_seed = [0x55u8; 32];
let r_seed = [0x66u8; 32];
let now = 100;
db.session_seed_put(&guard, oprf_seed, r_seed, now, 10)
db.session_seed_put(oprf_seed, r_seed, now, 10)
.expect("put session seed");
let hit = db.session_seed_get(oprf_seed, now).expect("get");
assert_eq!(hit, Some(r_seed));
Expand Down
Loading
Loading