diff --git a/docs/.vitepress/config.mts b/docs/.vitepress/config.mts index 0b4e6d76c..9abfe4a9b 100644 --- a/docs/.vitepress/config.mts +++ b/docs/.vitepress/config.mts @@ -130,6 +130,7 @@ const config: UserConfig = { {text: 'Basic Concepts', link: '/guide/basics'}, {text: 'Configuration', link: '/guide/configuration'}, {text: 'Runtime Architecture', link: '/guide/runtime'}, + {text: 'Message Encryption', link: '/guide/runtime/encryption'}, {text: 'Instrumentation and Metrics', link: '/guide/logging'}, {text: 'Diagnostics', link: '/guide/diagnostics'}, {text: 'Serverless Hosting', link: '/guide/serverless'}, diff --git a/docs/guide/runtime/encryption.md b/docs/guide/runtime/encryption.md new file mode 100644 index 000000000..0ef82109b --- /dev/null +++ b/docs/guide/runtime/encryption.md @@ -0,0 +1,194 @@ +# 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 + +```csharp +opts.UseEncryption(new InMemoryKeyProvider( + defaultKeyId: "k1", + keys: new Dictionary { ["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.** +> `UseSystemTextJsonForSerialization` and `UseNewtonsoftForSerialization` only +> replace the default serializer when its content-type is `application/json`, +> so calling them after `UseEncryption` is a no-op against the default and +> leaves the encrypting serializer in place. Calling `UseEncryption` more than +> once throws — configure encryption exactly once during host setup. + +## The `IKeyProvider` interface + +```csharp +public interface IKeyProvider +{ + string DefaultKeyId { get; } + ValueTask 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`: + +```csharp +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: + +```csharp +opts.RegisterEncryptionSerializer(provider); +opts.Policies.ForMessagesOfType().Encrypt(); +``` + +`Encrypt()` is symmetric: outgoing messages of type `T` are encrypted, and +inbound messages of type `T` MUST arrive encrypted (see +[Receive-side enforcement](#receive-side-enforcement) below). + +Per-endpoint (sender-side): + +```csharp +opts.RegisterEncryptionSerializer(provider); +opts.PublishAllMessages().ToRabbitExchange("sensitive").Encrypted(); +``` + +Per-listener (receive-side): + +```csharp +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()` 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().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. + +## Key rotation + +Static `DefaultKeyId`. Rotate by deploying a new provider with the new +key-id alongside the old keys: + +1. Add the new key under `key-2025-q1`, keep `key-2024-q4` listed. +2. Update `DefaultKeyId` to `key-2025-q1`. +3. Deploy. New outgoing messages encrypt under `key-2025-q1`; in-flight or + replayed messages with `key-2024-q4` still decrypt. +4. After the longest plausible message lifetime, drop `key-2024-q4` on 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 `MessageDecryptionException` on a known-good +> ciphertext can mean either body tampering OR routing-metadata tampering +> (`MessageType` swap attack). + +## Error handling + +The encrypting serializer and receive-side guard raise three distinct +exception types on receive: + +- `EncryptionKeyNotFoundException` — missing or unknown `key-id` header, + 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 your `IKeyProvider` rather 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 `IKeyProvider` over your KMS; + ready-made adapter packages may ship later. +- **Replay protection** — encryption does not prevent replay; use Wolverine's + existing `DeduplicationId` / `MessageIdentity` if you need it. diff --git a/src/Samples/EncryptionDemo/DemoHandlers.cs b/src/Samples/EncryptionDemo/DemoHandlers.cs new file mode 100644 index 000000000..754e0af88 --- /dev/null +++ b/src/Samples/EncryptionDemo/DemoHandlers.cs @@ -0,0 +1,17 @@ +namespace EncryptionDemo; + +public sealed record PaymentDetails(string CardNumber, decimal Amount); + +public sealed record OrderShipped(Guid OrderId); + +public static class PaymentDetailsHandler +{ + public static void Handle(PaymentDetails msg) => + Console.WriteLine($"Payment received: {msg.Amount} on card ending {msg.CardNumber[^4..]}"); +} + +public static class OrderShippedHandler +{ + public static void Handle(OrderShipped msg) => + Console.WriteLine($"Order {msg.OrderId} shipped."); +} diff --git a/src/Samples/EncryptionDemo/EncryptionDemo.csproj b/src/Samples/EncryptionDemo/EncryptionDemo.csproj new file mode 100644 index 000000000..6e8077858 --- /dev/null +++ b/src/Samples/EncryptionDemo/EncryptionDemo.csproj @@ -0,0 +1,9 @@ + + + Exe + EncryptionDemo + + + + + diff --git a/src/Samples/EncryptionDemo/Program.cs b/src/Samples/EncryptionDemo/Program.cs new file mode 100644 index 000000000..d5caaa476 --- /dev/null +++ b/src/Samples/EncryptionDemo/Program.cs @@ -0,0 +1,52 @@ +using System.Security.Cryptography; +using EncryptionDemo; +using Microsoft.Extensions.Hosting; +using Wolverine; +using Wolverine.Runtime.Serialization.Encryption; + +// Single-process demo: shows the configuration surface (per-type Encrypt + per-listener +// RequireEncryption) without standing up a separate sender and receiver. Local queues +// are in-memory pass-through, so the byte-level encrypt/decrypt step is not actually +// exercised here — for that, see the two-host acceptance tests in +// src/Testing/CoreTests/Acceptance/encryption_acceptance.cs. In production, replace the +// LocalQueue endpoints below with a real transport (TCP / Rabbit / Service Bus / Kafka) +// so the encrypted bytes actually leave the process. + +var key = RandomNumberGenerator.GetBytes(32); + +using var host = await Host.CreateDefaultBuilder() + .UseWolverine(opts => + { + // Plain JSON globally + opts.UseSystemTextJsonForSerialization(); + + // Register the encrypting serializer alongside (without making it the default) + opts.RegisterEncryptionSerializer(new InMemoryKeyProvider( + "demo-key", + new Dictionary { ["demo-key"] = key })); + + // Encrypt only the sensitive message type + opts.Policies.ForMessagesOfType().Encrypt(); + + opts.PublishMessage().ToLocalQueue("payments"); + opts.PublishMessage().ToLocalQueue("orders"); + + // Receive-side enforcement: the "payments" listener accepts ONLY + // encrypted envelopes. A plain-JSON envelope addressed to this queue + // is routed to the dead-letter queue with EncryptionPolicyViolationException + // before any serializer runs, so a misconfigured sender (or a forged + // envelope) cannot deliver plaintext to a payment handler. + opts.LocalQueue("payments").RequireEncryption(); + + // The "orders" queue is left unmarked so non-sensitive types still + // flow during a rolling deploy. + opts.LocalQueue("orders"); + }) + .StartAsync(); + +var bus = host.MessageBus(); + +await bus.PublishAsync(new PaymentDetails("4111-1111-1111-1111", 99.99m)); // encrypted +await bus.PublishAsync(new OrderShipped(Guid.NewGuid())); // plain JSON + +await Task.Delay(2000); diff --git a/src/Testing/CoreTests/Acceptance/encryption_acceptance.cs b/src/Testing/CoreTests/Acceptance/encryption_acceptance.cs new file mode 100644 index 000000000..153d0c8bc --- /dev/null +++ b/src/Testing/CoreTests/Acceptance/encryption_acceptance.cs @@ -0,0 +1,616 @@ +using System.Net; +using System.Net.Sockets; +using Microsoft.Extensions.Hosting; +using Shouldly; +using Wolverine; +using Wolverine.Runtime.Serialization.Encryption; +using Wolverine.Tracking; +using Wolverine.Transports.Tcp; +using Wolverine.Util; +using Xunit; + +namespace CoreTests.Acceptance; + +public class encryption_acceptance : IDisposable +{ + private static byte[] Key32(byte fill) => Enumerable.Repeat(fill, 32).ToArray(); + + public encryption_acceptance() + { + // The handler-receive lists are static (Wolverine handlers are static + // methods discovered by convention), so per-test isolation has to be + // enforced explicitly. xUnit constructs a new test class instance per + // [Fact], so clearing here gives every test a clean slate without + // relying on each test to remember to .Clear() up front. + EncryptedPayloadHandler.Received.Clear(); + SensitiveSubtypeHandler.Received.Clear(); + } + + public void Dispose() + { + EncryptedPayloadHandler.Received.Clear(); + SensitiveSubtypeHandler.Received.Clear(); + } + + public sealed record EncryptedPayload(string Secret); + + public static class EncryptedPayloadHandler + { + // ConcurrentBag protects against handler invocations from different + // listener threads racing on List.Add — a real hazard once xUnit + // class-parallel runs ever shares a process with these tests. + public static readonly System.Collections.Concurrent.ConcurrentBag Received = new(); + public static void Handle(EncryptedPayload payload) => Received.Add(payload); + } + + public sealed record EncryptedNoOp(string Value); + + public static class EncryptedNoOpHandler + { + public static void Handle(EncryptedNoOp _) { /* no-op; failure paths are tested */ } + } + + public interface ISensitivePayload { } + + public sealed record SensitiveSubtype(string Secret) : ISensitivePayload; + + public static class SensitiveSubtypeHandler + { + public static readonly System.Collections.Concurrent.ConcurrentBag Received = new(); + public static void Handle(SensitiveSubtype payload) => Received.Add(payload); + } + + [Fact] + public async Task routing_assigns_encrypting_serializer_for_published_message() + { + // Local queues do not serialize on send (in-memory pass-through), so + // EncryptingMessageSerializer.WriteAsync is NOT invoked here and no + // per-envelope KeyIdHeader is stamped. This is a routing-decision test: + // it verifies the published envelope is tagged with the encrypted + // content-type and the encrypting serializer is selected. Byte-level + // encryption (and the on-the-wire shape) is covered by + // EncryptingMessageSerializerTests. + using var host = await Host.CreateDefaultBuilder() + .UseWolverine(opts => + { + opts.UseEncryption(new InMemoryKeyProvider( + "k1", + new Dictionary { ["k1"] = Key32(0x42) })); + opts.PublishAllMessages().ToLocalQueue("encrypted-queue"); + opts.LocalQueue("encrypted-queue"); + }) + .StartAsync(); + + var bus = host.MessageBus(); + + var session = await host.TrackActivity().ExecuteAndWaitAsync(_ => + bus.PublishAsync(new EncryptedPayload("x"))); + + var sentEnvelope = session.Sent.SingleEnvelope(); + sentEnvelope.ContentType.ShouldBe(EncryptionHeaders.EncryptedContentType); + sentEnvelope.Serializer.ShouldBeOfType(); + } + + [Fact] + public async Task receive_with_unknown_key_id_routes_to_error_queue_two_host() + { + var receiverPort = PortFinder.GetAvailablePort(); + + // Sender host: knows "ghost" key, encrypts under it. + using var sender = await Host.CreateDefaultBuilder() + .UseWolverine(opts => + { + opts.UseEncryption(new InMemoryKeyProvider( + "ghost", + new Dictionary { ["ghost"] = Key32(0x33) })); + opts.PublishAllMessages().To($"tcp://localhost:{receiverPort}"); + opts.ServiceName = "sender"; + }) + .StartAsync(); + + // Receiver host: only knows "k1", will reject "ghost" with EncryptionKeyNotFoundException. + using var receiver = await Host.CreateDefaultBuilder() + .UseWolverine(opts => + { + opts.UseEncryption(new InMemoryKeyProvider( + "k1", + new Dictionary { ["k1"] = Key32(0x42) })); + opts.ListenAtPort(receiverPort); + opts.ServiceName = "receiver"; + }) + .StartAsync(); + + var session = await receiver + .TrackActivity(TimeSpan.FromSeconds(10)) + .DoNotAssertOnExceptionsDetected() + .IncludeExternalTransports() + .WaitForCondition(new WaitForAnyDeadLetteredEnvelope()) + .ExecuteAndWaitAsync(_ => + sender.MessageBus().PublishAsync(new EncryptedNoOp("x"))); + + session.ShouldHaveDeadLetteredWith(); + } + + [Fact] + public async Task receive_with_wrong_key_bytes_routes_to_error_queue() + { + var receiverPort = PortFinder.GetAvailablePort(); + + // Both hosts know key-id "k1" but with different bytes. AES-GCM auth tag + // fails on receive => MessageDecryptionException => user policy moves to DLQ. + using var sender = await Host.CreateDefaultBuilder() + .UseWolverine(opts => + { + opts.UseEncryption(new InMemoryKeyProvider( + "k1", + new Dictionary { ["k1"] = Key32(0x33) })); + opts.PublishAllMessages().To($"tcp://localhost:{receiverPort}"); + opts.ServiceName = "sender"; + }) + .StartAsync(); + + using var receiver = await Host.CreateDefaultBuilder() + .UseWolverine(opts => + { + opts.UseEncryption(new InMemoryKeyProvider( + "k1", + new Dictionary { ["k1"] = Key32(0x42) })); // different bytes! + opts.ListenAtPort(receiverPort); + opts.ServiceName = "receiver"; + }) + .StartAsync(); + + var session = await receiver + .TrackActivity(TimeSpan.FromSeconds(10)) + .DoNotAssertOnExceptionsDetected() + .IncludeExternalTransports() + .WaitForCondition(new WaitForAnyDeadLetteredEnvelope()) + .ExecuteAndWaitAsync(_ => + sender.MessageBus().PublishAsync(new EncryptedNoOp("x"))); + + session.ShouldHaveDeadLetteredWith(); + } + + [Fact] + public async Task receive_unencrypted_message_for_required_type_routes_to_error_queue() + { + var receiverPort = PortFinder.GetAvailablePort(); + + // Sender does NOT call UseEncryption — emits plain JSON for EncryptedPayload. + using var sender = await Host.CreateDefaultBuilder() + .UseWolverine(opts => + { + opts.PublishAllMessages().To($"tcp://localhost:{receiverPort}"); + opts.ServiceName = "sender"; + }) + .StartAsync(); + + // Receiver marks EncryptedPayload as encryption-required. The HandlerPipeline + // guard must DLQ the forged plain-JSON envelope before any serializer runs. + using var receiver = await Host.CreateDefaultBuilder() + .UseWolverine(opts => + { + opts.UseEncryption(new InMemoryKeyProvider( + "k1", + new Dictionary { ["k1"] = Key32(0x42) })); + opts.Policies.ForMessagesOfType().Encrypt(); + opts.ListenAtPort(receiverPort); + opts.ServiceName = "receiver"; + }) + .StartAsync(); + + var session = await receiver + .TrackActivity(TimeSpan.FromSeconds(10)) + .DoNotAssertOnExceptionsDetected() + .IncludeExternalTransports() + .WaitForCondition(new WaitForAnyDeadLetteredEnvelope()) + .ExecuteAndWaitAsync(_ => + sender.MessageBus() + .PublishAsync(new EncryptedPayload("forged-plaintext"))); + + session.ShouldHaveDeadLetteredWith(); + EncryptedPayloadHandler.Received.ShouldNotContain(p => p.Secret == "forged-plaintext"); + } + + [Fact] + public async Task receive_unencrypted_message_on_required_listener_routes_to_error_queue() + { + var receiverPort = PortFinder.GetAvailablePort(); + + // Sender does NOT call UseEncryption — emits plain JSON. + using var sender = await Host.CreateDefaultBuilder() + .UseWolverine(opts => + { + opts.PublishAllMessages().To($"tcp://localhost:{receiverPort}"); + opts.ServiceName = "sender"; + }) + .StartAsync(); + + // Marker is on the LISTENER (.RequireEncryption()), not on the message type. + // The HandlerPipeline guard must DLQ the forged plain-JSON envelope via the + // destination-URI branch of RequiresEncryption, not via type resolution. + using var receiver = await Host.CreateDefaultBuilder() + .UseWolverine(opts => + { + opts.UseEncryption(new InMemoryKeyProvider( + "k1", + new Dictionary { ["k1"] = Key32(0x42) })); + opts.ListenAtPort(receiverPort).RequireEncryption(); + opts.ServiceName = "receiver"; + }) + .StartAsync(); + + var session = await receiver + .TrackActivity(TimeSpan.FromSeconds(10)) + .DoNotAssertOnExceptionsDetected() + .IncludeExternalTransports() + .WaitForCondition(new WaitForAnyDeadLetteredEnvelope()) + .ExecuteAndWaitAsync(_ => + sender.MessageBus() + .PublishAsync(new EncryptedPayload("forged-plaintext-listener"))); + + session.ShouldHaveDeadLetteredWith(); + EncryptedPayloadHandler.Received.ShouldNotContain(p => p.Secret == "forged-plaintext-listener"); + } + + [Fact] + public async Task encrypted_message_for_required_type_round_trips_two_host() + { + var receiverPort = PortFinder.GetAvailablePort(); + + // Negative control: both sides configured with UseEncryption + per-type marker. + // Encrypted bytes go over the wire, decrypt successfully, and the handler runs. + // Proves the listener-side encryption-required check does not block legitimate + // encrypted traffic. + using var sender = await Host.CreateDefaultBuilder() + .UseWolverine(opts => + { + opts.UseEncryption(new InMemoryKeyProvider( + "k1", + new Dictionary { ["k1"] = Key32(0x42) })); + opts.Policies.ForMessagesOfType().Encrypt(); + opts.PublishAllMessages().To($"tcp://localhost:{receiverPort}"); + opts.ServiceName = "sender"; + }) + .StartAsync(); + + using var receiver = await Host.CreateDefaultBuilder() + .UseWolverine(opts => + { + opts.UseEncryption(new InMemoryKeyProvider( + "k1", + new Dictionary { ["k1"] = Key32(0x42) })); + opts.Policies.ForMessagesOfType().Encrypt(); + opts.ListenAtPort(receiverPort); + opts.ServiceName = "receiver"; + }) + .StartAsync(); + + await receiver + .TrackActivity(TimeSpan.FromSeconds(10)) + .IncludeExternalTransports() + .WaitForMessageToBeReceivedAt(receiver) + .ExecuteAndWaitAsync(_ => + sender.MessageBus() + .PublishAsync(new EncryptedPayload("legit-secret"))); + + EncryptedPayloadHandler.Received.Single().Secret.ShouldBe("legit-secret"); + } + + [Fact] + public async Task plain_message_for_unmarked_type_passes_when_encryption_is_configured() + { + // Rolling-deploy scenario. Receiver has UseEncryption configured, but + // EncryptedNoOp is not marked as required and the listener is NOT + // marked with .RequireEncryption() either. Sender publishes plain JSON. + // Receiver MUST process it normally so unmarked types still flow during + // gradual rollouts — the encryption guard only fires for marked types or + // marked listeners. (Listener-marked endpoints DLQ unmarked types too; + // that path is covered by receive_unencrypted_message_on_required_listener.) + var receiverPort = PortFinder.GetAvailablePort(); + + using var sender = await Host.CreateDefaultBuilder() + .UseWolverine(opts => + { + opts.PublishAllMessages().To($"tcp://localhost:{receiverPort}"); + opts.ServiceName = "sender"; + }) + .StartAsync(); + + using var receiver = await Host.CreateDefaultBuilder() + .UseWolverine(opts => + { + opts.UseEncryption(new InMemoryKeyProvider( + "k1", + new Dictionary { ["k1"] = Key32(0x42) })); + // EncryptedNoOp deliberately NOT marked. + opts.ListenAtPort(receiverPort); + opts.ServiceName = "receiver"; + }) + .StartAsync(); + + var session = await receiver + .TrackActivity(TimeSpan.FromSeconds(10)) + .IncludeExternalTransports() + .WaitForMessageToBeReceivedAt(receiver) + .ExecuteAndWaitAsync(_ => + sender.MessageBus() + .PublishAsync(new EncryptedNoOp("rolling-deploy"))); + + session.AllRecordsInOrder() + .ShouldNotContain(r => r.MessageEventType == MessageEventType.MovedToErrorQueue); + } + + [Fact] + public async Task wire_does_not_contain_plaintext_when_encryption_is_required() + { + // In-process MITM proxy between sender and receiver: the Wolverine + // sender publishes to snifferPort; the test's TcpListener accepts + // that connection, dials the real receiver, and pumps both directions + // while teeing the sender->receiver bytes into a MemoryStream. Any + // plaintext fragment of the canary that ever crosses the wire shows + // up in the captured buffer. This is the only test that proves the + // bytes Wolverine actually transmits are not the plaintext — every + // other encryption test inspects the serializer's output or relies + // on a successful round-trip. + var snifferPort = PortFinder.GetAvailablePort(); + var receiverPort = PortFinder.GetAvailablePort(); + var canary = "WIRE-CANARY-" + Guid.NewGuid().ToString("N"); + + var captured = new MemoryStream(); + using var snifferCts = new CancellationTokenSource(); + var sniffer = new TcpListener(IPAddress.Loopback, snifferPort); + sniffer.Start(); + + var proxyTask = Task.Run(async () => + { + try + { + using var inbound = await sniffer.AcceptTcpClientAsync(snifferCts.Token); + using var inboundStream = inbound.GetStream(); + using var upstream = new TcpClient(); + await upstream.ConnectAsync(IPAddress.Loopback, receiverPort, snifferCts.Token); + using var upstreamStream = upstream.GetStream(); + + var senderToReceiver = PumpAsync(inboundStream, upstreamStream, captured, snifferCts.Token); + var receiverToSender = PumpAsync(upstreamStream, inboundStream, sink: null, snifferCts.Token); + + await Task.WhenAny(senderToReceiver, receiverToSender); + } + catch (OperationCanceledException) { } + catch (ObjectDisposedException) { } + catch (IOException) { } + catch (SocketException) { } + }); + + try + { + using var sender = await Host.CreateDefaultBuilder() + .UseWolverine(opts => + { + opts.UseEncryption(new InMemoryKeyProvider( + "k1", + new Dictionary { ["k1"] = Key32(0x42) })); + opts.Policies.ForMessagesOfType().Encrypt(); + opts.PublishAllMessages().To($"tcp://localhost:{snifferPort}"); + opts.ServiceName = "sender"; + }) + .StartAsync(); + + using var receiver = await Host.CreateDefaultBuilder() + .UseWolverine(opts => + { + opts.UseEncryption(new InMemoryKeyProvider( + "k1", + new Dictionary { ["k1"] = Key32(0x42) })); + opts.Policies.ForMessagesOfType().Encrypt(); + opts.ListenAtPort(receiverPort); + opts.ServiceName = "receiver"; + }) + .StartAsync(); + + await receiver + .TrackActivity(TimeSpan.FromSeconds(10)) + .IncludeExternalTransports() + .WaitForMessageToBeReceivedAt(receiver) + .ExecuteAndWaitAsync(_ => + sender.MessageBus() + .PublishAsync(new EncryptedPayload(canary))); + + EncryptedPayloadHandler.Received.ShouldContain(p => p.Secret == canary); + + byte[] capturedBytes; + lock (captured) { capturedBytes = captured.ToArray(); } + + capturedBytes.Length.ShouldBeGreaterThan(0); + // UTF8.GetString never throws on invalid sequences — substrings of + // valid ASCII (the canary and the content-type marker) will match + // contiguously regardless of the surrounding binary noise. + var dump = System.Text.Encoding.UTF8.GetString(capturedBytes); + dump.ShouldNotContain(canary); + dump.ShouldContain(EncryptionHeaders.EncryptedContentType); + } + finally + { + snifferCts.Cancel(); + try { sniffer.Stop(); } catch { } + try { await proxyTask.WaitAsync(TimeSpan.FromSeconds(2)); } catch { } + } + } + + [Fact] + public async Task durable_persistence_path_serializes_ciphertext_not_plaintext() + { + // Disk-leak boundary. The outbox path is: + // + // DestinationEndpoint.SendAsync -> applies route.Rules (the + // encryption rule sets + // envelope.Serializer + ContentType) + // PersistOrSendAsync -> hands envelope to outbox + // IMessageOutbox.StoreOutgoingAsync + // -> reads envelope.Data + // Envelope.Data getter -> lazy; first read triggers + // Serializer.Write(this) + // + // So by the time the outbox writes bytes to disk, the encrypting + // serializer has been assigned and the byte read is ciphertext. + // This test locks the chain at the data-materialisation point: a + // future change that pre-fills envelope.Data before the encryption + // rule runs (or stores the inner serializer's output) would flip + // this assertion red even without a real database. + // + // Both materialisation entry points are exercised: + // - sync Data getter (current outbox path via EnvelopeSerializer) + // - async GetDataAsync (the path a future migration would use, + // and the one EncryptingMessageSerializer's IAsyncMessageSerializer + // surface is built for). + // + // Out of scope: SendRawMessageAsync (DestinationEndpoint.cs) accepts + // pre-serialized bytes that bypass the lazy serializer entirely. That + // is by design — callers using it have already chosen their bytes — + // and is not part of the contract this test locks. + var canary = "PERSIST-CANARY-" + Guid.NewGuid().ToString("N"); + var encrypting = new EncryptingMessageSerializer( + new Wolverine.Runtime.Serialization.SystemTextJsonSerializer( + Wolverine.Runtime.Serialization.SystemTextJsonSerializer.DefaultOptions()), + new InMemoryKeyProvider( + "k1", new Dictionary { ["k1"] = Key32(0x42) })); + + // Sync path: mirrors what IMessageOutbox.StoreOutgoingAsync reads today. + var syncEnvelope = new Envelope(new EncryptedPayload(canary)) + { + Serializer = encrypting, + ContentType = encrypting.ContentType + }; + var syncBytes = syncEnvelope.Data!; + syncBytes.Length.ShouldBeGreaterThan(0); + System.Text.Encoding.UTF8.GetString(syncBytes).ShouldNotContain(canary); + syncEnvelope.ContentType.ShouldBe(EncryptionHeaders.EncryptedContentType); + + // Async path: locks the same contract for any persistence-layer + // migration that switches to GetDataAsync (preferred for async + // serializers and a known refactor target). + var asyncEnvelope = new Envelope(new EncryptedPayload(canary)) + { + Serializer = encrypting, + ContentType = encrypting.ContentType + }; + var asyncBytes = (await asyncEnvelope.GetDataAsync())!; + asyncBytes.Length.ShouldBeGreaterThan(0); + System.Text.Encoding.UTF8.GetString(asyncBytes).ShouldNotContain(canary); + asyncEnvelope.ContentType.ShouldBe(EncryptionHeaders.EncryptedContentType); + } + + private static async Task PumpAsync(NetworkStream src, NetworkStream dst, MemoryStream? sink, CancellationToken ct) + { + var buf = new byte[4096]; + try + { + while (!ct.IsCancellationRequested) + { + int n = await src.ReadAsync(buf, ct).ConfigureAwait(false); + if (n <= 0) return; + // Capture BEFORE forwarding: this guarantees that any byte the + // receiver could possibly have observed is already in 'sink' + // when the test asserts after WaitForMessageToBeReceivedAt. + if (sink is not null) lock (sink) { sink.Write(buf, 0, n); } + await dst.WriteAsync(buf.AsMemory(0, n), ct).ConfigureAwait(false); + } + } + catch (OperationCanceledException) { } + catch (ObjectDisposedException) { } + catch (IOException) { } + catch (SocketException) { } + } + + [Fact] + public async Task receive_unencrypted_message_for_required_supertype_routes_to_error_queue() + { + var receiverPort = PortFinder.GetAvailablePort(); + + // Sender does NOT call UseEncryption — emits plain JSON for SensitiveSubtype. + using var sender = await Host.CreateDefaultBuilder() + .UseWolverine(opts => + { + opts.PublishAllMessages().To($"tcp://localhost:{receiverPort}"); + opts.ServiceName = "sender"; + }) + .StartAsync(); + + // Receiver marks the SUPERTYPE (interface) as encryption-required. The + // wire MessageType resolves to the concrete SensitiveSubtype, which is + // not in RequiredEncryptedTypes by exact match. The polymorphic guard + // must still DLQ the envelope before the serializer runs. + using var receiver = await Host.CreateDefaultBuilder() + .UseWolverine(opts => + { + opts.UseEncryption(new InMemoryKeyProvider( + "k1", + new Dictionary { ["k1"] = Key32(0x42) })); + opts.Policies.ForMessagesOfType().Encrypt(); + opts.ListenAtPort(receiverPort); + opts.ServiceName = "receiver"; + }) + .StartAsync(); + + var session = await receiver + .TrackActivity(TimeSpan.FromSeconds(10)) + .DoNotAssertOnExceptionsDetected() + .IncludeExternalTransports() + .WaitForCondition(new WaitForAnyDeadLetteredEnvelope()) + .ExecuteAndWaitAsync(_ => + sender.MessageBus() + .PublishAsync(new SensitiveSubtype("forged-plaintext-subtype"))); + + session.ShouldHaveDeadLetteredWith(); + SensitiveSubtypeHandler.Received + .ShouldNotContain(p => p.Secret == "forged-plaintext-subtype"); + } +} + +/// +/// Waits for ANY envelope to be dead-lettered. Used when the receive-side deserialization +/// fails before envelope.Message can be materialized, so the typed +/// WaitForDeadLetteredMessage<T> condition never matches. +/// +internal sealed class WaitForAnyDeadLetteredEnvelope : ITrackedCondition +{ + private bool _found; + + public void Record(EnvelopeRecord record) + { + if (record.MessageEventType == Wolverine.Tracking.MessageEventType.MovedToErrorQueue) + { + _found = true; + } + } + + public bool IsCompleted() => _found; +} + +internal static class TrackedSessionEncryptionAssertions +{ + /// + /// Asserts that an envelope was dead-lettered AND that some tracking record + /// in the session carries an exception of . + /// Tolerates whichever record slot the tracking pipeline writes the exception + /// into — currently a sibling MessageFailed record, but historically and + /// potentially again the MovedToErrorQueue record itself. Without this + /// helper, a future change to where the exception is recorded would silently + /// invalidate every receive-side test even though the production behavior + /// (DLQ + correct exception) is unchanged. + /// + public static void ShouldHaveDeadLetteredWith(this Wolverine.Tracking.ITrackedSession session) + where TException : Exception + { + session.MovedToErrorQueue.RecordsInOrder().ShouldNotBeEmpty(); + + var allRecords = session.AllRecordsInOrder().ToList(); + var matched = allRecords.FirstOrDefault(r => r.Exception is TException); + + matched.ShouldNotBeNull( + customMessage: $"Expected a tracking record carrying {typeof(TException).Name}. " + + $"Got record exceptions: " + + $"[{string.Join(", ", allRecords.Select(r => r.Exception?.GetType().Name ?? ""))}]."); + } +} diff --git a/src/Testing/CoreTests/Runtime/Serialization/Encryption/CachingKeyProviderTests.cs b/src/Testing/CoreTests/Runtime/Serialization/Encryption/CachingKeyProviderTests.cs new file mode 100644 index 000000000..0ccb50278 --- /dev/null +++ b/src/Testing/CoreTests/Runtime/Serialization/Encryption/CachingKeyProviderTests.cs @@ -0,0 +1,254 @@ +using Shouldly; +using Wolverine.Runtime.Serialization.Encryption; +using Xunit; + +namespace CoreTests.Runtime.Serialization.Encryption; + +public class CachingKeyProviderTests +{ + private static byte[] Key32(byte fill) => Enumerable.Repeat(fill, 32).ToArray(); + + private sealed class CountingProvider : IKeyProvider + { + private readonly Dictionary _keys; + public int CallCount; + public Func? Hook; + + public CountingProvider(Dictionary keys, string defaultKeyId) + { + _keys = keys; + DefaultKeyId = defaultKeyId; + } + + public string DefaultKeyId { get; } + + public async ValueTask GetKeyAsync(string keyId, CancellationToken cancellationToken) + { + Interlocked.Increment(ref CallCount); + if (Hook is not null) await Hook().ConfigureAwait(false); + return _keys[keyId]; + } + } + + [Fact] + public async Task first_call_hits_inner_then_cache_serves_subsequent_calls() + { + var inner = new CountingProvider(new() { ["k1"] = Key32(0x01) }, "k1"); + var caching = new CachingKeyProvider(inner, TimeSpan.FromMinutes(1)); + + await caching.GetKeyAsync("k1", default); + await caching.GetKeyAsync("k1", default); + await caching.GetKeyAsync("k1", default); + + inner.CallCount.ShouldBe(1); + } + + [Fact] + public async Task ttl_expiry_re_fetches() + { + var inner = new CountingProvider(new() { ["k1"] = Key32(0x01) }, "k1"); + var caching = new CachingKeyProvider(inner, TimeSpan.FromMilliseconds(50)); + + await caching.GetKeyAsync("k1", default); + await Task.Delay(80); + await caching.GetKeyAsync("k1", default); + + inner.CallCount.ShouldBe(2); + } + + [Fact] + public async Task concurrent_requests_for_same_key_deduplicate() + { + var inner = new CountingProvider(new() { ["k1"] = Key32(0x01) }, "k1"); + var release = new TaskCompletionSource(); + inner.Hook = () => release.Task; + + var caching = new CachingKeyProvider(inner, TimeSpan.FromMinutes(1)); + + var task1 = caching.GetKeyAsync("k1", default).AsTask(); + var task2 = caching.GetKeyAsync("k1", default).AsTask(); + var task3 = caching.GetKeyAsync("k1", default).AsTask(); + + // GetOrAdd is synchronous, so dedup has already happened — no need to + // sleep before releasing. Only ONE call has even reached Hook(). + release.SetResult(); + + await Task.WhenAll(task1, task2, task3); + inner.CallCount.ShouldBe(1); + } + + [Fact] + public async Task different_keys_do_not_block_each_other() + { + var inner = new CountingProvider( + new() { ["k1"] = Key32(0x01), ["k2"] = Key32(0x02) }, + "k1"); + var caching = new CachingKeyProvider(inner, TimeSpan.FromMinutes(1)); + + await caching.GetKeyAsync("k1", default); + await caching.GetKeyAsync("k2", default); + + inner.CallCount.ShouldBe(2); + } + + [Fact] + public void ttl_must_be_positive() + { + var inner = new CountingProvider(new() { ["k1"] = Key32(0x01) }, "k1"); + Should.Throw(() => + new CachingKeyProvider(inner, TimeSpan.Zero)); + } + + [Fact] + public async Task faulted_fetch_is_evicted_so_next_caller_retries() + { + // First call: provider throws. Second call (after eviction): provider succeeds. + var attempt = 0; + var provider = new ThrowingThenSucceedingProvider(_ => + { + attempt++; + if (attempt == 1) throw new InvalidOperationException("transient"); + return Key32(0x01); + }, "k1"); + + var caching = new CachingKeyProvider(provider, TimeSpan.FromMinutes(1)); + + await Should.ThrowAsync(async () => + await caching.GetKeyAsync("k1", default)); + + // The faulted entry should have been evicted; second call must hit the inner + // provider again and succeed. + var key = await caching.GetKeyAsync("k1", default); + key.ShouldBe(Key32(0x01)); + attempt.ShouldBe(2); + } + + private sealed class ThrowingThenSucceedingProvider : IKeyProvider + { + private readonly Func _resolve; + + public ThrowingThenSucceedingProvider(Func resolve, string defaultKeyId) + { + _resolve = resolve; + DefaultKeyId = defaultKeyId; + } + + public string DefaultKeyId { get; } + + public ValueTask GetKeyAsync(string keyId, CancellationToken cancellationToken) + => ValueTask.FromResult(_resolve(keyId)); + } + + [Fact] + public async Task per_caller_cancellation_does_not_propagate_to_co_waiters() + { + using var firstCallerCts = new CancellationTokenSource(); + using var secondCallerCts = new CancellationTokenSource(); + + var gate = new TaskCompletionSource(); + var inner = new GatedKeyProvider("k1", gate.Task); + var sut = new CachingKeyProvider(inner, TimeSpan.FromMinutes(5)); + + var first = sut.GetKeyAsync("k1", firstCallerCts.Token).AsTask(); + var second = sut.GetKeyAsync("k1", secondCallerCts.Token).AsTask(); + + firstCallerCts.Cancel(); + + await Should.ThrowAsync(() => first); + + var keyBytes = Key32(0x42); + gate.SetResult(keyBytes); + + var secondResult = await second; + secondResult.ShouldBe(keyBytes); + } + + private sealed class GatedKeyProvider : IKeyProvider + { + private readonly Task _gate; + public GatedKeyProvider(string defaultKeyId, Task gate) + { + DefaultKeyId = defaultKeyId; + _gate = gate; + } + public string DefaultKeyId { get; } + public async ValueTask GetKeyAsync(string keyId, CancellationToken cancellationToken) + => await _gate.ConfigureAwait(false); + } + + [Fact] + public async Task cache_evicts_least_recently_used_when_max_entries_exceeded() + { + var inner = new MultiKeyCountingProvider("a"); + var sut = new CachingKeyProvider(inner, TimeSpan.FromMinutes(5), maxEntries: 3); + + await sut.GetKeyAsync("a", default); + await sut.GetKeyAsync("b", default); + await sut.GetKeyAsync("c", default); + await sut.GetKeyAsync("a", default); // touch 'a' so 'b' becomes oldest + await sut.GetKeyAsync("d", default); // forces eviction of 'b' + + inner.CallsFor("a").ShouldBe(1); + inner.CallsFor("b").ShouldBe(1); + inner.CallsFor("c").ShouldBe(1); + inner.CallsFor("d").ShouldBe(1); + + await sut.GetKeyAsync("b", default); // evicted, must re-fetch + inner.CallsFor("b").ShouldBe(2); + + await sut.GetKeyAsync("a", default); // still cached + inner.CallsFor("a").ShouldBe(1); + } + + private sealed class MultiKeyCountingProvider : IKeyProvider + { + private readonly Dictionary _counts = new(); + public MultiKeyCountingProvider(string defaultKeyId) { DefaultKeyId = defaultKeyId; } + public string DefaultKeyId { get; } + public ValueTask GetKeyAsync(string keyId, CancellationToken cancellationToken) + { + lock (_counts) { _counts[keyId] = _counts.GetValueOrDefault(keyId) + 1; } + return new ValueTask(Enumerable.Repeat((byte)keyId[0], 32).ToArray()); + } + public int CallsFor(string keyId) + { + lock (_counts) { return _counts.GetValueOrDefault(keyId); } + } + } + + [Fact] + public async Task max_entries_one_evicts_immediately_on_second_distinct_key() + { + // Edge case: a 1-slot cache must still satisfy the LRU contract — every + // distinct key kicks the previous one out, but a repeat of the just-fetched + // key is still served from cache. + var inner = new MultiKeyCountingProvider("a"); + var sut = new CachingKeyProvider(inner, TimeSpan.FromMinutes(5), maxEntries: 1); + + await sut.GetKeyAsync("a", default); + await sut.GetKeyAsync("a", default); // still cached + inner.CallsFor("a").ShouldBe(1); + + await sut.GetKeyAsync("b", default); // evicts 'a' + inner.CallsFor("b").ShouldBe(1); + + await sut.GetKeyAsync("a", default); // 'a' was evicted, must re-fetch + inner.CallsFor("a").ShouldBe(2); + } + + [Fact] + public async Task entry_just_before_ttl_is_still_cached() + { + // Boundary opposite to ttl_expiry_re_fetches: prove the entry remains + // valid up to (but not past) the TTL, so a TTL refactor that flipped + // > vs >= would be caught. + var inner = new CountingProvider(new() { ["k1"] = Key32(0x01) }, "k1"); + var caching = new CachingKeyProvider(inner, TimeSpan.FromSeconds(5)); + + await caching.GetKeyAsync("k1", default); + await Task.Delay(50); // well within TTL + await caching.GetKeyAsync("k1", default); + + inner.CallCount.ShouldBe(1); + } +} diff --git a/src/Testing/CoreTests/Runtime/Serialization/Encryption/EncryptingMessageSerializerTests.cs b/src/Testing/CoreTests/Runtime/Serialization/Encryption/EncryptingMessageSerializerTests.cs new file mode 100644 index 000000000..63b87b5df --- /dev/null +++ b/src/Testing/CoreTests/Runtime/Serialization/Encryption/EncryptingMessageSerializerTests.cs @@ -0,0 +1,784 @@ +using Shouldly; +using Wolverine; +using Wolverine.Runtime.Serialization; +using Wolverine.Runtime.Serialization.Encryption; +using Wolverine.Util; +using Xunit; + +namespace CoreTests.Runtime.Serialization.Encryption; + +public class EncryptingMessageSerializerTests +{ + private static byte[] Key32(byte fill) => Enumerable.Repeat(fill, 32).ToArray(); + + private static EncryptingMessageSerializer NewSut(IMessageSerializer? inner = null, IKeyProvider? provider = null) + { + inner ??= new SystemTextJsonSerializer(SystemTextJsonSerializer.DefaultOptions()); + provider ??= new InMemoryKeyProvider("k1", new Dictionary { ["k1"] = Key32(0x01) }); + return new EncryptingMessageSerializer(inner, provider); + } + + [Fact] + public void content_type_is_dedicated_encrypted_value() + { + NewSut().ContentType.ShouldBe(EncryptionHeaders.EncryptedContentType); + } + + [Fact] + public void implements_async_serializer() + { + NewSut().ShouldBeAssignableTo(); + } + + [Fact] + public void sync_write_bridges_to_async_and_round_trips() + { + var sut = NewSut(); + var envelope = new Envelope { Message = new HelloMessage("sync-write") }; + + // Sync surface should produce the same on-the-wire envelope shape as async. + var bytes = sut.Write(envelope); + + bytes.Length.ShouldBeGreaterThan(12 + 16); + envelope.Headers.ContainsKey(EncryptionHeaders.KeyIdHeader).ShouldBeTrue(); + envelope.Headers[EncryptionHeaders.KeyIdHeader].ShouldBe("k1"); + } + + [Fact] + public void sync_read_from_data_envelope_bridges_to_async_and_decrypts() + { + var sut = NewSut(); + var sendEnvelope = new Envelope { Message = new HelloMessage("sync-read") }; + var bytes = sut.Write(sendEnvelope); + + var recvEnvelope = new Envelope + { + Data = bytes, + ContentType = EncryptionHeaders.EncryptedContentType, + MessageType = typeof(HelloMessage).ToMessageTypeName(), + Headers = + { + [EncryptionHeaders.KeyIdHeader] = sendEnvelope.Headers[EncryptionHeaders.KeyIdHeader], + [EncryptionHeaders.InnerContentTypeHeader] = sendEnvelope.Headers[EncryptionHeaders.InnerContentTypeHeader] + } + }; + + var msg = sut.ReadFromData(typeof(HelloMessage), recvEnvelope); + + msg.ShouldBeOfType().Greeting.ShouldBe("sync-read"); + } + + [Fact] + public void WriteMessage_no_envelope_path_throws_invalid_operation() + { + // The no-envelope overload cannot stamp a key-id header, so encryption + // is impossible. Returning the inner serializer's plaintext under a + // ContentType that advertises encryption would be a silent confidentiality + // bug, so this overload fails loudly instead. + var inner = new TrackingInnerSerializer(); + var sut = new EncryptingMessageSerializer(inner, + new InMemoryKeyProvider("k1", new Dictionary { ["k1"] = Key32(0x01) })); + + var ex = Should.Throw(() => sut.WriteMessage(new HelloMessage("plain"))); + + inner.WriteMessageCallCount.ShouldBe(0); + ex.Message.ShouldContain("Envelope"); + } + + [Fact] + public void ReadFromData_byte_array_no_envelope_path_throws_invalid_operation() + { + // Symmetric to WriteMessage(object): no envelope context means no + // key-id header, so decryption cannot proceed. Fail loudly so a stray + // caller can't silently bypass decryption. + var inner = new TrackingInnerSerializer(); + var sut = new EncryptingMessageSerializer(inner, + new InMemoryKeyProvider("k1", new Dictionary { ["k1"] = Key32(0x01) })); + + var ex = Should.Throw(() => sut.ReadFromData(new byte[] { 1, 2, 3 })); + + inner.ReadFromDataBytesCallCount.ShouldBe(0); + ex.ShouldNotBeAssignableTo(); + ex.Message.ShouldContain("Envelope"); + } + + private sealed class TrackingInnerSerializer : IMessageSerializer + { + public int WriteMessageCallCount; + public int WriteAsyncCallCount; + public int ReadFromDataBytesCallCount; + public Func? ReadFromDataBytesBehavior; + + public string ContentType => EnvelopeConstants.JsonContentType; + + public byte[] Write(Envelope model) + { + return new SystemTextJsonSerializer(SystemTextJsonSerializer.DefaultOptions()).Write(model); + } + + public byte[] WriteMessage(object message) + { + WriteMessageCallCount++; + return new SystemTextJsonSerializer(SystemTextJsonSerializer.DefaultOptions()).WriteMessage(message); + } + + public object ReadFromData(Type messageType, Envelope envelope) + => new SystemTextJsonSerializer(SystemTextJsonSerializer.DefaultOptions()).ReadFromData(messageType, envelope); + + public object ReadFromData(byte[] data) + { + ReadFromDataBytesCallCount++; + if (ReadFromDataBytesBehavior is not null) return ReadFromDataBytesBehavior(data); + return new SystemTextJsonSerializer(SystemTextJsonSerializer.DefaultOptions()).ReadFromData(data); + } + } + + [Fact] + public async Task write_async_sets_content_type_and_key_id_headers() + { + var sut = NewSut(); + var envelope = new Envelope { Message = new HelloMessage("world") }; + + var bytes = await sut.WriteAsync(envelope); + + envelope.Headers.ContainsKey(EncryptionHeaders.KeyIdHeader).ShouldBeTrue(); + envelope.Headers[EncryptionHeaders.KeyIdHeader].ShouldBe("k1"); + + envelope.Headers.ContainsKey(EncryptionHeaders.InnerContentTypeHeader).ShouldBeTrue(); + envelope.Headers[EncryptionHeaders.InnerContentTypeHeader].ShouldBe(EnvelopeConstants.JsonContentType); + + bytes.Length.ShouldBeGreaterThan(12 + 16); // at least nonce + tag + } + + [Fact] + public async Task write_async_produces_unique_nonces_across_messages() + { + var sut = NewSut(); + var nonces = new HashSet(); + + for (var i = 0; i < 1000; i++) + { + var envelope = new Envelope { Message = new HelloMessage("x") }; + var bytes = await sut.WriteAsync(envelope); + nonces.Add(Convert.ToHexString(bytes.AsSpan(0, 12))); + } + + nonces.Count.ShouldBe(1000); + } + + [Fact] + public async Task write_async_ciphertext_is_not_plaintext_json() + { + var sut = NewSut(); + var envelope = new Envelope { Message = new HelloMessage("super-secret-string") }; + + var bytes = await sut.WriteAsync(envelope); + var dump = System.Text.Encoding.UTF8.GetString(bytes); + + dump.ShouldNotContain("super-secret-string"); + } + + [Fact] + public async Task round_trip_through_system_text_json() + { + var sut = NewSut(); + + var sendEnvelope = new Envelope { Message = new HelloMessage("hello") }; + var bytes = await sut.WriteAsync(sendEnvelope); + + var recvEnvelope = new Envelope + { + Data = bytes, + ContentType = EncryptionHeaders.EncryptedContentType, + MessageType = typeof(HelloMessage).ToMessageTypeName(), + Headers = + { + [EncryptionHeaders.KeyIdHeader] = sendEnvelope.Headers[EncryptionHeaders.KeyIdHeader], + [EncryptionHeaders.InnerContentTypeHeader] = sendEnvelope.Headers[EncryptionHeaders.InnerContentTypeHeader] + } + }; + + var msg = await sut.ReadFromDataAsync(typeof(HelloMessage), recvEnvelope); + + msg.ShouldBeOfType().Greeting.ShouldBe("hello"); + } + + [Fact] + public async Task round_trip_through_newtonsoft() + { + var newtonsoft = new NewtonsoftSerializer(NewtonsoftSerializer.DefaultSettings()); + var sut = NewSut(inner: newtonsoft); + + var sendEnvelope = new Envelope { Message = new HelloMessage("hi") }; + var bytes = await sut.WriteAsync(sendEnvelope); + + var recvEnvelope = new Envelope + { + Data = bytes, + ContentType = EncryptionHeaders.EncryptedContentType, + MessageType = typeof(HelloMessage).ToMessageTypeName(), + Headers = + { + [EncryptionHeaders.KeyIdHeader] = sendEnvelope.Headers[EncryptionHeaders.KeyIdHeader], + [EncryptionHeaders.InnerContentTypeHeader] = sendEnvelope.Headers[EncryptionHeaders.InnerContentTypeHeader] + } + }; + + var msg = await sut.ReadFromDataAsync(typeof(HelloMessage), recvEnvelope); + msg.ShouldBeOfType().Greeting.ShouldBe("hi"); + } + + [Fact] + public async Task missing_key_id_header_throws_key_not_found() + { + var sut = NewSut(); + var sendEnvelope = new Envelope { Message = new HelloMessage("x") }; + var bytes = await sut.WriteAsync(sendEnvelope); + + var recvEnvelope = new Envelope { Data = bytes, ContentType = EncryptionHeaders.EncryptedContentType }; + + var ex = await Should.ThrowAsync(async () => + await sut.ReadFromDataAsync(typeof(HelloMessage), recvEnvelope)); + + ex.KeyId.ShouldBe(""); + } + + [Fact] + public async Task unknown_key_id_throws_key_not_found_with_inner() + { + var sut = NewSut(); + var sendEnvelope = new Envelope { Message = new HelloMessage("x") }; + var bytes = await sut.WriteAsync(sendEnvelope); + + var recvEnvelope = new Envelope + { + Data = bytes, + ContentType = EncryptionHeaders.EncryptedContentType, + Headers = + { + [EncryptionHeaders.KeyIdHeader] = "ghost-key", + [EncryptionHeaders.InnerContentTypeHeader] = sendEnvelope.Headers[EncryptionHeaders.InnerContentTypeHeader] + } + }; + + var ex = await Should.ThrowAsync(async () => + await sut.ReadFromDataAsync(typeof(HelloMessage), recvEnvelope)); + + ex.KeyId.ShouldBe("ghost-key"); + ex.InnerException.ShouldBeOfType(); + } + + [Fact] + public async Task tampered_ciphertext_byte_throws_decryption_exception() + { + var sut = NewSut(); + var sendEnvelope = new Envelope { Message = new HelloMessage("x") }; + var bytes = await sut.WriteAsync(sendEnvelope); + + bytes[bytes.Length / 2] ^= 0xFF; + + var recvEnvelope = new Envelope + { + Data = bytes, + ContentType = EncryptionHeaders.EncryptedContentType, + Headers = + { + [EncryptionHeaders.KeyIdHeader] = sendEnvelope.Headers[EncryptionHeaders.KeyIdHeader], + [EncryptionHeaders.InnerContentTypeHeader] = sendEnvelope.Headers[EncryptionHeaders.InnerContentTypeHeader] + } + }; + + await Should.ThrowAsync(async () => + await sut.ReadFromDataAsync(typeof(HelloMessage), recvEnvelope)); + } + + [Fact] + public async Task tampered_tag_byte_throws_decryption_exception() + { + var sut = NewSut(); + var sendEnvelope = new Envelope { Message = new HelloMessage("x") }; + var bytes = await sut.WriteAsync(sendEnvelope); + + bytes[bytes.Length - 1] ^= 0xFF; + + var recvEnvelope = new Envelope + { + Data = bytes, + ContentType = EncryptionHeaders.EncryptedContentType, + Headers = + { + [EncryptionHeaders.KeyIdHeader] = sendEnvelope.Headers[EncryptionHeaders.KeyIdHeader], + [EncryptionHeaders.InnerContentTypeHeader] = sendEnvelope.Headers[EncryptionHeaders.InnerContentTypeHeader] + } + }; + + await Should.ThrowAsync(async () => + await sut.ReadFromDataAsync(typeof(HelloMessage), recvEnvelope)); + } + + [Fact] + public async Task body_shorter_than_28_bytes_throws_decryption_exception() + { + var sut = NewSut(); + var recvEnvelope = new Envelope + { + Data = new byte[20], + ContentType = EncryptionHeaders.EncryptedContentType, + Headers = + { + [EncryptionHeaders.KeyIdHeader] = "k1" + } + }; + + await Should.ThrowAsync(async () => + await sut.ReadFromDataAsync(typeof(HelloMessage), recvEnvelope)); + } + + [Fact] + public void BuildAad_layout_matches_specified_byte_format() + { + // "wlv-enc-v1" || u16_be(len(MT)) || MT || u16_be(len(KeyId)) || KeyId || u16_be(len(ICT)) || ICT + var aad = EncryptingMessageSerializer.BuildAad( + messageType: "PaymentDetails", + keyId: "k1", + innerContentType: "application/json"); + + var expected = new List(); + expected.AddRange(System.Text.Encoding.ASCII.GetBytes("wlv-enc-v1")); + var mt = System.Text.Encoding.UTF8.GetBytes("PaymentDetails"); + expected.AddRange(new[] { (byte)(mt.Length >> 8), (byte)(mt.Length & 0xFF) }); + expected.AddRange(mt); + var kid = System.Text.Encoding.UTF8.GetBytes("k1"); + expected.AddRange(new[] { (byte)(kid.Length >> 8), (byte)(kid.Length & 0xFF) }); + expected.AddRange(kid); + var ict = System.Text.Encoding.UTF8.GetBytes("application/json"); + expected.AddRange(new[] { (byte)(ict.Length >> 8), (byte)(ict.Length & 0xFF) }); + expected.AddRange(ict); + + aad.ShouldBe(expected.ToArray()); + } + + [Fact] + public void BuildAad_treats_null_message_type_as_empty() + { + var aad = EncryptingMessageSerializer.BuildAad( + messageType: null, keyId: "k1", innerContentType: "application/json"); + var aadEmpty = EncryptingMessageSerializer.BuildAad( + messageType: "", keyId: "k1", innerContentType: "application/json"); + aad.ShouldBe(aadEmpty); + } + + [Fact] + public async Task WriteAsync_uses_aad_with_messageType_keyId_innerContentType() + { + var key32 = Enumerable.Repeat((byte)0x42, 32).ToArray(); + var provider = new InMemoryKeyProvider( + defaultKeyId: "k1", + keys: new Dictionary { ["k1"] = key32 }); + + var inner = new SystemTextJsonSerializer(SystemTextJsonSerializer.DefaultOptions()); + var sut = new EncryptingMessageSerializer(inner, provider); + + var envelope = new Envelope(new EncryptedPayloadStub("hello")) + { + MessageType = typeof(EncryptedPayloadStub).ToMessageTypeName(), + Headers = new Dictionary() + }; + + var output = await sut.WriteAsync(envelope); + + var nonce = output.AsSpan(0, 12).ToArray(); + var tag = output.AsSpan(output.Length - 16, 16).ToArray(); + var ciphertext = output.AsSpan(12, output.Length - 12 - 16).ToArray(); + var plaintext = new byte[ciphertext.Length]; + + var aad = EncryptingMessageSerializer.BuildAad( + typeof(EncryptedPayloadStub).ToMessageTypeName(), "k1", inner.ContentType); + + using var aes = new System.Security.Cryptography.AesGcm(key32, tagSizeInBytes: 16); + Should.NotThrow(() => aes.Decrypt(nonce, ciphertext, tag, plaintext, aad)); + + // Sanity: same input WITHOUT AAD should fail (proves AAD is bound). + Should.Throw( + () => aes.Decrypt(nonce, ciphertext, tag, new byte[ciphertext.Length])); + } + + [Fact] + public async Task ReadFromDataAsync_round_trips_when_aad_intact() + { + var (sut, envelopeOnWire) = await PrepareEncryptedEnvelopeAsync( + messageType: typeof(EncryptedPayloadStub).ToMessageTypeName(), keyId: "k1"); + + var result = await sut.ReadFromDataAsync(typeof(EncryptedPayloadStub), envelopeOnWire); + result.ShouldBeOfType().Secret.ShouldBe("hello"); + } + + [Fact] + public async Task ReadFromDataAsync_throws_MessageDecryption_when_messageType_tampered() + { + var (sut, envelopeOnWire) = await PrepareEncryptedEnvelopeAsync( + messageType: typeof(EncryptedPayloadStub).ToMessageTypeName(), keyId: "k1"); + + envelopeOnWire.MessageType = "RefundIssued"; + + await Should.ThrowAsync( + () => sut.ReadFromDataAsync(typeof(EncryptedPayloadStub), envelopeOnWire).AsTask()); + } + + [Fact] + public async Task ReadFromDataAsync_throws_MessageDecryption_when_keyId_header_tampered() + { + // Two keys with identical bytes — proves AAD (not key lookup) catches the tamper. + var bytes = Key32(0x42); + var provider = new InMemoryKeyProvider( + defaultKeyId: "k1", + keys: new Dictionary { ["k1"] = bytes, ["k2"] = bytes }); + + var (sut, envelopeOnWire) = await PrepareEncryptedEnvelopeAsync( + messageType: typeof(EncryptedPayloadStub).ToMessageTypeName(), keyId: "k1", provider: provider); + + envelopeOnWire.Headers[EncryptionHeaders.KeyIdHeader] = "k2"; + + await Should.ThrowAsync( + () => sut.ReadFromDataAsync(typeof(EncryptedPayloadStub), envelopeOnWire).AsTask()); + } + + [Fact] + public async Task ReadFromDataAsync_throws_MessageDecryption_when_innerContentType_tampered() + { + var (sut, envelopeOnWire) = await PrepareEncryptedEnvelopeAsync( + messageType: typeof(EncryptedPayloadStub).ToMessageTypeName(), keyId: "k1"); + + envelopeOnWire.Headers[EncryptionHeaders.InnerContentTypeHeader] = "application/x-msgpack"; + + await Should.ThrowAsync( + () => sut.ReadFromDataAsync(typeof(EncryptedPayloadStub), envelopeOnWire).AsTask()); + } + + private static async Task<(EncryptingMessageSerializer sut, Envelope envelopeOnWire)> + PrepareEncryptedEnvelopeAsync(string messageType, string keyId, IKeyProvider? provider = null) + { + provider ??= new InMemoryKeyProvider( + defaultKeyId: keyId, + keys: new Dictionary { [keyId] = Key32(0x42) }); + + var inner = new SystemTextJsonSerializer(SystemTextJsonSerializer.DefaultOptions()); + var sut = new EncryptingMessageSerializer(inner, provider); + + var sendEnvelope = new Envelope(new EncryptedPayloadStub("hello")) + { + MessageType = messageType, + Headers = new Dictionary() + }; + + var bytes = await sut.WriteAsync(sendEnvelope); + + var envelopeOnWire = new Envelope + { + Data = bytes, + ContentType = sut.ContentType, + MessageType = sendEnvelope.MessageType, + Headers = new Dictionary(sendEnvelope.Headers) + }; + + return (sut, envelopeOnWire); + } + + [Fact] + public async Task round_trip_with_newtonsoft_sender_and_system_text_json_receiver() + { + // Cross-inner-serializer compatibility: AAD binds the SENDER's _inner.ContentType + // ("application/json") into the auth tag. As long as the receiver's encrypting + // serializer is also wrapping a JSON inner (whichever flavor), the AAD inputs + // match (InnerContentTypeHeader is sent over the wire) and decryption succeeds. + // The plaintext bytes happen to be valid JSON for both libraries, so the inner + // dispatch on the receive side parses the same wire payload regardless of + // sender/receiver inner choice. + var key = Key32(0x42); + var senderProvider = new InMemoryKeyProvider("k1", + new Dictionary { ["k1"] = key }); + var receiverProvider = new InMemoryKeyProvider("k1", + new Dictionary { ["k1"] = key }); + + var senderInner = new NewtonsoftSerializer(NewtonsoftSerializer.DefaultSettings()); + var receiverInner = new SystemTextJsonSerializer(SystemTextJsonSerializer.DefaultOptions()); + + var sender = new EncryptingMessageSerializer(senderInner, senderProvider); + var receiver = new EncryptingMessageSerializer(receiverInner, receiverProvider); + + var sendEnvelope = new Envelope { Message = new HelloMessage("cross-flavor") }; + var bytes = await sender.WriteAsync(sendEnvelope); + + var recvEnvelope = new Envelope + { + Data = bytes, + ContentType = EncryptionHeaders.EncryptedContentType, + MessageType = typeof(HelloMessage).ToMessageTypeName(), + Headers = + { + [EncryptionHeaders.KeyIdHeader] = sendEnvelope.Headers[EncryptionHeaders.KeyIdHeader], + [EncryptionHeaders.InnerContentTypeHeader] = sendEnvelope.Headers[EncryptionHeaders.InnerContentTypeHeader] + } + }; + + var msg = await receiver.ReadFromDataAsync(typeof(HelloMessage), recvEnvelope); + msg.ShouldBeOfType().Greeting.ShouldBe("cross-flavor"); + } + + [Fact] + public async Task round_trip_with_system_text_json_sender_and_newtonsoft_receiver() + { + var key = Key32(0x42); + var senderProvider = new InMemoryKeyProvider("k1", + new Dictionary { ["k1"] = key }); + var receiverProvider = new InMemoryKeyProvider("k1", + new Dictionary { ["k1"] = key }); + + var senderInner = new SystemTextJsonSerializer(SystemTextJsonSerializer.DefaultOptions()); + var receiverInner = new NewtonsoftSerializer(NewtonsoftSerializer.DefaultSettings()); + + var sender = new EncryptingMessageSerializer(senderInner, senderProvider); + var receiver = new EncryptingMessageSerializer(receiverInner, receiverProvider); + + var sendEnvelope = new Envelope { Message = new HelloMessage("cross-flavor-reverse") }; + var bytes = await sender.WriteAsync(sendEnvelope); + + var recvEnvelope = new Envelope + { + Data = bytes, + ContentType = EncryptionHeaders.EncryptedContentType, + MessageType = typeof(HelloMessage).ToMessageTypeName(), + Headers = + { + [EncryptionHeaders.KeyIdHeader] = sendEnvelope.Headers[EncryptionHeaders.KeyIdHeader], + [EncryptionHeaders.InnerContentTypeHeader] = sendEnvelope.Headers[EncryptionHeaders.InnerContentTypeHeader] + } + }; + + var msg = await receiver.ReadFromDataAsync(typeof(HelloMessage), recvEnvelope); + msg.ShouldBeOfType().Greeting.ShouldBe("cross-flavor-reverse"); + } + + [Fact] + public async Task WriteAsync_wraps_wrong_key_length_from_custom_provider_in_EncryptionKeyNotFoundException() + { + // Custom IKeyProvider implementations that return a key that is not + // exactly 32 bytes would otherwise surface as a raw CryptographicException + // from the AesGcm constructor — at a call site outside the WriteAsync + // try-catch, with no key-id information attached. The serializer must + // wrap this into the same diagnostic shape as a missing/unknown key. + var sut = new EncryptingMessageSerializer( + new SystemTextJsonSerializer(SystemTextJsonSerializer.DefaultOptions()), + new ShortKeyProvider("k1")); + + var envelope = new Envelope { Message = new HelloMessage("x") }; + + var ex = await Should.ThrowAsync( + async () => await sut.WriteAsync(envelope)); + + ex.KeyId.ShouldBe("k1"); + ex.InnerException.ShouldBeOfType(); + ex.InnerException!.Message.ShouldContain("32 bytes"); + ex.InnerException!.Message.ShouldContain("16 bytes"); + } + + [Fact] + public async Task ReadFromDataAsync_wraps_wrong_key_length_from_custom_provider_in_EncryptionKeyNotFoundException() + { + // Same hazard on the receive path: a provider that returns a wrong-sized + // key for a key-id that resolved successfully must produce an + // EncryptionKeyNotFoundException, not a raw CryptographicException + // bubbling out of AesGcm's constructor. + var sut = new EncryptingMessageSerializer( + new SystemTextJsonSerializer(SystemTextJsonSerializer.DefaultOptions()), + new ShortKeyProvider("k1")); + + var recvEnvelope = new Envelope + { + Data = new byte[40], + ContentType = EncryptionHeaders.EncryptedContentType, + Headers = + { + [EncryptionHeaders.KeyIdHeader] = "k1" + } + }; + + var ex = await Should.ThrowAsync( + async () => await sut.ReadFromDataAsync(typeof(HelloMessage), recvEnvelope)); + + ex.KeyId.ShouldBe("k1"); + ex.InnerException.ShouldBeOfType(); + ex.InnerException!.Message.ShouldContain("32 bytes"); + } + + private sealed class ShortKeyProvider : IKeyProvider + { + public ShortKeyProvider(string defaultKeyId) { DefaultKeyId = defaultKeyId; } + public string DefaultKeyId { get; } + public ValueTask GetKeyAsync(string keyId, CancellationToken cancellationToken) + => ValueTask.FromResult(new byte[16]); + } + + [Fact] + public async Task WriteAsync_throws_when_provider_returns_null_default_key_id() + { + // A custom IKeyProvider that returns a null/empty DefaultKeyId would + // otherwise crash with NullReferenceException inside BuildAad or + // surface an opaque ArgumentNullException from the provider's lookup. + // The serializer must reject this up front with a clear, key-id-aware + // diagnostic. + var sut = new EncryptingMessageSerializer( + new SystemTextJsonSerializer(SystemTextJsonSerializer.DefaultOptions()), + new NullDefaultKeyIdProvider()); + + var envelope = new Envelope { Message = new HelloMessage("x") }; + + var ex = await Should.ThrowAsync( + async () => await sut.WriteAsync(envelope)); + + ex.InnerException.ShouldBeOfType(); + ex.InnerException!.Message.ShouldContain("DefaultKeyId"); + } + + private sealed class NullDefaultKeyIdProvider : IKeyProvider + { + public string DefaultKeyId => null!; + public ValueTask GetKeyAsync(string keyId, CancellationToken cancellationToken) + => ValueTask.FromResult(Enumerable.Repeat((byte)0x01, 32).ToArray()); + } + + [Fact] + public async Task WriteAsync_propagates_OperationCanceledException_from_key_provider() + { + // The catch filter in WriteAsync intentionally excludes + // OperationCanceledException so caller cancellation flows through + // unchanged instead of being re-thrown as EncryptionKeyNotFound. + // Lock that contract: a provider that throws OCE must surface OCE, + // not a wrapped MessageEncryptionException. + var sut = new EncryptingMessageSerializer( + new SystemTextJsonSerializer(SystemTextJsonSerializer.DefaultOptions()), + new CancellingKeyProvider("k1")); + + var envelope = new Envelope { Message = new HelloMessage("x") }; + + var ex = await Should.ThrowAsync( + async () => await sut.WriteAsync(envelope)); + ex.ShouldNotBeAssignableTo(); + } + + [Fact] + public async Task ReadFromDataAsync_propagates_OperationCanceledException_from_key_provider() + { + var sut = new EncryptingMessageSerializer( + new SystemTextJsonSerializer(SystemTextJsonSerializer.DefaultOptions()), + new CancellingKeyProvider("k1")); + + var recvEnvelope = new Envelope + { + Data = new byte[40], + ContentType = EncryptionHeaders.EncryptedContentType, + Headers = + { + [EncryptionHeaders.KeyIdHeader] = "k1" + } + }; + + var ex = await Should.ThrowAsync( + async () => await sut.ReadFromDataAsync(typeof(HelloMessage), recvEnvelope)); + ex.ShouldNotBeAssignableTo(); + } + + private sealed class CancellingKeyProvider : IKeyProvider + { + public CancellingKeyProvider(string defaultKeyId) { DefaultKeyId = defaultKeyId; } + public string DefaultKeyId { get; } + public ValueTask GetKeyAsync(string keyId, CancellationToken cancellationToken) + => throw new OperationCanceledException(); + } + + [Fact] + public async Task ReadFromDataAsync_with_empty_envelope_data_throws_decryption_exception() + { + // Wolverine's Envelope itself rejects a null Data assignment with + // WolverineSerializationException at the property setter, so the + // serializer never sees null. The realistic boundary it does have to + // defend against is an empty data buffer (a transport that produced + // a zero-length frame): the body-length guard must produce a + // MessageDecryptionException, not a downstream span-slicing crash. + var sut = NewSut(); + + var recvEnvelope = new Envelope + { + Data = Array.Empty(), + ContentType = EncryptionHeaders.EncryptedContentType, + Headers = + { + [EncryptionHeaders.KeyIdHeader] = "k1" + } + }; + + await Should.ThrowAsync( + async () => await sut.ReadFromDataAsync(typeof(HelloMessage), recvEnvelope)); + } + + [Fact] + public async Task ReadFromDataAsync_rejects_forged_plaintext_under_encrypted_content_type() + { + // Forgery scenario: a sender (or attacker) emits an envelope that + // claims the encrypted content-type, supplies a plausible key-id and + // inner-content-type header, but the body is actually plain JSON of + // sufficient length to pass the 28-byte minimum. The auth tag check + // must still reject it as MessageDecryptionException. + var sut = NewSut(); + + var forgedJson = System.Text.Encoding.UTF8.GetBytes( + "{\"Greeting\":\"this-is-totally-not-encrypted-but-long-enough\"}"); + forgedJson.Length.ShouldBeGreaterThan(28); + + var recvEnvelope = new Envelope + { + Data = forgedJson, + ContentType = EncryptionHeaders.EncryptedContentType, + MessageType = typeof(HelloMessage).ToMessageTypeName(), + Headers = + { + [EncryptionHeaders.KeyIdHeader] = "k1", + [EncryptionHeaders.InnerContentTypeHeader] = "application/json" + } + }; + + await Should.ThrowAsync( + async () => await sut.ReadFromDataAsync(typeof(HelloMessage), recvEnvelope)); + } + + [Fact] + public async Task ReadFromDataAsync_with_body_exactly_28_bytes_throws_decryption_exception() + { + // Boundary opposite to body_shorter_than_28_bytes: a 28-byte body is + // the minimum the length guard accepts (12 nonce + 16 tag, zero + // ciphertext). It then reaches AesGcm.Decrypt, where the tag check + // fails for arbitrary input. Verifies the path past the length guard + // still produces a MessageDecryptionException, not a raw crypto + // exception. + var sut = NewSut(); + + var twentyEight = new byte[28]; + System.Security.Cryptography.RandomNumberGenerator.Fill(twentyEight); + + var recvEnvelope = new Envelope + { + Data = twentyEight, + ContentType = EncryptionHeaders.EncryptedContentType, + Headers = + { + [EncryptionHeaders.KeyIdHeader] = "k1" + } + }; + + await Should.ThrowAsync( + async () => await sut.ReadFromDataAsync(typeof(HelloMessage), recvEnvelope)); + } + + private sealed record HelloMessage(string Greeting); + private sealed record EncryptedPayloadStub(string Secret); +} diff --git a/src/Testing/CoreTests/Runtime/Serialization/Encryption/InMemoryKeyProviderTests.cs b/src/Testing/CoreTests/Runtime/Serialization/Encryption/InMemoryKeyProviderTests.cs new file mode 100644 index 000000000..49a31d9d9 --- /dev/null +++ b/src/Testing/CoreTests/Runtime/Serialization/Encryption/InMemoryKeyProviderTests.cs @@ -0,0 +1,65 @@ +using Shouldly; +using Wolverine.Runtime.Serialization.Encryption; +using Xunit; + +namespace CoreTests.Runtime.Serialization.Encryption; + +public class InMemoryKeyProviderTests +{ + private static byte[] Key32(byte fill) => Enumerable.Repeat(fill, 32).ToArray(); + + [Fact] + public async Task returns_key_for_known_id() + { + var keys = new Dictionary { ["k1"] = Key32(0x01) }; + var provider = new InMemoryKeyProvider("k1", keys); + + var result = await provider.GetKeyAsync("k1", CancellationToken.None); + + result.ShouldBe(Key32(0x01)); + } + + [Fact] + public async Task throws_keynotfound_for_unknown_id() + { + var provider = new InMemoryKeyProvider("k1", new Dictionary { ["k1"] = Key32(0x01) }); + + await Should.ThrowAsync(async () => + await provider.GetKeyAsync("missing", CancellationToken.None)); + } + + [Fact] + public void default_key_id_is_exposed() + { + var provider = new InMemoryKeyProvider("k-default", new Dictionary { ["k-default"] = Key32(0x01) }); + + provider.DefaultKeyId.ShouldBe("k-default"); + } + + [Fact] + public void ctor_rejects_keys_not_32_bytes() + { + Should.Throw(() => + new InMemoryKeyProvider("bad", new Dictionary { ["bad"] = new byte[16] })); + } + + [Fact] + public void ctor_rejects_default_key_not_in_dictionary() + { + Should.Throw(() => + new InMemoryKeyProvider("missing-default", new Dictionary { ["other"] = Key32(0x01) })); + } + + [Fact] + public async Task constructor_takes_defensive_copy_of_caller_arrays() + { + var keyBytes = Key32(0x42); + var provider = new InMemoryKeyProvider("k1", + new Dictionary { ["k1"] = keyBytes }); + + Array.Clear(keyBytes); + + var stored = await provider.GetKeyAsync("k1", default); + stored.ShouldAllBe(b => b == 0x42); + } +} diff --git a/src/Testing/CoreTests/Runtime/Serialization/Encryption/WolverineOptionsEncryptionTests.cs b/src/Testing/CoreTests/Runtime/Serialization/Encryption/WolverineOptionsEncryptionTests.cs new file mode 100644 index 000000000..63d7bfd33 --- /dev/null +++ b/src/Testing/CoreTests/Runtime/Serialization/Encryption/WolverineOptionsEncryptionTests.cs @@ -0,0 +1,369 @@ +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Shouldly; +using Wolverine; +using Wolverine.ErrorHandling; +using Wolverine.Runtime; +using Wolverine.Runtime.Serialization; +using Wolverine.Runtime.Serialization.Encryption; +using Wolverine.Runtime.WorkerQueues; +using Wolverine.Tracking; +using Wolverine.Transports.Local; +using Wolverine.Util; +using Xunit; + +namespace CoreTests.Runtime.Serialization.Encryption; + +public class WolverineOptionsEncryptionTests +{ + private static byte[] Key32(byte fill) => Enumerable.Repeat(fill, 32).ToArray(); + + private static IKeyProvider NewProvider() => + new InMemoryKeyProvider("k1", new Dictionary { ["k1"] = Key32(0x01) }); + + [Fact] + public async Task use_encryption_swaps_default_serializer() + { + using var host = await Host.CreateDefaultBuilder() + .UseWolverine(opts => opts.UseEncryption(NewProvider())) + .StartAsync(); + + var options = host.Services.GetRequiredService().Options; + options.DefaultSerializer.ShouldBeOfType(); + options.DefaultSerializer.ContentType.ShouldBe(EncryptionHeaders.EncryptedContentType); + } + + [Fact] + public async Task use_encryption_keeps_inner_json_serializer_resolvable() + { + using var host = await Host.CreateDefaultBuilder() + .UseWolverine(opts => opts.UseEncryption(NewProvider())) + .StartAsync(); + + var options = host.Services.GetRequiredService().Options; + var json = options.TryFindSerializer(EnvelopeConstants.JsonContentType); + json.ShouldNotBeNull(); + json.ShouldBeAssignableTo(); + json.ShouldNotBeAssignableTo(); + } + + [Fact] + public void use_encryption_rejects_null_provider() + { + var opts = new WolverineOptions(); + Should.Throw(() => opts.UseEncryption(null!)); + } + + [Fact] + public async Task per_type_encrypt_routes_only_matching_type_to_encrypting_serializer() + { + using var host = await Host.CreateDefaultBuilder() + .UseWolverine(opts => + { + opts.UseSystemTextJsonForSerialization(); + opts.RegisterEncryptionSerializer(NewProvider()); + opts.Policies.ForMessagesOfType().Encrypt(); + + opts.PublishAllMessages().ToLocalQueue("target"); + opts.LocalQueue("target"); + }) + .StartAsync(); + + var bus = host.MessageBus(); + + var sessionA = await host.TrackActivity().DoNotAssertOnExceptionsDetected() + .ExecuteAndWaitAsync(_ => bus.PublishAsync(new EncryptedTypeA("x"))); + var sessionB = await host.TrackActivity().DoNotAssertOnExceptionsDetected() + .ExecuteAndWaitAsync(_ => bus.PublishAsync(new PlainTypeB("x"))); + + var sentEncrypted = sessionA.Sent.SingleEnvelope(); + sentEncrypted.ContentType.ShouldBe(EncryptionHeaders.EncryptedContentType); + sentEncrypted.Serializer.ShouldBeOfType(); + + var sentPlain = sessionB.Sent.SingleEnvelope(); + sentPlain.ContentType.ShouldBe(EnvelopeConstants.JsonContentType); + sentPlain.Serializer.ShouldNotBeOfType(); + } + + [Fact] + public async Task endpoint_encrypted_routes_outgoing_through_encrypting_content_type() + { + using var host = await Host.CreateDefaultBuilder() + .UseWolverine(opts => + { + opts.UseSystemTextJsonForSerialization(); + opts.RegisterEncryptionSerializer(NewProvider()); + + opts.PublishAllMessages().ToLocalQueue("encrypted-q").Encrypted(); + opts.LocalQueue("encrypted-q"); + }) + .StartAsync(); + + var bus = host.MessageBus(); + + var session = await host.TrackActivity().DoNotAssertOnExceptionsDetected() + .ExecuteAndWaitAsync(_ => bus.PublishAsync(new EncryptedTypeA("x"))); + + var sent = session.Sent.SingleEnvelope(); + sent.ContentType.ShouldBe(EncryptionHeaders.EncryptedContentType); + sent.Serializer.ShouldBeOfType(); + } + + [Fact] + public void encrypt_without_registered_serializer_throws() + { + var opts = new WolverineOptions(); + opts.UseSystemTextJsonForSerialization(); + + Should.Throw(() => + opts.Policies.ForMessagesOfType().Encrypt()); + } + + [Fact] + public void Encrypt_for_message_type_registers_in_RequiredEncryptedTypes() + { + var opts = new WolverineOptions(); + opts.UseEncryption(new InMemoryKeyProvider( + "k1", new Dictionary + { + ["k1"] = Enumerable.Repeat((byte)0x42, 32).ToArray() + })); + + opts.Policies.ForMessagesOfType().Encrypt(); + + opts.RequiredEncryptedTypes.ShouldContain(typeof(SecretMessage)); + } + + [Fact] + public async Task RequiresEncryption_uses_listener_endpoint_uri_not_envelope_destination() + { + // Regression test: the listener-side guard must work even when the + // inbound envelope has no Destination header (most broker transports + // do not populate envelope.Destination on receive). The guard reads + // the listener's own _endpoint.Uri, so this still fires. + + using var host = await Host.CreateDefaultBuilder() + .UseWolverine(opts => + { + opts.UseEncryption(new InMemoryKeyProvider( + "k1", new Dictionary + { + ["k1"] = Enumerable.Repeat((byte)0x42, 32).ToArray() + })); + + opts.LocalQueue("encryption-required-queue").RequireEncryption(); + }) + .StartAsync(); + + var runtime = host.Services.GetRequiredService(); + var endpoint = (LocalQueue?)runtime.Endpoints.EndpointByName("encryption-required-queue") + ?? throw new InvalidOperationException("encryption-required-queue not found"); + + // Sanity: the listener URI is in the required set after RequireEncryption(). + runtime.Options.RequiredEncryptedListenerUris.ShouldContain(endpoint.Uri); + + // Build an envelope as if it had arrived from a transport that does NOT + // populate envelope.Destination on receive. Plain JSON content-type means + // it has not been encrypted. + var envelope = new Envelope + { + Destination = null, // simulate broker transport + ContentType = "application/json", // plain, not encrypted + MessageType = typeof(EncryptionRequiredMsg).ToMessageTypeName(), + Data = System.Text.Encoding.UTF8.GetBytes("""{"Value":"forged"}""") + }; + + var receiver = (BufferedReceiver)endpoint.Agent!; + var continuation = await receiver.Pipeline.TryDeserializeEnvelope(envelope); + + // The guard must fire via the listener's own endpoint URI. If it instead + // keyed off envelope.Destination (which broker transports do not populate + // on receive), the listener marker would be missed and the envelope would + // fall through to deserialization rather than going to the dead-letter queue. + var moveToErrorQueue = continuation.ShouldBeOfType(); + moveToErrorQueue.Exception.ShouldBeOfType(); + } + + [Fact] + public async Task RequireEncryption_on_listener_registers_listener_uri() + { + using var host = await Host.CreateDefaultBuilder() + .UseWolverine(opts => + { + opts.UseEncryption(new InMemoryKeyProvider( + "k1", new Dictionary + { + ["k1"] = Enumerable.Repeat((byte)0x42, 32).ToArray() + })); + + opts.LocalQueue("test-encrypted").RequireEncryption(); + }) + .StartAsync(); + + var runtime = host.Services.GetRequiredService(); + var queueUri = runtime.Endpoints.EndpointByName("test-encrypted")?.Uri + ?? throw new InvalidOperationException("test-encrypted queue not found"); + runtime.Options.RequiredEncryptedListenerUris.ShouldContain(queueUri); + } + + [Fact] + public void UseSystemTextJsonForSerialization_after_UseEncryption_keeps_encryption_active() + { + var opts = new WolverineOptions(); + var provider = new InMemoryKeyProvider("k1", + new Dictionary { ["k1"] = Enumerable.Repeat((byte)0x42, 32).ToArray() }); + opts.UseEncryption(provider); + + opts.UseSystemTextJsonForSerialization(_ => { }); + + opts.DefaultSerializer.ShouldBeOfType(); + } + + [Fact] + public async Task Encrypted_on_endpoint_without_UseEncryption_throws_at_startup() + { + var ex = await Should.ThrowAsync(async () => + { + using var host = await Host.CreateDefaultBuilder() + .UseWolverine(opts => + { + opts.PublishAllMessages().ToLocalQueue("misconfigured").Encrypted(); + }) + .StartAsync(); + }); + + ex.Message.ShouldContain("encrypting serializer"); + } + + [Fact] + public async Task RequireEncryption_on_listener_without_UseEncryption_throws_at_startup() + { + // Symmetric to Encrypted() on the sender side: a listener marked + // RequireEncryption() with no encrypting serializer registered would + // dead-letter every inbound envelope, because there is no serializer + // capable of producing the encrypted content-type for it to accept. + var ex = await Should.ThrowAsync(async () => + { + using var host = await Host.CreateDefaultBuilder() + .UseWolverine(opts => + { + opts.LocalQueue("misconfigured-listener").RequireEncryption(); + }) + .StartAsync(); + }); + + ex.Message.ShouldContain("encrypting serializer"); + } + + [Fact] + public void second_UseEncryption_call_throws_to_prevent_double_wrapping() + { + var opts = new WolverineOptions(); + var provider1 = new InMemoryKeyProvider("k1", + new Dictionary { ["k1"] = Enumerable.Repeat((byte)0x42, 32).ToArray() }); + var provider2 = new InMemoryKeyProvider("k2", + new Dictionary { ["k2"] = Enumerable.Repeat((byte)0x33, 32).ToArray() }); + + opts.UseEncryption(provider1); + + var ex = Should.Throw(() => opts.UseEncryption(provider2)); + ex.Message.ShouldContain("already been called"); + } + + [Fact] + public async Task no_endpoint_pipeline_still_enforces_per_type_encryption_marker() + { + // The HandlerPipeline has two constructors — with and without endpoint. + // The no-endpoint variant is used by non-listener invocation paths. + // Its RequiresEncryption check must short-circuit the listener-URI + // branch (no endpoint to read .Uri from) but still apply the per-type + // marker so a plain envelope for a marked type is dead-lettered. + using var host = await Host.CreateDefaultBuilder() + .UseWolverine(opts => + { + opts.UseEncryption(new InMemoryKeyProvider( + "k1", new Dictionary + { + ["k1"] = Enumerable.Repeat((byte)0x42, 32).ToArray() + })); + opts.Policies.ForMessagesOfType().Encrypt(); + }) + .StartAsync(); + + var runtime = (WolverineRuntime)host.Services.GetRequiredService(); + var pipelineNoEndpoint = new HandlerPipeline(runtime, runtime); + + var envelope = new Envelope + { + ContentType = "application/json", + MessageType = typeof(EncryptionRequiredMsg).ToMessageTypeName(), + Data = System.Text.Encoding.UTF8.GetBytes("""{"Value":"forged"}""") + }; + + var continuation = await pipelineNoEndpoint.TryDeserializeEnvelope(envelope); + + var moveToErrorQueue = continuation.ShouldBeOfType(); + moveToErrorQueue.Exception.ShouldBeOfType(); + } + + [Fact] + public async Task unmarked_type_is_serialized_by_inner_not_encrypting_serializer() + { + // Negative AAD-binding test. With per-type Encrypt() registered for + // EncryptedTypeA only, publishing PlainTypeB must NOT touch the + // encrypting serializer at all — the routing layer picks the inner + // (json) serializer, no key-id header is stamped, and no AAD binding + // happens. Complements per_type_encrypt_routes_only_matching_type + // by adding an explicit assertion that the encryption-only headers + // are absent on the unmarked path. + using var host = await Host.CreateDefaultBuilder() + .UseWolverine(opts => + { + opts.UseSystemTextJsonForSerialization(); + opts.RegisterEncryptionSerializer(NewProvider()); + opts.Policies.ForMessagesOfType().Encrypt(); + + opts.PublishAllMessages().ToLocalQueue("target"); + opts.LocalQueue("target"); + }) + .StartAsync(); + + var bus = host.MessageBus(); + + var session = await host.TrackActivity().DoNotAssertOnExceptionsDetected() + .ExecuteAndWaitAsync(_ => bus.PublishAsync(new PlainTypeB("x"))); + + var sent = session.Sent.SingleEnvelope(); + sent.Serializer.ShouldNotBeOfType(); + sent.ContentType.ShouldBe(EnvelopeConstants.JsonContentType); + sent.Headers.ContainsKey(EncryptionHeaders.KeyIdHeader).ShouldBeFalse(); + sent.Headers.ContainsKey(EncryptionHeaders.InnerContentTypeHeader).ShouldBeFalse(); + } + + [Fact] + public void second_RegisterEncryptionSerializer_call_throws_to_prevent_double_wrapping() + { + var opts = new WolverineOptions(); + opts.UseSystemTextJsonForSerialization(); + + var provider1 = new InMemoryKeyProvider("k1", + new Dictionary { ["k1"] = Enumerable.Repeat((byte)0x42, 32).ToArray() }); + var provider2 = new InMemoryKeyProvider("k2", + new Dictionary { ["k2"] = Enumerable.Repeat((byte)0x33, 32).ToArray() }); + + opts.RegisterEncryptionSerializer(provider1); + + var ex = Should.Throw(() => opts.RegisterEncryptionSerializer(provider2)); + ex.Message.ShouldContain("already registered"); + } +} + +public sealed record EncryptedTypeA(string Value); +public sealed record PlainTypeB(string Value); +public sealed record SecretMessage(string S); +public sealed record EncryptionRequiredMsg(string Value); + +public static class EncryptionRequiredMsgHandler +{ + public static void Handle(EncryptionRequiredMsg _) { } +} diff --git a/src/Testing/CoreTests/Runtime/Serialization/Encryption/WolverineOptionsIsEncryptionRequiredTests.cs b/src/Testing/CoreTests/Runtime/Serialization/Encryption/WolverineOptionsIsEncryptionRequiredTests.cs new file mode 100644 index 000000000..5760feae2 --- /dev/null +++ b/src/Testing/CoreTests/Runtime/Serialization/Encryption/WolverineOptionsIsEncryptionRequiredTests.cs @@ -0,0 +1,103 @@ +using Shouldly; +using Wolverine; +using Xunit; + +namespace CoreTests.Runtime.Serialization.Encryption; + +public class WolverineOptionsIsEncryptionRequiredTests +{ + public interface IBase { } + public interface ISecondMarker { } + public abstract class AbstractBase { } + public sealed record ConcreteImpl(string X) : IBase; + public sealed record DoublyMarked(string X) : IBase, ISecondMarker; + public sealed class ConcreteAbstract : AbstractBase { } + public sealed record Unrelated(string X); + public sealed record ExactType(string X); + + [Fact] + public void exact_type_match_returns_true() + { + var opts = new WolverineOptions(); + opts.RequiredEncryptedTypes.Add(typeof(ExactType)); + + opts.IsEncryptionRequired(typeof(ExactType)).ShouldBeTrue(); + } + + [Fact] + public void subtype_of_registered_interface_returns_true() + { + var opts = new WolverineOptions(); + opts.RequiredEncryptedTypes.Add(typeof(IBase)); + + opts.IsEncryptionRequired(typeof(ConcreteImpl)).ShouldBeTrue(); + } + + [Fact] + public void subtype_of_registered_abstract_class_returns_true() + { + var opts = new WolverineOptions(); + opts.RequiredEncryptedTypes.Add(typeof(AbstractBase)); + + opts.IsEncryptionRequired(typeof(ConcreteAbstract)).ShouldBeTrue(); + } + + [Fact] + public void unrelated_type_returns_false() + { + var opts = new WolverineOptions(); + opts.RequiredEncryptedTypes.Add(typeof(IBase)); + + opts.IsEncryptionRequired(typeof(Unrelated)).ShouldBeFalse(); + } + + [Fact] + public void empty_set_returns_false() + { + var opts = new WolverineOptions(); + + opts.IsEncryptionRequired(typeof(ExactType)).ShouldBeFalse(); + } + + [Fact] + public void null_type_returns_false() + { + var opts = new WolverineOptions(); + opts.RequiredEncryptedTypes.Add(typeof(IBase)); + + opts.IsEncryptionRequired(null!).ShouldBeFalse(); + } + + [Fact] + public void type_matching_multiple_registered_markers_returns_true() + { + // A type can implement several marker interfaces that are each registered + // independently. The polymorphic scan must short-circuit on the first + // match without depending on registration order, and the cache must + // memoize a single boolean answer regardless of how many markers matched. + var opts = new WolverineOptions(); + opts.RequiredEncryptedTypes.Add(typeof(IBase)); + opts.RequiredEncryptedTypes.Add(typeof(ISecondMarker)); + + opts.IsEncryptionRequired(typeof(DoublyMarked)).ShouldBeTrue(); + } + + [Fact] + public void result_is_cached_per_type() + { + var opts = new WolverineOptions(); + opts.RequiredEncryptedTypes.Add(typeof(IBase)); + + // First call: scans set, caches true for ConcreteImpl. + opts.IsEncryptionRequired(typeof(ConcreteImpl)).ShouldBeTrue(); + + // Mutate the set: remove the marker after the answer is cached. + opts.RequiredEncryptedTypes.Remove(typeof(IBase)); + + // Cache wins — answer for ConcreteImpl is still true. + opts.IsEncryptionRequired(typeof(ConcreteImpl)).ShouldBeTrue(); + + // A type whose answer was never cached uses the (now-empty) live set. + opts.IsEncryptionRequired(typeof(ExactType)).ShouldBeFalse(); + } +} diff --git a/src/Testing/CoreTests/Runtime/Serialization/Encryption/exception_hierarchy.cs b/src/Testing/CoreTests/Runtime/Serialization/Encryption/exception_hierarchy.cs new file mode 100644 index 000000000..13f66e730 --- /dev/null +++ b/src/Testing/CoreTests/Runtime/Serialization/Encryption/exception_hierarchy.cs @@ -0,0 +1,57 @@ +using Shouldly; +using Wolverine; +using Wolverine.Runtime.Serialization.Encryption; +using Xunit; + +namespace CoreTests.Runtime.Serialization.Encryption; + +public class exception_hierarchy +{ + [Fact] + public void key_not_found_extends_message_encryption_exception() + { + var ex = new EncryptionKeyNotFoundException("key-1", new InvalidOperationException("inner")); + + ex.ShouldBeAssignableTo(); + ex.KeyId.ShouldBe("key-1"); + ex.InnerException!.Message.ShouldBe("inner"); + ex.Message.ShouldContain("key-1"); + } + + [Fact] + public void decryption_extends_message_encryption_exception() + { + var inner = new System.Security.Cryptography.CryptographicException("tag mismatch"); + var ex = new MessageDecryptionException("key-1", inner); + + ex.ShouldBeAssignableTo(); + ex.KeyId.ShouldBe("key-1"); + ex.InnerException.ShouldBeSameAs(inner); + } + + [Fact] + public void EncryptionPolicyViolationException_inherits_MessageEncryptionException() + { + // Policy violations are not key-related (no key is involved when an + // envelope is rejected for missing encryption), so KeyId is not on the + // base class and not on this subclass. + var ex = new EncryptionPolicyViolationException(new Envelope { MessageType = "X", ContentType = "Y" }); + ex.ShouldBeAssignableTo(); + } + + [Fact] + public void EncryptionPolicyViolationException_message_names_type_and_content_type_only() + { + var envelope = new Envelope + { + MessageType = "PaymentDetails", + ContentType = "application/json" + }; + + var ex = new EncryptionPolicyViolationException(envelope); + + ex.Message.ShouldContain("PaymentDetails"); + ex.Message.ShouldContain("application/json"); + ex.Message.ShouldContain("encryption is required"); + } +} diff --git a/src/Wolverine/Configuration/IListenerConfiguration.cs b/src/Wolverine/Configuration/IListenerConfiguration.cs index 3fbb06c08..3fa7fffc3 100644 --- a/src/Wolverine/Configuration/IListenerConfiguration.cs +++ b/src/Wolverine/Configuration/IListenerConfiguration.cs @@ -1,6 +1,7 @@ using System.Threading.Tasks.Dataflow; using Newtonsoft.Json; using Wolverine.Runtime.Serialization; +using Wolverine.Runtime.Serialization.Encryption; using Wolverine.Transports; namespace Wolverine.Configuration; @@ -158,6 +159,14 @@ public interface IListenerConfiguration : IEndpointConfiguration /// /// public T ListenOnlyAtLeader(); + + /// + /// Mark this listener as accepting only AES-256-GCM encrypted envelopes. + /// Inbound envelopes whose content-type is not the encrypted variant are + /// routed to the dead-letter queue with + /// before any serializer runs. + /// + public T RequireEncryption(); } public interface IListenerConfiguration : IListenerConfiguration; diff --git a/src/Wolverine/Configuration/ListenerConfiguration.cs b/src/Wolverine/Configuration/ListenerConfiguration.cs index 9f1b8185e..887fa6628 100644 --- a/src/Wolverine/Configuration/ListenerConfiguration.cs +++ b/src/Wolverine/Configuration/ListenerConfiguration.cs @@ -6,6 +6,7 @@ using Wolverine.Runtime; using Wolverine.Runtime.Interop; using Wolverine.Runtime.Serialization; +using Wolverine.Runtime.Serialization.Encryption; using Wolverine.Transports; using Wolverine.Transports.Local; @@ -117,7 +118,7 @@ public TSelf PartitionProcessingByGroupId(PartitionSlots numberOfSlots) /// /// In the case of being part of tenancy aware group of message transports, this /// setting makes this listening endpoint a "global" endpoint rather than a tenant id - /// aware endpoint that spans multiple message brokers. + /// aware endpoint that spans multiple message brokers. /// /// public TSelf GlobalListener() @@ -126,6 +127,66 @@ public TSelf GlobalListener() return this.As(); } + /// + /// Mark this listener as accepting only AES-256-GCM encrypted envelopes. + /// Inbound envelopes whose content-type is not + /// application/wolverine-encrypted+json are routed to the + /// dead-letter queue with + /// before any serializer runs. Requires the encrypting serializer to be + /// registered first via or + /// ; without it + /// the listener has no way to decrypt accepted envelopes and would + /// dead-letter every inbound message. + /// + public TSelf RequireEncryption() + { + add(endpoint => + { + var runtime = endpoint.Runtime + ?? throw new InvalidOperationException( + "Endpoint runtime is not set. .RequireEncryption() requires a fully-configured endpoint."); + + if (runtime.Options.TryFindSerializer(EncryptionHeaders.EncryptedContentType) is null) + { + throw new InvalidOperationException( + "No encrypting serializer is registered. Call " + + "WolverineOptions.UseEncryption(provider) or " + + "WolverineOptions.RegisterEncryptionSerializer(provider) " + + "before configuring a listener with .RequireEncryption()."); + } + + runtime.Options.RequiredEncryptedListenerUris.Add(endpoint.Uri); + }); + return this.As(); + } + + /// + /// Force this endpoint to use the AES-256-GCM encrypting serializer for + /// all outgoing messages. Requires the encrypting serializer to be + /// registered first via or + /// ; if it is + /// not registered, the host fails to start. + /// + public TSelf Encrypted() + { + add(endpoint => + { + var runtime = endpoint.Runtime + ?? throw new InvalidOperationException( + "Endpoint runtime is not set. .Encrypted() requires a fully-configured endpoint."); + + var encrypting = runtime.Options.TryFindSerializer(EncryptionHeaders.EncryptedContentType) + ?? throw new InvalidOperationException( + "No encrypting serializer is registered. Call " + + "WolverineOptions.UseEncryption(provider) or " + + "WolverineOptions.RegisterEncryptionSerializer(provider) " + + "before configuring an endpoint with .Encrypted()."); + + endpoint.OutgoingRules.Add(new EncryptOutgoingEndpointRule(encrypting)); + }); + return this.As(); + } + /// /// "Pin" this endpoint so that it is only active on the leader node /// diff --git a/src/Wolverine/Configuration/SubscriberConfiguration.cs b/src/Wolverine/Configuration/SubscriberConfiguration.cs index e99ec6499..1af41cd18 100644 --- a/src/Wolverine/Configuration/SubscriberConfiguration.cs +++ b/src/Wolverine/Configuration/SubscriberConfiguration.cs @@ -115,6 +115,35 @@ public T GlobalSender() return this.As(); } + /// + /// Force this sender endpoint to use the AES-256-GCM encrypting serializer + /// for all outgoing messages. Requires the encrypting serializer to be + /// registered first via or + /// ; if it is not + /// registered, the host fails to start. + /// + public T Encrypted() + { + add(endpoint => + { + var runtime = endpoint.Runtime + ?? throw new InvalidOperationException( + "Endpoint runtime is not set. .Encrypted() requires a fully-configured endpoint."); + + var encrypting = runtime.Options.TryFindSerializer( + Wolverine.Runtime.Serialization.Encryption.EncryptionHeaders.EncryptedContentType) + ?? throw new InvalidOperationException( + "No encrypting serializer is registered. Call " + + "WolverineOptions.UseEncryption(provider) or " + + "WolverineOptions.RegisterEncryptionSerializer(provider) " + + "before configuring an endpoint with .Encrypted()."); + + var rule = new Wolverine.Runtime.Serialization.Encryption.EncryptOutgoingEndpointRule(encrypting); + endpoint.OutgoingRules.Add(rule); + }); + return this.As(); + } + public T Named(string name) { add(e => e.EndpointName = name); diff --git a/src/Wolverine/MessageTypePolicies.cs b/src/Wolverine/MessageTypePolicies.cs index b7ba0e6e1..1ed0c7410 100644 --- a/src/Wolverine/MessageTypePolicies.cs +++ b/src/Wolverine/MessageTypePolicies.cs @@ -2,6 +2,7 @@ using JasperFx.Core.Reflection; using Wolverine.Logging; using Wolverine.RateLimiting; +using Wolverine.Runtime.Serialization.Encryption; namespace Wolverine; @@ -73,4 +74,27 @@ public MessageTypePolicies RateLimit(string? key, RateLimit defaultLimit, return this; } + + /// + /// Mark messages assignable to as requiring AES-256-GCM + /// encryption on send and on receive. Resolves the encrypting serializer at the + /// time this method is invoked, so or + /// must be called + /// before this method is invoked. Inbound envelopes of this type whose + /// content-type is not the encrypted content-type are routed to the dead-letter + /// queue with . + /// + public MessageTypePolicies Encrypt() + { + var encrypting = _parent.TryFindSerializer(EncryptionHeaders.EncryptedContentType) + ?? throw new InvalidOperationException( + "No encrypting serializer is registered. Call " + + "WolverineOptions.UseEncryption(provider) or " + + "WolverineOptions.RegisterEncryptionSerializer(provider) " + + $"before .ForMessagesOfType<{typeof(T).Name}>().Encrypt()."); + + _parent.MetadataRules.Add(new EncryptMessageTypeRule(encrypting)); + _parent.RequiredEncryptedTypes.Add(typeof(T)); + return this; + } } \ No newline at end of file diff --git a/src/Wolverine/Runtime/HandlerPipeline.cs b/src/Wolverine/Runtime/HandlerPipeline.cs index 10d05ce9f..e886b6d46 100644 --- a/src/Wolverine/Runtime/HandlerPipeline.cs +++ b/src/Wolverine/Runtime/HandlerPipeline.cs @@ -6,6 +6,7 @@ using Wolverine.Logging; using Wolverine.Runtime.Handlers; using Wolverine.Runtime.Serialization; +using Wolverine.Runtime.Serialization.Encryption; using Wolverine.Transports; namespace Wolverine.Runtime; @@ -110,10 +111,16 @@ public async Task InvokeAsync(Envelope envelope, IChannelCallback channel, Activ public async ValueTask TryDeserializeEnvelope(Envelope envelope) { if (envelope.Message != null) return NullContinuation.Instance; - + // Try to deserialize try { + if (RequiresEncryption(envelope) + && envelope.ContentType != EncryptionHeaders.EncryptedContentType) + { + return new MoveToErrorQueue(new EncryptionPolicyViolationException(envelope)); + } + var serializer = envelope.Serializer ?? _runtime.Options.DetermineSerializer(envelope); serializer.UnwrapEnvelopeIfNecessary(envelope); @@ -168,6 +175,22 @@ public async ValueTask TryDeserializeEnvelope(Envelope envelope) } } + private bool RequiresEncryption(Envelope envelope) + { + var options = _runtime.Options; + + // Use the listener's own URI, not envelope.Destination: the latter is sender- + // controlled and not populated on broker transports (Rabbit/Kafka/SB). + // For per-type enforcement, defer to IsEncryptionRequired so the check + // mirrors the polymorphic send-side rule (CanBeCastTo) and a plaintext + // envelope for a concrete subtype of an interface/abstract marker is rejected. + return (_endpoint?.Uri is not null + && options.RequiredEncryptedListenerUris.Contains(_endpoint.Uri)) + || (!string.IsNullOrEmpty(envelope.MessageType) + && _graph.TryFindMessageType(envelope.MessageType, out var type) + && options.IsEncryptionRequired(type)); + } + private async Task executeAsync(MessageContext context, Envelope envelope, Activity? activity) { if (envelope.IsExpired()) diff --git a/src/Wolverine/Runtime/Serialization/Encryption/CachingKeyProvider.cs b/src/Wolverine/Runtime/Serialization/Encryption/CachingKeyProvider.cs new file mode 100644 index 000000000..05914a7c3 --- /dev/null +++ b/src/Wolverine/Runtime/Serialization/Encryption/CachingKeyProvider.cs @@ -0,0 +1,138 @@ +namespace Wolverine.Runtime.Serialization.Encryption; + +/// +/// Wraps an inner with a per-key TTL cache and +/// single-flight deduplication of concurrent requests for the same key-id. +/// The cache is bounded by an LRU policy with a configurable maximum number +/// of entries (default 1024); deployments that need to keep more keys hot +/// (e.g. many tenants with per-tenant keys) should raise this limit. +/// is forwarded to the inner without caching. +/// +public sealed class CachingKeyProvider : IKeyProvider +{ + private readonly IKeyProvider _inner; + private readonly TimeSpan _ttl; + private readonly LruEntryStore _cache; + + public CachingKeyProvider(IKeyProvider inner, TimeSpan ttl, int maxEntries = 1024) + { + if (ttl <= TimeSpan.Zero) + throw new ArgumentOutOfRangeException(nameof(ttl), "TTL must be positive."); + if (maxEntries <= 0) + throw new ArgumentOutOfRangeException(nameof(maxEntries), "maxEntries must be positive."); + + _inner = inner ?? throw new ArgumentNullException(nameof(inner)); + _ttl = ttl; + _cache = new LruEntryStore(maxEntries); + } + + public string DefaultKeyId => _inner.DefaultKeyId; + + public async ValueTask GetKeyAsync(string keyId, CancellationToken cancellationToken) + { + while (true) + { + var entry = _cache.GetOrAdd(keyId, id => new CacheEntry(FetchAsync(id))); + + if (entry.IsExpired(_ttl)) + { + _cache.TryRemove(keyId, entry); + continue; + } + + try + { + return await entry.Task.WaitAsync(cancellationToken).ConfigureAwait(false); + } + catch (OperationCanceledException) + { + // Per-caller cancellation: leave the shared inner task untouched + // so other waiters still see the value when the inner completes. + throw; + } + catch + { + _cache.TryRemove(keyId, entry); + throw; + } + } + } + + private async Task FetchAsync(string keyId) + { + return await _inner.GetKeyAsync(keyId, CancellationToken.None).ConfigureAwait(false); + } + + private sealed class CacheEntry + { + public Task Task { get; } + public DateTimeOffset CreatedAt { get; } + + public CacheEntry(Task task) + { + Task = task; + CreatedAt = DateTimeOffset.UtcNow; + } + + public bool IsExpired(TimeSpan ttl) + => DateTimeOffset.UtcNow - CreatedAt > ttl; + } + + private sealed class LruEntryStore + { + // _index gives O(1) lookup; _order tracks recency (head = MRU, tail = LRU). + private readonly int _maxEntries; + private readonly object _lock = new(); + private readonly Dictionary>> _index = new(); + private readonly LinkedList> _order = new(); + + public LruEntryStore(int maxEntries) + { + _maxEntries = maxEntries; + } + + public CacheEntry GetOrAdd(string keyId, Func factory) + { + lock (_lock) + { + if (_index.TryGetValue(keyId, out var existing)) + { + _order.Remove(existing); + _order.AddFirst(existing); + return existing.Value.Value; + } + + if (_index.Count >= _maxEntries) + { + var evicted = _order.Last!; + _order.RemoveLast(); + _index.Remove(evicted.Value.Key); + } + + // The factory must be non-blocking — it wraps a hot Task, not awaits one. + // Anything heavier here would hold the lock for the duration of I/O. + var newEntry = factory(keyId); + var node = new LinkedListNode>( + new KeyValuePair(keyId, newEntry)); + _order.AddFirst(node); + _index[keyId] = node; + return newEntry; + } + } + + public bool TryRemove(string keyId, CacheEntry expected) + { + lock (_lock) + { + if (_index.TryGetValue(keyId, out var node) + && ReferenceEquals(node.Value.Value, expected)) + { + _order.Remove(node); + _index.Remove(keyId); + return true; + } + return false; + } + } + } +} diff --git a/src/Wolverine/Runtime/Serialization/Encryption/EncryptMessageTypeRule.cs b/src/Wolverine/Runtime/Serialization/Encryption/EncryptMessageTypeRule.cs new file mode 100644 index 000000000..307282d4b --- /dev/null +++ b/src/Wolverine/Runtime/Serialization/Encryption/EncryptMessageTypeRule.cs @@ -0,0 +1,36 @@ +using JasperFx.Core.Reflection; +using Wolverine.Runtime.Serialization; + +namespace Wolverine.Runtime.Serialization.Encryption; + +/// +/// Outgoing-envelope rule that swaps the serializer to an encrypting variant +/// for messages assignable to . Captures the +/// encrypting serializer at construction time so the rule needs no runtime +/// lookup at modify time. +/// +internal sealed class EncryptMessageTypeRule : IEnvelopeRule +{ + private readonly IMessageSerializer _encryptingSerializer; + + public EncryptMessageTypeRule(IMessageSerializer encryptingSerializer) + { + _encryptingSerializer = encryptingSerializer + ?? throw new ArgumentNullException(nameof(encryptingSerializer)); + } + + public void Modify(Envelope envelope) + { + if (envelope.Message is null) return; + if (!envelope.Message.GetType().CanBeCastTo()) return; + + // Mirror MessageRoute.cs:114-115 — swap Serializer and ContentType together. + // ContentType alone is not enough; envelope.Serializer.Write(envelope) is what + // actually produces the wire bytes. + envelope.Serializer = _encryptingSerializer; + envelope.ContentType = _encryptingSerializer.ContentType; + } + + public override string ToString() => + $"Encrypt messages assignable to {typeof(T).FullNameInCode()}"; +} diff --git a/src/Wolverine/Runtime/Serialization/Encryption/EncryptingMessageSerializer.cs b/src/Wolverine/Runtime/Serialization/Encryption/EncryptingMessageSerializer.cs new file mode 100644 index 000000000..3d5d3651d --- /dev/null +++ b/src/Wolverine/Runtime/Serialization/Encryption/EncryptingMessageSerializer.cs @@ -0,0 +1,265 @@ +using System.Security.Cryptography; + +namespace Wolverine.Runtime.Serialization.Encryption; + +/// +/// Decorates an inner with AES-256-GCM body +/// encryption. Emits dedicated content-type +/// application/wolverine-encrypted+json; receive-side dispatch is +/// content-type driven and lands here, then unwraps to the inner serializer +/// after decryption. +/// +/// +/// When the inner serializer implements +/// the async paths preserve full asynchrony. The synchronous Write / +/// ReadFromData(envelope) paths must call into the (async) +/// via GetAwaiter().GetResult(); Wolverine's runtime exercises sync call +/// sites at Envelope.cs (Data property getter) and BatchedSender, +/// so blocking is unavoidable on those paths. Most production paths use the +/// async surface and never block. +/// +/// The contract has no +/// parameter, so calls into +/// from WriteAsync and +/// ReadFromDataAsync use CancellationToken.None. A slow KMS +/// fetch cannot be cancelled by host shutdown; key-provider implementations +/// SHOULD apply their own internal timeouts. +/// +/// WriteMessage(object) and ReadFromData(byte[]) have no +/// envelope context, so the key-id header cannot be read or written. These +/// overloads throw because returning +/// plaintext on a serializer whose advertised content-type is the encrypted +/// content-type would be a silent confidentiality bug. Wolverine's normal +/// pipeline always carries an envelope and never reaches these overloads. +/// +public sealed class EncryptingMessageSerializer : IAsyncMessageSerializer +{ + private readonly IMessageSerializer _inner; + private readonly IAsyncMessageSerializer? _innerAsync; + private readonly IKeyProvider _keyProvider; + + public EncryptingMessageSerializer(IMessageSerializer inner, IKeyProvider keyProvider) + { + _inner = inner ?? throw new ArgumentNullException(nameof(inner)); + _keyProvider = keyProvider ?? throw new ArgumentNullException(nameof(keyProvider)); + _innerAsync = inner as IAsyncMessageSerializer; + } + + public IMessageSerializer Inner => _inner; + public string ContentType => EncryptionHeaders.EncryptedContentType; + + private const string AadMagic = "wlv-enc-v1"; + + private static void EnsureKeyMatchesAes256(string keyId, byte[]? key) + { + // The AesGcm constructor would throw CryptographicException for any other length, + // but at a code site that sits outside the WriteAsync/ReadFromDataAsync try-catch, + // so it would surface as a raw CryptographicException to user code. Wrap here + // with the key-id surfaced so misconfigured custom IKeyProvider implementations + // produce a diagnosable error instead of an opaque crypto exception. + if (key is null || key.Length != 32) + { + throw new EncryptionKeyNotFoundException( + keyId, + new InvalidOperationException( + $"Key provider returned a key of {key?.Length ?? 0} bytes for key-id '{keyId}'; " + + "AES-256-GCM requires exactly 32 bytes.")); + } + } + + internal static byte[] BuildAad(string? messageType, string keyId, string innerContentType) + { + var mtSrc = messageType ?? string.Empty; + + var mtLen = System.Text.Encoding.UTF8.GetByteCount(mtSrc); + if (mtLen > ushort.MaxValue) throw new ArgumentOutOfRangeException(nameof(messageType)); + + var kidLen = System.Text.Encoding.UTF8.GetByteCount(keyId); + if (kidLen > ushort.MaxValue) throw new ArgumentOutOfRangeException(nameof(keyId)); + + var ictLen = System.Text.Encoding.UTF8.GetByteCount(innerContentType); + if (ictLen > ushort.MaxValue) throw new ArgumentOutOfRangeException(nameof(innerContentType)); + + var size = AadMagic.Length + 2 + mtLen + 2 + kidLen + 2 + ictLen; + var buf = new byte[size]; + var span = buf.AsSpan(); + var pos = 0; + + // ASCII-only magic; GetBytes(string, Span) writes directly into buf. + pos += System.Text.Encoding.ASCII.GetBytes(AadMagic, span); + + span[pos++] = (byte)(mtLen >> 8); span[pos++] = (byte)(mtLen & 0xFF); + pos += System.Text.Encoding.UTF8.GetBytes(mtSrc, span.Slice(pos)); + + span[pos++] = (byte)(kidLen >> 8); span[pos++] = (byte)(kidLen & 0xFF); + pos += System.Text.Encoding.UTF8.GetBytes(keyId, span.Slice(pos)); + + span[pos++] = (byte)(ictLen >> 8); span[pos++] = (byte)(ictLen & 0xFF); + System.Text.Encoding.UTF8.GetBytes(innerContentType, span.Slice(pos)); + + return buf; + } + + public byte[] Write(Envelope envelope) + { +#pragma warning disable VSTHRD002 // Documented blocking call, see class remarks + return WriteAsync(envelope).AsTask().GetAwaiter().GetResult(); +#pragma warning restore VSTHRD002 + } + + public byte[] WriteMessage(object message) + { + // No envelope is available, so the key-id header cannot be written. Returning + // the inner serializer's plaintext on a serializer whose ContentType advertises + // encryption would be a silent confidentiality bug for any caller who picks + // this overload by ContentType lookup. Fail loudly instead. + throw new InvalidOperationException( + "EncryptingMessageSerializer.WriteMessage(object) cannot encrypt without an Envelope. " + + "Wolverine's normal write paths use Write(envelope) / WriteAsync(envelope); custom callers " + + "must pass an Envelope so the key-id header can be stamped."); + } + + public object ReadFromData(Type messageType, Envelope envelope) + { +#pragma warning disable VSTHRD002 + return ReadFromDataAsync(messageType, envelope).AsTask().GetAwaiter().GetResult()!; +#pragma warning restore VSTHRD002 + } + + public object ReadFromData(byte[] data) + { + // No envelope context, so the key-id header is unavailable and decryption + // cannot be performed. Wolverine's normal receive pipeline always carries + // an envelope (HandlerPipeline.TryDeserializeEnvelope). Fail loudly so + // a stray caller can't silently bypass decryption. + throw new InvalidOperationException( + "EncryptingMessageSerializer.ReadFromData(byte[]) cannot decrypt without an Envelope. " + + "Wolverine's normal receive paths use ReadFromData(messageType, envelope); custom callers " + + "must pass an Envelope so the key-id header can be read."); + } + + public async ValueTask WriteAsync(Envelope envelope) + { + var keyId = _keyProvider.DefaultKeyId; + if (string.IsNullOrEmpty(keyId)) + { + throw new EncryptionKeyNotFoundException( + keyId: "", + innerException: new InvalidOperationException( + $"Key provider {_keyProvider.GetType().Name} returned a null/empty DefaultKeyId. " + + "Implementations must return a stable, non-empty key-id.")); + } + byte[] key; + try + { + key = await _keyProvider.GetKeyAsync(keyId, CancellationToken.None).ConfigureAwait(false); + } + catch (Exception ex) when (ex is not OperationCanceledException and not OutOfMemoryException) + { + throw new EncryptionKeyNotFoundException(keyId, ex); + } + + EnsureKeyMatchesAes256(keyId, key); + + var plaintext = _innerAsync is not null + ? await _innerAsync.WriteAsync(envelope).ConfigureAwait(false) + : _inner.Write(envelope); + + var nonce = new byte[12]; + RandomNumberGenerator.Fill(nonce); + + var ciphertext = new byte[plaintext.Length]; + var tag = new byte[16]; + + var aad = BuildAad(envelope.MessageType, keyId, _inner.ContentType); + using var aes = new AesGcm(key, tagSizeInBytes: 16); + aes.Encrypt(nonce, plaintext, ciphertext, tag, aad); + + envelope.Headers[EncryptionHeaders.KeyIdHeader] = keyId; + envelope.Headers[EncryptionHeaders.InnerContentTypeHeader] = _inner.ContentType; + + var output = new byte[nonce.Length + ciphertext.Length + tag.Length]; + Buffer.BlockCopy(nonce, 0, output, 0, nonce.Length); + Buffer.BlockCopy(ciphertext, 0, output, nonce.Length, ciphertext.Length); + Buffer.BlockCopy(tag, 0, output, nonce.Length + ciphertext.Length, tag.Length); + return output; + } + + public async ValueTask ReadFromDataAsync(Type messageType, Envelope envelope) + { + if (!envelope.Headers.TryGetValue(EncryptionHeaders.KeyIdHeader, out var keyId) + || string.IsNullOrEmpty(keyId)) + { + throw new EncryptionKeyNotFoundException( + keyId: "", + innerException: new InvalidOperationException( + $"Envelope is missing required header '{EncryptionHeaders.KeyIdHeader}'.")); + } + + byte[] key; + try + { + key = await _keyProvider.GetKeyAsync(keyId, CancellationToken.None).ConfigureAwait(false); + } + catch (Exception ex) when (ex is not OperationCanceledException and not OutOfMemoryException) + { + throw new EncryptionKeyNotFoundException(keyId, ex); + } + + EnsureKeyMatchesAes256(keyId, key); + + var body = envelope.Data ?? Array.Empty(); + if (body.Length < 12 + 16) + { + throw new MessageDecryptionException(keyId, + new CryptographicException( + $"Encrypted body too short ({body.Length} bytes); expected at least 28 (12-byte nonce + 16-byte tag).")); + } + + var nonce = body.AsSpan(0, 12).ToArray(); + var tag = body.AsSpan(body.Length - 16, 16).ToArray(); + var ciphertext = body.AsSpan(12, body.Length - 12 - 16).ToArray(); + var plaintext = new byte[ciphertext.Length]; + + var hasInnerCt = envelope.Headers.TryGetValue(EncryptionHeaders.InnerContentTypeHeader, out var innerCt); + // AAD uses empty string when the header is missing. Wolverine's WriteAsync + // always writes the header with the inner serializer's content-type, so a + // missing header here means a non-Wolverine sender or tampering — the tag + // check below will reject it because the AAD won't match. + var innerCtForAad = hasInnerCt ? innerCt ?? string.Empty : string.Empty; + var aad = BuildAad(envelope.MessageType, keyId, innerCtForAad); + + try + { + using var aes = new AesGcm(key, tagSizeInBytes: 16); + aes.Decrypt(nonce, ciphertext, tag, plaintext, aad); + } + catch (CryptographicException ex) + { + throw new MessageDecryptionException(keyId, ex); + } + + // Defense-in-depth: the tag check passed, so AAD matched. If the sender + // bound an empty inner-content-type into AAD (Wolverine never does this), + // we have no trustworthy content-type for the inner serializer. Reject. + if (!hasInnerCt || string.IsNullOrEmpty(innerCt)) + { + throw new MessageDecryptionException(keyId, + new CryptographicException( + $"Encrypted envelope is missing required header '{EncryptionHeaders.InnerContentTypeHeader}'.")); + } + + // Restore plaintext to a synthetic envelope view that the inner serializer expects. + var innerEnvelope = new Envelope + { + Data = plaintext, + ContentType = innerCt, + MessageType = envelope.MessageType, + Headers = new Dictionary(envelope.Headers) + }; + + return _innerAsync is not null + ? await _innerAsync.ReadFromDataAsync(messageType, innerEnvelope).ConfigureAwait(false) + : _inner.ReadFromData(messageType, innerEnvelope); + } +} diff --git a/src/Wolverine/Runtime/Serialization/Encryption/EncryptionConfigurationExtensions.cs b/src/Wolverine/Runtime/Serialization/Encryption/EncryptionConfigurationExtensions.cs new file mode 100644 index 000000000..8c2b7c433 --- /dev/null +++ b/src/Wolverine/Runtime/Serialization/Encryption/EncryptionConfigurationExtensions.cs @@ -0,0 +1,27 @@ +namespace Wolverine.Runtime.Serialization.Encryption; + +/// +/// Per-endpoint outgoing rule that swaps each envelope to the encrypting serializer. +/// The encrypting serializer is resolved at endpoint compile time by the +/// Encrypted() instance method on +/// and injected into this rule via the constructor. +/// +internal sealed class EncryptOutgoingEndpointRule : IEnvelopeRule +{ + private readonly IMessageSerializer _encryptingSerializer; + + public EncryptOutgoingEndpointRule(IMessageSerializer encryptingSerializer) + { + _encryptingSerializer = encryptingSerializer ?? throw new ArgumentNullException(nameof(encryptingSerializer)); + } + + public void Modify(Envelope envelope) + { + // Mirror MessageRoute.cs:114-115 — swap Serializer and ContentType together. + envelope.Serializer = _encryptingSerializer; + envelope.ContentType = _encryptingSerializer.ContentType; + } + + public override string ToString() => + $"Encrypt outgoing envelopes via {EncryptionHeaders.EncryptedContentType}"; +} diff --git a/src/Wolverine/Runtime/Serialization/Encryption/EncryptionHeaders.cs b/src/Wolverine/Runtime/Serialization/Encryption/EncryptionHeaders.cs new file mode 100644 index 000000000..04ded6f31 --- /dev/null +++ b/src/Wolverine/Runtime/Serialization/Encryption/EncryptionHeaders.cs @@ -0,0 +1,8 @@ +namespace Wolverine.Runtime.Serialization.Encryption; + +public static class EncryptionHeaders +{ + public const string EncryptedContentType = "application/wolverine-encrypted+json"; + public const string KeyIdHeader = "wolverine.encryption.key-id"; + public const string InnerContentTypeHeader = "wolverine.encryption.inner-content-type"; +} diff --git a/src/Wolverine/Runtime/Serialization/Encryption/EncryptionKeyNotFoundException.cs b/src/Wolverine/Runtime/Serialization/Encryption/EncryptionKeyNotFoundException.cs new file mode 100644 index 000000000..651be1ffe --- /dev/null +++ b/src/Wolverine/Runtime/Serialization/Encryption/EncryptionKeyNotFoundException.cs @@ -0,0 +1,12 @@ +namespace Wolverine.Runtime.Serialization.Encryption; + +public sealed class EncryptionKeyNotFoundException : MessageEncryptionException +{ + public string KeyId { get; } + + public EncryptionKeyNotFoundException(string keyId, Exception? innerException = null) + : base($"No encryption key available for key-id '{keyId}'", innerException) + { + KeyId = keyId; + } +} diff --git a/src/Wolverine/Runtime/Serialization/Encryption/EncryptionPolicyViolationException.cs b/src/Wolverine/Runtime/Serialization/Encryption/EncryptionPolicyViolationException.cs new file mode 100644 index 000000000..5a87d0c0f --- /dev/null +++ b/src/Wolverine/Runtime/Serialization/Encryption/EncryptionPolicyViolationException.cs @@ -0,0 +1,21 @@ +namespace Wolverine.Runtime.Serialization.Encryption; + +/// +/// Thrown when an inbound envelope arrives without encryption but the +/// receiving message type or listener has been marked as requiring it. +/// The envelope is routed to the dead-letter queue without invoking any +/// serializer; no body bytes are interpreted. +/// +public sealed class EncryptionPolicyViolationException : MessageEncryptionException +{ + public EncryptionPolicyViolationException(Envelope envelope) + : base(BuildMessage(envelope)) + { + } + + private static string BuildMessage(Envelope envelope) + { + return $"Envelope of type '{envelope.MessageType}' arrived with content-type " + + $"'{envelope.ContentType}' but encryption is required for this type or listener."; + } +} diff --git a/src/Wolverine/Runtime/Serialization/Encryption/IKeyProvider.cs b/src/Wolverine/Runtime/Serialization/Encryption/IKeyProvider.cs new file mode 100644 index 000000000..eac984895 --- /dev/null +++ b/src/Wolverine/Runtime/Serialization/Encryption/IKeyProvider.cs @@ -0,0 +1,33 @@ +namespace Wolverine.Runtime.Serialization.Encryption; + +/// +/// Resolves AES-256 encryption keys by key-id. Implementations may be backed by +/// in-memory dictionaries (tests/samples), local config, or remote KMS providers. +/// Wrap remote providers with in production — +/// the encrypting serializer hits this on every send and every receive. +/// +public interface IKeyProvider +{ + /// + /// The key-id that outgoing messages are encrypted under. Static at provider + /// construction; rotation happens by deploying a new provider with a new default. + /// + string DefaultKeyId { get; } + + /// + /// Resolve the 32-byte AES-256 key for the given key-id. + /// + /// + /// The returned array is treated as a borrowed reference owned by the + /// provider. Callers MUST NOT mutate the returned bytes or call + /// CryptographicOperations.ZeroMemory on them — doing so will corrupt + /// providers that cache key material (such as ). + /// Providers that intend to support caller-side zeroization must return a fresh + /// copy on every call and document that contract explicitly. + /// + /// Implementations SHOULD throw (e.g. ) when + /// the key is not available; the encrypting serializer wraps any thrown exception + /// into . + /// + ValueTask GetKeyAsync(string keyId, CancellationToken cancellationToken); +} diff --git a/src/Wolverine/Runtime/Serialization/Encryption/InMemoryKeyProvider.cs b/src/Wolverine/Runtime/Serialization/Encryption/InMemoryKeyProvider.cs new file mode 100644 index 000000000..e3832d10d --- /dev/null +++ b/src/Wolverine/Runtime/Serialization/Encryption/InMemoryKeyProvider.cs @@ -0,0 +1,51 @@ +namespace Wolverine.Runtime.Serialization.Encryption; + +public sealed class InMemoryKeyProvider : IKeyProvider +{ + private readonly Dictionary _keys; + + public InMemoryKeyProvider(string defaultKeyId, IDictionary keys) + { + if (string.IsNullOrEmpty(defaultKeyId)) + throw new ArgumentException("defaultKeyId is required", nameof(defaultKeyId)); + if (keys is null) throw new ArgumentNullException(nameof(keys)); + + foreach (var (id, bytes) in keys) + { + if (bytes is null || bytes.Length != 32) + throw new ArgumentException( + $"Key '{id}' must be exactly 32 bytes for AES-256; got {bytes?.Length ?? 0}.", + nameof(keys)); + } + + if (!keys.ContainsKey(defaultKeyId)) + throw new ArgumentException( + $"defaultKeyId '{defaultKeyId}' is not present in the keys dictionary.", + nameof(defaultKeyId)); + + _keys = new Dictionary(keys.Count); + foreach (var (id, bytes) in keys) + { + _keys[id] = bytes.AsSpan().ToArray(); + } + + DefaultKeyId = defaultKeyId; + } + + public string DefaultKeyId { get; } + + public ValueTask GetKeyAsync(string keyId, CancellationToken cancellationToken) + { + if (_keys.TryGetValue(keyId, out var key)) + { + // The IKeyProvider contract documents the returned array as a borrowed + // reference that callers MUST NOT mutate. For the in-memory provider + // the marginal cost (32 bytes per call) is negligible and a defensive + // copy guarantees that a misbehaving consumer cannot corrupt all + // subsequent encryptions by writing to the cached key. + return ValueTask.FromResult(key.AsSpan().ToArray()); + } + + throw new KeyNotFoundException($"No key registered for key-id '{keyId}'."); + } +} diff --git a/src/Wolverine/Runtime/Serialization/Encryption/MessageDecryptionException.cs b/src/Wolverine/Runtime/Serialization/Encryption/MessageDecryptionException.cs new file mode 100644 index 000000000..140499c06 --- /dev/null +++ b/src/Wolverine/Runtime/Serialization/Encryption/MessageDecryptionException.cs @@ -0,0 +1,12 @@ +namespace Wolverine.Runtime.Serialization.Encryption; + +public sealed class MessageDecryptionException : MessageEncryptionException +{ + public string KeyId { get; } + + public MessageDecryptionException(string keyId, Exception innerException) + : base($"Failed to decrypt message body using key-id '{keyId}' (auth tag mismatch or malformed body)", innerException) + { + KeyId = keyId; + } +} diff --git a/src/Wolverine/Runtime/Serialization/Encryption/MessageEncryptionException.cs b/src/Wolverine/Runtime/Serialization/Encryption/MessageEncryptionException.cs new file mode 100644 index 000000000..7ca99b0a8 --- /dev/null +++ b/src/Wolverine/Runtime/Serialization/Encryption/MessageEncryptionException.cs @@ -0,0 +1,9 @@ +namespace Wolverine.Runtime.Serialization.Encryption; + +public abstract class MessageEncryptionException : Exception +{ + protected MessageEncryptionException(string message, Exception? innerException = null) + : base(message, innerException) + { + } +} diff --git a/src/Wolverine/WolverineOptions.Encryption.cs b/src/Wolverine/WolverineOptions.Encryption.cs new file mode 100644 index 000000000..2452d0886 --- /dev/null +++ b/src/Wolverine/WolverineOptions.Encryption.cs @@ -0,0 +1,112 @@ +using System.Collections.Concurrent; +using Wolverine.Runtime.Serialization.Encryption; + +namespace Wolverine; + +public sealed partial class WolverineOptions +{ + /// + /// Encrypt all outgoing messages by default with AES-256-GCM. The current + /// is wrapped as the inner serializer and + /// remains resolvable under its original content-type so receive-side + /// dispatch can decrypt and dispatch back through it. + /// + /// Required key provider. Must not be null. + public void UseEncryption(IKeyProvider keyProvider) + { + if (keyProvider is null) throw new ArgumentNullException(nameof(keyProvider)); + + // Calling UseEncryption twice would wrap an already-wrapping serializer: + // outgoing messages would be encrypted twice, but the receive-side path + // only unwraps one layer, so messages would silently be undecryptable. + if (DefaultSerializer is EncryptingMessageSerializer) + { + throw new InvalidOperationException( + "UseEncryption has already been called on this WolverineOptions. " + + "Calling it more than once would double-wrap the default serializer " + + "and produce envelopes that cannot be decrypted on receive. " + + "Configure encryption exactly once during host setup."); + } + + var inner = DefaultSerializer; + var encrypting = new EncryptingMessageSerializer(inner, keyProvider); + + // The setter below also registers the encrypting serializer under its + // own content-type via _serializers; the inner serializer remains in + // _serializers under its original content-type for receive-side use. + DefaultSerializer = encrypting; + } + + /// + /// Register the encrypting serializer alongside the existing default serializer + /// without changing the default. Use this when you want per-message-type or + /// per-endpoint opt-in encryption while leaving non-opted-in messages serialized + /// normally. + /// + /// Required key provider. Must not be null. + public void RegisterEncryptionSerializer(IKeyProvider keyProvider) + { + if (keyProvider is null) throw new ArgumentNullException(nameof(keyProvider)); + + // Same hazard as UseEncryption: a second registration replaces the first + // under the same content-type but the inner-of-inner reference would now + // point at the previous EncryptingMessageSerializer, double-wrapping every + // future per-type / per-endpoint encryption. + if (TryFindSerializer(EncryptionHeaders.EncryptedContentType) is not null) + { + throw new InvalidOperationException( + "An encrypting serializer is already registered on this WolverineOptions. " + + "Configure encryption exactly once during host setup."); + } + + var inner = DefaultSerializer; + var encrypting = new EncryptingMessageSerializer(inner, keyProvider); + + AddSerializer(encrypting); + } + + private readonly ConcurrentDictionary _encryptionRequiredCache = new(); + + // Message types whose envelopes MUST arrive encrypted. Populated by + // MessageTypePolicies.Encrypt(). Read by IsEncryptionRequired on every + // inbound envelope. Internal so the receive-side guard cannot be silently + // disabled at runtime by host code mutating a public collection. + internal HashSet RequiredEncryptedTypes { get; } = new(); + + // Listener endpoint URIs that MUST receive only encrypted envelopes. + // Populated by ListenerConfiguration.RequireEncryption(). Internal for the + // same reason as RequiredEncryptedTypes. + internal HashSet RequiredEncryptedListenerUris { get; } = new(); + + /// + /// Returns true if envelopes carrying a message of + /// must arrive encrypted. Performs an exact match against + /// RequiredEncryptedTypes first; on miss, scans for any registered + /// required type that is assignable from + /// (mirrors the polymorphic send-side rule in EncryptMessageTypeRule<T>). + /// Per-type result is cached for O(1) lookup on subsequent envelopes. + /// + internal bool IsEncryptionRequired(Type messageType) + { + if (messageType is null) return false; + + // Cache lookup first so previously computed answers survive any later + // mutation of RequiredEncryptedTypes (the documented contract). Cheap + // when the cache is empty (no-encryption-configured deployments). + if (_encryptionRequiredCache.TryGetValue(messageType, out var cached)) return cached; + + // No cached answer. If no markers are configured, return false without + // caching — avoids unbounded cache growth on no-encryption hosts. + if (RequiredEncryptedTypes.Count == 0) return false; + + return _encryptionRequiredCache.GetOrAdd(messageType, static (mt, set) => + { + if (set.Contains(mt)) return true; + foreach (var required in set) + { + if (required.IsAssignableFrom(mt)) return true; + } + return false; + }, RequiredEncryptedTypes); + } +} diff --git a/src/Wolverine/WolverineOptions.Serialization.cs b/src/Wolverine/WolverineOptions.Serialization.cs index a58110804..8613bd1b8 100644 --- a/src/Wolverine/WolverineOptions.Serialization.cs +++ b/src/Wolverine/WolverineOptions.Serialization.cs @@ -86,7 +86,15 @@ public void UseSystemTextJsonForSerialization(Action? con configuration?.Invoke(options); var serializer = new SystemTextJsonSerializer(options); - _defaultSerializer = serializer; + + if (_defaultSerializer?.ContentType == "application/json") + { + _defaultSerializer = serializer; + } + else + { + _defaultSerializer ??= serializer; + } _serializers[serializer.ContentType] = serializer; } @@ -101,6 +109,10 @@ internal IMessageSerializer FindSerializer(string contentType) throw new ArgumentOutOfRangeException(nameof(contentType)); } + /// + /// Try to resolve a previously-registered serializer by its content-type. + /// Returns null when no serializer is registered under the given content-type. + /// internal IMessageSerializer? TryFindSerializer(string contentType) { if (_serializers.TryGetValue(contentType, out var s)) diff --git a/wolverine.slnx b/wolverine.slnx index 70ff2269b..df05ec598 100644 --- a/wolverine.slnx +++ b/wolverine.slnx @@ -130,6 +130,7 @@ +