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 `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 ‘“iv”=>b64(iv), “ct”=>b64(ct), “tag”=>b64(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.
* 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, ServerMiddleware

Constant Summary collapse

CIPHER_NAME =

rubocop:disable Metrics/ModuleLength

'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_versionObject (readonly)

Returns the value of attribute active_version.



75
76
77
# File 'lib/wurk/encryption.rb', line 75

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.



144
145
146
147
148
149
# File 'lib/wurk/encryption.rb', line 144

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
  ::JSON.parse(plain, quirks_mode: true)
end

.disable!Object

Test helper — not part of the public Sidekiq surface.



100
101
102
103
104
105
106
# File 'lib/wurk/encryption.rb', line 100

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

.enable(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.

Raises:

  • (ArgumentError)


87
88
89
90
91
92
93
94
95
96
97
# File 'lib/wurk/encryption.rb', line 87

def enable(active_version:, &resolver) # rubocop:disable Naming/PredicateMethod
  raise ArgumentError, 'active_version is required' unless active_version
  raise ArgumentError, 'block returning the key bytes is required' unless resolver

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

.enabled?Boolean

Returns:

  • (Boolean)


77
78
79
# File 'lib/wurk/encryption.rb', line 77

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.



121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
# File 'lib/wurk/encryption.rb', line 121

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.



154
155
156
157
# File 'lib/wurk/encryption.rb', line 154

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:



111
112
113
114
115
116
# File 'lib/wurk/encryption.rb', line 111

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

  @key_cache ||= {}
  @key_cache[version] ||= validate_key!(version, @resolver.call(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>”` when the job opted in. Cleartext preceding args are untouched so operators can still triage on user_id / object_id / etc.



163
164
165
166
167
168
169
# File 'lib/wurk/encryption.rb', line 163

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

  args[0..-2] + ['<encrypted>']
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).



178
179
180
181
182
183
184
185
186
187
# File 'lib/wurk/encryption.rb', line 178

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::DeadSet.new.kill(Wurk.dump_json(record), ex: cause)
  nil
end