Skip to content

Payload encryption at rest

AES-256-GCM on flagged queues in two commands: the key format, which messages it covers, and the failure that stores plaintext with only a warning.

Updated View as Markdown

Queen can encrypt message payloads with AES-256-GCM before they are written to PostgreSQL, so a database dump, a stolen backup or a read-only replica does not yield message contents. It needs exactly two things true at once: a key on the process and a flag on the queue. With only one of the two in place the broker stores plaintext without raising an error.

Enabling it

  1. Generate 32 random bytes as 64 hexadecimal characters, set QUEEN_ENCRYPTION_KEY to them, and restart. Every broker sharing the database needs the same key, or messages one writes are unreadable to another.

    openssl rand -hex 32
  2. Confirm the boot log line. If it is absent, encryption is off: see the silent failure.

    encryption service initialized (AES-256-GCM)
  3. Flag each queue that should be encrypted, with encryptionEnabled in the options of POST /api/v1/configure:

    curl -s -X POST http://localhost:6632/api/v1/configure -H 'Content-Type: application/json' -d '{"queue":"orders","options":{"encryptionEnabled":true}}'

/configure is a full replace: a later call that does not repeat encryptionEnabled: true turns encryption off for that queue, and subsequent pushes store plaintext. Include every non-default option the queue should keep, not only the flag.

What gets encrypted

The flag lives on queen.queues.encryption_enabled, written by configure_queue_v1, and the broker caches the answer per tenant and queue. Encryption applies when the key is loaded and the flag is true, on all four write paths: POST /api/v1/push, POST /api/v1/transaction, the stream emit path, and the disk spool the broker falls back to when PostgreSQL is unreachable, so a payload buffered to local disk during an outage is enveloped before it is written, not after it is replayed.

Two cases resolve to “no encryption”, both without an error: a queue with no queen.queues row yet (created implicitly by a push, never /configured), and a flag lookup that could not reach the database.

A stream’s sink output is enveloped like any other write. Before POST /streams/v1/cycle packs a cycle’s push_items, the broker resolves encryptionEnabled for each sink queue and encrypts that queue’s payloads, so a closed window lands in the log as ciphertext exactly as a pushed message does. The flag is resolved on the default tenant on this path, which is the only tenant a streams deployment serves. Consumers of the sink queue decrypt by shape on the way out, so nothing downstream has to know a stream produced the message.

Only the payload is enveloped. The transactionId, trace name, queue and partition names, offsets, timestamps, producerSub, consumer-group cursors and DLQ metadata are not, so anyone with database access still sees the shape of your traffic, the names of your queues, and the identity of your producers.

The stored envelope

{
  "encrypted": "<base64 ciphertext>",
  "iv": "<base64, 16 bytes>",
  "authTag": "<base64, 16 bytes>"
}

Standard base64 with padding, authTag in camelCase. The envelope stores all 16 IV bytes but only the first 12 are fed to the cipher: the previous OpenSSL-based implementation never set the GCM IV length, so it used OpenSSL’s default 12-byte nonce, and matching that byte for byte is what keeps messages written by 0.16.0 readable here, and vice versa.

Reading it back

Decryption is triggered by shape, not by the stored flag: any payload that parses as a JSON object carrying encrypted, iv and authTag string fields is decrypted. That is why a queue whose flag has since been turned off still returns readable messages, and why messages migrated from an older broker decrypt without touching their flags. The isEncrypted field in a message detail response reports the stored per-message flag, a separate thing from whether the broker just decrypted it.

Three refusals, all of which return the payload as stored:

Refusal Behaviour
No key configured The read serves the envelope JSON itself: a consumer receives {"encrypted":"…","iv":"…","authTag":"…"} as its message body. That is the signal that a broker in the group is missing the key
Wrong key GCM tag verification fails and the envelope is served as-is. No error, no log line on the read path
Wrong sizes An IV or tag that is not 16 bytes is rejected without attempting the cipher

A bad key silently stores plaintext

QUEEN_ENCRYPTION_KEY is read at boot, and every rejection path disables encryption for the whole process and lets the broker start normally:

Key value Outcome Log
Unset or empty Encryption disabled none
Not exactly 64 characters Encryption disabled warn: QUEEN_ENCRYPTION_KEY must be 64 hex chars; encryption DISABLED
64 characters, not all hex Encryption disabled warn: QUEEN_ENCRYPTION_KEY is not valid hex; encryption DISABLED
64 hex characters Encryption enabled info: encryption service initialized (AES-256-GCM)

With encryption disabled, a queue whose flag is true keeps accepting pushes and stores every payload in plaintext: the push succeeds, the response says queued, and only the boot log shows it. The same design covers a runtime cipher failure: if the cipher call fails for an individual message, the broker warns (sampled at one line per 10,000 occurrences, so it cannot flood stderr at ingest rate) and stores that message in plaintext rather than failing the push.

The streams cycle degrades the same way and for the same reason. A cipher failure on a sink payload logs encryption failed; stored plaintext under the streams target, with the queue name and a suppressed count on the same 1-in-10,000 sampler, and the cycle commits: the window result, the state writes and the source ack are one transaction, so failing the encryption would mean replaying the whole window rather than storing one message in the clear. Alert on that log line, since the cycle response reports success either way.

Verify at the deployment level, since the queue’s flag alone does not tell you whether the key loaded:

  1. Grep the boot log for encryption service initialized. Its absence, with a key set, means the key is malformed.

  2. Push one message to a flagged queue and read the row directly in PostgreSQL: a segment’s frames should not contain your plaintext.

  3. Make that check part of the deploy, not a one-off. A key arriving from a secret store as an empty string, or with a trailing newline, fails the 64-character check above.

Rotation

Rotation is a drain-then-restart operation: drain the affected queues first (consume to the head, or let retention age the old segments out), then restart every broker in the group with the new key at the same moment. Verify the drain with the queue’s own depth, not with elapsed time.

The drain is what makes it safe. One key is loaded per process and decryption tries only that key, so every message still in the log written under the old key becomes an undecryptable envelope from the instant the new key is in place.

  • Trust boundaries: what else is and is not protected in a Queen deployment.
  • PostgreSQL TLS: protecting the payload in transit to the database, a different problem from protecting it at rest.
  • The QUEEN_ENCRYPTION_KEY entry, and the boot block that masks it, are in the configuration reference.
Navigation

Type to search…

↑↓ navigate↵ selectEsc close