Skip to content

feat: SQLite refactor#197

Merged
Dzejkop merged 27 commits intomainfrom
sqlite-refactor
Feb 25, 2026
Merged

feat: SQLite refactor#197
Dzejkop merged 27 commits intomainfrom
sqlite-refactor

Conversation

@lukejmann
Copy link
Copy Markdown
Contributor

@lukejmann lukejmann commented Feb 9, 2026

rusqlite doesn't compile for wasm32-unknown-unknown, and SQLCipher requires OpenSSL which added build complexity. sqlite3mc solves both: it compiles to WASM via sqlite-wasm-rs, and its crypto is fully built-in.

  • Replace rusqlite and SQLCipher with a custom safe SQLite wrapper backed by sqlite3mc on all platforms
  • Both libsqlite3-sys and sqlite-wasm-rs expose the same ~20 SQLite C API functions, so one thin safe wrapper (storage/db/) handles both via a cfg-switched FFI module
  • All unsafe and C types are confined to ffi.rs
  • Native compiles the sqlite3mc amalgamation via cc in build.rs; WASM uses sqlite-wasm-rs
  • Both platforms use ChaCha20-Poly1305 with the same PRAGMA key syntax and database format
  • File locking uses flock/LockFileEx on native, no-op on WASM
  • UniFFI attributes are gated with cfg_attr so the crate compiles for WASM

sqlite3mc
sqlite3mc is a modified SQLite amalgamation that adds transparent page-level encryption. It ships as a single C file (sqlite3mc_amalgamation.c) that replaces the standard sqlite3.c -- no external libraries needed.
Encryption is handled at the pager layer. When a database is keyed with PRAGMA key, sqlite3mc derives a page encryption key from the provided material using PBKDF2-SHA256, then encrypts every database page on write and decrypts on read using ChaCha20-Poly1305 (the default cipher). Each page gets its own one-time key derived from the master key, the page number, and a 16-byte nonce, with a Poly1305 authentication tag appended for tamper detection. This is fully transparent to the SQL layer -- queries, transactions, and WAL journaling work identically to an unencrypted database.
sqlite3mc supports multiple cipher schemes (AES-128/256-CBC, SQLCipher-compatible AES-256, RC4, Ascon-128) selectable at runtime via PRAGMA cipher. We default to ChaCha20-Poly1305 via the CODEC_TYPE=CODEC_TYPE_CHACHA20 compile flag. All crypto primitives are compiled directly into the amalgamation.

Closes #184


Note

High Risk
Large refactor of encrypted on-disk storage and transaction/query code, plus new build-time SQLite FFI/crypto plumbing; regressions could impact data integrity, keying correctness, or runtime portability across targets.

Overview
Migrates credential storage (vault + cache) off rusqlite/SQLCipher onto a new internal walletkit-db crate that provides a minimal safe SQLite wrapper backed by sqlite3mc, enabling encrypted storage without OpenSSL and making the storage stack compile on wasm32.

Storage open/key/config/integrity-check logic is centralized in walletkit_db::cipher (WAL + synchronous=FULL + secure_delete=ON), and all vault/cache SQL execution paths are updated to the new statement/transaction APIs (including explicit transactional helpers for replay-guard idempotency).

Adds WASM-specific behavior: UniFFI exports/derives are cfg_attr-gated, and the cross-process storage lock becomes a native file lock with a WASM no-op implementation; intermediate keys are now passed as Zeroizing<[u8;32]> references to reduce key material lifetime/copies.

Written by Cursor Bugbot for commit 71bba79. This will update automatically on new commits. Configure here.

@lukejmann lukejmann changed the title Sqlite-refactor SQLite refactor Feb 9, 2026
@lukejmann lukejmann changed the title SQLite refactor feat: SQLite refactor Feb 9, 2026
Comment thread walletkit-core/src/storage/db/cipher.rs Outdated
)
})?;

k_intermediate.zeroize();
Copy link
Copy Markdown
Contributor

@Dzejkop Dzejkop Feb 10, 2026

Choose a reason for hiding this comment

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

This won't be called if we error out earlier, right? Consider putting k_intermediate into a Zeroizing wrapper so it zeroes on drop

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.

Also under the hood of execute_batch the pragma statement below which contains the key hex repr will be cloned into a CString https://github.com/worldcoin/walletkit/blob/sqlite-refactor/walletkit-core/src/storage/db/ffi.rs#L108-L108 which is not zeroed on drop

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.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

updated – see bb4b868


/// Opens an in-memory database (useful for tests).
#[allow(dead_code)]
pub fn open_in_memory() -> DbResult<Self> {
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.

Test only? Can we move it into its own impl in #[cfg(test)]?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

updated – de1608a

Comment thread deny.toml Outdated
@@ -8,7 +8,7 @@ unknown-registry = "deny"
[bans]
deny = [
# openssl-sys is allowed to bundle it in Android apps which require it for sqlcipher
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.

this comment seems to be outdated

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

updated

Comment thread walletkit-core/src/storage/db/tests.rs Outdated
Comment on lines +121 to +126
use std::path::PathBuf;
let path = PathBuf::from(format!(
"{}/walletkit-db-cipher-test-{}.sqlite",
std::env::temp_dir().display(),
uuid::Uuid::new_v4()
));
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.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

updated

k_intermediate: [u8; 32],
read_only: bool,
) -> DbResult<Connection> {
let conn = Connection::open(path, read_only)?;
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.

Consider adding PRAGMA SECURE_DELETE: https://www.sqlite.org/pragma.html#pragma_secure_delete

Doesn't seem like we'd make use of it now but could be relevant in the future if we ever start deleting data

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

updated bf5cfe2

Comment thread walletkit-core/src/storage/db/ffi.rs Outdated
pub(super) struct RawStmt {
ptr: *mut c_void,
/// Kept to extract error messages via `sqlite3_errmsg`.
db: *mut c_void,
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.

Consider changing this to &'db RawDb to ensure statements don't outlive the db

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

updated – cfa102a

SqlcipherError::CipherUnavailable => StorageError::CacheDb(err.to_string()),
}
/// Maps an owned database error into a cache storage error.
pub(super) fn map_db_err_owned(err: &DbError) -> StorageError {
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.

Same as map_db_err? Consider removing this one?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

updated – 7b5f36b

Comment thread walletkit-db/src/lib.rs
@@ -0,0 +1,33 @@
//! Minimal safe `SQLite` wrapper backed by `sqlite3mc`.
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.

This should be an internal crate and not a module IMO

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

agreed – updated 328ef59

Comment thread walletkit-db/src/value.rs

/// Convenience macro for building parameter lists.
///
/// Usage: `params![1_i64, blob.as_slice(), "text"]`
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.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

updated: b557cc3

Comment thread walletkit-db/src/value.rs

impl From<&str> for Value {
fn from(v: &str) -> Self {
Self::Text(v.to_string())
Copy link
Copy Markdown
Contributor

@Dzejkop Dzejkop Feb 10, 2026

Choose a reason for hiding this comment

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

We allocate and copy string here only for it to be immediately taken as a reference and sent to sqlite copying it again

https://github.com/worldcoin/walletkit/blob/sqlite-refactor/walletkit-core/src/storage/db/ffi.rs#L253-L266

I think we should consider adding a Cow and track lifetimes to avoid copying/allocation

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.

Actually on second check this From is never utilizied. In fact it looks like Value::Text is never constructed.

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 let's leave it as is

Comment thread walletkit-db/src/ffi.rs
Comment thread walletkit-db/src/ffi.rs
Comment thread walletkit-db/src/ffi.rs
}

pub fn column_blob(&self, col: i32) -> Vec<u8> {
// Safety: blob pointer is valid until the next step/reset/finalize.
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.

So this isn't actually enforced by the interface - so if we're being pedantic this means that column_blob should be unsafe.

I'm wondering if it's worth it to modify Statement::step to return some sort of exclusive guard. This would allow us to:

  1. Ensure that column_xxx methods are only called after step returns SQLITE_ROW
  2. Enforce this safety constraint

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

agreed added in cf12d41

Comment thread walletkit-db/src/ffi.rs Outdated
}

fn errmsg_from_ptr(db: *mut c_void) -> String {
// Safety: db is a valid sqlite3 handle (or null, which we check).
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.

This is actually not true, because RawStmt can outlive RawDb at which point the db pointer will be invalid, related to #197 (comment)

lukejmann and others added 8 commits February 17, 2026 11:21
Co-authored-by: Jakub Trąd <jakubtrad@gmail.com>
Co-authored-by: Jakub Trąd <jakubtrad@gmail.com>
Resolve conflicts keeping walletkit-db in place of rusqlite:
- Cargo.toml: keep walletkit-db workspace member
- walletkit-core/Cargo.toml: walletkit-db dep instead of rusqlite,
  restore rand as optional with dev-dep
- vault/mod.rs: walletkit-db imports, rewrite fetch_credential_and_blinding_factor
  to use prepare/bind/step API

Co-authored-by: Cursor <cursoragent@cursor.com>
@socket-security
Copy link
Copy Markdown

socket-security Bot commented Feb 18, 2026

All alerts resolved. Learn more about Socket for GitHub.

This PR previously contained dependency changes with security issues that have been resolved, removed, or ignored.

View full report

@lukejmann
Copy link
Copy Markdown
Contributor Author

Warning

Review the following alerts detected in dependencies.

According to your organization's Security Policy, it is recommended to resolve "Warn" alerts. Learn more about Socket for GitHub.

Action Severity Alert  (click "▶" to expand/collapse)
Warn High
License policy violation: cargo unicode-ident under Unicode-3.0
License: Unicode-3.0 - the applicable license policy does not allow this license (4) (unicode-ident-1.0.22/Cargo.toml)

License: Unicode-3.0 - the applicable license policy does not allow this license (4) (unicode-ident-1.0.22/Cargo.toml)

License: Unicode-3.0 - the applicable license policy does not allow this license (4) (unicode-ident-1.0.22/LICENSE-UNICODE)

From: ?cargo/reqwest@0.12.28cargo/tempfile@3.24.0cargo/ruint@1.17.2cargo/tokio@1.49.0cargo/rustls@0.23.36cargo/serde_json@1.0.149cargo/uniffi@0.31.0cargo/chrono@0.4.43cargo/sqlite-wasm-rs@0.5.2cargo/thiserror@2.0.18cargo/cc@1.2.54cargo/uuid@1.20.0cargo/alloy-core@1.5.3cargo/taceo-oprf@0.5.0cargo/alloy-primitives@1.5.6cargo/alloy@1.7.1cargo/sha2@0.10.9cargo/rand@0.8.5cargo/ciborium@0.2.2cargo/hkdf@0.12.4cargo/ctor@0.2.9cargo/strum@0.27.2cargo/chacha20poly1305@0.10.1cargo/semaphore-rs@0.5.0cargo/serde@1.0.228cargo/zeroize@1.8.2cargo/tracing-subscriber@0.3.22cargo/mockito@1.7.1cargo/unicode-ident@1.0.22

ℹ Read more on: This package | This alert | What is a license policy violation?

Next steps: Take a moment to review the security alert above. Review
the linked package source code to understand the potential risk. Ensure the
package is not malicious before proceeding. If you're unsure how to proceed,
reach out to your security team or ask the Socket team for help at
support@socket.dev.

Suggestion: Find a package that does not violate your license policy or adjust your policy to allow this package's license.

_Mark the package as acceptable risk_. To ignore this alert only
in this pull request, reply with the comment
`@SocketSecurity ignore cargo/unicode-ident@1.0.22`. You can
also ignore all packages with `@SocketSecurity ignore-all`.
To ignore an alert for all future pull requests, use Socket's Dashboard to
change the [triage state of this alert](https://socket.dev/dashboard/org/worldcoin/diff-scan/210d9691-6338-42f6-b855-487610fca762?tab=alerts&alert_item_key=QYdTwzkq7Lt37zGw7KmAvY_vf58EVpHhZfF_TaJPxqT0).

Warn High
License policy violation: cargo webpki-roots under CDLA-Permissive-2.0
License: CDLA-Permissive-2.0 - the applicable license policy does not allow this license (4) (webpki-roots-1.0.5/LICENSE)

License: CDLA-Permissive-2.0 - the applicable license policy does not allow this license (4) (webpki-roots-1.0.5/Cargo.toml)

From: ?cargo/reqwest@0.12.28cargo/taceo-oprf@0.5.0cargo/alloy@1.7.1cargo/webpki-roots@1.0.5

ℹ Read more on: This package | This alert | What is a license policy violation?

Next steps: Take a moment to review the security alert above. Review
the linked package source code to understand the potential risk. Ensure the
package is not malicious before proceeding. If you're unsure how to proceed,
reach out to your security team or ask the Socket team for help at
support@socket.dev.

Suggestion: Find a package that does not violate your license policy or adjust your policy to allow this package's license.

_Mark the package as acceptable risk_. To ignore this alert only
in this pull request, reply with the comment
`@SocketSecurity ignore cargo/webpki-roots@1.0.5`. You can
also ignore all packages with `@SocketSecurity ignore-all`.
To ignore an alert for all future pull requests, use Socket's Dashboard to
change the [triage state of this alert](https://socket.dev/dashboard/org/worldcoin/diff-scan/210d9691-6338-42f6-b855-487610fca762?tab=alerts&alert_item_key=QZd47fqnADRLFKxdEjbGgx8r5r47R3dNHmAddPqAj_0I).

View full report

@SocketSecurity ignore cargo/unicode-ident@1.0.22
@SocketSecurity ignore cargo/webpki-roots

)?;
let vault =
VaultDb::new(&self.paths.vault_db_path(), keys.intermediate_key(), &guard)?;
VaultDb::new(&self.paths.vault_db_path(), *keys.intermediate_key(), &guard)?;
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.

keys.intermediate_key() returns Zeroizing<[u8; 32]>`

VaultDb::new(...) takes in [u8; 32] and then passes that to cipher::open_encrypted which https://github.com/worldcoin/walletkit/blob/sqlite-refactor/walletkit-db/src/cipher.rs#L53-L53

immediately puts the [u8; 32] into a Zeroizing<[u8; 32]>

could we just pass a &Zeroizing<[u8; 32]> reference to VaultDb::new and then transitively to open_encrypted? Same for CacheDb::new.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

good catch – updated in b96745f

Dzejkop and others added 4 commits February 23, 2026 12:36
Copy link
Copy Markdown

@cursor cursor Bot left a comment

Choose a reason for hiding this comment

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

Cursor Bugbot has reviewed your changes and found 1 potential issue.

Bugbot Autofix is OFF. To automatically fix reported issues with Cloud Agents, enable Autofix in the Cursor dashboard.

/// See [`Connection::prepare`].
pub fn prepare(&self, sql: &str) -> DbResult<Statement<'_>> {
self.conn.prepare(sql)
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Transaction missing query_row_optional forces code duplication

Low Severity

Transaction delegates execute_batch, execute, query_row, and prepare to Connection but omits query_row_optional. This forces get_cache_entry_tx in cache/util.rs to manually reimplement the prepare → bind → step → match pattern that Connection::query_row_optional already encapsulates. Adding a one-line delegation (self.conn.query_row_optional(sql, params, mapper)) to Transaction would eliminate ~30 lines of duplicated logic and reduce divergence risk.

Additional Locations (1)

Fix in Cursor Fix in Web

@Dzejkop Dzejkop merged commit 229816a into main Feb 25, 2026
16 checks passed
@Dzejkop Dzejkop deleted the sqlite-refactor branch February 25, 2026 19:55
This was referenced Mar 11, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Remove vendored openssl

3 participants