Enabling per-event encryption
Encrypt the payload client-side and the server stores ciphertext it cannot read. See Encryption.
Celeriant gives you one thing for this: a 12-byte Iv field on every event, stored next to the payload and handed back unchanged. There is no encryption helper in any client and no key handling on the server. The crypto is yours. AES-GCM is what the field is sized for, and what this guide uses.
| Client | Field |
|---|---|
| .NET | AggregateEvent.Iv (byte[]?) |
| Rust | DatablockAggregateEvent::iv (Option<[u8; 12]>) |
The server stores the IV as a fixed 12-byte array. In .NET, where the field is a plain byte[], pass exactly 12 bytes.
Write
Encrypt, put the nonce in Iv and the ciphertext plus tag in EventValue:
using System.Security.Cryptography;
byte[] plaintext = Encoding.UTF8.GetBytes(json);
byte[] nonce = RandomNumberGenerator.GetBytes(AesGcm.NonceByteSizes.MaxSize); // 12 bytes
byte[] cipher = new byte[plaintext.Length];
byte[] tag = new byte[AesGcm.TagByteSizes.MaxSize]; // 16 bytes
byte[] aad = key.AggregateId.ToByteArray(); // binds the ciphertext to its aggregate
using (var aes = new AesGcm(dataKey, tag.Length))
aes.Encrypt(nonce, plaintext, cipher, tag, aad);
byte[] stored = [.. cipher, .. tag];
await pool.WriteAsync(key,
[new AggregateEvent
{
ClientSeq = next,
EventTypeMajor = 1,
EventTimestamp = DateTimeOffset.UtcNow,
EventValue = stored,
Iv = nonce,
}],
clientId: writerId);
The associated data is optional but cheap. Without it, anyone who can write to storage can move a valid ciphertext from one aggregate to another and it still decrypts. With it, decryption fails.
Read
Reverse it; the server hands back exactly what you stored:
var stored = e.EventValue;
var cipher = stored.AsSpan(0, stored.Length - 16); // last 16 bytes are the GCM tag
var tag = stored.AsSpan(stored.Length - 16);
byte[] plaintext = new byte[cipher.Length];
using (var aes = new AesGcm(dataKey, 16))
aes.Decrypt(e.Iv!, cipher, tag, plaintext, key.AggregateId.ToByteArray());
What you own
- Keys. The server never sees one. Where keys live, how they rotate, and who can read them is your design. A server compromise yields ciphertext.
- Nonce uniqueness. AES-GCM breaks if a nonce repeats under one key. Random 12-byte nonces are fine at ordinary volumes; a very high-volume writer should rotate data keys rather than draw nonces forever under one.
- Metadata. Event type, timestamps, sequences, client id and aggregate key stay in the clear so the server can order, filter and route. Encryption hides what an event says, not that it happened.
- Storage size. A batch containing any event with an
Ivis stored uncompressed. Ciphertext does not compress anyway; plaintext events written in the same batch lose compression too. - Validation. The server skips schema validation for any event with an
Iv, even if a schema is registered for its type. Validate before you encrypt.