Message Encryption
Wolverine ships with optional application-layer AES-256-GCM encryption of message bodies. Use it when transport-level TLS is not enough — typical drivers:
- Compliance regimes (PCI-DSS, HIPAA, GDPR) that require at-rest message body encryption above what the broker provides.
- Hosted/shared brokers where the operator should not be able to read message contents from queue inspection or backups.
- Selective protection of sensitive message types (
PaymentDetails,MedicalRecord) while keeping the rest in plain JSON for debuggability.
Quickstart
opts.UseEncryption(new InMemoryKeyProvider(
defaultKeyId: "k1",
keys: new Dictionary<string, byte[]> { ["k1"] = key32 }));This encrypts every outgoing message body with AES-256-GCM under the key
registered as k1. Inbound messages with the encrypted content-type
(application/wolverine-encrypted+json) are decrypted automatically.
Configuration order is order-insensitive.
UseSystemTextJsonForSerializationandUseNewtonsoftForSerializationonly replace the default serializer when its content-type isapplication/json, so calling them afterUseEncryptionis a no-op against the default and leaves the encrypting serializer in place. CallingUseEncryptionmore than once throws — configure encryption exactly once during host setup.
The IKeyProvider interface
public interface IKeyProvider
{
string DefaultKeyId { get; }
ValueTask<byte[]> GetKeyAsync(string keyId, CancellationToken cancellationToken);
}Wolverine ships an InMemoryKeyProvider for tests and samples. For
production, write a thin adapter over your KMS — Azure Key Vault, AWS KMS,
HashiCorp Vault. Wrap it with CachingKeyProvider:
opts.UseEncryption(new CachingKeyProvider(myKmsProvider, ttl: TimeSpan.FromMinutes(5)));The serializer hits the provider on every send and every receive; the cache keeps that bounded.
The byte array returned by GetKeyAsync is treated as a borrowed reference
owned by the provider. Callers must not mutate it or call
CryptographicOperations.ZeroMemory on it — doing so corrupts caching
providers like InMemoryKeyProvider.
Selective encryption
Per-message-type:
opts.RegisterEncryptionSerializer(provider);
opts.Policies.ForMessagesOfType<PaymentDetails>().Encrypt();Encrypt<T>() is symmetric: outgoing messages of type T are encrypted, and
inbound messages of type T MUST arrive encrypted (see
Receive-side enforcement below).
Per-endpoint (sender-side):
opts.RegisterEncryptionSerializer(provider);
opts.PublishAllMessages().ToRabbitExchange("sensitive").Encrypted();Per-listener (receive-side):
opts.UseEncryption(provider);
opts.ListenAtPort(5500).RequireEncryption();RequireEncryption() marks a listener as accepting only encrypted envelopes.
It is the receive-side counterpart to the sender-side .Encrypted() extension.
The two are intentionally named differently because subscribers and listeners
have different configuration surfaces, and the asymmetric naming prevents
method-shadowing on LocalQueueConfiguration (which is both a subscriber and
a listener).
Both per-type and per-endpoint require RegisterEncryptionSerializer(provider)
(or UseEncryption(provider)) earlier in the same configuration so the
encrypting serializer is registered with the runtime.
Selection precedence on send: per-type > endpoint > global default. Per-type
rules run after per-endpoint rules in the runtime pipeline, so a per-type
marker takes effect last and wins. For the encryption feature specifically
this distinction is moot — both per-type Encrypt<T>() and per-endpoint
Encrypted() swap to the same encrypting-serializer instance, so the
resulting envelope is the same regardless of which marker fired last. The
distinction matters if you write your own envelope rules that compete with
the built-in ones.
Receive-side enforcement
By default, receive-side dispatch is content-type-driven: any envelope
arriving with application/wolverine-encrypted+json is decrypted; envelopes
with other content-types are deserialized normally. This preserves mixed-mode
configurations and rolling-deploy scenarios where some senders have not yet
been upgraded.
When a type is marked via Policies.ForMessagesOfType<T>().Encrypt() OR a
listener is marked via .RequireEncryption(), inbound envelopes for that
type/listener that arrive without encryption (content-type ≠
application/wolverine-encrypted+json) are routed to the dead-letter queue
with EncryptionPolicyViolationException. No bytes are ever passed to a
serializer for a forged plaintext envelope. Either marker is sufficient
on its own.
Fault events
Wolverine’s auto-published Fault<T> events interact with encryption in
three ways worth calling out explicitly.
Per-type encryption auto-pairs with Fault<T>. When you call
Policies.ForMessagesOfType<PaymentDetails>().Encrypt(), Wolverine also
registers the encrypting serializer rule for Fault<PaymentDetails> and
adds typeof(Fault<PaymentDetails>) to the receive-side encryption
requirement set. The auto-published fault for a failing PaymentDetails
handler reaches the broker as ciphertext, and a Fault<PaymentDetails>
arriving plaintext at any listener is dead-lettered with
EncryptionPolicyViolationException — same protection, both sides. No
manual setup. Skipped for value-type T because Fault<T> requires
T : class.
Exception messages may carry payload-derived plaintext. A handler that
throws new ValidationException($"Card {model.CardNumber} declined")
captures the card number into Fault<T>.Exception.Message. Even with
encryption enabled on the wire, the exception text is in the body and
visible to any subscriber that decrypts the fault. For regulated
environments, suppress the message and/or stack trace via the redaction
knobs on PublishFaultEvents:
// Type-only — redacts every Fault<T>.Exception.Message and .StackTrace
// across the whole host. Type names survive (they are in your source
// code anyway).
opts.PublishFaultEvents(includeExceptionMessage: false, includeStackTrace: false);
// Per-type — only PaymentDetails faults are redacted; other types keep
// the full default. The per-type call is fully specified — the values
// you pass are stored as-is and do not inherit subsequent changes to
// the global defaults above.
opts.Policies.ForMessagesOfType<PaymentDetails>()
.PublishFault(includeExceptionMessage: false);The redaction recurses through inner exceptions and
AggregateException.InnerExceptions. The Type field is always
preserved. Redacted values are string.Empty for Message and null
for StackTrace.
Note: redaction targets
Fault<T>.Exceptiononly. The original message instanceTcarried asFault<T>.Messageis unchanged — that is what fault events are for. IfTitself is sensitive, the per-typeEncrypt()pairing above is the right tool: the entire fault body (includingTand theExceptionInfo) travels encrypted on the wire.
RequireEncryption() is a receive-side guard only. Marking a listener
with .RequireEncryption() rejects unencrypted inbound envelopes on that
listener but does not constrain outbound republishes. An auto-published
Fault<T> triggered by a failure on a RequireEncryption() listener
routes via the global routing graph; whether it is encrypted on its
outbound hop depends on the per-type / per-endpoint encryption
configuration for Fault<T> (or, with the auto-pairing above, for T).
The two markers solve different problems and intentionally do not
cross-cut.
Key rotation
Static DefaultKeyId. Rotate by deploying a new provider with the new
key-id alongside the old keys:
- Add the new key under
key-2025-q1, keepkey-2024-q4listed. - Update
DefaultKeyIdtokey-2025-q1. - Deploy. New outgoing messages encrypt under
key-2025-q1; in-flight or replayed messages withkey-2024-q4still decrypt. - After the longest plausible message lifetime, drop
key-2024-q4on a follow-up deploy.
Integrity guarantees and header-leak caveat
The message body is encrypted with AES-256-GCM (confidentiality + integrity).
MessageType, the encryption key-id header, and the inner-content-type
header are not encrypted, but they ARE bound into the AEAD tag as
associated authenticated data. Tampering any of those three on the wire
causes decryption to fail; the envelope goes to DLQ as
MessageDecryptionException. This blocks cross-handler attacks where an
attacker re-stamps a legitimately encrypted envelope with a different
MessageType to route the decrypted body into the wrong handler.
CorrelationId, SagaId, TenantId, and any custom headers are NEITHER
encrypted NOR integrity-protected — brokers may need them for routing and
they can vary in transit.
Rule: if a value is sensitive, put it in the message body, not in headers.
Operator note: a
MessageDecryptionExceptionon a known-good ciphertext can mean either body tampering OR routing-metadata tampering (MessageTypeswap attack).
Error handling
The encrypting serializer and receive-side guard raise three distinct exception types on receive:
EncryptionKeyNotFoundException— missing or unknownkey-idheader, or the key provider could not resolve the key.MessageDecryptionException— GCM tag mismatch (body tampering or routing-metadata tampering) or malformed body. Always poison: tampered or corrupted ciphertext will not decrypt on retry.EncryptionPolicyViolationException— an envelope arrived without encryption but the receiving message type or listener has been marked as requiring it. Raised by the receive-side guard before any serializer runs; no bytes are interpreted.
All three extend MessageEncryptionException for users who want to match
any of them.
Note on retry policies: all three exception types are raised before handler dispatch (deserialization or the receive-side guard), so Wolverine’s pipeline routes them directly to the dead-letter queue — user
OnException<>retry rules do not apply to them in the current runtime. If your provider is a remote KMS that can have transient outages, consider implementing the retry/backoff inside yourIKeyProviderrather than relying on Wolverine’s failure policies.
For diagnostics, configure a logger or a sink on the dead-letter queue and
filter on Envelope.Headers["exception-type"] when storage is configured.
What’s not included
- AES-CBC — Wolverine ships GCM only. CBC requires a separate MAC for integrity; GCM provides authenticated encryption by construction.
- Header encryption — only the body is encrypted.
- Asymmetric / per-recipient encryption — not supported.
- Cloud-KMS adapters — write a thin
IKeyProviderover your KMS; ready-made adapter packages may ship later. - Replay protection — encryption does not prevent replay; use Wolverine’s
existing
DeduplicationId/MessageIdentityif you need it.