module

JoobQ::Encryption

Transparent, opt-in, field-level encryption of job payloads.

JoobQ stores a job as JSON in Redis: in the queue list, the processing list, the retry and scheduled sets and the dead-letter set. Anyone who can read Redis, a backup or an export can read the job's arguments. A property declared with encrypted_property is written as an AES-256-GCM sealed string instead, and is read back to its plain value when a worker loads the job, before perform runs:

class ChargeJob
  include JoobQ::Job

  property order_id : Int64 = 0 # stays readable (useful for the console and for debugging)
  encrypted_property card_token : String = ""
  encrypted_property billing : Hash(String, String)? = nil

  def perform
    charge(card_token) # plain text here
  end
end

What this is: confidentiality of those properties at rest in Redis, and integrity: a sealed value that is moved to another job class or field, or altered, is refused. The keys are not in Redis.

What it is not: encryption of jobs in general (only the properties you declare); protection while the job runs, in your own logs or in the messages your perform raises (the library scrubs the plain values of encrypted properties from the error messages and backtraces it records, but it cannot know what you print); hiding the size of a value.

Keys

One key (32 bytes, as 64 hex digits or base64, e.g. openssl rand -base64 32) is current and seals; any number of older keys only open. A key is written id:key, the id being 1 to 16 characters of a-z 0-9 _ - (a bare key without an id gets the id default). The id is stored with every sealed value, which is how a worker knows which key to use.

JoobQ.configure do
  payload_encryption current: "2026a:#{ENV["NEW_KEY"]}", previous: ["2025:#{ENV["OLD_KEY"]}"]
end

Without that call the keys come from the environment: JOOBQ_PAYLOAD_KEYS (comma separated id:key entries, the first one current) or, as the simplest form, JOOBQ_PAYLOAD_KEY (one entry). The environment is read on first use; changing it needs a restart.

To rotate: add the new key first, deploy, and keep the old one until no job sealed with it can be in Redis (queues, retries, scheduled jobs, dead letters); a job that is retried is written again with the current key. A value sealed with a key that is gone cannot be opened.

Failure mode

A job is never run with a value it could not open.

  • Problems with the job: an unknown key id, a bad tag (wrong class or field, altered), a damaged value. The worker moves it to the dead-letter set as the job itself (sealed values untouched, status Dead) with error type decryption_error and a message that names the class, the field and the reason, never a value. It can be redriven (DeadLetterQueue#redrive, the API's retry) once the key is back, and runs then.
  • Problems with this process's keys: none configured, or JOOBQ_PAYLOAD_KEYS malformed (Encryption::ConfigurationError). The job is fine, so it is not dead-lettered: the worker logs, waits five seconds and puts it back on its queue. A queue of a job class with encrypted properties refuses to start (Queue#start raises) without valid keys, and enqueuing raises to the caller.

Payloads written before the property was encrypted (plain values) are read as they are, and written sealed the next time the job is stored. JoobQ.config.allow_plaintext_payloads = false makes the worker refuse them (Queue#add and the HTTP API still accept them: they are about to be sealed). While it is true, anyone who can write to Redis can put an unsealed value in a job and it will run: sealing gives confidentiality, and integrity only against tampering with a sealed value.

Limits worth knowing

  • The associated data is the class and the property, not the job id: a sealed value can be copied between two jobs of the same class and property by someone who can write to Redis.
  • Nonces are random 96 bit values: keep to about 2^32 seals per key, then rotate.
  • Sealing is not deterministic: two serialisations of one job differ. Code that relies on identical strings collapsing (a sorted set member, a LREM by value) must not re-serialise a job to find it again.
  • Strings shorter than MIN_SCRUB (4) characters and numbers of fewer than MIN_NUMBER (6) digits are not scrubbed from recorded errors, booleans never: they would match everywhere.
  • With allow_plaintext_payloads = false a worker that meets a plain value dead-letters the job without its payload (it holds the plain values); that legacy job is gone, so drain old jobs before flipping the flag.

Format

jqe1:<key id>:<base64url (no padding) of nonce, ciphertext and tag>: AES-256-GCM, a fresh random 96 bit nonce per value, the additional authenticated data being joobq/payload/v1, the job class and the property name (separated by NUL). A String is sealed as it is; any other type is sealed as its JSON text. nil is stored as null. Renaming the class or the property makes existing sealed values unreadable: pass job_name: to encrypted_property to keep the name stable.

Constants

DEFAULT_ID = "default"
ENV_KEY = "JOOBQ_PAYLOAD_KEY"
ENV_KEYS = "JOOBQ_PAYLOAD_KEYS"
KEY_ID = /\A[a-z0-9_-]{1,16}\z/
KEY_LEN = 32
MIN_NUMBER = 6

The same for the text of a number (an id of fewer digits would match everywhere).

MIN_SCRUB = 4

Plain values shorter than this are not scrubbed from messages: a one- or two-character value would match everywhere and make every message unreadable. Do not put a secret that short in a job and expect it hidden.

NONCE_LEN = 12
NOT_SCRUBBABLE = "[REDACTED: not scrubbable]"

Shown instead of a message that could not be scrubbed.

PREFIX = "jqe1:"

Begins every sealed value; used to recognise one without any key.

REDACTED = "[REDACTED]"

Replaces a plain value in text the library records.

TAG_LEN = 16
UNION_LIMIT = 16384

Above this many bytes of secrets a single regex would not compile (PCRE2 has a pattern size limit).

Class methods

bounces

How many times a worker has put a job back for want of keys (a metric, and what specs wait on).

Source
context(job : String, field : String) : Bytes

The additional authenticated data of a property.

Source
count_bounce
Source
key_backoff

How long a worker waits before it puts back a job it could not open for want of a keys (see ConfigurationError).

Source
key_backoff=(value : Time::Span)
Source
keys

The keys in force: JoobQ.config.payload_keys, else the environment (read once).

Source
open(sealed : String, job : String, field : String, keys : Keys = self.keys) : String

Raises Error (without any value in its message) for a value that is not sealed, was sealed for another class or property, was altered, or was sealed with a key that is not configured.

Source
reset_env_keys

Forgets the keys read from the environment, so the next use reads it again (specs, tools).

Source
scrub(text : String, secrets : Array(String)) : String

text with every given plain value replaced by REDACTED: in one pass when the values are few and small (longest first, the replacement never searched again), value by value (longest first) otherwise. Invalid UTF-8 in the text is replaced first.

Source
scrub(text : String | Nil, secrets : Array(String)) : String | Nil
Source
scrub_for(text : String, job) : String

text scrubbed of job's plain values; NOT_SCRUBBABLE when that cannot be done. Never raises.

Source
scrub_safely(text : String, secrets : Array(String)) : String

Like scrub, but it never raises: whatever goes wrong, the text is replaced by NOT_SCRUBBABLE, so the failure path that records an error can always finish (the job must still retry or dead-letter).

Source
seal(plain : String, job : String, field : String, keys : Keys = self.keys) : String
Source
sealed?(value : String) : Bool
Source
secret_forms(value) : Array(String)

Every form in which value could show up in a message: for a string itself and its inspect/JSON text; for a hash or array also its to_s, and every string leaf inside (numbers only from 6 digits, booleans never: they would match everywhere); for a number its text if long enough. Longest first, without duplicates.

Source
secrets_of(job) : Array(String) | Nil

The plain values of the job's encrypted properties, or nil when they cannot be worked out.

Source
strict

Runs the block with plain payload values refused when allow_plaintext_payloads is false. Only the worker path uses it: Queue#add and the HTTP API still accept plain values (they are about to be sealed).

Source
strict?
Source

Nested types