diff --git a/src/NATS.Server/JetStream/Storage/FileStore.cs b/src/NATS.Server/JetStream/Storage/FileStore.cs index e0b5cc8..dc6b85b 100644 --- a/src/NATS.Server/JetStream/Storage/FileStore.cs +++ b/src/NATS.Server/JetStream/Storage/FileStore.cs @@ -1183,7 +1183,17 @@ public sealed class FileStore : IStreamStore, IAsyncDisposable, IDisposable if ((flags & EncryptionFlag) != 0) { var key = NormalizeKey(_options.EncryptionKey); - data = AeadEncryptor.Decrypt(data, key, _options.Cipher); + try + { + data = AeadEncryptor.Decrypt(data, key, _options.Cipher); + } + catch (CryptographicException ex) + { + // AEAD tag verification failed — wrong key or corrupted data. + // Wrap as InvalidDataException so RecoverBlocks propagates it + // as a fatal key-mismatch error (same behaviour as FSV1 key-hash check). + throw new InvalidDataException("AEAD decryption failed: wrong key or corrupted block.", ex); + } } if ((flags & CompressionFlag) != 0) diff --git a/tests/NATS.Server.Tests/FileStoreEncryptionTests.cs b/tests/NATS.Server.Tests/FileStoreEncryptionTests.cs new file mode 100644 index 0000000..340eb9e --- /dev/null +++ b/tests/NATS.Server.Tests/FileStoreEncryptionTests.cs @@ -0,0 +1,104 @@ +// Go: TestFileStoreEncryption server/filestore_test.go +// Reference: golang/nats-server/server/filestore.go:816-907 (genEncryptionKeys, recoverAEK, setupAEK) +// Tests that block files are encrypted at rest and can be recovered with the same key. + +using System.Security.Cryptography; +using NATS.Server.JetStream.Storage; + +namespace NATS.Server.Tests; + +public class FileStoreEncryptionTests +{ + [Fact] + public async Task Encrypted_block_round_trips_message() + { + // Go: TestFileStoreEncryption server/filestore_test.go + var dir = Directory.CreateTempSubdirectory(); + var key = new byte[32]; + RandomNumberGenerator.Fill(key); + + await using (var store = new FileStore(new FileStoreOptions + { + Directory = dir.FullName, + Cipher = StoreCipher.ChaCha, + EncryptionKey = key, + })) + { + await store.AppendAsync("test.subj", "hello encrypted"u8.ToArray(), default); + } + + // Raw block file should NOT contain plaintext + var blkFiles = Directory.GetFiles(dir.FullName, "*.blk"); + blkFiles.ShouldNotBeEmpty(); + var raw = File.ReadAllBytes(blkFiles[0]); + System.Text.Encoding.UTF8.GetString(raw).ShouldNotContain("hello encrypted"); + + // Recover with same key should return plaintext + await using var recovered = new FileStore(new FileStoreOptions + { + Directory = dir.FullName, + Cipher = StoreCipher.ChaCha, + EncryptionKey = key, + }); + var msg = await recovered.LoadAsync(1, default); + msg.ShouldNotBeNull(); + System.Text.Encoding.UTF8.GetString(msg.Payload.Span).ShouldBe("hello encrypted"); + } + + [Fact] + public async Task Encrypted_block_with_aes_round_trips() + { + var dir = Directory.CreateTempSubdirectory(); + var key = new byte[32]; + RandomNumberGenerator.Fill(key); + + await using (var store = new FileStore(new FileStoreOptions + { + Directory = dir.FullName, + Cipher = StoreCipher.Aes, + EncryptionKey = key, + })) + { + await store.AppendAsync("aes.subj", "aes payload"u8.ToArray(), default); + } + + await using var recovered = new FileStore(new FileStoreOptions + { + Directory = dir.FullName, + Cipher = StoreCipher.Aes, + EncryptionKey = key, + }); + var msg = await recovered.LoadAsync(1, default); + msg.ShouldNotBeNull(); + System.Text.Encoding.UTF8.GetString(msg.Payload.Span).ShouldBe("aes payload"); + } + + [Fact] + public async Task Wrong_key_fails_to_decrypt() + { + var dir = Directory.CreateTempSubdirectory(); + var key1 = new byte[32]; + var key2 = new byte[32]; + RandomNumberGenerator.Fill(key1); + RandomNumberGenerator.Fill(key2); + + await using (var store = new FileStore(new FileStoreOptions + { + Directory = dir.FullName, + Cipher = StoreCipher.ChaCha, + EncryptionKey = key1, + })) + { + await store.AppendAsync("secret", "data"u8.ToArray(), default); + } + + // Recovery with wrong key should throw or return no messages + var act = () => new FileStore(new FileStoreOptions + { + Directory = dir.FullName, + Cipher = StoreCipher.ChaCha, + EncryptionKey = key2, + }); + Should.Throw(act); + } +}