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")); + } +}