diff --git a/.gitignore b/.gitignore index f6c3abc1..2e47de1c 100644 --- a/.gitignore +++ b/.gitignore @@ -425,3 +425,5 @@ dist/ # SonarScanner local analysis artifacts .sonarqube/ .scannerwork/ + +*.scdb diff --git a/global.json b/global.json new file mode 100644 index 00000000..d249a65c --- /dev/null +++ b/global.json @@ -0,0 +1,6 @@ +{ + "test": { + "runner": "Microsoft.Testing.Platform" + } +} + diff --git a/src/SharpCoreDB/DatabaseExtensions.cs b/src/SharpCoreDB/DatabaseExtensions.cs index 354161d0..86985cb8 100644 --- a/src/SharpCoreDB/DatabaseExtensions.cs +++ b/src/SharpCoreDB/DatabaseExtensions.cs @@ -1,4 +1,4 @@ -// +// src\SharpCoreDB\DatabaseExtensions.cs // Copyright (c) 2025-2026 MPCoreDeveloper and GitHub Copilot. All rights reserved. // Licensed under the MIT License. See LICENSE file in the project root for full license information. // @@ -81,6 +81,7 @@ public IDatabase CreateWithOptions(string dbPath, string masterPassword, Databas return options.StorageMode switch { + // ✅ ENCRYPTION: Pass 'this' to access instance method (needed for DI resolution) StorageMode.SingleFile => CreateSingleFileDatabase(dbPath, masterPassword, options), StorageMode.Directory => CreateDirectoryDatabase(dbPath, masterPassword, options), _ => throw new ArgumentException($"Invalid storage mode: {options.StorageMode}") @@ -94,7 +95,8 @@ private IDatabase CreateDirectoryDatabase(string dbPath, string masterPassword, return new Database(services, dbPath, masterPassword, options.IsReadOnly, config); } - private static IDatabase CreateSingleFileDatabase(string dbPath, string masterPassword, DatabaseOptions options) + // ✅ ENCRYPTION: Changed from static to instance method to access DI services + private IDatabase CreateSingleFileDatabase(string dbPath, string masterPassword, DatabaseOptions options) { if (options.DatabaseConfig is not null) { @@ -102,7 +104,19 @@ private static IDatabase CreateSingleFileDatabase(string dbPath, string masterPa } options.WalBufferSizePages = options.WalBufferSizePages > 0 ? options.WalBufferSizePages : 2048; options.FileShareMode = System.IO.FileShare.ReadWrite; - var provider = SingleFileStorageProvider.Open(dbPath, options); + + // ✅ ENCRYPTION: Resolve ICryptoService from DI and pass to provider + SharpCoreDB.Interfaces.ICryptoService? cryptoService = null; + if (options.EnableEncryption) + { + cryptoService = services.GetService(); + if (cryptoService is null) + throw new InvalidOperationException( + "ICryptoService must be registered in DI when EnableEncryption is true. " + + "Call services.AddSharpCoreDB() or register ICryptoService manually."); + } + + var provider = SingleFileStorageProvider.Open(dbPath, options, cryptoService); return new SingleFileDatabase(provider, dbPath, masterPassword, options); } diff --git a/src/SharpCoreDB/DatabaseOptions.cs b/src/SharpCoreDB/DatabaseOptions.cs index aa3de735..648f8636 100644 --- a/src/SharpCoreDB/DatabaseOptions.cs +++ b/src/SharpCoreDB/DatabaseOptions.cs @@ -1,4 +1,4 @@ -// +// src\SharpCoreDB\DatabaseOptions.cs // Copyright (c) 2025-2026 MPCoreDeveloper and GitHub Copilot. All rights reserved. // Licensed under the MIT License. See LICENSE file in the project root for full license information. // diff --git a/src/SharpCoreDB/SingleFileTable.cs b/src/SharpCoreDB/SingleFileTable.cs index 04343db8..233b5234 100644 --- a/src/SharpCoreDB/SingleFileTable.cs +++ b/src/SharpCoreDB/SingleFileTable.cs @@ -1,4 +1,4 @@ -// +// src\SharpCoreDB\SingleFileTable.cs // Copyright (c) 2025-2026 MPCoreDeveloper and GitHub Copilot. All rights reserved. // Licensed under the MIT License. See LICENSE file in the project root for full license information. // diff --git a/src/SharpCoreDB/Storage/Scdb/SingleFileDatabase.Batch.cs b/src/SharpCoreDB/Storage/Scdb/SingleFileDatabase.Batch.cs index 3db5eff5..3032a7bc 100644 --- a/src/SharpCoreDB/Storage/Scdb/SingleFileDatabase.Batch.cs +++ b/src/SharpCoreDB/Storage/Scdb/SingleFileDatabase.Batch.cs @@ -1,4 +1,4 @@ -// +// src\SharpCoreDB\Storage\Scdb\SingleFileDatabase.Batch.cs // Copyright (c) 2025-2026 MPCoreDeveloper and GitHub Copilot. All rights reserved. // Licensed under the MIT License. See LICENSE file in the project root for full license information. // diff --git a/src/SharpCoreDB/Storage/SingleFileStorageProvider.cs b/src/SharpCoreDB/Storage/SingleFileStorageProvider.cs index 724f1594..22c061c2 100644 --- a/src/SharpCoreDB/Storage/SingleFileStorageProvider.cs +++ b/src/SharpCoreDB/Storage/SingleFileStorageProvider.cs @@ -1,4 +1,4 @@ -// +// src\SharpCoreDB\Storage\SingleFileStorageProvider.cs // Copyright (c) 2025-2026 MPCoreDeveloper and GitHub Copilot. All rights reserved. // Licensed under the MIT License. See LICENSE file in the project root for full license information. // @@ -96,6 +96,11 @@ public sealed class SingleFileStorageProvider : IStorageProvider private const int WRITE_BATCH_SIZE = 200; // Batch 200 writes together (increased from 50) private const int WRITE_BATCH_TIMEOUT_MS = 200; // Or flush after 200ms (increased from 50ms) + // ✅ ENCRYPTION: AES-256-GCM instance for block-level encryption at rest + // Null when encryption is disabled. When non-null, all block reads/writes + // pass through Encrypt/Decrypt to ensure data is encrypted on disk. + private readonly SharpCoreDB.Services.AesGcmEncryption? _encryption; + private bool _isInTransaction; private bool _disposed; private ScdbFileHeader _header; @@ -109,13 +114,14 @@ public sealed class SingleFileStorageProvider : IStorageProvider /// Optional memory-mapped file /// File header structure private SingleFileStorageProvider(string filePath, DatabaseOptions options, FileStream fileStream, - MemoryMappedFile? mmf, ScdbFileHeader header) + MemoryMappedFile? mmf, ScdbFileHeader header, SharpCoreDB.Services.AesGcmEncryption? encryption = null) { _filePath = filePath; _options = options; _fileStream = fileStream; _memoryMappedFile = mmf; _header = header; + _encryption = encryption; _blockCache = new ConcurrentDictionary(); // Initialize subsystems @@ -134,13 +140,27 @@ private SingleFileStorageProvider(string filePath, DatabaseOptions options, File /// Path to .scdb file /// Database options /// Initialized provider - public static SingleFileStorageProvider Open(string filePath, DatabaseOptions options) + public static SingleFileStorageProvider Open(string filePath, DatabaseOptions options, SharpCoreDB.Interfaces.ICryptoService? cryptoService = null) { ArgumentException.ThrowIfNullOrWhiteSpace(filePath); ArgumentNullException.ThrowIfNull(options); options.Validate(); + // ✅ ENCRYPTION: Initialize AES-256-GCM if encryption is requested + SharpCoreDB.Services.AesGcmEncryption? encryption = null; + if (options.EnableEncryption) + { + if (cryptoService is null) + throw new InvalidOperationException( + "ICryptoService must be registered in DI when EnableEncryption is true. " + + "Call services.AddSharpCoreDB() or register ICryptoService manually."); + if (options.EncryptionKey is null || options.EncryptionKey.Length != 32) + throw new InvalidOperationException( + "EncryptionKey must be exactly 32 bytes (256 bits) when EnableEncryption is true."); + encryption = cryptoService.GetAesGcmEncryption(options.EncryptionKey); + } + // Ensure .scdb extension if (!filePath.EndsWith(".scdb", StringComparison.OrdinalIgnoreCase)) { @@ -200,7 +220,7 @@ public static SingleFileStorageProvider Open(string filePath, DatabaseOptions op } } - return new SingleFileStorageProvider(filePath, options, fileStream, mmf, header); + return new SingleFileStorageProvider(filePath, options, fileStream, mmf, header, encryption); } /// @@ -280,7 +300,19 @@ public bool BlockExists(string blockName) return null; } - // Create a sub-stream view of the block + // ✅ ENCRYPTION: If encryption is enabled, read the full block, decrypt, + // and return a read-only MemoryStream over the plaintext. + // BlockStream cannot decrypt in-place, so we must materialize the block. + if (_encryption is not null) + { + var encryptedData = new byte[(int)entry.Length]; + _fileStream.Position = (long)entry.Offset; + _fileStream.ReadExactly(encryptedData); + var plaintextData = _encryption.Decrypt(encryptedData); + return new MemoryStream(plaintextData, index: 0, count: plaintextData.Length, writable: false); + } + + // Create a sub-stream view of the block (unencrypted path) return new BlockStream(_fileStream, entry.Offset, entry.Length, FileAccess.Read); } @@ -307,12 +339,19 @@ public unsafe ReadOnlySpan GetReadSpan(string blockName) var buffer = new byte[checked((int)Math.Min(entry.Length, (ulong)int.MaxValue))]; _fileStream.Position = (long)entry.Offset; _fileStream.ReadExactly(buffer); + // ✅ ENCRYPTION: Decrypt if encryption is enabled + if (_encryption is not null) + { + return _encryption.Decrypt(buffer); + } return buffer; } // Use memory-mapped file for zero-copy access - if (_memoryMappedFile != null) + if (_memoryMappedFile != null && _encryption is null) { + // ✅ ENCRYPTION: Only use zero-copy mmap when encryption is disabled. + // Encrypted data must be read into a buffer and decrypted. try { var viewOffset = checked((long)entry.Offset); @@ -341,6 +380,12 @@ public unsafe ReadOnlySpan GetReadSpan(string blockName) var buffer2 = new byte[(int)entry.Length]; _fileStream.Position = (long)entry.Offset; _fileStream.ReadExactly(buffer2); + + // ✅ ENCRYPTION: Decrypt block data after reading from disk + if (_encryption is not null) + { + return _encryption.Decrypt(buffer2); + } return buffer2; } @@ -461,6 +506,21 @@ public async Task WriteBlockAsync(string blockName, ReadOnlyMemory data, C // ✅ Convert to array immediately (before async operations) var checksumArray = checksumSpan.ToArray(); + // ✅ ENCRYPTION: Encrypt block data before writing to disk + // Checksum is computed on PLAINTEXT (above) so we can verify integrity after decryption. + // The on-disk bytes are [Nonce][Ciphertext][Tag] when encryption is enabled. + byte[] dataToStore; + if (_encryption is not null) + { + dataToStore = _encryption.Encrypt(data.ToArray()); + // Update entry length to reflect encrypted size (plaintext + 12 nonce + 16 tag) + entry = entry with { Length = (ulong)dataToStore.Length, Flags = entry.Flags | (uint)BlockFlags.Dirty }; + } + else + { + dataToStore = data.ToArray(); + } + // Write to WAL first (crash safety) if (_isInTransaction) { @@ -468,11 +528,10 @@ public async Task WriteBlockAsync(string blockName, ReadOnlyMemory data, C } // ✅ Phase 1 Task 1.3: Queue write instead of direct I/O - // Copy data to array (required for safe batching) var writeOp = new WriteOperation { BlockName = blockName, - Data = data.ToArray(), + Data = dataToStore, Checksum = checksumArray, Offset = offset, Entry = SetChecksum(entry, checksumArray) @@ -712,11 +771,25 @@ public async Task UpdateBlockAsync( _fileStream.Position = (long)entry.Offset; await _fileStream.ReadExactlyAsync(buffer, cancellationToken).ConfigureAwait(false); - // Validate checksum; if mismatch, attempt self-heal - if (!ValidateChecksum(entry, buffer.Span)) + // ✅ ENCRYPTION: Decrypt block data after reading from disk + // The on-disk bytes are [Nonce][Ciphertext][Tag] when encryption is enabled. + // We decrypt first, THEN validate the checksum against the plaintext. + byte[] plaintextBytes; + if (_encryption is not null) + { + plaintextBytes = _encryption.Decrypt(buffer.ToArray()); + } + else + { + plaintextBytes = new byte[entry.Length]; + buffer.Span.CopyTo(plaintextBytes); + } + + // Validate checksum against PLAINTEXT; if mismatch, attempt self-heal + if (!ValidateChecksum(entry, plaintextBytes)) { Console.WriteLine($"[SingleFileStorageProvider] Checksum mismatch for block '{blockName}', attempting self-heal"); - var repairedEntry = SetChecksum(entry, SHA256.HashData(buffer.Span)); + var repairedEntry = SetChecksum(entry, SHA256.HashData(plaintextBytes)); _blockRegistry.AddOrUpdateBlock(blockName, repairedEntry); await _blockRegistry.FlushAsync(cancellationToken).ConfigureAwait(false); @@ -736,10 +809,8 @@ public async Task UpdateBlockAsync( }; } - // ✅ Phase 3.3: Copy to result array (caller owns this memory) - var result = new byte[entry.Length]; - buffer.Span.CopyTo(result); - return result; + // Return the decrypted plaintext to the caller + return plaintextBytes; } finally { diff --git a/tests/SharpCoreDB.Tests/Security/SingleFileEncryptionTests.cs b/tests/SharpCoreDB.Tests/Security/SingleFileEncryptionTests.cs new file mode 100644 index 00000000..0bb64d91 --- /dev/null +++ b/tests/SharpCoreDB.Tests/Security/SingleFileEncryptionTests.cs @@ -0,0 +1,342 @@ +// tests/SharpCoreDB.Tests/Security/SingleFileEncryptionTests.cs +// Regression tests for SingleFile (.scdb) encryption via DatabaseOptions.EncryptionKey. +// Validates the fix where SingleFileStorageProvider was not applying AES-256-GCM +// encryption to block reads/writes, resulting in plaintext data on disk. + +using System; +using System.IO; +using System.Linq; +using System.Security.Cryptography; +using System.Text; +using System.Threading.Tasks; +using Microsoft.Extensions.DependencyInjection; +using SharpCoreDB; +using SharpCoreDB.Interfaces; +using SharpCoreDB.Services; +using Xunit; + +namespace SharpCoreDB.Tests.Security; + +public sealed class SingleFileEncryptionTests : IAsyncLifetime +{ + private string _testDir = null!; + private ServiceProvider _serviceProvider = null!; + private DatabaseFactory _factory = null!; + + private const string SecretData = "classified-payload-regression-test-7f3a9c"; + private const string DummyPassword = "unused-in-singlefile-mode"; + + public ValueTask InitializeAsync() + { + _testDir = Path.Combine(Path.GetTempPath(), "SharpCoreDB_EncTests", Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(_testDir); + + var services = new ServiceCollection(); + services.AddSharpCoreDB(); + _serviceProvider = services.BuildServiceProvider(); + _factory = _serviceProvider.GetRequiredService(); + + return ValueTask.CompletedTask; + } + + public async ValueTask DisposeAsync() + { + await _serviceProvider.DisposeAsync(); + + try + { + if (Directory.Exists(_testDir)) + Directory.Delete(_testDir, recursive: true); + } + catch + { + // Best-effort cleanup on Windows where file handles may linger. + } + } + + // ── Helpers ────────────────────────────────────────────────────────── + + private static byte[] GenerateKey() + { + var key = new byte[32]; + RandomNumberGenerator.Fill(key); + return key; + } + + private string DbPath(string name) => Path.Combine(_testDir, $"{name}.scdb"); + + private DatabaseOptions EncryptedOptions(byte[] key) => new() + { + StorageMode = StorageMode.SingleFile, + EnableEncryption = true, + EncryptionKey = key, + CreateImmediately = true + }; + + private static bool FileContainsBytes(string filePath, byte[] needle) + { + var haystack = File.ReadAllBytes(filePath); + for (int i = 0; i <= haystack.Length - needle.Length; i++) + { + bool match = true; + for (int j = 0; j < needle.Length; j++) + { + if (haystack[i + j] != needle[j]) { match = false; break; } + } + if (match) return true; + } + return false; + } + + private static string ComputeSha256(string filePath) + { + using var stream = File.OpenRead(filePath); + var hash = SHA256.HashData(stream); + return Convert.ToHexString(hash); + } + + private async Task CreateEncryptedDbWithSecret(string path, byte[] key) + { + var db = _factory.CreateWithOptions(path, DummyPassword, EncryptedOptions(key)); + try + { + db.ExecuteSQL("CREATE TABLE Secrets (Id INT, Data TEXT)"); + db.ExecuteSQL($"INSERT INTO Secrets VALUES (1, '{SecretData}')"); + db.ForceSave(); + } + finally + { + await db.DisposeAsync(); + } + } + + // ── 1. Plaintext-at-rest ───────────────────────────────────────────── + + /// + /// The secret must NOT appear verbatim in the .scdb file when encryption is enabled. + /// This is the primary regression test for the original bug where SingleFile mode + /// wrote all block data as plaintext regardless of EnableEncryption. + /// + [Fact] + public async Task EncryptedData_IsNotPlaintext_OnDisk() + { + var path = DbPath("atrest"); + var key = GenerateKey(); + + await CreateEncryptedDbWithSecret(path, key); + + var secretBytes = Encoding.UTF8.GetBytes(SecretData); + Assert.False(FileContainsBytes(path, secretBytes), + "Secret data was found in plaintext on disk. Encryption is not being applied to SingleFile blocks."); + } + + // ── 2. Correct-key roundtrip ───────────────────────────────────────── + + /// + /// Data written with a key must be readable when the database is reopened + /// with the same key. + /// + [Fact] + public async Task CorrectKey_CanRoundtrip_Data() + { + var path = DbPath("roundtrip"); + var key = GenerateKey(); + + await CreateEncryptedDbWithSecret(path, key); + + // Reopen with the same key + var db = _factory.CreateWithOptions(path, DummyPassword, EncryptedOptions(key)); + try + { + var rows = db.ExecuteQuery("SELECT * FROM Secrets"); + Assert.Single(rows); + Assert.Equal(SecretData, rows[0]["Data"]?.ToString()); + } + finally + { + await db.DisposeAsync(); + } + } + + // ── 3. Wrong-key data inaccessibility ──────────────────────────────── + + /// + /// Opening with a different 32-byte key must NOT return the original data. + /// The current implementation surfaces this as "Table Secrets does not exist" + /// because the table directory decrypts to garbage and no tables are loaded. + /// A future improvement should throw an explicit AuthenticationException. + /// + [Fact] + public async Task WrongKey_CannotAccess_Data() + { + var path = DbPath("wrongkey"); + var correctKey = GenerateKey(); + var wrongKey = GenerateKey(); + + await CreateEncryptedDbWithSecret(path, correctKey); + + var db = _factory.CreateWithOptions(path, DummyPassword, EncryptedOptions(wrongKey)); + try + { + // The original table must not be accessible. + // Current behavior: table directory decrypts to garbage → table not found. + var tables = db.GetTables(); + Assert.DoesNotContain(tables, t => t.Name.Equals("Secrets", StringComparison.OrdinalIgnoreCase)); + } + finally + { + await db.DisposeAsync(); + } + } + + // ── 4. Wrong-key file integrity ────────────────────────────────────── + + /// + /// Opening with a wrong key must NOT mutate the file on disk. + /// The file hash before and after a wrong-key open must be identical. + /// This guards against a wrong-key session accidentally overwriting + /// encrypted blocks with garbage or plaintext. + /// + [Fact] + public async Task WrongKey_DoesNotMutate_File() + { + var path = DbPath("fileintegrity"); + var correctKey = GenerateKey(); + var wrongKey = GenerateKey(); + + await CreateEncryptedDbWithSecret(path, correctKey); + + var hashBefore = ComputeSha256(path); + + // Open with wrong key, perform a read attempt, then dispose + var db = _factory.CreateWithOptions(path, DummyPassword, EncryptedOptions(wrongKey)); + try + { + // Attempt to query (will fail to find the table, but must not write) + try { db.ExecuteQuery("SELECT * FROM Secrets"); } + catch { /* Expected: table not found or decryption garbage */ } + } + finally + { + await db.DisposeAsync(); + } + + var hashAfter = ComputeSha256(path); + Assert.Equal(hashBefore, hashAfter); + } + + // ── 5. Wrong-key reopen survival ───────────────────────────────────── + + /// + /// After a wrong-key open-and-close cycle, reopening with the CORRECT key + /// must still return the original data. This ensures the wrong-key session + /// did not corrupt the encrypted blocks or the table directory. + /// + [Fact] + public async Task WrongKey_OriginalData_SurvivesReopen() + { + var path = DbPath("survival"); + var correctKey = GenerateKey(); + var wrongKey = GenerateKey(); + + await CreateEncryptedDbWithSecret(path, correctKey); + + // Wrong-key open and close + var wrongDb = _factory.CreateWithOptions(path, DummyPassword, EncryptedOptions(wrongKey)); + await wrongDb.DisposeAsync(); + + // Correct-key reopen must still work + var db = _factory.CreateWithOptions(path, DummyPassword, EncryptedOptions(correctKey)); + try + { + var rows = db.ExecuteQuery("SELECT * FROM Secrets"); + Assert.Single(rows); + Assert.Equal(SecretData, rows[0]["Data"]?.ToString()); + } + finally + { + await db.DisposeAsync(); + } + } + + // ── 6. Key length validation ───────────────────────────────────────── + + /// + /// DatabaseOptions.Validate() must reject EncryptionKey values that are + /// not exactly 32 bytes when EnableEncryption is true. + /// + [Theory] + [InlineData(0)] + [InlineData(16)] + [InlineData(31)] + [InlineData(33)] + [InlineData(64)] + public void EncryptionKey_MustBe32Bytes(int keyLength) + { + var options = new DatabaseOptions + { + StorageMode = StorageMode.SingleFile, + EnableEncryption = true, + EncryptionKey = new byte[keyLength] + }; + + Assert.Throws(() => options.Validate()); + } + + // ── 7. DI requirement ──────────────────────────────────────────────── + + /// + /// When EnableEncryption is true, ICryptoService must be registered in DI. + /// Creating a SingleFile database without ICryptoService must throw. + /// + [Fact] + public void EncryptionKey_RequiresCryptoService_InDI() + { + // Build a provider WITHOUT ICryptoService + var emptyServices = new ServiceCollection(); + using var emptyProvider = emptyServices.BuildServiceProvider(); + var emptyFactory = new DatabaseFactory(emptyProvider); + + var path = DbPath("nodiservice"); + var key = GenerateKey(); + + Assert.Throws(() => + emptyFactory.CreateWithOptions(path, DummyPassword, EncryptedOptions(key))); + } + + // ── 8. Control: no encryption means plaintext ──────────────────────── + + /// + /// When EnableEncryption is false, data IS stored in plaintext. + /// This is the control test that confirms the plaintext scan in test 1 + /// is meaningful (i.e., the scan technique works). + /// + [Fact] + public async Task NoEncryption_DataIsPlaintext_Control() + { + var path = DbPath("control"); + + var options = new DatabaseOptions + { + StorageMode = StorageMode.SingleFile, + EnableEncryption = false, + CreateImmediately = true + }; + + var db = _factory.CreateWithOptions(path, DummyPassword, options); + try + { + db.ExecuteSQL("CREATE TABLE Secrets (Id INT, Data TEXT)"); + db.ExecuteSQL($"INSERT INTO Secrets VALUES (1, '{SecretData}')"); + db.ForceSave(); + } + finally + { + await db.DisposeAsync(); + } + + var secretBytes = Encoding.UTF8.GetBytes(SecretData); + Assert.True(FileContainsBytes(path, secretBytes), + "Control test: secret should be plaintext when encryption is disabled."); + } +}