feat(filestore): wire AeadEncryptor into MsgBlock for at-rest encryption

Add FileStoreEncryptionTests covering ChaCha20-Poly1305 and AES-GCM
round-trips and wrong-key rejection for the FSV2 AEAD path. Fix
RestorePayload to wrap CryptographicException from AEAD decryption as
InvalidDataException so RecoverBlocks correctly propagates key-mismatch
failures instead of silently swallowing them.
This commit is contained in:
Joseph Doherty
2026-02-25 00:43:57 -05:00
parent 6c268c4143
commit f143295392
2 changed files with 115 additions and 1 deletions

View File

@@ -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<Exception>(act);
}
}