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
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 {"v"=>N, "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 theencryption_error:prefix onerror_message, plus thejobs.encryption_errorstatsd counter — so operators can alert on rotation gaps. 'encryption_error'- DECRYPTION_ERROR_CLASS =
'Wurk::Encryption::DecryptionError'
Class Attribute Summary collapse
-
.active_version ⇒ Object
readonly
Returns the value of attribute active_version.
Class Method Summary collapse
-
.decrypt(envelope) ⇒ Object
Decrypt the envelope produced by
encrypt. -
.disable! ⇒ Object
Test helper — not part of the public Sidekiq surface.
-
.enable(active_version:, &resolver) ⇒ Object
Install crypto with a key resolver.
- .enabled? ⇒ Boolean
-
.encrypt(value) ⇒ Object
Encrypt
value(any JSON-serializable Ruby value) underactive_version. -
.envelope?(value) ⇒ Boolean
Used by both server middleware and the Web UI redactor — single source of truth so the two cannot drift.
-
.key_for(version) ⇒ Object
Resolve and cache the 32-byte key for
version. -
.redact_args(job) ⇒ Object
Web UI display helper (§4.7).
-
.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.
Class Attribute Details
.active_version ⇒ Object (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.
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
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.
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.
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.
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.
168 169 170 171 172 173 174 |
# File 'lib/wurk/encryption.rb', line 168 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>'] 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).
183 184 185 186 187 188 189 190 191 192 |
# File 'lib/wurk/encryption.rb', line 183 def route_to_dead(job, cause) record = job.merge( 'error_class' => DECRYPTION_ERROR_CLASS, 'error_message' => "#{DEAD_REASON}: #{cause.class}: #{cause.}", '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 |