diff --git a/.changeset/tauri-encryption-key.md b/.changeset/tauri-encryption-key.md new file mode 100644 index 000000000..ffd5da5e0 --- /dev/null +++ b/.changeset/tauri-encryption-key.md @@ -0,0 +1,15 @@ +--- +'@powersync/tauri-plugin': minor +--- + +Add an optional `encryptionKey` to `TauriSQLOpenOptions`. When set, the native +plugin keys every pooled SQLite connection via `PRAGMA key` before any other +statement runs, encrypting the on-disk database with SQLCipher. This is a +desktop-only, additive feature — omitting `encryptionKey` is byte-for-byte +identical to today's behavior. + +The Rust crate gains a new opt-in Cargo feature, `encryption`, which selects +`rusqlite`'s `bundled-sqlcipher` build. It is off by default, so existing +consumers of `tauri-plugin-powersync` see no change in build output, binary +size, or platform requirements unless they explicitly enable it (e.g. +`tauri-plugin-powersync = { version = "...", features = ["encryption"] }`). diff --git a/packages/tauri/Cargo.toml b/packages/tauri/Cargo.toml index 94aa543df..95a0a596a 100644 --- a/packages/tauri/Cargo.toml +++ b/packages/tauri/Cargo.toml @@ -21,9 +21,15 @@ thiserror = "2" powersync = { version = "0.0.5", features = ["tokio", "reqwest"] } reqwest = "0.13.2" http-client = { version = "6.5.3", default-features = false } -rusqlite = { version = "0.39.0", features = ["bundled"] } tokio = { version = "1.50.0", features = ["time"] } tokio-stream = "0.1" +[dependencies.rusqlite] +version = "0.39.0" +features = ["bundled"] + +[features] +encryption = ["rusqlite/bundled-sqlcipher"] + [build-dependencies] tauri-plugin = { version = "2.5.4", features = ["build"] } diff --git a/packages/tauri/guest-js/command.ts b/packages/tauri/guest-js/command.ts index ba7efcf99..2475fb789 100644 --- a/packages/tauri/guest-js/command.ts +++ b/packages/tauri/guest-js/command.ts @@ -6,6 +6,7 @@ export interface OpenDatabase { name: string; // Serialized schema for core extension schema: unknown; + encryption_key?: string; } export interface ExecuteSql { diff --git a/packages/tauri/guest-js/database.ts b/packages/tauri/guest-js/database.ts index 25cf8e7bd..e92934693 100644 --- a/packages/tauri/guest-js/database.ts +++ b/packages/tauri/guest-js/database.ts @@ -33,6 +33,21 @@ export interface TauriSQLOpenOptions extends SQLOpenOptions { * A promise that resolves to the directory in which the PowerSync database should be stored. */ dbLocationAsync?: () => Promise; + + /** + * Optional SQLCipher key. When set, the native plugin runs `PRAGMA key` + * against every pooled connection before any other statement, encrypting + * the database at rest. Desktop only — has no effect on mobile builds, + * which link plain (unencrypted) SQLite. Callers are responsible for + * deriving and persisting this key themselves; the plugin does not manage + * key storage. + * + * Requires the Rust crate to be built with `features = ["encryption"]` + * (SQLCipher is opt-in, off by default). If that feature is not enabled, + * `_initialize()` rejects with a clear error instead of silently opening + * an unencrypted database. + */ + encryptionKey?: string; } /** @@ -168,10 +183,12 @@ export class PowerSyncTauriDatabase extends BasePowerSyncDatabase { const path = await this.resolvePath(); + const { encryptionKey } = this.options.database as TauriSQLOpenOptions; const result = await powersyncCommand({ OpenDatabase: { name: path, - schema: this.schema.toJSON() + schema: this.schema.toJSON(), + ...(encryptionKey ? { encryption_key: encryptionKey } : {}) } }); diff --git a/packages/tauri/src/commands.rs b/packages/tauri/src/commands.rs index dc9aa1546..91fc87516 100644 --- a/packages/tauri/src/commands.rs +++ b/packages/tauri/src/commands.rs @@ -43,6 +43,10 @@ pub enum Command { pub struct OpenDatabase { pub name: String, pub schema: Box, + /// Optional SQLCipher key. When present, every pool connection is keyed via + /// `PRAGMA key` before first use — see `PowerSync::open_database`. + #[serde(default)] + pub encryption_key: Option, } #[derive(Deserialize)] @@ -285,6 +289,7 @@ pub(crate) async fn powersync( app, &open.name, SchemaOrCustom::from(open.schema.as_ref()), + open.encryption_key.as_deref(), )?; let event_key = db.event_key; diff --git a/packages/tauri/src/error.rs b/packages/tauri/src/error.rs index befc44a20..2ae2ca5e2 100644 --- a/packages/tauri/src/error.rs +++ b/packages/tauri/src/error.rs @@ -12,6 +12,8 @@ pub enum PowerSyncTauriError { IllegalHandleType, #[error("Could not obtain connection within timeout")] TimeoutExpired, + #[error("{0}")] + EncryptionUnavailable(String), } impl Serialize for PowerSyncTauriError { diff --git a/packages/tauri/src/lib.rs b/packages/tauri/src/lib.rs index 9a6462df8..e1103358d 100644 --- a/packages/tauri/src/lib.rs +++ b/packages/tauri/src/lib.rs @@ -46,6 +46,7 @@ impl PowerSync { app: AppHandle, name: &str, schema: SchemaOrCustom, + encryption_key: Option<&str>, ) -> Result> { let mut map = self.databases.lock().unwrap(); let mut entry = map.entry(name.to_owned()); @@ -58,10 +59,14 @@ impl PowerSync { PowerSyncEnvironment::powersync_auto_extension()?; let pool = if name == ":memory:" { + // In-memory DBs are never encrypted — nothing persisted for a key to protect. ConnectionPool::single_connection( Connection::open_in_memory().map_err(PowerSyncError::from)?, ) + } else if let Some(key) = encryption_key { + open_encrypted_pool(name, key)? } else { + // No key supplied: byte-for-byte the pre-existing path. ConnectionPool::open(name)? }; @@ -89,6 +94,55 @@ impl PowerSync { } } +/// Builds a connection pool whose every connection is keyed with SQLCipher +/// BEFORE any other statement runs. This mirrors `ConnectionPool::open`'s +/// pragmas, but injects `PRAGMA key` as statement #1 — which +/// `ConnectionPool::open` cannot do, because it runs `PRAGMA journal_mode=WAL` +/// first, and that reads the encrypted file header and fails before a key can +/// be set. +#[cfg(feature = "encryption")] +fn open_encrypted_pool(name: &str, key: &str) -> Result { + // Writer — key first, then replicate the pragmas `ConnectionPool::open` sets on its writer. + let writer = Connection::open(name).map_err(PowerSyncError::from)?; + writer + .pragma_update(None, "key", key) + .map_err(PowerSyncError::from)?; + writer + .pragma_update(None, "journal_mode", "WAL") + .map_err(PowerSyncError::from)?; + writer + .pragma_update(None, "journal_size_limit", 6 * 1024 * 1024) + .map_err(PowerSyncError::from)?; + writer + .pragma_update(None, "busy_timeout", 30_000) + .map_err(PowerSyncError::from)?; + writer + .pragma_update(None, "cache_size", 50 * 1024) + .map_err(PowerSyncError::from)?; + + // 5 readers — key first, then query_only. + let mut readers = Vec::with_capacity(5); + for _ in 0..5 { + let reader = Connection::open(name).map_err(PowerSyncError::from)?; + reader + .pragma_update(None, "key", key) + .map_err(PowerSyncError::from)?; + reader + .pragma_update(None, "query_only", true) + .map_err(PowerSyncError::from)?; + readers.push(reader); + } + + Ok(ConnectionPool::wrap_connections(writer, readers)) +} + +#[cfg(not(feature = "encryption"))] +fn open_encrypted_pool(_name: &str, _key: &str) -> Result { + Err(crate::error::PowerSyncTauriError::EncryptionUnavailable( + "encryption_key was supplied but this build lacks the `encryption` feature; rebuild tauri-plugin-powersync with features = [\"encryption\"]".into(), + )) +} + /// Initializes the plugin. pub fn init() -> TauriPlugin { Builder::new("powersync") @@ -105,3 +159,91 @@ pub fn init() -> TauriPlugin { }) .build() } + +#[cfg(all(test, feature = "encryption"))] +mod tests { + use super::*; + + fn temp_db_path(label: &str) -> std::path::PathBuf { + std::env::temp_dir().join(format!( + "tauri-plugin-powersync-test-{}-{}.sqlite", + label, + std::process::id() + )) + } + + #[test] + fn encrypted_pool_round_trips_with_correct_key() { + PowerSyncEnvironment::powersync_auto_extension().unwrap(); + let path = temp_db_path("roundtrip"); + let _ = std::fs::remove_file(&path); + + { + let pool = open_encrypted_pool(path.to_str().unwrap(), "correct-horse-battery-staple").unwrap(); + pool.writer_sync() + .execute("CREATE TABLE t (id INTEGER PRIMARY KEY)", []) + .unwrap(); + } + + // Reopening with the SAME key must see the table that was just created. + let pool = open_encrypted_pool(path.to_str().unwrap(), "correct-horse-battery-staple").unwrap(); + let count: i64 = pool + .writer_sync() + .query_row( + "SELECT count(*) FROM sqlite_master WHERE type='table' AND name='t'", + [], + |row| row.get(0), + ) + .unwrap(); + assert_eq!(count, 1); + + let _ = std::fs::remove_file(&path); + } + + #[test] + fn encrypted_pool_rejects_wrong_key() { + PowerSyncEnvironment::powersync_auto_extension().unwrap(); + let path = temp_db_path("wrongkey"); + let _ = std::fs::remove_file(&path); + + { + let pool = open_encrypted_pool(path.to_str().unwrap(), "right-key").unwrap(); + pool.writer_sync() + .execute("CREATE TABLE t (id INTEGER PRIMARY KEY)", []) + .unwrap(); + } + + // Reopening with the WRONG key must fail to read the schema — SQLCipher + // returns a "not a database" / decryption error on the first real read. + // That read happens inside `open_encrypted_pool` itself (it installs update + // hooks via a query against the writer connection right after keying it), + // so the error surfaces from `open_encrypted_pool`, not a later query. + let result = open_encrypted_pool(path.to_str().unwrap(), "wrong-key"); + assert!(result.is_err(), "wrong key must not be able to read the schema"); + + let _ = std::fs::remove_file(&path); + } +} + +#[cfg(all(test, not(feature = "encryption")))] +mod feature_off_tests { + use super::*; + + #[test] + fn open_encrypted_pool_hard_errors_without_encryption_feature() { + let path = std::env::temp_dir().join(format!( + "tauri-plugin-powersync-test-feature-off-{}.sqlite", + std::process::id() + )); + let _ = std::fs::remove_file(&path); + + let result = open_encrypted_pool(path.to_str().unwrap(), "some-key"); + assert!( + matches!(result, Err(crate::error::PowerSyncTauriError::EncryptionUnavailable(_))), + "expected a hard EncryptionUnavailable error when the `encryption` feature is off, got {:?}", + result.map(|_| ()) + ); + + let _ = std::fs::remove_file(&path); + } +}