diff --git a/README.md b/README.md index 2e95045a..6fc74e9a 100644 --- a/README.md +++ b/README.md @@ -195,17 +195,71 @@ modelBuilder.Entity() 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 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); diff --git a/src/BLite.Core/Collections/DocumentCollection.cs b/src/BLite.Core/Collections/DocumentCollection.cs index 7df6a113..b9bccb2e 100644 --- a/src/BLite.Core/Collections/DocumentCollection.cs +++ b/src/BLite.Core/Collections/DocumentCollection.cs @@ -144,9 +144,11 @@ public async Task ForcePruneAsync() /// /// Configures a generalized retention policy for this collection. /// The policy is persisted in collection metadata and survives engine restarts. - /// Called internally from when applying builder configuration. + /// Can be called directly on the collection or via the model builder in + /// configuration. /// - internal void SetRetentionPolicy(RetentionPolicy policy) + /// The retention policy to apply. Must not be null. + public void SetRetentionPolicy(RetentionPolicy policy) { if (policy == null) throw new ArgumentNullException(nameof(policy)); var meta = _indexManager.GetMetadata(); diff --git a/src/BLite.Core/Encryption/CryptoOptions.cs b/src/BLite.Core/Encryption/CryptoOptions.cs index 40ed7030..c84299db 100644 --- a/src/BLite.Core/Encryption/CryptoOptions.cs +++ b/src/BLite.Core/Encryption/CryptoOptions.cs @@ -26,7 +26,7 @@ namespace BLite.Core.Encryption; /// public enum KdfAlgorithm : byte { - /// PBKDF2 with HMAC-SHA-256 (default, 600 000 iterations — OWASP 2023). + /// PBKDF2 with HMAC-SHA-256 (default, 600 000 iterations — OWASP recommended minimum). Pbkdf2Sha256 = 1 // Reserved values: @@ -81,7 +81,7 @@ public sealed class CryptoOptions /// /// Key-derivation function (default: ). /// - /// 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. @@ -223,4 +223,75 @@ public void ClearSecret() _masterKey = null; } } + + /// + /// Generates a cryptographically random 32-byte salt suitable for use with + /// or manual PBKDF2 key derivation via + /// . + /// + /// A new 32-byte array filled with cryptographically strong random bytes. + /// + /// + /// In the simple passphrase mode (new CryptoOptions("passphrase")), BLite + /// automatically generates a unique salt per file and persists it in the 64-byte file + /// header — you do not need to call this method for that workflow. + /// + /// + /// 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 + /// : + /// + /// + /// // 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)); + /// + /// + public static byte[] GenerateSalt() + { + var salt = new byte[32]; + RandomNumberGenerator.Fill(salt); + return salt; + } + + /// + /// 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). + /// + /// The user-supplied secret. Must not be null or empty. + /// + /// The per-database salt. Must be at least 16 bytes; 32 bytes (from + /// ) is recommended. + /// + /// + /// 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 not stored when using + /// (the salt file workflow), so + /// callers must track it themselves. + /// + /// A 32-byte derived key. + /// + /// This helper is intended for the advanced scenario where you manage the salt yourself and + /// pass the pre-derived key via . + /// For the common case, pass the passphrase directly to + /// new CryptoOptions("passphrase") — BLite derives the key internally and + /// stores the salt in the encrypted file header automatically. + /// + 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); + } } diff --git a/tests/BLite.Tests/EncryptionTests.cs b/tests/BLite.Tests/EncryptionTests.cs index c210cc8c..0f651b10 100644 --- a/tests/BLite.Tests/EncryptionTests.cs +++ b/tests/BLite.Tests/EncryptionTests.cs @@ -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(() => CryptoOptions.DeriveKey(null!, salt)); + } + + [Fact] + public void CryptoOptions_DeriveKey_ThrowsOnShortSalt() + { + Assert.Throws(() => 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] @@ -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); @@ -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. @@ -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); @@ -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 @@ -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); @@ -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);