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
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
-
.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) under `active_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.
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.}", '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 |