Skip to main content
Table of Contents

Bus-Envelope Integrity (HMAC)

Derived page. The behaviour described here is specified by the bus-envelope-integrity capability under openspec/specs/. That specification is the source; this page explains and illustrates it. Where the two disagree, the specification is right and this page is a bug.

Stratara supports an opt-in HMAC signature on every CommandEnvelope + EventBundle that travels on the bus. When enabled, consumers verify the signature before deserializing the body; tampered envelopes are rejected.

When to turn this on

  • Multi-tenant brokers — if your bus connects multiple apps with different trust levels (e.g. a shared RabbitMQ cluster).
  • Defense-in-depth for compliance regimes where "message provenance" needs an auditable verifier.

You don't need this for single-app, single-host scenarios — the network + broker auth already gates who can publish.

Wire it

services.AddBusEnvelopeIntegrity(options =>
{
    options.Mode = BusEnvelopeIntegrityMode.Strict;
    options.SharedKey = Convert.FromBase64String(
        builder.Configuration["BusEnvelopeIntegrity:SharedKey"]!);
});

SharedKey is a byte[] of at least 32 bytes — decode it from wherever your secrets live. The overload AddBusEnvelopeIntegrity(configuration) binds Mode from the BusEnvelopeIntegrity section (BusEnvelopeIntegrityOptions.SectionName) and leaves the key to you.

Three modes:

Mode Producer behaviour Consumer behaviour
Off (default) No signature added No verification — accepts any envelope
Permissive Always signs Verifies; accepts an unsigned envelope and one whose signature does not verify, recording each (for rolling deployments)
Strict Always signs Rejects unsigned envelopes + envelopes with invalid signatures

Roll-out pattern: deploy producers with Permissive first, wait for the entire fleet to be running the signed version, then flip consumers to Strict.

What to expect in the log during a roll. A consumer records the two failure cases as two different events, because they mean different things:

Event Command worker Projection / saga worker Recorded command (Orleans) Meaning
Unsigned envelope, dispatched 105_004 111_004 117_115 Expected while publishers are still being restarted. After the roll it means a publisher was missed
Signature present but does not verify, dispatched 105_003 111_003 117_116 Not expected during a roll: the publisher holds a different key, or the envelope was altered in transit
Unsigned envelope, rejected (Strict) 105_105 111_105 117_117 A publisher is not signing
Signature does not verify, rejected (Strict) 105_104 111_104 117_118 Key mismatch or tampering

Alert on 105_003 / 111_003 / 117_116; watch 105_004 / 111_004 / 117_115 fall to zero before flipping to Strict.

On the Orleans execution model the command a host records before its dispatch returns is signed with the same signer over the same fields, and the drain verifies it under the same mode before it resumes it — a command handed over live, without passing through storage, is not verified. A record that does not verify under Strict is not retried: it is kept for an operator at once, with the reason recorded, and returning it verifies it again. Records written before the host signed carry no signature, so the roll is the same: Permissive until 117_115 has fallen to zero — one OrleansDispatchOptions.IntentGrace after the last host without a signer stopped recording — then Strict. The silo that runs the drain needs the signer and the mode as the recording hosts have them.

The same pattern applies when the projection itself changes, and 3.4.0 is the first release where it does. Signatures produced by a pre-3.4.0 publisher do not verify against a 3.4.0 consumer. Move producers to 3.4.0 while consumers run Permissive, let the in-flight messages drain, then return consumers to Strict. Upgrading both sides straight into Strict rejects everything still in the queue.

What's signed

Every field of the message except the signature itself:

  • CommandEnvelope: the envelope id, the command type, the session context, the heavy-lane flag, a digest of the command body and — where the command names the owner of its protected fields through INamesDataOwner — that owner's tenant and user.
  • EventBundle: the session context, and a digest over every field of every event it carries.

A recorded command carries the same signature, and the same coverage. The Orleans execution model records a command before it hands it over, and the record holds the envelope above. Where the command runs is taken from what the signature covers — the heavy-lane flag — and the record's stored row is held to the envelope's id and flag: a row that says something else is kept for an operator under strict mode and resumed as the envelope says otherwise, logged either way. The aggregate the record names is not covered: it decides which activation accepts the command, not what the command writes. What it writes comes from the command body, which the digest covers, and the append is held to its stream's version, so a changed aggregate id costs the command its place in an order and nothing else.

A named data owner is covered, and so is its absence. The receiver reads a command's protected fields for the owner the envelope carries, so a signed command does not verify when it is presented with another owner — or with none, which would have the receiver read it for its session instead. The owner is part of the projection only where there is one: a command that names no owner projects to exactly what it projected to before a command could name one. A fleet in Strict mode therefore takes that release without a pass through Permissive. What does need an order is the first command that names an owner: a consumer on an earlier release does not verify it, so upgrade every host that handles commands before any host dispatches one.

Two properties of the projection are load-bearing, and both are why it looks the way it does.

Every field is length-prefixed. Joining fields with a separator that a field is allowed to contain would let content be shifted across a boundary without changing the projection — so a signature captured from one message could be presented with a different command type and still verify, defeating the guard the type name is signed for.

The projection is built from field values, never by re-serializing. The payloads survive the envelope's own deserialization as strings, and everything else is a scalar with a canonical text form. So a message that has been on the wire projects to exactly what its publisher signed, whatever the serializer did with property order or escaping — and a later release that serializes payloads differently does not invalidate older signatures. Verification also runs before upcasting, on the received bytes.

Changed in 3.4.0. Until then the signature covered identity only — for an event bundle that was everything except the events. That answers minting a session context, which was the threat it was designed for, but not transplanting an observed signature onto different events: an attacker who had seen one signed message could publish arbitrary events into that tenant, and the projection and saga workers would act on them. Signatures produced before 3.4.0 do not verify against 3.4.0 and later. See the rollout below — this is the migration the three modes exist for.

The signer interface

public interface IBusEnvelopeSigner
{
    string Sign(string payload);
    bool Verify(string payload, string? signature);
}

payload is the canonical projection — BusEnvelopeCanonical.Of(envelope) produces it, and the framework calls it for you. Treat the string as opaque bytes to sign; do not parse it.

Default impl: HmacBusEnvelopeSigner — HMAC-SHA-256 over the canonical projection. Constant-time compare via CryptographicOperations.FixedTimeEquals. Length-check happens before the compare (v3.0.13+ — protects against ArgumentException from missized attacker-supplied signatures).

Startup probe

BusEnvelopeIntegrityStartupProbe (v3.0.13+) warns at host-start if Mode != Off but no signer is registered, or if Mode == Off on any host that is not in Development. Every host that is not your laptop should default to at least Permissive.

Changed in 3.4.0: the off-mode warning used to fire only when the environment was named Production. A host named Production-EU or prod is a production host that a name check does not recognise — and it is exactly the host whose messages travel a real broker. The warning is now governed by "not Development", which also surfaces the deviation on staging and QA.

Configuration

{
  "BusEnvelopeIntegrity": {
    "Mode": "Strict"
  }
}

The section name is BusEnvelopeIntegrity — it is what AddBusEnvelopeIntegrity(configuration) binds. Keep the shared key out of appsettings.json; read it from your secret store and assign it in the Action<BusEnvelopeIntegrityOptions> overload as shown above.

Rotate the key by shipping the new key to all participants first, then redeploying. There's no built-in rolling-key support — the shared key is a single value at any point in time.

Size and depth guards

A signature answers who sent this. It does not answer how big is it — verification happens after the bytes are already in memory, and a hostile publisher can exhaust a consumer without ever forging anything. Two limits apply to every inbound envelope, independently of Mode, and bind from the BusEnvelopeJson section:

{
  "BusEnvelopeJson": {
    "MaxDepth": 32,
    "MaxBodyBytes": 1048576
  }
}

MaxBodyBytes (default 1_048_576 bytes — one mebibyte) is checked against the raw payload length before deserialisation, so an oversized message is rejected rather than allocated. MaxDepth (default 32, against System.Text.Json's own 64) bounds nesting, which is what a payload built to exhaust the stack relies on. Raise MaxBodyBytes if your commands legitimately carry more, and treat the raise as a capacity decision — it is the ceiling on what one hostile message can cost you.