Module: Wurk::Encryption

Defined in:
lib/wurk/encryption.rb

Overview

Sidekiq Enterprise encryption. AES-256-GCM over the last positional argument of perform. Implemented as a client/server middleware pair — client envelopes the last arg into a {v, iv, ct, tag} Hash, server peels it back before invoking perform.

Activation:

Sidekiq::Enterprise::Crypto.enable(active_version: 1) do |version|
File.read("config/crypto/secret.#{Rails.env}.#{version}.key", mode: 'rb')
end

The block is the only key source: file, ENV, KMS — anything that maps Integer version → 32-byte binary key. Wurk caches resolved keys per version in-process; rotate by writing a new key file, bumping active_version, and calling enable again.

Per-worker opt-in:

class PrivateJob
include Sidekiq::Job
sidekiq_options encrypt: true
def perform(public_arg, secret_bag); end
end

Wire format (per docs/target/sidekiq-ent.md §4.4): the last arg becomes a plain JSON Hash {"__wurk_enc__"=>true, "v"=>N, "iv"=>…, "ct"=>…, "tag"=>…} — not a base64 blob of a binary envelope — so the args array stays valid JSON for inspectors that don't know about encryption.

Constraints:

* `perform` must take ≥ 2 positional args. Pass `nil` first if no
cleartext payload exists.
* Only the last positional argument is encrypted. All earlier args
remain plaintext.
* Incompatible with Wurk::Unique (each ciphertext differs → digest
defeats the lock). Documented invariant.
* A worker that also sets `track: true` gets its full Wurk::Status
lifecycle but no stored result — `perform`'s return value is dropped and
the row is flagged `result_withheld`, rather than landing in a plaintext
HASH beside the arguments this envelope exists to protect. See
Wurk::Middleware::Status#withhold_result?.
* Web UI redacts the last arg when `encrypt: true` is set on the job.

Spec: docs/target/sidekiq-ent.md §4.

Defined Under Namespace

Classes: ClientMiddleware, DecryptionError, Error, KeyMissingError, NotSealedError, ServerMiddleware, UnsafeEnablementError

Constant Summary collapse

CIPHER_NAME =
'aes-256-gcm'
KEY_BYTES =
32
IV_BYTES =

GCM standard: 96-bit IV.

12
TAG_BYTES =
16
ENVELOPE_MARKER =
'__wurk_enc__'
DEAD_REASON =

Reason tag stamped on the dead-set record when a job can't be decrypted. Surfaced as error_class (dashboard "Dead" column) and the encryption_error: prefix on error_message, plus the jobs.encryption_error statsd counter — so operators can alert on rotation gaps.

'encryption_error'
DECRYPTION_ERROR_CLASS =
'Wurk::Encryption::DecryptionError'

Class Attribute Summary collapse

Class Method Summary collapse

Class Attribute Details

.active_version ⇒ Object (readonly)

Returns the value of attribute active_version.



93
94
95
# File 'lib/wurk/encryption.rb', line 93

def active_version
  @active_version
end

Class Method Details

.decrypt(envelope) ⇒ Object

Decrypt the envelope produced by encrypt. Raises OpenSSL::Cipher::CipherError on tag mismatch (bad key / tamper) — server middleware lets it bubble so the failure flows through the retry/dead pipeline per §4.6.



166
167
168
169
170
171
172
173
174
175
176
177
# File 'lib/wurk/encryption.rb', line 166

def decrypt(envelope)
  version = Integer(envelope['v'])
  cipher = build_decrypt_cipher(envelope, key_for(version))
  plain = cipher.update(::Base64.strict_decode64(envelope['ct'])) + cipher.final
  # No `quirks_mode:` — an encrypted arg may be a bare scalar ("hi", 42,
  # null), and every json the gemspec's `>= 3.2` floor can resolve parses
  # those at the top level by default (RFC 7159, json >= 2.0). The option
  # was therefore already a no-op, and json 3.0.0 removed it outright:
  # passing it raises ArgumentError and every encrypted job dies in the
  # server middleware.
  ::JSON.parse(plain)
end

.disable! ⇒ Object

Test helper — not part of the public Sidekiq surface.



122
123
124
125
126
127
128
# File 'lib/wurk/encryption.rb', line 122

def disable!
  @enabled = false
  @active_version = nil
  @resolver = nil
  @key_cache = nil
  nil
end

.enable(config = Wurk.configuration, active_version:, &resolver) ⇒ Object

Install crypto with a key resolver. active_version is the version used to encrypt new pushes; the resolver block must still return keys for any older in-flight versions so they decrypt.

Idempotent: re-calling rebinds the resolver and rebuilds the cache. Middleware is installed at most once per chain. config selects the chains and capsules to check, not a separate crypto context: enabled state, active version, resolver and key cache remain process-global.

Raises:

  • (ArgumentError)


107
108
109
110
111
112
113
114
115
116
117
118
119
# File 'lib/wurk/encryption.rb', line 107

def enable(config = Wurk.configuration, active_version:, &resolver)
  raise ArgumentError, 'active_version is required' unless active_version
  raise ArgumentError, 'block returning the key bytes is required' unless resolver

  refuse_unreachable_enablement!(config)

  @active_version = Integer(active_version)
  @resolver = resolver
  @key_cache = {}
  @enabled = true
  register_middleware!(config)
  true
end

.enabled? ⇒ Boolean

Returns:

  • (Boolean)


95
96
97
# File 'lib/wurk/encryption.rb', line 95

def enabled?
  @enabled == true
end

.encrypt(value) ⇒ Object

Encrypt value (any JSON-serializable Ruby value) under active_version. Returns a Hash literal — JSON-friendly, so the job payload stays inspectable.



143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
# File 'lib/wurk/encryption.rb', line 143

def encrypt(value)
  version = @active_version
  key = key_for(version)
  iv = ::OpenSSL::Random.random_bytes(IV_BYTES)
  cipher = ::OpenSSL::Cipher.new(CIPHER_NAME).encrypt
  cipher.key = key
  cipher.iv = iv
  ct = cipher.update(::JSON.dump(value)) + cipher.final
  tag = cipher.auth_tag(TAG_BYTES)

  {
    ENVELOPE_MARKER => true,
    'v' => version,
    'iv' => ::Base64.strict_encode64(iv),
    'ct' => ::Base64.strict_encode64(ct),
    'tag' => ::Base64.strict_encode64(tag)
  }
end

.envelope?(value) ⇒ Boolean

Used by both server middleware and the Web UI redactor — single source of truth so the two cannot drift.

Returns:

  • (Boolean) —

    true if value looks like a Wurk crypto envelope.



182
183
184
185
# File 'lib/wurk/encryption.rb', line 182

def envelope?(value)
  value.is_a?(::Hash) && value[ENVELOPE_MARKER] == true &&
    value.key?('v') && value.key?('iv') && value.key?('ct') && value.key?('tag')
end

.key_for(version) ⇒ Object

Resolve and cache the 32-byte key for version. Re-raises with a Wurk-specific exception when the resolver returns nothing usable so callers don't have to detect "nil from block" themselves.

Raises:



133
134
135
136
137
138
# File 'lib/wurk/encryption.rb', line 133

def key_for(version)
  raise Error, 'Wurk::Encryption not enabled' unless enabled?

  @key_cache ||= {}
  @key_cache[version] ||= validate_key!(version, resolve(version))
end

.redact_args(job) ⇒ Object

Web UI display helper (§4.7). Given a job hash, returns the args array with the last element replaced by the literal "[encrypted data]" when the job opted in. Cleartext preceding args are untouched so operators can still triage on user_id / object_id / etc.

Masks on the encrypt flag or an envelope-shaped last arg: a stored job hash doesn't always carry encrypt (it's a sidekiq_options, not persisted on every record), so envelope detection is the real guard — it keeps ciphertext out of the dashboard regardless of the flag.



215
216
217
218
219
220
221
# File 'lib/wurk/encryption.rb', line 215

def redact_args(job)
  args = job['args'] || job[:args] || []
  return args if args.empty?
  return args unless job['encrypt'] || job[:encrypt] || envelope?(args.last)

  args[0..-2] + ['[encrypted data]']
end

.route_to_dead(job, cause) ⇒ Object

A decryption failure means the key is gone (rotated away) or the ciphertext is bad — neither heals with time, so retrying 25× over ~21 days is a pointless crash loop. Instead the server middleware routes the job straight to the dead set, tagged encryption_error, and ACKs it (raises JobRetry::Skip). Done in <1s, death handlers fire so operators get paged. The still-encrypted envelope is kept on the record; earlier plaintext args stay visible for triage (§4.6).



230
231
232
233
234
235
236
237
238
239
240
241
242
# File 'lib/wurk/encryption.rb', line 230

def route_to_dead(job, cause)
  record = job.merge(
    'error_class' => DECRYPTION_ERROR_CLASS,
    'error_message' => "#{DEAD_REASON}: #{cause.class}: #{cause.message}",
    'failed_at' => ::Process.clock_gettime(::Process::CLOCK_REALTIME, :millisecond)
  )
  Wurk::Metrics::Statsd.increment('jobs.encryption_error', tags: ["worker:#{job['class']}"])
  Wurk.logger.error { "#{DEAD_REASON}: #{job['class']} jid=#{job['jid']} sent to the dead set: #{cause.message}" }
  # No trim: a misconfigured producer or a lost key can fail a whole backlog at
  # once, and the cap would destroy jobs that are recoverable once it is fixed.
  Wurk::DeadSet.new.kill(Wurk.dump_json(record), ex: cause, trim: false)
  nil
end

.seal(job) ⇒ Object

job safe to write back to Redis: a copy with the last arg re-sealed when crypto is on and the job opted in, otherwise job itself.



199
200
201
202
203
204
# File 'lib/wurk/encryption.rb', line 199

def seal(job)
  return job unless enabled? && job['encrypt']

  sealed = seal_args(job['args'])
  sealed.equal?(job['args']) ? job : job.merge('args' => sealed)
end

.seal_args(args) ⇒ Object

args with its last element enveloped, unless it already is one. The single sealing rule shared by the client middleware and every server path that writes a payload back to Redis (InterruptHandler, limiter dead-routing): a job hash seen mid-chain may hold decrypted args.



191
192
193
194
195
# File 'lib/wurk/encryption.rb', line 191

def seal_args(args)
  return args unless args.is_a?(::Array) && !args.empty? && !envelope?(args.last)

  args[0..-2] + [encrypt(args.last)]
end