Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .github/workflows/SignedPackageFileList.txt
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
**/CommunityToolkit.Datasync.Client
**/CommunityToolkit.Datasync.Client.EncryptedSqlite
**/CommunityToolkit.Datasync.Server.Abstractions
**/CommunityToolkit.Datasync.Server.Automapper
**/CommunityToolkit.Datasync.Server.CosmosDb
Expand Down
14 changes: 14 additions & 0 deletions Datasync.Toolkit.sln
Original file line number Diff line number Diff line change
Expand Up @@ -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}"
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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}
Expand Down
2 changes: 2 additions & 0 deletions Directory.Packages.props
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@
<PackageVersion Include="Microsoft.EntityFrameworkCore.InMemory" Version="$(EFCoreVersion)" />
<PackageVersion Include="Microsoft.EntityFrameworkCore.Proxies" Version="$(EFCoreVersion)" />
<PackageVersion Include="Microsoft.EntityFrameworkCore.Sqlite" Version="$(EFCoreVersion)" />
<PackageVersion Include="Microsoft.EntityFrameworkCore.Sqlite.Core" Version="$(EFCoreVersion)" />
<PackageVersion Include="Microsoft.EntityFrameworkCore.Relational" Version="$(EFCoreVersion)" />
<PackageVersion Include="Microsoft.EntityFrameworkCore.SqlServer" Version="$(EFCoreVersion)" />
<PackageVersion Include="Microsoft.Extensions.Caching.Memory" Version="$(DotNetVersion)" />
Expand All @@ -38,6 +39,7 @@
<PackageVersion Include="NSubstitute" Version="5.3.0" />
<PackageVersion Include="NSwag.AspNetCore" Version="14.7.1" />
<PackageVersion Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="10.0.2" />
<PackageVersion Include="SQLite3MC.PCLRaw.bundle" Version="2.3.5" />
<PackageVersion Include="Swashbuckle.AspNetCore" Version="10.2.1" />
<PackageVersion Include="System.Formats.Asn1" Version="$(DotNetVersion)" />
<PackageVersion Include="System.Linq.Async" Version="7.0.0" />
Expand Down
82 changes: 82 additions & 0 deletions docs/in-depth/client/encryption.md
Original file line number Diff line number Diff line change
@@ -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 &mdash; the SQLite Encryption Extension (SEE) &mdash; 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) &mdash; 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<TodoItem> TodoItems => Set<TodoItem>();
}

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/).
3 changes: 3 additions & 0 deletions docs/in-depth/client/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
1 change: 1 addition & 0 deletions mkdocs.shared.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<Description>Adds an encrypted SQLite offline store (via SQLite3 Multiple Ciphers - free, no paid license) for the Datasync Toolkit offline client.</Description>
</PropertyGroup>

<ItemGroup>
<InternalsVisibleTo Include="CommunityToolkit.Datasync.Client.EncryptedSqlite.Test" />
</ItemGroup>

<ItemGroup>
<ProjectReference Include="..\CommunityToolkit.Datasync.Client\CommunityToolkit.Datasync.Client.csproj" />
</ItemGroup>

<ItemGroup>
<PackageReference Include="SQLite3MC.PCLRaw.bundle" />
</ItemGroup>
</Project>
Original file line number Diff line number Diff line change
@@ -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;

/// <summary>
/// Extension methods for configuring an <b>encrypted</b> SQLite store for an offline Datasync context.
/// </summary>
/// <remarks>
/// <para>
/// These extensions are provided by the <c>CommunityToolkit.Datasync.Client.EncryptedSqlite</c> package. They
/// configure Entity Framework Core to use SQLite with on-disk encryption provided by the free, open-source
/// (MIT-licensed) <see href="https://github.com/utelle/SQLite3MultipleCiphers">SQLite3 Multiple Ciphers</see>
/// engine - so no paid third-party license (such as the SQLite Encryption Extension) is required.
/// </para>
/// <para>
/// Reference exactly one SQLitePCLRaw bundle in your application. This package brings the SQLite3 Multiple
/// Ciphers bundle; do not also reference <c>SQLitePCLRaw.bundle_e_sqlite3</c> (the default, unencrypted bundle)
/// or any other bundle, otherwise the duplicate <c>SQLitePCL.Batteries_V2</c> initializers will conflict.
/// </para>
/// </remarks>
public static class EncryptedSqliteDbContextOptionsExtensions
{
/// <summary>
/// Configures the context to connect to an encrypted SQLite database using the supplied connection string and
/// encryption key. The key is applied via the <c>Password</c> connection-string keyword (emitting
/// <c>PRAGMA key</c>) using the SQLite3 Multiple Ciphers default cipher.
/// </summary>
/// <param name="optionsBuilder">The builder being used to configure the context.</param>
/// <param name="connectionString">The SQLite connection string, for example <c>"Data Source=app.db"</c>.</param>
/// <param name="password">The encryption key. Supply this from secure storage; never hard-code it.</param>
/// <param name="sqliteOptionsAction">An optional action to configure the underlying SQLite provider options.</param>
/// <returns>The same builder so that further configuration can be chained.</returns>
public static DbContextOptionsBuilder UseEncryptedSqlite(
this DbContextOptionsBuilder optionsBuilder,
string connectionString,
string password,
Action<SqliteDbContextOptionsBuilder>? 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);
}

/// <summary>
/// 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
/// <see cref="EncryptedSqliteFactory.CreateConnection(string, string, EncryptedSqliteOptions?)"/>). 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.
/// </summary>
/// <param name="optionsBuilder">The builder being used to configure the context.</param>
/// <param name="connection">The SQLite connection to use. The caller is responsible for disposing it.</param>
/// <param name="sqliteOptionsAction">An optional action to configure the underlying SQLite provider options.</param>
/// <returns>The same builder so that further configuration can be chained.</returns>
public static DbContextOptionsBuilder UseEncryptedSqlite(
this DbContextOptionsBuilder optionsBuilder,
SqliteConnection connection,
Action<SqliteDbContextOptionsBuilder>? sqliteOptionsAction = null)
{
ArgumentNullException.ThrowIfNull(optionsBuilder);
ArgumentNullException.ThrowIfNull(connection);

SqliteBatteries.EnsureInitialized();
return optionsBuilder.UseSqlite(connection, sqliteOptionsAction);
}

/// <summary>
/// Changes the encryption key of an existing encrypted SQLite database by issuing <c>PRAGMA rekey</c>.
/// </summary>
/// <param name="connection">A connection to the encrypted database. It will be opened if it is not already open.</param>
/// <param name="newPassword">The new encryption key. Supply this from secure storage; never hard-code it.</param>
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();
}
}
Original file line number Diff line number Diff line change
@@ -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;

/// <summary>
/// Helpers for working with encrypted SQLite databases backed by the free, open-source
/// <see href="https://github.com/utelle/SQLite3MultipleCiphers">SQLite3 Multiple Ciphers</see> engine.
/// </summary>
public static class EncryptedSqliteFactory
{
/// <summary>
/// Registers the SQLite3 Multiple Ciphers provider with SQLitePCLRaw. This is called automatically by the
/// <c>UseEncryptedSqlite</c> extension methods and <see cref="CreateConnection"/>; call it yourself only if you
/// open a <see cref="SqliteConnection"/> directly before using any of those helpers.
/// </summary>
public static void Initialize()
=> SqliteBatteries.EnsureInitialized();

/// <summary>
/// Creates and opens an encrypted <see cref="SqliteConnection"/>. Use this when you need full control of the
/// connection - for example to open a SQLCipher-compatible database via <paramref name="options"/>, 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.
/// </summary>
/// <param name="connectionString">The SQLite connection string, for example <c>"Data Source=app.db"</c>.</param>
/// <param name="password">The encryption key. Supply this from secure storage; never hard-code it.</param>
/// <param name="options">Optional cipher configuration (for example to use a SQLCipher-compatible format).</param>
/// <returns>An open, keyed <see cref="SqliteConnection"/>.</returns>
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;
}
}
}
Loading