Skip to content

feat(tauri): optional SQLCipher encryption_key on OpenDatabase#1038

Open
msvargas wants to merge 6 commits into
powersync-ja:mainfrom
msvargas:feat/tauri-encryption-key
Open

feat(tauri): optional SQLCipher encryption_key on OpenDatabase#1038
msvargas wants to merge 6 commits into
powersync-ja:mainfrom
msvargas:feat/tauri-encryption-key

Conversation

@msvargas

@msvargas msvargas commented Jul 10, 2026

Copy link
Copy Markdown
Contributor

Summary

Adds an optional encryptionKey to TauriSQLOpenOptions (Tauri SDK). When
set, every pooled SQLite connection is keyed via PRAGMA key before any
other statement runs, encrypting the on-disk database with SQLCipher.
Omitting it is byte-for-byte identical to today's ConnectionPool::open
path — this is purely additive.

The SQLCipher rusqlite build is gated behind a new opt-in Cargo feature,
encryption (rusqlite/bundled-sqlcipher). It's off by default, so existing
consumers of tauri-plugin-powersync see no change in build output, binary
size, or platform requirements unless they explicitly opt in:

tauri-plugin-powersync = { version = "...", features = ["encryption"] }

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 main and reworked as an opt-in feature, so the fix doesn't
live 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 just PRAGMA key after open

ConnectionPool::open runs PRAGMA journal_mode=WAL as 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 key as the true first statement, then replicates open's other
pragmas (WAL/journal-size/busy-timeout/cache-size on the writer, query_only
on readers) via the existing public ConnectionPool::wrap_connections seam.
Pragma parity with ConnectionPool::open was checked directly against the
pinned powersync 0.0.5 source, not assumed.

Known limitations, flagged for your judgment rather than silently assumed

  • Fixed: a default (non-encryption-feature) build previously linked
    plain SQLite, which silently no-ops on PRAGMA key — so passing
    encryptionKey from JS on a default build used to open a fully plaintext
    database on disk with zero error, while the caller believed it was
    encrypted. open_encrypted_pool is now #[cfg(feature = "encryption")],
    with a #[cfg(not(feature = "encryption"))] counterpart that hard-errors
    (EncryptionUnavailable) instead, so a feature-off build now fails loudly
    at open_database time rather than silently persisting unencrypted data.
  • The encryption Cargo feature has no manifest-level target restriction —
    nothing in Cargo.toml stops --features encryption from being requested
    on 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 how
    this crate is built elsewhere.
  • rusqlite's bundled and bundled-sqlcipher features both vendor a full
    SQLite build and are typically mutually exclusive; when both are unified
    onto the same resolved rusqlite (default bundled here, plus
    bundled-sqlcipher when encryption is enabled), libsqlite3-sys
    0.37.0's build script currently gives bundled-sqlcipher priority, so the
    right thing happens — but that's an implementation detail of
    libsqlite3-sys, not a documented Cargo contract. Worth a CI fixture test
    if this feature sticks around long-term.

Testing

  • cargo test --features encryption — new unit tests: round-trips through
    the 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 prove
    open_encrypted_pool's real SQLCipher behavior. A default-feature
    cargo test no longer runs these tests, and passing encryptionKey on a
    default 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).
  • Not covered here: a real Tauri IPC integration test (the existing
    tests/integration.test.ts harness uses :memory: databases, which this
    feature 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.

msvargas added 5 commits July 10, 2026 12:09
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-bot

changeset-bot Bot commented Jul 10, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 15a7109

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 1 package
Name Type
@powersync/tauri-plugin Minor

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 simolus3 left a comment

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.

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.

Comment thread packages/tauri/src/lib.rs

PowerSyncEnvironment::powersync_auto_extension()?;
let pool = if name == ":memory:" {
// In-memory DBs are never encrypted — nothing persisted for a key to protect.

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.

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.

Comment thread packages/tauri/src/lib.rs
.pragma_update(None, "busy_timeout", 30_000)
.map_err(PowerSyncError::from)?;
writer
.pragma_update(None, "cache_size", 50 * 1024)

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 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...)

Comment thread packages/tauri/src/lib.rs
.build()
}

#[cfg(all(test, feature = "encryption"))]

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'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.

Comment thread packages/tauri/Cargo.toml
tokio = { version = "1.50.0", features = ["time"] }
tokio-stream = "0.1"

[dependencies.rusqlite]

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.

You also flagged this, but I think we could fix the "technically both targets are enabled" issue by:

  1. Adding a bundled default-feature to this package, and only enable rusqlite/bundled when that is enabled.
  2. Users could then disable default features if they want to add their own SQLite build, if an app enables rusqlite/bundled-sqlcipher then 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.

Comment thread packages/tauri/src/lib.rs
Ok(ConnectionPool::wrap_connections(writer, readers))
}

#[cfg(not(feature = "encryption"))]

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.

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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Product Visibility This requires documentation changes and or announcing.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants