diff --git a/.github/workflows/SignedPackageFileList.txt b/.github/workflows/SignedPackageFileList.txt
index 7c59cb6..ec2a6ae 100644
--- a/.github/workflows/SignedPackageFileList.txt
+++ b/.github/workflows/SignedPackageFileList.txt
@@ -1,4 +1,5 @@
**/CommunityToolkit.Datasync.Client
+**/CommunityToolkit.Datasync.Client.EncryptedSqlite
**/CommunityToolkit.Datasync.Server.Abstractions
**/CommunityToolkit.Datasync.Server.Automapper
**/CommunityToolkit.Datasync.Server.CosmosDb
diff --git a/Datasync.Toolkit.sln b/Datasync.Toolkit.sln
index 665b469..85193fd 100644
--- a/Datasync.Toolkit.sln
+++ b/Datasync.Toolkit.sln
@@ -60,6 +60,10 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "CommunityToolkit.Datasync.C
EndProject
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "CommunityToolkit.Datasync.Client.Test", "tests\CommunityToolkit.Datasync.Client.Test\CommunityToolkit.Datasync.Client.Test.csproj", "{2889E6B2-9CD1-437C-A43C-98CFAFF68B99}"
EndProject
+Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "CommunityToolkit.Datasync.Client.EncryptedSqlite", "src\CommunityToolkit.Datasync.Client.EncryptedSqlite\CommunityToolkit.Datasync.Client.EncryptedSqlite.csproj", "{9BF4A280-C2CF-4EA1-9B5C-1FC600BB4AA3}"
+EndProject
+Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "CommunityToolkit.Datasync.Client.EncryptedSqlite.Test", "tests\CommunityToolkit.Datasync.Client.EncryptedSqlite.Test\CommunityToolkit.Datasync.Client.EncryptedSqlite.Test.csproj", "{5AC81D60-8D19-45F6-96D8-2A7BBF5C311F}"
+EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "CommunityToolkit.Datasync.Server.CosmosDb", "src\CommunityToolkit.Datasync.Server.CosmosDb\CommunityToolkit.Datasync.Server.CosmosDb.csproj", "{D9356867-0A30-4B17-BD4C-0F7EF70984C6}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "CommunityToolkit.Datasync.Server.MongoDB", "src\CommunityToolkit.Datasync.Server.MongoDB\CommunityToolkit.Datasync.Server.MongoDB.csproj", "{DC20ACF9-12E9-41D9-B672-CB5FD85548E9}"
@@ -158,6 +162,14 @@ Global
{2889E6B2-9CD1-437C-A43C-98CFAFF68B99}.Debug|Any CPU.Build.0 = Debug|Any CPU
{2889E6B2-9CD1-437C-A43C-98CFAFF68B99}.Release|Any CPU.ActiveCfg = Release|Any CPU
{2889E6B2-9CD1-437C-A43C-98CFAFF68B99}.Release|Any CPU.Build.0 = Release|Any CPU
+ {9BF4A280-C2CF-4EA1-9B5C-1FC600BB4AA3}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {9BF4A280-C2CF-4EA1-9B5C-1FC600BB4AA3}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {9BF4A280-C2CF-4EA1-9B5C-1FC600BB4AA3}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {9BF4A280-C2CF-4EA1-9B5C-1FC600BB4AA3}.Release|Any CPU.Build.0 = Release|Any CPU
+ {5AC81D60-8D19-45F6-96D8-2A7BBF5C311F}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {5AC81D60-8D19-45F6-96D8-2A7BBF5C311F}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {5AC81D60-8D19-45F6-96D8-2A7BBF5C311F}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {5AC81D60-8D19-45F6-96D8-2A7BBF5C311F}.Release|Any CPU.Build.0 = Release|Any CPU
{A9967817-2A2C-4C6D-A133-967A6062E9B3}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{A9967817-2A2C-4C6D-A133-967A6062E9B3}.Debug|Any CPU.Build.0 = Debug|Any CPU
{A9967817-2A2C-4C6D-A133-967A6062E9B3}.Release|Any CPU.ActiveCfg = Release|Any CPU
@@ -211,6 +223,8 @@ Global
{45D47A4E-AD58-40C8-B4CC-95BC888C47A7} = {84AD662A-4B9E-4E64-834D-72529FB7FCE5}
{D3B72031-D4BD-44D3-973C-2752AB1570F6} = {84AD662A-4B9E-4E64-834D-72529FB7FCE5}
{2889E6B2-9CD1-437C-A43C-98CFAFF68B99} = {D59F1489-5D74-4F52-B78B-88037EAB2838}
+ {9BF4A280-C2CF-4EA1-9B5C-1FC600BB4AA3} = {84AD662A-4B9E-4E64-834D-72529FB7FCE5}
+ {5AC81D60-8D19-45F6-96D8-2A7BBF5C311F} = {D59F1489-5D74-4F52-B78B-88037EAB2838}
{D9356867-0A30-4B17-BD4C-0F7EF70984C6} = {84AD662A-4B9E-4E64-834D-72529FB7FCE5}
{DC20ACF9-12E9-41D9-B672-CB5FD85548E9} = {84AD662A-4B9E-4E64-834D-72529FB7FCE5}
{4FC45D20-0BA9-484B-9040-641687659AF6} = {D59F1489-5D74-4F52-B78B-88037EAB2838}
diff --git a/Directory.Packages.props b/Directory.Packages.props
index 5fa1f64..661e784 100644
--- a/Directory.Packages.props
+++ b/Directory.Packages.props
@@ -23,6 +23,7 @@
+
@@ -38,6 +39,7 @@
+
diff --git a/docs/in-depth/client/encryption.md b/docs/in-depth/client/encryption.md
new file mode 100644
index 0000000..f9ffbc2
--- /dev/null
+++ b/docs/in-depth/client/encryption.md
@@ -0,0 +1,82 @@
+# Encrypted offline store
+
+The offline store is a standard Entity Framework Core SQLite database, so by default it is **not** encrypted. If your application stores sensitive data offline, you can encrypt the database file on disk.
+
+## Background: SQLitePCLRaw 3 and encryption
+
+Historically, applications enabled SQLite encryption by swapping the default `SQLitePCLRaw.bundle_e_sqlite3` bundle for `SQLitePCLRaw.bundle_e_sqlcipher`. As of **SQLitePCLRaw 3.0** (pulled in by EF Core 10), the free encryption bundles (`bundle_e_sqlcipher` and `bundle_e_sqlite3mc`) are no longer distributed, and the maintainer's recommended replacement — the SQLite Encryption Extension (SEE) — requires a **paid license**.
+
+The `CommunityToolkit.Datasync.Client.EncryptedSqlite` package provides encryption **without a paid third-party license** by using [SQLite3 Multiple Ciphers](https://github.com/utelle/SQLite3MultipleCiphers) (SQLite3MC) — an open-source, MIT-licensed encryption engine that is compatible with SQLCipher database files.
+
+!!! warning Reference exactly one SQLitePCLRaw bundle
+ A project may reference only **one** SQLitePCLRaw bundle. The base `CommunityToolkit.Datasync.Client` package uses the bundle-less `Microsoft.EntityFrameworkCore.Sqlite.Core` provider so that you can choose the native library:
+
+ * For a **plaintext** offline store, add `SQLitePCLRaw.bundle_e_sqlite3`.
+ * For an **encrypted** offline store, add `CommunityToolkit.Datasync.Client.EncryptedSqlite` (which brings the SQLite3MC bundle).
+
+ Do not reference both. Two bundles produce a duplicate `SQLitePCL.Batteries_V2` and will not compile.
+
+## Set up
+
+1. Install the `CommunityToolkit.Datasync.Client.EncryptedSqlite` package from NuGet. Do not add any other SQLitePCLRaw bundle to the application.
+
+2. Configure your `OfflineDbContext` to use the encrypted store via `UseEncryptedSqlite`, supplying the encryption key from secure storage (for example the platform keychain/keystore):
+
+ public class AppDbContext : OfflineDbContext
+ {
+ private readonly string encryptionKey;
+
+ public AppDbContext(string encryptionKey)
+ {
+ this.encryptionKey = encryptionKey;
+ }
+
+ protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
+ {
+ if (!optionsBuilder.IsConfigured)
+ {
+ optionsBuilder.UseEncryptedSqlite("Data Source=app.db", this.encryptionKey);
+ }
+
+ base.OnConfiguring(optionsBuilder);
+ }
+
+ protected override void OnDatasyncInitialization(DatasyncOfflineOptionsBuilder optionsBuilder)
+ {
+ optionsBuilder.UseEndpoint(new Uri("https://YOURSITEHERE.azurewebsites.net/"));
+ }
+
+ public DbSet TodoItems => Set();
+ }
+
+ The `UseEncryptedSqlite` extension lives in the `Microsoft.EntityFrameworkCore` namespace (like the built-in `UseSqlite`), so no additional `using` directive is required for the common case.
+
+!!! note Never hard-code the key
+ The encryption key should come from a secure source such as the device keychain/keystore, a user-derived passphrase, or a secret store. Do not hard-code it in source or configuration.
+
+## Changing the key
+
+Use `RekeyEncryptedSqlite` on a connection opened with the current key to re-encrypt the database with a new key:
+
+ using CommunityToolkit.Datasync.Client.EncryptedSqlite;
+
+ using SqliteConnection connection = EncryptedSqliteFactory.CreateConnection("Data Source=app.db", currentKey);
+ connection.RekeyEncryptedSqlite(newKey);
+
+## Opening an existing SQLCipher database
+
+If you are migrating from a database created with SQLCipher, select the `sqlcipher` cipher (and the matching legacy compatibility level) when opening the connection, then pass that connection to `UseEncryptedSqlite`:
+
+ using CommunityToolkit.Datasync.Client.EncryptedSqlite;
+
+ EncryptedSqliteOptions options = new() { Cipher = "sqlcipher", LegacyCompatibility = 4 };
+ SqliteConnection connection = EncryptedSqliteFactory.CreateConnection("Data Source=legacy.db", key, options);
+
+ // The caller owns the connection and is responsible for disposing it.
+ optionsBuilder.UseEncryptedSqlite(connection);
+
+The `CreateConnection` helper opens and keys the connection for you; because the context does not own the connection, dispose it yourself when you are finished.
+
+## Support and further information
+
+For more information about the underlying encryption engine and the available cipher schemes, review the [SQLite3 Multiple Ciphers documentation](https://utelle.github.io/SQLite3MultipleCiphers/).
diff --git a/docs/in-depth/client/index.md b/docs/in-depth/client/index.md
index 5a8a27f..fad252f 100644
--- a/docs/in-depth/client/index.md
+++ b/docs/in-depth/client/index.md
@@ -31,6 +31,9 @@ Use the `OfflineDbContext` as the base for your offline storage:
The Datasync Community Toolkit does not rely on the storage of the `UpdatedAt` field in your model for synchronization.
+!!! note Choosing a SQLite native library
+ The client uses the bundle-less `Microsoft.EntityFrameworkCore.Sqlite.Core` provider, so your application must reference exactly one SQLitePCLRaw bundle: add `SQLitePCLRaw.bundle_e_sqlite3` for a plaintext store, or the `CommunityToolkit.Datasync.Client.EncryptedSqlite` package for an [encrypted offline store](./encryption.md).
+
Each synchronizable entity in an offline context **MUST** have the following properties:
* `Id` - string, primary key - the globally unique ID for the entity.
diff --git a/mkdocs.shared.yml b/mkdocs.shared.yml
index 3d934c5..fe2a81e 100644
--- a/mkdocs.shared.yml
+++ b/mkdocs.shared.yml
@@ -61,6 +61,7 @@ nav:
- The basics: in-depth/client/index.md
- Authentication: in-depth/client/auth.md
- Online operations: in-depth/client/online.md
+ - Encrypted offline store: in-depth/client/encryption.md
- Advanced topics:
- MAUI AOT: in-depth/client/advanced/maui-aot.md
- Samples:
diff --git a/src/CommunityToolkit.Datasync.Client.EncryptedSqlite/CommunityToolkit.Datasync.Client.EncryptedSqlite.csproj b/src/CommunityToolkit.Datasync.Client.EncryptedSqlite/CommunityToolkit.Datasync.Client.EncryptedSqlite.csproj
new file mode 100644
index 0000000..9618d1e
--- /dev/null
+++ b/src/CommunityToolkit.Datasync.Client.EncryptedSqlite/CommunityToolkit.Datasync.Client.EncryptedSqlite.csproj
@@ -0,0 +1,17 @@
+
+
+ Adds an encrypted SQLite offline store (via SQLite3 Multiple Ciphers - free, no paid license) for the Datasync Toolkit offline client.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/CommunityToolkit.Datasync.Client.EncryptedSqlite/EncryptedSqliteDbContextOptionsExtensions.cs b/src/CommunityToolkit.Datasync.Client.EncryptedSqlite/EncryptedSqliteDbContextOptionsExtensions.cs
new file mode 100644
index 0000000..f4d19a2
--- /dev/null
+++ b/src/CommunityToolkit.Datasync.Client.EncryptedSqlite/EncryptedSqliteDbContextOptionsExtensions.cs
@@ -0,0 +1,106 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+// See the LICENSE file in the project root for more information.
+
+using System.Data;
+using CommunityToolkit.Datasync.Client.EncryptedSqlite;
+using Microsoft.Data.Sqlite;
+using Microsoft.EntityFrameworkCore.Infrastructure;
+
+namespace Microsoft.EntityFrameworkCore;
+
+///
+/// Extension methods for configuring an encrypted SQLite store for an offline Datasync context.
+///
+///
+///
+/// These extensions are provided by the CommunityToolkit.Datasync.Client.EncryptedSqlite package. They
+/// configure Entity Framework Core to use SQLite with on-disk encryption provided by the free, open-source
+/// (MIT-licensed) SQLite3 Multiple Ciphers
+/// engine - so no paid third-party license (such as the SQLite Encryption Extension) is required.
+///
+///
+/// Reference exactly one SQLitePCLRaw bundle in your application. This package brings the SQLite3 Multiple
+/// Ciphers bundle; do not also reference SQLitePCLRaw.bundle_e_sqlite3 (the default, unencrypted bundle)
+/// or any other bundle, otherwise the duplicate SQLitePCL.Batteries_V2 initializers will conflict.
+///
+///
+public static class EncryptedSqliteDbContextOptionsExtensions
+{
+ ///
+ /// Configures the context to connect to an encrypted SQLite database using the supplied connection string and
+ /// encryption key. The key is applied via the Password connection-string keyword (emitting
+ /// PRAGMA key) using the SQLite3 Multiple Ciphers default cipher.
+ ///
+ /// The builder being used to configure the context.
+ /// The SQLite connection string, for example "Data Source=app.db".
+ /// The encryption key. Supply this from secure storage; never hard-code it.
+ /// An optional action to configure the underlying SQLite provider options.
+ /// The same builder so that further configuration can be chained.
+ public static DbContextOptionsBuilder UseEncryptedSqlite(
+ this DbContextOptionsBuilder optionsBuilder,
+ string connectionString,
+ string password,
+ Action? sqliteOptionsAction = null)
+ {
+ ArgumentNullException.ThrowIfNull(optionsBuilder);
+ ArgumentException.ThrowIfNullOrWhiteSpace(connectionString);
+ ArgumentException.ThrowIfNullOrEmpty(password);
+
+ SqliteBatteries.EnsureInitialized();
+ string keyedConnectionString = new SqliteConnectionStringBuilder(connectionString) { Password = password }.ConnectionString;
+ return optionsBuilder.UseSqlite(keyedConnectionString, sqliteOptionsAction);
+ }
+
+ ///
+ /// Configures the context to connect to an encrypted SQLite database using a connection that the caller has
+ /// already created (and, if required, keyed - for example via
+ /// ). Use this overload
+ /// when you need full control over the connection, such as opening a SQLCipher-compatible database or keeping a
+ /// single connection alive for the lifetime of the application.
+ ///
+ /// The builder being used to configure the context.
+ /// The SQLite connection to use. The caller is responsible for disposing it.
+ /// An optional action to configure the underlying SQLite provider options.
+ /// The same builder so that further configuration can be chained.
+ public static DbContextOptionsBuilder UseEncryptedSqlite(
+ this DbContextOptionsBuilder optionsBuilder,
+ SqliteConnection connection,
+ Action? sqliteOptionsAction = null)
+ {
+ ArgumentNullException.ThrowIfNull(optionsBuilder);
+ ArgumentNullException.ThrowIfNull(connection);
+
+ SqliteBatteries.EnsureInitialized();
+ return optionsBuilder.UseSqlite(connection, sqliteOptionsAction);
+ }
+
+ ///
+ /// Changes the encryption key of an existing encrypted SQLite database by issuing PRAGMA rekey.
+ ///
+ /// A connection to the encrypted database. It will be opened if it is not already open.
+ /// The new encryption key. Supply this from secure storage; never hard-code it.
+ public static void RekeyEncryptedSqlite(this SqliteConnection connection, string newPassword)
+ {
+ ArgumentNullException.ThrowIfNull(connection);
+ ArgumentException.ThrowIfNullOrEmpty(newPassword);
+
+ SqliteBatteries.EnsureInitialized();
+ if (connection.State != ConnectionState.Open)
+ {
+ connection.Open();
+ }
+
+ using SqliteCommand command = connection.CreateCommand();
+
+ // PRAGMA rekey is not supported in WAL journal mode (the SQLite3 Multiple Ciphers build opens databases in
+ // WAL by default), so switch to a rollback journal before re-keying.
+ command.CommandText = "PRAGMA journal_mode = DELETE;";
+ _ = command.ExecuteNonQuery();
+
+#pragma warning disable CA2100 // The key is escaped via SqliteLiteral.Quote; PRAGMA arguments cannot be parameterized.
+ command.CommandText = $"PRAGMA rekey = {SqliteLiteral.Quote(newPassword)};";
+#pragma warning restore CA2100
+ _ = command.ExecuteNonQuery();
+ }
+}
diff --git a/src/CommunityToolkit.Datasync.Client.EncryptedSqlite/EncryptedSqliteFactory.cs b/src/CommunityToolkit.Datasync.Client.EncryptedSqlite/EncryptedSqliteFactory.cs
new file mode 100644
index 0000000..55b1124
--- /dev/null
+++ b/src/CommunityToolkit.Datasync.Client.EncryptedSqlite/EncryptedSqliteFactory.cs
@@ -0,0 +1,58 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+// See the LICENSE file in the project root for more information.
+
+using Microsoft.Data.Sqlite;
+
+namespace CommunityToolkit.Datasync.Client.EncryptedSqlite;
+
+///
+/// Helpers for working with encrypted SQLite databases backed by the free, open-source
+/// SQLite3 Multiple Ciphers engine.
+///
+public static class EncryptedSqliteFactory
+{
+ ///
+ /// Registers the SQLite3 Multiple Ciphers provider with SQLitePCLRaw. This is called automatically by the
+ /// UseEncryptedSqlite extension methods and ; call it yourself only if you
+ /// open a directly before using any of those helpers.
+ ///
+ public static void Initialize()
+ => SqliteBatteries.EnsureInitialized();
+
+ ///
+ /// Creates and opens an encrypted . Use this when you need full control of the
+ /// connection - for example to open a SQLCipher-compatible database via , or to keep a
+ /// single connection alive for the lifetime of the application. The caller owns the returned connection and is
+ /// responsible for disposing it.
+ ///
+ /// The SQLite connection string, for example "Data Source=app.db".
+ /// The encryption key. Supply this from secure storage; never hard-code it.
+ /// Optional cipher configuration (for example to use a SQLCipher-compatible format).
+ /// An open, keyed .
+ public static SqliteConnection CreateConnection(string connectionString, string password, EncryptedSqliteOptions? options = null)
+ {
+ ArgumentException.ThrowIfNullOrWhiteSpace(connectionString);
+ ArgumentException.ThrowIfNullOrEmpty(password);
+
+ SqliteBatteries.EnsureInitialized();
+
+ if (options is null)
+ {
+ // The Password keyword causes Microsoft.Data.Sqlite to emit PRAGMA key on open using the default cipher.
+ SqliteConnectionStringBuilder builder = new(connectionString) { Password = password };
+ SqliteConnection connection = new(builder.ConnectionString);
+ connection.Open();
+ return connection;
+ }
+ else
+ {
+ // Apply the cipher configuration and key explicitly so that a non-default (for example SQLCipher) cipher
+ // is selected before the key is set.
+ SqliteConnection connection = new(connectionString);
+ connection.Open();
+ options.Apply(connection, password);
+ return connection;
+ }
+ }
+}
diff --git a/src/CommunityToolkit.Datasync.Client.EncryptedSqlite/EncryptedSqliteOptions.cs b/src/CommunityToolkit.Datasync.Client.EncryptedSqlite/EncryptedSqliteOptions.cs
new file mode 100644
index 0000000..3a22b6a
--- /dev/null
+++ b/src/CommunityToolkit.Datasync.Client.EncryptedSqlite/EncryptedSqliteOptions.cs
@@ -0,0 +1,65 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+// See the LICENSE file in the project root for more information.
+
+using System.Text;
+using Microsoft.Data.Sqlite;
+
+namespace CommunityToolkit.Datasync.Client.EncryptedSqlite;
+
+///
+/// Optional cipher configuration for an encrypted SQLite offline store. These options are only required when you
+/// need to deviate from the SQLite3 Multiple Ciphers default cipher - most commonly to open or create a database in
+/// a SQLCipher-compatible format.
+///
+///
+/// See the SQLite3
+/// Multiple Ciphers PRAGMA reference for the full set of supported cipher schemes and legacy values.
+///
+public sealed class EncryptedSqliteOptions
+{
+ ///
+ /// The cipher scheme to use, for example "sqlcipher" to read or write databases that are compatible with
+ /// SQLCipher. When (the default), the SQLite3 Multiple Ciphers default cipher is used.
+ ///
+ public string? Cipher { get; set; }
+
+ ///
+ /// The legacy compatibility level for the selected , for example 4 to match a database
+ /// created by SQLCipher version 4. When (the default), no legacy PRAGMA is issued.
+ ///
+ public int? LegacyCompatibility { get; set; }
+
+ ///
+ /// Applies the cipher configuration and encryption key to an already-open .
+ ///
+ /// An open connection to the encrypted database.
+ /// The encryption key supplied by the caller.
+ public void Apply(SqliteConnection connection, string password)
+ {
+ ArgumentNullException.ThrowIfNull(connection);
+ ArgumentException.ThrowIfNullOrEmpty(password);
+
+ // The cipher configuration and key must be applied as the very first statements on the connection: when
+ // opening an existing encrypted database, no other statement (not even SELECT quote(...)) can run until the
+ // key has been set, otherwise SQLite reports "file is not a database".
+ StringBuilder pragmas = new();
+ if (Cipher is not null)
+ {
+ _ = pragmas.Append($"PRAGMA cipher = {SqliteLiteral.Quote(Cipher)};");
+ }
+
+ if (LegacyCompatibility is int legacy)
+ {
+ _ = pragmas.Append($"PRAGMA legacy = {legacy};");
+ }
+
+ _ = pragmas.Append($"PRAGMA key = {SqliteLiteral.Quote(password)};");
+
+ using SqliteCommand command = connection.CreateCommand();
+#pragma warning disable CA2100 // Values are escaped via SqliteLiteral.Quote; PRAGMA arguments cannot be parameterized.
+ command.CommandText = pragmas.ToString();
+#pragma warning restore CA2100
+ _ = command.ExecuteNonQuery();
+ }
+}
diff --git a/src/CommunityToolkit.Datasync.Client.EncryptedSqlite/SqliteBatteries.cs b/src/CommunityToolkit.Datasync.Client.EncryptedSqlite/SqliteBatteries.cs
new file mode 100644
index 0000000..c19098e
--- /dev/null
+++ b/src/CommunityToolkit.Datasync.Client.EncryptedSqlite/SqliteBatteries.cs
@@ -0,0 +1,41 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+// See the LICENSE file in the project root for more information.
+
+namespace CommunityToolkit.Datasync.Client.EncryptedSqlite;
+
+///
+/// Ensures the SQLite3 Multiple Ciphers (SQLite3MC) native provider is registered with SQLitePCLRaw.
+///
+///
+/// The encryption-enabled offline store references the bundle-less Microsoft.EntityFrameworkCore.Sqlite.Core
+/// provider together with the SQLite3MC.PCLRaw.bundle native package. Unlike the full
+/// Microsoft.EntityFrameworkCore.Sqlite package, the .Core variant does not automatically register a
+/// SQLitePCLRaw provider, so must be called once - before any
+/// is opened - to activate the SQLite3MC provider.
+///
+internal static class SqliteBatteries
+{
+ private static readonly object gate = new();
+ private static bool initialized;
+
+ ///
+ /// Registers the SQLite3MC provider exactly once for the lifetime of the process.
+ ///
+ internal static void EnsureInitialized()
+ {
+ if (initialized)
+ {
+ return;
+ }
+
+ lock (gate)
+ {
+ if (!initialized)
+ {
+ SQLitePCL.Batteries_V2.Init();
+ initialized = true;
+ }
+ }
+ }
+}
diff --git a/src/CommunityToolkit.Datasync.Client.EncryptedSqlite/SqliteLiteral.cs b/src/CommunityToolkit.Datasync.Client.EncryptedSqlite/SqliteLiteral.cs
new file mode 100644
index 0000000..822da75
--- /dev/null
+++ b/src/CommunityToolkit.Datasync.Client.EncryptedSqlite/SqliteLiteral.cs
@@ -0,0 +1,20 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+// See the LICENSE file in the project root for more information.
+
+namespace CommunityToolkit.Datasync.Client.EncryptedSqlite;
+
+///
+/// Helpers for building SQLite literals. PRAGMA statement arguments (such as the encryption key) cannot be supplied
+/// as parameters, so the value must be embedded as a properly escaped SQL string literal. Escaping the value rather
+/// than running a query (for example SELECT quote(...)) is important because, when opening an encrypted
+/// database, no statement can execute until the key has been set.
+///
+internal static class SqliteLiteral
+{
+ ///
+ /// Returns as a single-quoted SQLite string literal, doubling any embedded quotes.
+ ///
+ internal static string Quote(string value)
+ => $"'{value.Replace("'", "''")}'";
+}
diff --git a/src/CommunityToolkit.Datasync.Client/CommunityToolkit.Datasync.Client.csproj b/src/CommunityToolkit.Datasync.Client/CommunityToolkit.Datasync.Client.csproj
index fa1bf45..e712cbb 100644
--- a/src/CommunityToolkit.Datasync.Client/CommunityToolkit.Datasync.Client.csproj
+++ b/src/CommunityToolkit.Datasync.Client/CommunityToolkit.Datasync.Client.csproj
@@ -10,7 +10,13 @@
-
+
+
diff --git a/tests/CommunityToolkit.Datasync.Client.EncryptedSqlite.Test/CommunityToolkit.Datasync.Client.EncryptedSqlite.Test.csproj b/tests/CommunityToolkit.Datasync.Client.EncryptedSqlite.Test/CommunityToolkit.Datasync.Client.EncryptedSqlite.Test.csproj
new file mode 100644
index 0000000..a9f2202
--- /dev/null
+++ b/tests/CommunityToolkit.Datasync.Client.EncryptedSqlite.Test/CommunityToolkit.Datasync.Client.EncryptedSqlite.Test.csproj
@@ -0,0 +1,28 @@
+
+
+ enable
+
+
+
+
+
+
+
+ runtime; build; native; contentfiles; analyzers; buildtransitive
+ all
+
+
+ runtime; build; native; contentfiles; analyzers; buildtransitive
+ all
+
+
+ runtime; build; native; contentfiles; analyzers; buildtransitive
+ all
+
+
+
diff --git a/tests/CommunityToolkit.Datasync.Client.EncryptedSqlite.Test/EncryptedSqlite_OfflineDbContext_Tests.cs b/tests/CommunityToolkit.Datasync.Client.EncryptedSqlite.Test/EncryptedSqlite_OfflineDbContext_Tests.cs
new file mode 100644
index 0000000..b3be9b1
--- /dev/null
+++ b/tests/CommunityToolkit.Datasync.Client.EncryptedSqlite.Test/EncryptedSqlite_OfflineDbContext_Tests.cs
@@ -0,0 +1,49 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+// See the LICENSE file in the project root for more information.
+
+using CommunityToolkit.Datasync.Client.EncryptedSqlite.Test.Helpers;
+using Microsoft.EntityFrameworkCore;
+
+namespace CommunityToolkit.Datasync.Client.EncryptedSqlite.Test;
+
+[ExcludeFromCodeCoverage]
+public class EncryptedSqlite_OfflineDbContext_Tests : EncryptedSqliteTestBase
+{
+ [Fact]
+ public void OfflineDbContext_PersistsToEncryptedStore()
+ {
+ const string password = "Offl1ne-K3y!";
+ string itemId;
+
+ // Write using an OfflineDbContext backed by the encrypted store.
+ {
+ DbContextOptionsBuilder builder = new();
+ _ = builder.UseEncryptedSqlite(ConnectionString, password);
+ using OfflineTodoContext context = new(builder.Options);
+ _ = context.Database.EnsureCreated();
+
+ TodoItem item = new() { Title = "offline" };
+ itemId = item.Id;
+ _ = context.TodoItems.Add(item);
+ _ = context.SaveChanges();
+
+ // Saving a synchronizable entity enqueues an operation in the offline operations queue.
+ _ = context.DatasyncOperationsQueue.Count().Should().BeGreaterThan(0);
+ }
+
+ // The on-disk file must be encrypted.
+ AssertFileIsEncrypted();
+
+ // Re-open with the same key and read the entity back.
+ {
+ DbContextOptionsBuilder builder = new();
+ _ = builder.UseEncryptedSqlite(ConnectionString, password);
+ using OfflineTodoContext context = new(builder.Options);
+
+ TodoItem? reloaded = context.TodoItems.Find(itemId);
+ _ = reloaded.Should().NotBeNull();
+ _ = reloaded!.Title.Should().Be("offline");
+ }
+ }
+}
diff --git a/tests/CommunityToolkit.Datasync.Client.EncryptedSqlite.Test/EncryptedSqlite_Tests.cs b/tests/CommunityToolkit.Datasync.Client.EncryptedSqlite.Test/EncryptedSqlite_Tests.cs
new file mode 100644
index 0000000..5a1cba6
--- /dev/null
+++ b/tests/CommunityToolkit.Datasync.Client.EncryptedSqlite.Test/EncryptedSqlite_Tests.cs
@@ -0,0 +1,101 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+// See the LICENSE file in the project root for more information.
+
+using CommunityToolkit.Datasync.Client.EncryptedSqlite.Test.Helpers;
+using Microsoft.Data.Sqlite;
+using Microsoft.EntityFrameworkCore;
+
+namespace CommunityToolkit.Datasync.Client.EncryptedSqlite.Test;
+
+[ExcludeFromCodeCoverage]
+public class EncryptedSqlite_Tests : EncryptedSqliteTestBase
+{
+ [Fact]
+ public void EncryptedStore_RoundTrips()
+ {
+ const string password = "R0undTr1p!";
+
+ Seed(password);
+
+ _ = ReadCount(password).Should().Be(1);
+ }
+
+ [Fact]
+ public void EncryptedStore_FileHasNoPlaintextHeader()
+ {
+ Seed("Head3rT3st!");
+
+ AssertFileIsEncrypted();
+ }
+
+ [Fact]
+ public void WrongPassword_Fails()
+ {
+ Seed("correct-horse-battery-staple");
+
+ Action act = () => ReadCount("the-wrong-password");
+
+ _ = act.Should().Throw();
+ }
+
+ [Fact]
+ public void NoPassword_Fails()
+ {
+ Seed("correct-horse-battery-staple");
+
+ // The provider has been initialized (by Seed), but opening an encrypted database without a key must fail.
+ DbContextOptionsBuilder builder = new();
+ _ = builder.UseSqlite(ConnectionString);
+ using PlainTodoContext context = new(builder.Options);
+
+ Action act = () => context.TodoItems.Count();
+
+ _ = act.Should().Throw();
+ }
+
+ [Fact]
+ public void Rekey_ChangesTheEncryptionKey()
+ {
+ const string oldPassword = "0ld-K3y!";
+ const string newPassword = "N3w-K3y!";
+ Seed(oldPassword);
+
+ using (SqliteConnection connection = EncryptedSqliteFactory.CreateConnection(ConnectionString, oldPassword))
+ {
+ connection.RekeyEncryptedSqlite(newPassword);
+ }
+
+ _ = ReadCount(newPassword).Should().Be(1);
+
+ Action readWithOldKey = () => ReadCount(oldPassword);
+ _ = readWithOldKey.Should().Throw();
+ }
+
+ [Fact]
+ public void SqlCipherCompatibleFormat_RoundTrips()
+ {
+ const string password = "Sql-C1ph3r!";
+ EncryptedSqliteOptions options = new() { Cipher = "sqlcipher", LegacyCompatibility = 4 };
+
+ using (SqliteConnection writeConnection = EncryptedSqliteFactory.CreateConnection(ConnectionString, password, options))
+ {
+ DbContextOptionsBuilder builder = new();
+ _ = builder.UseEncryptedSqlite(writeConnection);
+ using PlainTodoContext context = new(builder.Options);
+ _ = context.Database.EnsureCreated();
+ _ = context.TodoItems.Add(new TodoItem { Title = "compat" });
+ _ = context.SaveChanges();
+ }
+
+ AssertFileIsEncrypted();
+
+ using (SqliteConnection readConnection = EncryptedSqliteFactory.CreateConnection(ConnectionString, password, options))
+ {
+ DbContextOptionsBuilder builder = new();
+ _ = builder.UseEncryptedSqlite(readConnection);
+ using PlainTodoContext context = new(builder.Options);
+ _ = context.TodoItems.Count().Should().Be(1);
+ }
+ }
+}
diff --git a/tests/CommunityToolkit.Datasync.Client.EncryptedSqlite.Test/GlobalUsings.cs b/tests/CommunityToolkit.Datasync.Client.EncryptedSqlite.Test/GlobalUsings.cs
new file mode 100644
index 0000000..e531aaf
--- /dev/null
+++ b/tests/CommunityToolkit.Datasync.Client.EncryptedSqlite.Test/GlobalUsings.cs
@@ -0,0 +1,7 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+// See the LICENSE file in the project root for more information.
+
+global using AwesomeAssertions;
+global using System.Diagnostics.CodeAnalysis;
+global using Xunit;
diff --git a/tests/CommunityToolkit.Datasync.Client.EncryptedSqlite.Test/Helpers/EncryptedSqliteTestBase.cs b/tests/CommunityToolkit.Datasync.Client.EncryptedSqlite.Test/Helpers/EncryptedSqliteTestBase.cs
new file mode 100644
index 0000000..e5a9365
--- /dev/null
+++ b/tests/CommunityToolkit.Datasync.Client.EncryptedSqlite.Test/Helpers/EncryptedSqliteTestBase.cs
@@ -0,0 +1,92 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+// See the LICENSE file in the project root for more information.
+
+using Microsoft.Data.Sqlite;
+using Microsoft.EntityFrameworkCore;
+
+namespace CommunityToolkit.Datasync.Client.EncryptedSqlite.Test.Helpers;
+
+///
+/// Base class for the encrypted SQLite tests. Provides a unique on-disk database file (with connection pooling
+/// disabled so the file is released between contexts) and convenience seed/read/assert helpers.
+///
+[ExcludeFromCodeCoverage]
+public abstract class EncryptedSqliteTestBase : IDisposable
+{
+ protected EncryptedSqliteTestBase()
+ {
+ DatabasePath = Path.Combine(Path.GetTempPath(), $"datasync-enc-{Guid.NewGuid():N}.db");
+ }
+
+ /// The path to the on-disk database file under test.
+ protected string DatabasePath { get; }
+
+ /// A connection string for with pooling disabled.
+ protected string ConnectionString => new SqliteConnectionStringBuilder
+ {
+ DataSource = DatabasePath,
+ Mode = SqliteOpenMode.ReadWriteCreate,
+ Pooling = false
+ }.ConnectionString;
+
+ /// Creates the encrypted database and inserts a single .
+ protected void Seed(string password)
+ {
+ DbContextOptionsBuilder builder = new();
+ _ = builder.UseEncryptedSqlite(ConnectionString, password);
+ using PlainTodoContext context = new(builder.Options);
+ _ = context.Database.EnsureCreated();
+ _ = context.TodoItems.Add(new TodoItem { Title = "Hello" });
+ _ = context.SaveChanges();
+ }
+
+ /// Opens the encrypted database with the supplied key and returns the number of stored items.
+ protected int ReadCount(string password)
+ {
+ DbContextOptionsBuilder builder = new();
+ _ = builder.UseEncryptedSqlite(ConnectionString, password);
+ using PlainTodoContext context = new(builder.Options);
+ return context.TodoItems.Count();
+ }
+
+ /// Asserts that the database file on disk does not start with the plaintext SQLite header.
+ protected void AssertFileIsEncrypted()
+ {
+ byte[] plaintextHeader = "SQLite format 3\0"u8.ToArray();
+ byte[] actual = File.ReadAllBytes(DatabasePath);
+ _ = actual.Length.Should().BeGreaterThanOrEqualTo(plaintextHeader.Length);
+ _ = actual.Take(plaintextHeader.Length).Should().NotEqual(plaintextHeader);
+ }
+
+ public void Dispose()
+ {
+ Dispose(true);
+ GC.SuppressFinalize(this);
+ }
+
+ protected virtual void Dispose(bool disposing)
+ {
+ if (!disposing)
+ {
+ return;
+ }
+
+ SqliteConnection.ClearAllPools();
+ foreach (string suffix in new[] { string.Empty, "-wal", "-shm", "-journal" })
+ {
+ try
+ {
+ string path = DatabasePath + suffix;
+ if (File.Exists(path))
+ {
+ File.Delete(path);
+ }
+ }
+ catch
+ {
+ // Best-effort cleanup of temporary files; ignore failures.
+ }
+ }
+ }
+}
diff --git a/tests/CommunityToolkit.Datasync.Client.EncryptedSqlite.Test/Helpers/TestModels.cs b/tests/CommunityToolkit.Datasync.Client.EncryptedSqlite.Test/Helpers/TestModels.cs
new file mode 100644
index 0000000..7c648db
--- /dev/null
+++ b/tests/CommunityToolkit.Datasync.Client.EncryptedSqlite.Test/Helpers/TestModels.cs
@@ -0,0 +1,44 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+// See the LICENSE file in the project root for more information.
+
+using CommunityToolkit.Datasync.Client.Offline;
+using Microsoft.EntityFrameworkCore;
+
+namespace CommunityToolkit.Datasync.Client.EncryptedSqlite.Test.Helpers;
+
+///
+/// A simple synchronizable entity used by the encrypted store tests.
+///
+[ExcludeFromCodeCoverage]
+public class TodoItem
+{
+ public string Id { get; set; } = Guid.NewGuid().ToString("N");
+ public string Title { get; set; } = string.Empty;
+ public bool Completed { get; set; }
+ public DateTimeOffset? UpdatedAt { get; set; }
+ public string? Version { get; set; }
+}
+
+///
+/// A plain (non-offline) EF Core context used to verify raw encryption behaviour.
+///
+[ExcludeFromCodeCoverage]
+public class PlainTodoContext(DbContextOptions options) : DbContext(options)
+{
+ public DbSet TodoItems => Set();
+}
+
+///
+/// An subclass used to verify that the encrypted store works with the offline client.
+///
+[ExcludeFromCodeCoverage]
+public class OfflineTodoContext(DbContextOptions options) : OfflineDbContext(options)
+{
+ public DbSet TodoItems => Set();
+
+ protected override void OnDatasyncInitialization(DatasyncOfflineOptionsBuilder optionsBuilder)
+ {
+ _ = optionsBuilder.UseEndpoint(new Uri("https://localhost/tables/todoitem"));
+ }
+}