feat(tauri): optional SQLCipher encryption_key on OpenDatabase#1038
feat(tauri): optional SQLCipher encryption_key on OpenDatabase#1038msvargas wants to merge 6 commits into
Conversation
Both tests require --features encryption (SQLCipher) to be meaningful; plain cargo test does not link SQLCipher so PRAGMA key is a silent no-op.
Documents the new opt-in encryptionKey on TauriSQLOpenOptions and the off-by-default `encryption` Cargo feature added in prior commits.
🦋 Changeset detectedLatest commit: 15a7109 The changes in this PR will be included in the next version bump. This PR includes changesets to release 1 package
Not sure what this means? Click here to learn what changesets are. Click here if you're a maintainer who wants to add another changeset to this PR |
Default builds link plain SQLite, which silently no-ops on PRAGMA key instead of erroring — so passing encryptionKey from JS on a build that didn't opt into the `encryption` Cargo feature used to open a fully plaintext database on disk with zero error, while the caller believed it was encrypted. Gate open_encrypted_pool behind #[cfg(feature = "encryption")], with a #[cfg(not(feature = "encryption"))] counterpart that returns a new PowerSyncTauriError::EncryptionUnavailable instead. Gate the SQLCipher round-trip/wrong-key tests behind the same feature (they now test a false premise otherwise), and add a small test proving the feature-off path hard-errors rather than silently succeeding. Document the feature requirement on TauriSQLOpenOptions.encryptionKey.
simolus3
left a comment
There was a problem hiding this comment.
Thanks for your contribution! I agree that we should support encryption in some way, and throwing instead of silently ignoring an encryption key is also much better.
|
|
||
| PowerSyncEnvironment::powersync_auto_extension()?; | ||
| let pool = if name == ":memory:" { | ||
| // In-memory DBs are never encrypted — nothing persisted for a key to protect. |
There was a problem hiding this comment.
Does anything break when we try to apply the key pragma here? Even for in-memory databases, I believe SQLite might spill large materialized views into files. I'm not sure if those are encrypted reliably, but applying the key pragma regardless of the connection type feels safer or less surprising.
| .pragma_update(None, "busy_timeout", 30_000) | ||
| .map_err(PowerSyncError::from)?; | ||
| writer | ||
| .pragma_update(None, "cache_size", 50 * 1024) |
There was a problem hiding this comment.
This value should be negative to refer to a 50 MiB cache, positive values refer to page sizes which quadruples the cache size (we've made that mistake before...)
| .build() | ||
| } | ||
|
|
||
| #[cfg(all(test, feature = "encryption"))] |
There was a problem hiding this comment.
I'm not sure these tests are all that useful , we don't run them in CI and we don't have any other unit tests. We could enable the encryption feature in the demo app and add an e2e test there instead.
| tokio = { version = "1.50.0", features = ["time"] } | ||
| tokio-stream = "0.1" | ||
|
|
||
| [dependencies.rusqlite] |
There was a problem hiding this comment.
You also flagged this, but I think we could fix the "technically both targets are enabled" issue by:
- Adding a
bundleddefault-feature to this package, and only enablerusqlite/bundledwhen that is enabled. - Users could then disable default features if they want to add their own SQLite build, if an app enables
rusqlite/bundled-sqlcipherthen it will also be enabled here since Cargo unifies features in a build.
We wouldn't need an explicit encryption feature in that case, and that ultimately also offers more flexibility.
| Ok(ConnectionPool::wrap_connections(writer, readers)) | ||
| } | ||
|
|
||
| #[cfg(not(feature = "encryption"))] |
There was a problem hiding this comment.
Since I also suggested removing this feature: We can detect encryption being available at runtime by running a pragma cipher_version and reporting an error if it doesn't return any rows.
Summary
Adds an optional
encryptionKeytoTauriSQLOpenOptions(Tauri SDK). Whenset, every pooled SQLite connection is keyed via
PRAGMA keybefore anyother statement runs, encrypting the on-disk database with SQLCipher.
Omitting it is byte-for-byte identical to today's
ConnectionPool::openpath — this is purely additive.
The SQLCipher
rusqlitebuild is gated behind a new opt-in Cargo feature,encryption(rusqlite/bundled-sqlcipher). It's off by default, so existingconsumers of
tauri-plugin-powersyncsee no change in build output, binarysize, or platform requirements unless they explicitly opt in:
We're building a Tauri + PowerSync desktop app storing sensitive per-tenant
business data and needed encryption-at-rest for the desktop client. Lacking
an upstream hook for it, we carried an unconditional version of this patch
as an in-repo vendored fork for the last few weeks. Opening this now, ported
onto current
mainand reworked as an opt-in feature, so the fix doesn'tlive only in our fork, and so anyone else who needs desktop
encryption-at-rest with PowerSync + Tauri gets it without vendoring.
Why
wrap_connections, not justPRAGMA keyafteropenConnectionPool::openrunsPRAGMA journal_mode=WALas its first statement,which reads the encrypted file header and fails before any key can be set on
an already-encrypted DB. The fix opens each connection directly, runs
PRAGMA keyas the true first statement, then replicatesopen's otherpragmas (WAL/journal-size/busy-timeout/cache-size on the writer, query_only
on readers) via the existing public
ConnectionPool::wrap_connectionsseam.Pragma parity with
ConnectionPool::openwas checked directly against thepinned
powersync0.0.5 source, not assumed.Known limitations, flagged for your judgment rather than silently assumed
encryption-feature) build previously linkedplain SQLite, which silently no-ops on
PRAGMA key— so passingencryptionKeyfrom JS on a default build used to open a fully plaintextdatabase on disk with zero error, while the caller believed it was
encrypted.
open_encrypted_poolis now#[cfg(feature = "encryption")],with a
#[cfg(not(feature = "encryption"))]counterpart that hard-errors(
EncryptionUnavailable) instead, so a feature-off build now fails loudlyat
open_databasetime rather than silently persisting unencrypted data.encryptionCargo feature has no manifest-level target restriction —nothing in
Cargo.tomlstops--features encryptionfrom being requestedon an Android/iOS build, which has no system OpenSSL toolchain for
bundled-sqlcipher. It's off by default and desktop-only in our own usage,but that's convention, not something Cargo enforces. Happy to add a
target-gated
compile_error!or similar if that's a real risk for howthis crate is built elsewhere.
rusqlite'sbundledandbundled-sqlcipherfeatures both vendor a fullSQLite build and are typically mutually exclusive; when both are unified
onto the same resolved
rusqlite(defaultbundledhere, plusbundled-sqlcipherwhenencryptionis enabled),libsqlite3-sys0.37.0's build script currently gives
bundled-sqlcipherpriority, so theright thing happens — but that's an implementation detail of
libsqlite3-sys, not a documented Cargo contract. Worth a CI fixture testif this feature sticks around long-term.
Testing
cargo test --features encryption— new unit tests: round-trips throughthe correct key, rejects a wrong key on reopen. Both verified to actually
exercise SQLCipher (they fail/behave differently under default features,
where the feature isn't linked). These tests now live in a module gated
#[cfg(all(test, feature = "encryption"))], since they specifically proveopen_encrypted_pool's real SQLCipher behavior. A default-featurecargo testno longer runs these tests, and passingencryptionKeyon adefault build now fails loudly instead of silently opening plaintext.
pnpm exec tsc -b— clean (packages/tauri and its dependency subgraph;full-workspace build has pre-existing unrelated errors in other packages,
confirmed unaffected by this diff).
tests/integration.test.tsharness uses:memory:databases, which thisfeature explicitly skips — encryption only applies to on-disk files).
cc @simolus3 — most of the recent history on this package is yours, would love your take on the design (especially the two known-limitations above) and any gaps I'm missing.