Skip to content

Commit 4ce19b8

Browse files
author
scdb-dev
committed
[Bug] SingleFile (.scdb) mode ignores DatabaseOptions.EncryptionKey, writing data in plaintext
1 parent 300d9c2 commit 4ce19b8

7 files changed

Lines changed: 450 additions & 21 deletions

File tree

‎.gitignore‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -425,3 +425,5 @@ dist/
425425
# SonarScanner local analysis artifacts
426426
.sonarqube/
427427
.scannerwork/
428+
429+
*.scdb

‎src/SharpCoreDB/DatabaseExtensions.cs‎

Lines changed: 17 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
// <copyright file="DatabaseExtensions.cs" company="MPCoreDeveloper">
1+
// src\SharpCoreDB\DatabaseExtensions.cs
22
// Copyright (c) 2025-2026 MPCoreDeveloper and GitHub Copilot. All rights reserved.
33
// Licensed under the MIT License. See LICENSE file in the project root for full license information.
44
// </copyright>
@@ -81,6 +81,7 @@ public IDatabase CreateWithOptions(string dbPath, string masterPassword, Databas
8181

8282
return options.StorageMode switch
8383
{
84+
// ✅ ENCRYPTION: Pass 'this' to access instance method (needed for DI resolution)
8485
StorageMode.SingleFile => CreateSingleFileDatabase(dbPath, masterPassword, options),
8586
StorageMode.Directory => CreateDirectoryDatabase(dbPath, masterPassword, options),
8687
_ => throw new ArgumentException($"Invalid storage mode: {options.StorageMode}")
@@ -94,15 +95,28 @@ private IDatabase CreateDirectoryDatabase(string dbPath, string masterPassword,
9495
return new Database(services, dbPath, masterPassword, options.IsReadOnly, config);
9596
}
9697

97-
private static IDatabase CreateSingleFileDatabase(string dbPath, string masterPassword, DatabaseOptions options)
98+
// ✅ ENCRYPTION: Changed from static to instance method to access DI services
99+
private IDatabase CreateSingleFileDatabase(string dbPath, string masterPassword, DatabaseOptions options)
98100
{
99101
if (options.DatabaseConfig is not null)
100102
{
101103
options.EnableMemoryMapping = options.DatabaseConfig.UseMemoryMapping;
102104
}
103105
options.WalBufferSizePages = options.WalBufferSizePages > 0 ? options.WalBufferSizePages : 2048;
104106
options.FileShareMode = System.IO.FileShare.ReadWrite;
105-
var provider = SingleFileStorageProvider.Open(dbPath, options);
107+
108+
// ✅ ENCRYPTION: Resolve ICryptoService from DI and pass to provider
109+
SharpCoreDB.Interfaces.ICryptoService? cryptoService = null;
110+
if (options.EnableEncryption)
111+
{
112+
cryptoService = services.GetService<SharpCoreDB.Interfaces.ICryptoService>();
113+
if (cryptoService is null)
114+
throw new InvalidOperationException(
115+
"ICryptoService must be registered in DI when EnableEncryption is true. " +
116+
"Call services.AddSharpCoreDB() or register ICryptoService manually.");
117+
}
118+
119+
var provider = SingleFileStorageProvider.Open(dbPath, options, cryptoService);
106120
return new SingleFileDatabase(provider, dbPath, masterPassword, options);
107121
}
108122

‎src/SharpCoreDB/DatabaseOptions.cs‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
// <copyright file="DatabaseOptions.cs" company="MPCoreDeveloper">
1+
// src\SharpCoreDB\DatabaseOptions.cs
22
// Copyright (c) 2025-2026 MPCoreDeveloper and GitHub Copilot. All rights reserved.
33
// Licensed under the MIT License. See LICENSE file in the project root for full license information.
44
// </copyright>

‎src/SharpCoreDB/SingleFileTable.cs‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
// <copyright file="SingleFileTable.cs" company="MPCoreDeveloper">
1+
// src\SharpCoreDB\SingleFileTable.cs
22
// Copyright (c) 2025-2026 MPCoreDeveloper and GitHub Copilot. All rights reserved.
33
// Licensed under the MIT License. See LICENSE file in the project root for full license information.
44
// </copyright>

‎src/SharpCoreDB/Storage/Scdb/SingleFileDatabase.Batch.cs‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
// <copyright file="SingleFileDatabase.Batch.cs" company="MPCoreDeveloper">
1+
// src\SharpCoreDB\Storage\Scdb\SingleFileDatabase.Batch.cs
22
// Copyright (c) 2025-2026 MPCoreDeveloper and GitHub Copilot. All rights reserved.
33
// Licensed under the MIT License. See LICENSE file in the project root for full license information.
44
// </copyright>

‎src/SharpCoreDB/Storage/SingleFileStorageProvider.cs‎

Lines changed: 86 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
// <copyright file="SingleFileStorageProvider.cs" company="MPCoreDeveloper">
1+
// src\SharpCoreDB\Storage\SingleFileStorageProvider.cs
22
// Copyright (c) 2025-2026 MPCoreDeveloper and GitHub Copilot. All rights reserved.
33
// Licensed under the MIT License. See LICENSE file in the project root for full license information.
44
// </copyright>
@@ -96,6 +96,11 @@ public sealed class SingleFileStorageProvider : IStorageProvider
9696
private const int WRITE_BATCH_SIZE = 200; // Batch 200 writes together (increased from 50)
9797
private const int WRITE_BATCH_TIMEOUT_MS = 200; // Or flush after 200ms (increased from 50ms)
9898

99+
// ✅ ENCRYPTION: AES-256-GCM instance for block-level encryption at rest
100+
// Null when encryption is disabled. When non-null, all block reads/writes
101+
// pass through Encrypt/Decrypt to ensure data is encrypted on disk.
102+
private readonly SharpCoreDB.Services.AesGcmEncryption? _encryption;
103+
99104
private bool _isInTransaction;
100105
private bool _disposed;
101106
private ScdbFileHeader _header;
@@ -109,13 +114,14 @@ public sealed class SingleFileStorageProvider : IStorageProvider
109114
/// <param name="mmf">Optional memory-mapped file</param>
110115
/// <param name="header">File header structure</param>
111116
private SingleFileStorageProvider(string filePath, DatabaseOptions options, FileStream fileStream,
112-
MemoryMappedFile? mmf, ScdbFileHeader header)
117+
MemoryMappedFile? mmf, ScdbFileHeader header, SharpCoreDB.Services.AesGcmEncryption? encryption = null)
113118
{
114119
_filePath = filePath;
115120
_options = options;
116121
_fileStream = fileStream;
117122
_memoryMappedFile = mmf;
118123
_header = header;
124+
_encryption = encryption;
119125
_blockCache = new ConcurrentDictionary<string, BlockMetadata>();
120126

121127
// Initialize subsystems
@@ -134,13 +140,27 @@ private SingleFileStorageProvider(string filePath, DatabaseOptions options, File
134140
/// <param name="filePath">Path to .scdb file</param>
135141
/// <param name="options">Database options</param>
136142
/// <returns>Initialized provider</returns>
137-
public static SingleFileStorageProvider Open(string filePath, DatabaseOptions options)
143+
public static SingleFileStorageProvider Open(string filePath, DatabaseOptions options, SharpCoreDB.Interfaces.ICryptoService? cryptoService = null)
138144
{
139145
ArgumentException.ThrowIfNullOrWhiteSpace(filePath);
140146
ArgumentNullException.ThrowIfNull(options);
141147

142148
options.Validate();
143149

150+
// ✅ ENCRYPTION: Initialize AES-256-GCM if encryption is requested
151+
SharpCoreDB.Services.AesGcmEncryption? encryption = null;
152+
if (options.EnableEncryption)
153+
{
154+
if (cryptoService is null)
155+
throw new InvalidOperationException(
156+
"ICryptoService must be registered in DI when EnableEncryption is true. " +
157+
"Call services.AddSharpCoreDB() or register ICryptoService manually.");
158+
if (options.EncryptionKey is null || options.EncryptionKey.Length != 32)
159+
throw new InvalidOperationException(
160+
"EncryptionKey must be exactly 32 bytes (256 bits) when EnableEncryption is true.");
161+
encryption = cryptoService.GetAesGcmEncryption(options.EncryptionKey);
162+
}
163+
144164
// Ensure .scdb extension
145165
if (!filePath.EndsWith(".scdb", StringComparison.OrdinalIgnoreCase))
146166
{
@@ -200,7 +220,7 @@ public static SingleFileStorageProvider Open(string filePath, DatabaseOptions op
200220
}
201221
}
202222

203-
return new SingleFileStorageProvider(filePath, options, fileStream, mmf, header);
223+
return new SingleFileStorageProvider(filePath, options, fileStream, mmf, header, encryption);
204224
}
205225

206226
/// <summary>
@@ -280,7 +300,19 @@ public bool BlockExists(string blockName)
280300
return null;
281301
}
282302

283-
// Create a sub-stream view of the block
303+
// ✅ ENCRYPTION: If encryption is enabled, read the full block, decrypt,
304+
// and return a read-only MemoryStream over the plaintext.
305+
// BlockStream cannot decrypt in-place, so we must materialize the block.
306+
if (_encryption is not null)
307+
{
308+
var encryptedData = new byte[(int)entry.Length];
309+
_fileStream.Position = (long)entry.Offset;
310+
_fileStream.ReadExactly(encryptedData);
311+
var plaintextData = _encryption.Decrypt(encryptedData);
312+
return new MemoryStream(plaintextData, index: 0, count: plaintextData.Length, writable: false);
313+
}
314+
315+
// Create a sub-stream view of the block (unencrypted path)
284316
return new BlockStream(_fileStream, entry.Offset, entry.Length, FileAccess.Read);
285317
}
286318

@@ -307,12 +339,19 @@ public unsafe ReadOnlySpan<byte> GetReadSpan(string blockName)
307339
var buffer = new byte[checked((int)Math.Min(entry.Length, (ulong)int.MaxValue))];
308340
_fileStream.Position = (long)entry.Offset;
309341
_fileStream.ReadExactly(buffer);
342+
// ✅ ENCRYPTION: Decrypt if encryption is enabled
343+
if (_encryption is not null)
344+
{
345+
return _encryption.Decrypt(buffer);
346+
}
310347
return buffer;
311348
}
312349

313350
// Use memory-mapped file for zero-copy access
314-
if (_memoryMappedFile != null)
351+
if (_memoryMappedFile != null && _encryption is null)
315352
{
353+
// ✅ ENCRYPTION: Only use zero-copy mmap when encryption is disabled.
354+
// Encrypted data must be read into a buffer and decrypted.
316355
try
317356
{
318357
var viewOffset = checked((long)entry.Offset);
@@ -341,6 +380,12 @@ public unsafe ReadOnlySpan<byte> GetReadSpan(string blockName)
341380
var buffer2 = new byte[(int)entry.Length];
342381
_fileStream.Position = (long)entry.Offset;
343382
_fileStream.ReadExactly(buffer2);
383+
384+
// ✅ ENCRYPTION: Decrypt block data after reading from disk
385+
if (_encryption is not null)
386+
{
387+
return _encryption.Decrypt(buffer2);
388+
}
344389
return buffer2;
345390
}
346391

@@ -461,18 +506,32 @@ public async Task WriteBlockAsync(string blockName, ReadOnlyMemory<byte> data, C
461506
// ✅ Convert to array immediately (before async operations)
462507
var checksumArray = checksumSpan.ToArray();
463508

509+
// ✅ ENCRYPTION: Encrypt block data before writing to disk
510+
// Checksum is computed on PLAINTEXT (above) so we can verify integrity after decryption.
511+
// The on-disk bytes are [Nonce][Ciphertext][Tag] when encryption is enabled.
512+
byte[] dataToStore;
513+
if (_encryption is not null)
514+
{
515+
dataToStore = _encryption.Encrypt(data.ToArray());
516+
// Update entry length to reflect encrypted size (plaintext + 12 nonce + 16 tag)
517+
entry = entry with { Length = (ulong)dataToStore.Length, Flags = entry.Flags | (uint)BlockFlags.Dirty };
518+
}
519+
else
520+
{
521+
dataToStore = data.ToArray();
522+
}
523+
464524
// Write to WAL first (crash safety)
465525
if (_isInTransaction)
466526
{
467527
await _walManager.LogWriteAsync(blockName, offset, data, cancellationToken).ConfigureAwait(false);
468528
}
469529

470530
// ✅ Phase 1 Task 1.3: Queue write instead of direct I/O
471-
// Copy data to array (required for safe batching)
472531
var writeOp = new WriteOperation
473532
{
474533
BlockName = blockName,
475-
Data = data.ToArray(),
534+
Data = dataToStore,
476535
Checksum = checksumArray,
477536
Offset = offset,
478537
Entry = SetChecksum(entry, checksumArray)
@@ -712,11 +771,25 @@ public async Task<long> UpdateBlockAsync(
712771
_fileStream.Position = (long)entry.Offset;
713772
await _fileStream.ReadExactlyAsync(buffer, cancellationToken).ConfigureAwait(false);
714773

715-
// Validate checksum; if mismatch, attempt self-heal
716-
if (!ValidateChecksum(entry, buffer.Span))
774+
// ✅ ENCRYPTION: Decrypt block data after reading from disk
775+
// The on-disk bytes are [Nonce][Ciphertext][Tag] when encryption is enabled.
776+
// We decrypt first, THEN validate the checksum against the plaintext.
777+
byte[] plaintextBytes;
778+
if (_encryption is not null)
779+
{
780+
plaintextBytes = _encryption.Decrypt(buffer.ToArray());
781+
}
782+
else
783+
{
784+
plaintextBytes = new byte[entry.Length];
785+
buffer.Span.CopyTo(plaintextBytes);
786+
}
787+
788+
// Validate checksum against PLAINTEXT; if mismatch, attempt self-heal
789+
if (!ValidateChecksum(entry, plaintextBytes))
717790
{
718791
Console.WriteLine($"[SingleFileStorageProvider] Checksum mismatch for block '{blockName}', attempting self-heal");
719-
var repairedEntry = SetChecksum(entry, SHA256.HashData(buffer.Span));
792+
var repairedEntry = SetChecksum(entry, SHA256.HashData(plaintextBytes));
720793
_blockRegistry.AddOrUpdateBlock(blockName, repairedEntry);
721794
await _blockRegistry.FlushAsync(cancellationToken).ConfigureAwait(false);
722795

@@ -736,10 +809,8 @@ public async Task<long> UpdateBlockAsync(
736809
};
737810
}
738811

739-
// ✅ Phase 3.3: Copy to result array (caller owns this memory)
740-
var result = new byte[entry.Length];
741-
buffer.Span.CopyTo(result);
742-
return result;
812+
// Return the decrypted plaintext to the caller
813+
return plaintextBytes;
743814
}
744815
finally
745816
{

0 commit comments

Comments
 (0)