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
62 changes: 58 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -195,17 +195,71 @@ modelBuilder.Entity<Order>()
var order = collection.FindById(new OrderId("ORD-123"));
```

### Encryption at Rest *(v5.0.0)*
### 🔒 Encryption at Rest *(v5.0.0)*
Transparent **AES-256-GCM** page-level encryption. Pages are encrypted before writing to disk and decrypted after reading — the rest of the engine (LINQ, CDC, WAL, transactions) works unchanged.

**Required namespace**: `using BLite.Core.Encryption;`

#### Simple passphrase mode (recommended)
Pass a passphrase string to `CryptoOptions`. BLite automatically generates a unique random salt per file, derives the AES-256 key via PBKDF2-SHA256 (600 000 iterations), and stores the salt in the 64-byte encrypted file header — no separate salt file needed.

```csharp
using BLite.Core;
using BLite.Core.Encryption;

// Create or open an encrypted single-file database
var crypto = new CryptoOptions("my-secret-passphrase");
using var engine = new BLiteEngine("secure.blite", crypto);
var col = engine.GetOrCreateCollection("users");
```

#### Using DocumentDbContext with encryption
```csharp
using BLite.Core;
using BLite.Core.Encryption;

public partial class AppDbContext : DocumentDbContext
{
public DocumentCollection<int, User> Users { get; set; } = null!;

// Encrypted single-file database
public AppDbContext(string path, CryptoOptions crypto)
: base(path, crypto)
{ }
}

// Create or open the encrypted database
var crypto = new CryptoOptions("my-secret-passphrase");
await using var db = new AppDbContext("secure.blite", crypto);
```

#### Advanced: manage salt externally, derive key manually
For scenarios where you need to control the salt lifecycle (e.g. storing it separately from the database file):

```csharp
// Passphrase mode (PBKDF2-SHA256, 600 000 iterations)
using var db = new AppDb("users.db", new CryptoOptions("my-secret-passphrase"));
using BLite.Core;
using BLite.Core.Encryption;

// One-time setup: generate a salt and persist it
byte[] salt = CryptoOptions.GenerateSalt(); // returns byte[32]
File.WriteAllBytes("secure.salt", salt);

// Master-key mode (HKDF-SHA256 — KMS / HSM friendly)
// Subsequent opens: read the salt, derive the key, open the database
byte[] storedSalt = File.ReadAllBytes("secure.salt");
byte[] key = CryptoOptions.DeriveKey("my-passphrase", storedSalt); // PBKDF2-SHA256, 32 bytes
using var engine = new BLiteEngine("secure.blite", CryptoOptions.FromMasterKey(key));
```

#### Master-key mode (KMS / HSM friendly)
Supply a pre-existing 32-byte key (e.g. from Azure Key Vault or AWS KMS). BLite uses HKDF-SHA256 to derive a unique subkey per physical file in multi-file mode.

```csharp
byte[] masterKey = await myKeyVault.GetKeyAsync("blite-prod");
using var db = new AppDb("users.db", CryptoOptions.FromMasterKey(masterKey));
```

#### Migration and key rotation
```csharp
// Offline migration (plaintext ↔ encrypted)
await BLiteEngine.MigrateToEncryptedAsync("old.db", "new-encrypted.db", keyProvider);
await BLiteEngine.MigrateToPlaintextAsync("old-encrypted.db", "new-plain.db", keyProvider);
Expand Down
6 changes: 4 additions & 2 deletions src/BLite.Core/Collections/DocumentCollection.cs
Original file line number Diff line number Diff line change
Expand Up @@ -144,9 +144,11 @@ public async Task ForcePruneAsync()
/// <summary>
/// Configures a generalized retention policy for this collection.
/// The policy is persisted in collection metadata and survives engine restarts.
/// Called internally from <see cref="DocumentDbContext"/> when applying builder configuration.
/// Can be called directly on the collection or via the model builder in
/// <see cref="DocumentDbContext"/> configuration.
/// </summary>
internal void SetRetentionPolicy(RetentionPolicy policy)
/// <param name="policy">The retention policy to apply. Must not be null.</param>
public void SetRetentionPolicy(RetentionPolicy policy)
Comment on lines 144 to +151
{
if (policy == null) throw new ArgumentNullException(nameof(policy));
var meta = _indexManager.GetMetadata();
Expand Down
75 changes: 73 additions & 2 deletions src/BLite.Core/Encryption/CryptoOptions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ namespace BLite.Core.Encryption;
/// </remarks>
public enum KdfAlgorithm : byte
{
/// <summary>PBKDF2 with HMAC-SHA-256 (default, 600 000 iterations — OWASP 2023).</summary>
/// <summary>PBKDF2 with HMAC-SHA-256 (default, 600 000 iterations — OWASP recommended minimum).</summary>
Pbkdf2Sha256 = 1

// Reserved values:
Expand Down Expand Up @@ -81,7 +81,7 @@ public sealed class CryptoOptions
/// </param>
/// <param name="kdf">Key-derivation function (default: <see cref="KdfAlgorithm.Pbkdf2Sha256"/>).</param>
/// <param name="iterations">
/// Number of KDF iterations (default: 600 000, aligned with OWASP 2023 guidance for PBKDF2-SHA256).
/// Number of KDF iterations (default: 600 000, aligned with current OWASP recommended minimum for PBKDF2-SHA256).
/// Higher values increase resistance to brute-force attacks at the cost of open time.
/// The iteration count is persisted in the file header, so existing databases continue to open
/// with whatever value they were created with.
Expand Down Expand Up @@ -223,4 +223,75 @@ public void ClearSecret()
_masterKey = null;
}
}

/// <summary>
/// Generates a cryptographically random 32-byte salt suitable for use with
/// <see cref="DeriveKey(string, byte[])"/> or manual PBKDF2 key derivation via
/// <see cref="System.Security.Cryptography.Rfc2898DeriveBytes"/>.
/// </summary>
Comment on lines +227 to +231
/// <returns>A new 32-byte array filled with cryptographically strong random bytes.</returns>
/// <remarks>
/// <para>
/// In the simple passphrase mode (<c>new CryptoOptions("passphrase")</c>), BLite
/// automatically generates a unique salt per file and persists it in the 64-byte file
/// header — you do <b>not</b> need to call this method for that workflow.
/// </para>
/// <para>
/// Use this method only when you need to manage the salt yourself, for example when you
/// want to derive the key externally and supply it via
/// <see cref="FromMasterKey(System.ReadOnlySpan{byte})"/>:
/// </para>
/// <code>
/// // One-time setup: generate and persist the salt
/// byte[] salt = CryptoOptions.GenerateSalt();
/// File.WriteAllBytes("secure.salt", salt);
///
/// // Subsequent opens: read the salt and derive the key, then open the database
/// byte[] salt = File.ReadAllBytes("secure.salt");
/// byte[] key = CryptoOptions.DeriveKey("my-passphrase", salt);
/// using var engine = new BLiteEngine("secure.blite", CryptoOptions.FromMasterKey(key));
/// </code>
/// </remarks>
public static byte[] GenerateSalt()
{
var salt = new byte[32];
RandomNumberGenerator.Fill(salt);
return salt;
}

/// <summary>
/// Derives a 32-byte AES-256 key from a passphrase and salt using PBKDF2-HMAC-SHA256
/// (600 000 iterations, aligned with current OWASP guidance for PBKDF2-SHA256).
/// </summary>
/// <param name="passphrase">The user-supplied secret. Must not be null or empty.</param>
/// <param name="salt">
/// The per-database salt. Must be at least 16 bytes; 32 bytes (from
/// <see cref="GenerateSalt"/>) is recommended.
/// </param>
/// <param name="iterations">
/// Number of PBKDF2 iterations (default: 600 000). Higher values increase resistance to
/// brute-force attacks at the cost of open time. Must match the value used when the database
/// was created; the iteration count is <b>not</b> stored when using
/// <see cref="FromMasterKey(System.ReadOnlySpan{byte})"/> (the salt file workflow), so
/// callers must track it themselves.
/// </param>
/// <returns>A 32-byte derived key.</returns>
/// <remarks>
/// This helper is intended for the advanced scenario where you manage the salt yourself and
/// pass the pre-derived key via <see cref="FromMasterKey(System.ReadOnlySpan{byte})"/>.
/// For the common case, pass the passphrase directly to
/// <c>new CryptoOptions("passphrase")</c> — BLite derives the key internally and
/// stores the salt in the encrypted file header automatically.
/// </remarks>
public static byte[] DeriveKey(string passphrase, byte[] salt, int iterations = 600_000)
{
if (string.IsNullOrEmpty(passphrase))
throw new ArgumentNullException(nameof(passphrase));
if (salt is null || salt.Length < 16)
throw new ArgumentException("Salt must be at least 16 bytes.", nameof(salt));
if (iterations < 1)
throw new ArgumentOutOfRangeException(nameof(iterations), "Iterations must be at least 1.");

return KeyDerivation.DeriveKeyPbkdf2(passphrase, salt, iterations);
}
Comment on lines +286 to +296
}
112 changes: 94 additions & 18 deletions tests/BLite.Tests/EncryptionTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -110,6 +110,82 @@ public void CryptoOptions_DefaultIterations_Are600000()
Assert.Equal(KdfAlgorithm.Pbkdf2Sha256, opts.Kdf);
}

[Fact]
public void CryptoOptions_GenerateSalt_Returns32Bytes()
{
var salt = CryptoOptions.GenerateSalt();
Assert.Equal(32, salt.Length);
}

[Fact]
public void CryptoOptions_GenerateSalt_ProducesUniqueValues()
{
var s1 = CryptoOptions.GenerateSalt();
var s2 = CryptoOptions.GenerateSalt();
Assert.NotEqual(s1, s2);
}

[Fact]
public void CryptoOptions_DeriveKey_Returns32Bytes()
{
var salt = CryptoOptions.GenerateSalt();
var key = CryptoOptions.DeriveKey("my-passphrase", salt);
Assert.Equal(32, key.Length);
}

[Fact]
public void CryptoOptions_DeriveKey_IsDeterministicWithSameSalt()
{
var salt = CryptoOptions.GenerateSalt();
var k1 = CryptoOptions.DeriveKey("my-passphrase", salt);
var k2 = CryptoOptions.DeriveKey("my-passphrase", salt);
Assert.Equal(k1, k2);
}

[Fact]
public void CryptoOptions_DeriveKey_DifferentSaltProducesDifferentKey()
{
var key1 = CryptoOptions.DeriveKey("my-passphrase", CryptoOptions.GenerateSalt());
var key2 = CryptoOptions.DeriveKey("my-passphrase", CryptoOptions.GenerateSalt());
Assert.NotEqual(key1, key2);
}

[Fact]
public void CryptoOptions_DeriveKey_ThrowsOnNullPassphrase()
{
var salt = CryptoOptions.GenerateSalt();
Assert.Throws<ArgumentNullException>(() => CryptoOptions.DeriveKey(null!, salt));
}

[Fact]
public void CryptoOptions_DeriveKey_ThrowsOnShortSalt()
{
Assert.Throws<ArgumentException>(() => CryptoOptions.DeriveKey("pass", new byte[8]));
}

[Fact]
public void CryptoOptions_DeriveKey_CanOpenEngineViaMasterKey()
{
var path = TempDb();
var salt = CryptoOptions.GenerateSalt();
var key = CryptoOptions.DeriveKey("test-pass", salt, iterations: 1);

// Create the encrypted database with the derived key
using (var engine = new BLiteEngine(path, CryptoOptions.FromMasterKey(key)))
{
var col = engine.GetOrCreateCollection("items");
Assert.NotNull(col);
}

// Re-derive the key from the same passphrase + salt and reopen
var key2 = CryptoOptions.DeriveKey("test-pass", salt, iterations: 1);
using (var engine = new BLiteEngine(path, CryptoOptions.FromMasterKey(key2)))
{
var col = engine.GetOrCreateCollection("items");
Assert.NotNull(col);
}
}

// ── KeyDerivation ────────────────────────────────────────────────────────

[Fact]
Expand Down Expand Up @@ -479,7 +555,7 @@ public async Task BLiteEngine_EncryptedDb_WrongPassphrase_Throws()
// ── WAL encryption ───────────────────────────────────────────────────────

[Fact]
public void WriteAheadLog_WithCrypto_RecordsAreEncryptedOnDisk()
public async Task WriteAheadLog_WithCrypto_RecordsAreEncryptedOnDisk()
{
var walPath = Path.Combine(Path.GetTempPath(), $"wal_enc_{Guid.NewGuid()}.wal");
_tempFiles.Add(walPath);
Expand All @@ -489,10 +565,10 @@ public void WriteAheadLog_WithCrypto_RecordsAreEncryptedOnDisk()

using (var wal = new BLite.Core.Transactions.WriteAheadLog(walPath, crypto))
{
wal.WriteBeginRecordAsync(1).GetAwaiter().GetResult();
wal.WriteDataRecordAsync(1, 42, new byte[] { 0xDE, 0xAD, 0xBE, 0xEF }).GetAwaiter().GetResult();
wal.WriteCommitRecordAsync(1).GetAwaiter().GetResult();
wal.FlushAsync().GetAwaiter().GetResult();
await wal.WriteBeginRecordAsync(1);
await wal.WriteDataRecordAsync(1, 42, new byte[] { 0xDE, 0xAD, 0xBE, 0xEF });
await wal.WriteCommitRecordAsync(1);
await wal.FlushAsync();
}

// The WAL file must exist and contain the 64-byte file header.
Expand All @@ -508,7 +584,7 @@ public void WriteAheadLog_WithCrypto_RecordsAreEncryptedOnDisk()
}

[Fact]
public void WriteAheadLog_WithCrypto_ReadAll_RoundTrip()
public async Task WriteAheadLog_WithCrypto_ReadAll_RoundTrip()
{
var walPath = Path.Combine(Path.GetTempPath(), $"wal_enc_{Guid.NewGuid()}.wal");
_tempFiles.Add(walPath);
Expand All @@ -521,10 +597,10 @@ public void WriteAheadLog_WithCrypto_ReadAll_RoundTrip()
// Write records
using (var wal = new BLite.Core.Transactions.WriteAheadLog(walPath, crypto))
{
wal.WriteBeginRecordAsync(7).GetAwaiter().GetResult();
wal.WriteDataRecordAsync(7, 99, afterImageBytes).GetAwaiter().GetResult();
wal.WriteCommitRecordAsync(7).GetAwaiter().GetResult();
wal.FlushAsync().GetAwaiter().GetResult();
await wal.WriteBeginRecordAsync(7);
await wal.WriteDataRecordAsync(7, 99, afterImageBytes);
await wal.WriteCommitRecordAsync(7);
await wal.FlushAsync();
}

// Read back using a fresh provider loaded from the same file header
Expand All @@ -544,7 +620,7 @@ public void WriteAheadLog_WithCrypto_ReadAll_RoundTrip()
}

[Fact]
public void WriteAheadLog_WithCrypto_TruncateAndReuse()
public async Task WriteAheadLog_WithCrypto_TruncateAndReuse()
{
var walPath = Path.Combine(Path.GetTempPath(), $"wal_enc_{Guid.NewGuid()}.wal");
_tempFiles.Add(walPath);
Expand All @@ -555,18 +631,18 @@ public void WriteAheadLog_WithCrypto_TruncateAndReuse()
using var wal = new BLite.Core.Transactions.WriteAheadLog(walPath, crypto);

// First batch
wal.WriteBeginRecordAsync(1).GetAwaiter().GetResult();
wal.WriteCommitRecordAsync(1).GetAwaiter().GetResult();
wal.FlushAsync().GetAwaiter().GetResult();
await wal.WriteBeginRecordAsync(1);
await wal.WriteCommitRecordAsync(1);
await wal.FlushAsync();

// Truncate
wal.TruncateAsync().GetAwaiter().GetResult();
await wal.TruncateAsync();
Assert.Equal(0, wal.GetCurrentSize());

// Second batch (new header should be written automatically)
wal.WriteBeginRecordAsync(2).GetAwaiter().GetResult();
wal.WriteCommitRecordAsync(2).GetAwaiter().GetResult();
wal.FlushAsync().GetAwaiter().GetResult();
await wal.WriteBeginRecordAsync(2);
await wal.WriteCommitRecordAsync(2);
await wal.FlushAsync();

var records = wal.ReadAll();
Assert.Equal(2, records.Count);
Expand Down