Module: ZeroClick::Sellers::Encryption

Defined in:
lib/zeroclick/sellers/encryption.rb

Overview

Encrypted request and response bodies.

ZeroClick can encrypt a buyer's request body to the seller's public key and ask for the reply encrypted back. The suite is pinned — ECDH-ES+A256KW key management, A256GCM content encryption — and the proxy carries its reply key in a custom protected-header parameter.

Implemented on the standard library alone: OpenSSL provides P-256 ECDH, AES key wrap (RFC 3394) and AES-256-GCM, and the one missing piece — NIST SP 800-56A Concat KDF — is a short SHA-256 loop. That matters: the gem promises zero runtime dependencies and CI fails the build if one appears, so reaching for a JOSE gem here would have cost the property outright. (The Go SDK could not manage this and isolates go-jose in a separate package to keep its own core clean.)

Interoperates with @zeroclickai/sellers; test/vectors/jwe-vectors.json is real TypeScript output and is what keeps them that way.

Defined Under Namespace

Classes: Envelope

Constant Summary collapse

ALG =
"ECDH-ES+A256KW"
ENC =
"A256GCM"
REPLY_JWK_PARAM =
"https://zeroclick.io/jwe/reply-jwk"
CURVE =
"prime256v1"
KEY_WRAP_IV =

RFC 3394's fixed initial value for AES key wrap.

["A6A6A6A6A6A6A6A6"].pack("H*")
KEY_WRAP_ROUNDS =
6
PRIVATE_JWK_MEMBERS =

Members whose presence means a JWK carries private key material. A reply key arriving with any of them is a protocol violation, not a key to quietly use.

%w[d p q dp dq qi oth k].freeze
ALLOWED_REPLY_JWK_MEMBERS =
%w[kty crv kid x y use alg].freeze
MESSAGES =
{
  "invalid_compact_jwe" => "The request body is not a valid Compact JWE",
  "unsupported_jwe_suite" => "The Compact JWE uses an unsupported algorithm suite",
  "jwe_kid_required" => "The Compact JWE protected header requires a key ID",
  "invalid_reply_jwk" => "The reply JWK is not a public P-256 key",
  "private_reply_jwk" => "The reply JWK must not contain private key material",
  "private_key_not_found" => "No private key was found for the Compact JWE key ID",
  "private_key_resolution_failed" => "The private key could not be resolved",
  "decryption_failed" => "The encrypted request could not be decrypted",
  "encryption_failed" => "The response could not be encrypted"
}.freeze

Class Method Summary collapse

Class Method Details

.aes_ecb(key, mode) ⇒ Object

AES Key Wrap (RFC 3394), built on AES-ECB.

NOT OpenSSL's "aes-256-wrap" cipher: that is absent from some builds — it works on macOS and raises unsupported cipher algorithm on the Ubuntu runners — so relying on it made the SDK's portability depend on how the host happened to compile OpenSSL. AES-ECB is everywhere, and the wrapping itself is a short, fully specified loop.

ECB is safe here precisely because RFC 3394 is what supplies the structure: each block is chained through the A register, and the fixed IV check on unwrap is what authenticates the result.



120
121
122
123
124
125
126
# File 'lib/zeroclick/sellers/encryption.rb', line 120

def aes_ecb(key, mode)
  cipher = OpenSSL::Cipher.new("aes-256-ecb")
  mode == :encrypt ? cipher.encrypt : cipher.decrypt
  cipher.key = key
  cipher.padding = 0
  cipher
end

.aes_key_unwrap(kek, ciphertext) ⇒ Object

Raises:

  • (ArgumentError)


147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
# File 'lib/zeroclick/sellers/encryption.rb', line 147

def aes_key_unwrap(kek, ciphertext)
  raise ArgumentError, "wrapped key must be a multiple of 8 bytes" unless (ciphertext.bytesize % 8).zero?

  blocks = ciphertext.scan(/.{8}/m)
  a = blocks.shift
  cipher = aes_ecb(kek, :decrypt)

  (KEY_WRAP_ROUNDS - 1).downto(0) do |round|
    (blocks.length - 1).downto(0) do |index|
      counter = (blocks.length * round) + index + 1
      b = cipher.update(xor_counter(a, counter) + blocks[index]) + cipher.final
      a = b[0, 8]
      blocks[index] = b[8, 8]
    end
  end

  # The fixed IV is the integrity check: a wrong KEK produces a different
  # A, so this is what makes unwrapping fail closed rather than return
  # plausible garbage.
  raise ArgumentError, "key unwrap integrity check failed" unless a == KEY_WRAP_IV

  blocks.join
end

.aes_key_wrap(kek, plaintext) ⇒ Object

Raises:

  • (ArgumentError)


128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
# File 'lib/zeroclick/sellers/encryption.rb', line 128

def aes_key_wrap(kek, plaintext)
  raise ArgumentError, "key to wrap must be a multiple of 8 bytes" unless (plaintext.bytesize % 8).zero?

  blocks = plaintext.scan(/.{8}/m)
  a = KEY_WRAP_IV
  cipher = aes_ecb(kek, :encrypt)

  KEY_WRAP_ROUNDS.times do |round|
    blocks.each_with_index do |block, index|
      b = cipher.update(a + block) + cipher.final
      counter = (blocks.length * round) + index + 1
      a = xor_counter(b[0, 8], counter)
      blocks[index] = b[8, 8]
    end
  end

  a + blocks.join
end

.b64u_decode(value) ⇒ Object

base64url, on core String/Array only.

Deliberately NOT the base64 stdlib: it stops being a default gem in Ruby 3.4, so requiring it would force this gem to declare a runtime dependency — and the point of implementing JWE here rather than behind a JOSE gem is that it needs none.

Raises:

  • (ArgumentError)


82
83
84
85
86
87
88
89
90
91
92
# File 'lib/zeroclick/sellers/encryption.rb', line 82

def b64u_decode(value)
  raise ArgumentError, "not base64url" unless value.is_a?(String)
  raise ArgumentError, "not base64url" unless value.match?(/\A[A-Za-z0-9_-]*\z/)

  padded = value.tr("-_", "+/")
  padded += "=" * ((4 - (padded.length % 4)) % 4)
  decoded = padded.unpack1("m0")
  raise ArgumentError, "not base64url" if decoded.nil?

  decoded
end

.b64u_encode(bytes) ⇒ Object



94
95
96
# File 'lib/zeroclick/sellers/encryption.rb', line 94

def b64u_encode(bytes)
  [bytes].pack("m0").tr("+/", "-_").delete("=")
end

.concat_kdf(shared_secret, key_bits, algorithm_id) ⇒ Object

NIST SP 800-56A Concat KDF, the one primitive OpenSSL does not expose.

Single round only: the suite derives 256 bits and SHA-256 produces exactly that, so the counter never advances past 1. A wider key would need the loop.



103
104
105
106
107
# File 'lib/zeroclick/sellers/encryption.rb', line 103

def concat_kdf(shared_secret, key_bits, algorithm_id)
  other_info = [algorithm_id.bytesize].pack("N") + algorithm_id +
               [0].pack("N") + [0].pack("N") + [key_bits].pack("N")
  OpenSSL::Digest::SHA256.digest([1].pack("N") + shared_secret + other_info)[0, key_bits / 8]
end

.decode_protected_header(compact_jwe) ⇒ Object



217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
# File 'lib/zeroclick/sellers/encryption.rb', line 217

def decode_protected_header(compact_jwe)
  segments = compact_jwe.split(".", -1)
  # The ciphertext segment (index 3) may legitimately be empty — an empty
  # plaintext is a real case. No other segment may be.
  if segments.length != 5 || segments.each_with_index.any? { |seg, i| seg.empty? && i != 3 }
    raise error("invalid_compact_jwe", "decrypt_request")
  end

  header = JSON.parse(b64u_decode(segments[0]))
  raise error("invalid_compact_jwe", "decrypt_request") unless header.is_a?(Hash)

  [header, segments]
rescue ArgumentError, JSON::ParserError
  raise error("invalid_compact_jwe", "decrypt_request")
end

.decrypt_request(body, resolve_private_key:) ⇒ Object

Decrypt a Compact JWE request body.

body is the raw request bytes, which for an encrypted request are the ASCII Compact JWE rather than JSON.

resolve_private_key is a callable taking the header's kid and returning that key's JWK (or nil when the kid is unknown).



269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
# File 'lib/zeroclick/sellers/encryption.rb', line 269

def decrypt_request(body, resolve_private_key:)
  compact_jwe = body.is_a?(String) ? body.dup.force_encoding(Encoding::UTF_8) : body.to_s
  raise error("invalid_compact_jwe", "decrypt_request") unless compact_jwe.valid_encoding?

  header, segments = decode_protected_header(compact_jwe)

  raise error("unsupported_jwe_suite", "decrypt_request") unless header["alg"] == ALG && header["enc"] == ENC

  kid = header["kid"]
  raise error("jwe_kid_required", "decrypt_request") unless kid.is_a?(String) && !kid.empty?

  cty = header["cty"]
  raise error("invalid_compact_jwe", "decrypt_request") if !cty.nil? && !cty.is_a?(String)

  reply_jwk = validated_reply_jwk(header)

  begin
    private_jwk = resolve_private_key.call(kid)
  rescue StandardError => e
    raise error("private_key_resolution_failed", "decrypt_request", kid: kid, cause: e.message)
  end
  raise error("private_key_not_found", "decrypt_request", kid: kid) if private_jwk.nil?

  plaintext = decrypt_segments(segments, header, private_jwk, kid)

  Envelope.new(plaintext: plaintext, protected_header: header, cty: cty, reply_jwk: reply_jwk)
end

.decrypt_segments(segments, header, private_jwk, kid) ⇒ Object



297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
# File 'lib/zeroclick/sellers/encryption.rb', line 297

def decrypt_segments(segments, header, private_jwk, kid)
  header_b64, encrypted_key, iv, ciphertext, tag = segments

  private_key = ec_key_from_jwk(private_jwk, private: true)
  epk = header["epk"]
  raise error("decryption_failed", "decrypt_request", kid: kid) unless epk.is_a?(Hash)

  shared = private_key.dh_compute_key(ec_key_from_jwk(epk).public_key)
  kek = concat_kdf(shared, 256, ALG)

  cek = aes_key_unwrap(kek, b64u_decode(encrypted_key))

  decipher = OpenSSL::Cipher.new("aes-256-gcm").decrypt
  decipher.key = cek
  decipher.iv = b64u_decode(iv)
  decipher.auth_tag = b64u_decode(tag)
  # The AAD is the RAW protected header segment, not a re-encoding of it.
  decipher.auth_data = header_b64
  # An empty ciphertext is a real case (the empty_plaintext vector), and
  # OpenSSL::Cipher#update rejects an empty string on older openssl gems
  # — Ruby 3.1 raises where 3.3 does not. Skip straight to #final, which
  # still verifies the GCM tag.
  encrypted_bytes = b64u_decode(ciphertext)
  raw = encrypted_bytes.empty? ? decipher.final : decipher.update(encrypted_bytes) + decipher.final

  # OpenSSL hands back ASCII-8BIT. The plaintext is a request body, and
  # leaving it binary makes `==` against any UTF-8 string false for every
  # non-ASCII byte — which is exactly how the unicode vector fails while
  # the bytes are identical.
  raw.force_encoding(Encoding::UTF_8)
  raw.valid_encoding? ? raw : raw.force_encoding(Encoding::BINARY)
rescue OpenSSL::OpenSSLError, ArgumentError => e
  raise error("decryption_failed", "decrypt_request", kid: kid, cause: e.message)
end

.ec_key_from_jwk(jwk, private: false) ⇒ Object

Build an OpenSSL EC key from a JWK.

Ruby 3.x has no JWK importer and EC keys are immutable, so the key is assembled as ASN.1 and parsed back — the only route from raw coordinates to a usable key.



181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
# File 'lib/zeroclick/sellers/encryption.rb', line 181

def ec_key_from_jwk(jwk, private: false)
  group = OpenSSL::PKey::EC::Group.new(CURVE)
  x = b64u_decode(jwk["x"] || jwk[:x])
  y = b64u_decode(jwk["y"] || jwk[:y])
  point = OpenSSL::PKey::EC::Point.new(
    group, OpenSSL::BN.new("04#{x.unpack1("H*")}#{y.unpack1("H*")}", 16)
  )

  unless private
    sequence = OpenSSL::ASN1::Sequence([
                                         OpenSSL::ASN1::Sequence([
                                                                   OpenSSL::ASN1::ObjectId("id-ecPublicKey"),
                                                                   OpenSSL::ASN1::ObjectId(CURVE)
                                                                 ]),
                                         OpenSSL::ASN1::BitString(point.to_octet_string(:uncompressed))
                                       ])
    return OpenSSL::PKey::EC.new(sequence.to_der)
  end

  d = b64u_decode(jwk["d"] || jwk[:d])
  sequence = OpenSSL::ASN1::Sequence([
                                       OpenSSL::ASN1::Integer(1),
                                       OpenSSL::ASN1::OctetString(d),
                                       OpenSSL::ASN1::ObjectId(CURVE, 0, :EXPLICIT),
                                       OpenSSL::ASN1::BitString(point.to_octet_string(:uncompressed), 1, :EXPLICIT)
                                     ])
  OpenSSL::PKey::EC.new(sequence.to_der)
end

.encrypt_compact(protected_header, plaintext, recipient_jwk) ⇒ Object



351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
# File 'lib/zeroclick/sellers/encryption.rb', line 351

def encrypt_compact(protected_header, plaintext, recipient_jwk)
  recipient = ec_key_from_jwk(recipient_jwk)

  # A fresh ephemeral key per message: ECDH-ES derives the KEK from it,
  # so reusing one would reuse the KEK across messages.
  ephemeral = OpenSSL::PKey::EC.generate(CURVE)
  shared = ephemeral.dh_compute_key(recipient.public_key)
  kek = concat_kdf(shared, 256, ALG)

  cek = SecureRandom.bytes(32)
  encrypted_key = aes_key_wrap(kek, cek)

  header = protected_header.merge("epk" => jwk_from_ec_public(ephemeral))
  header_b64 = b64u_encode(JSON.generate(header))

  iv = SecureRandom.bytes(12)
  cipher = OpenSSL::Cipher.new("aes-256-gcm").encrypt
  cipher.key = cek
  cipher.iv = iv
  cipher.auth_data = header_b64
  # Same empty-input constraint as the decrypt path above.
  plain_bytes = plaintext.to_s.dup.force_encoding(Encoding::BINARY)
  ciphertext = plain_bytes.empty? ? cipher.final : cipher.update(plain_bytes) + cipher.final

  [header_b64, b64u_encode(encrypted_key), b64u_encode(iv),
   b64u_encode(ciphertext), b64u_encode(cipher.auth_tag)].join(".")
rescue OpenSSL::OpenSSLError, ArgumentError => e
  raise error("encryption_failed", "encrypt_response", cause: e.message)
end

.encrypt_response(response, envelope) ⇒ Object

Encrypt a response back to the buyer's reply key.

When the request carried no reply key the response is returned unchanged — the buyer did not ask for an encrypted reply.



336
337
338
339
340
341
342
343
344
345
346
347
348
349
# File 'lib/zeroclick/sellers/encryption.rb', line 336

def encrypt_response(response, envelope)
  return response if envelope.reply_jwk.nil?

  protected_header = { "alg" => ALG, "enc" => ENC }
  content_type = response.headers["content-type"] || response.headers["Content-Type"]
  protected_header["cty"] = content_type if content_type

  compact = encrypt_compact(protected_header, response.body, envelope.reply_jwk)

  headers = response.headers.reject { |name, _| %w[content-type content-length].include?(name.to_s.downcase) }
  headers["content-type"] = "application/jose"

  Response.new(status: response.status, body: compact, headers: headers)
end

.error(code, operation, **context) ⇒ Object



72
73
74
# File 'lib/zeroclick/sellers/encryption.rb', line 72

def error(code, operation, **context)
  Error.new(code, operation: operation, message: MESSAGES.fetch(code), **context)
end

.jwk_from_ec_public(key, extra = {}) ⇒ Object



210
211
212
213
214
215
# File 'lib/zeroclick/sellers/encryption.rb', line 210

def jwk_from_ec_public(key, extra = {})
  point = key.public_key.to_octet_string(:uncompressed)
  # Uncompressed point: 0x04 || X || Y, 32 bytes each for P-256.
  { "kty" => "EC", "crv" => "P-256",
    "x" => b64u_encode(point[1, 32]), "y" => b64u_encode(point[33, 32]) }.merge(extra)
end

.validated_reply_jwk(header) ⇒ Object



233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
# File 'lib/zeroclick/sellers/encryption.rb', line 233

def validated_reply_jwk(header)
  value = header[REPLY_JWK_PARAM]
  return nil if value.nil?
  raise error("invalid_reply_jwk", "decrypt_request") unless value.is_a?(Hash)

  # Checked before shape: a key carrying private material is a different
  # and more serious problem than a malformed one, and earns its own code.
  raise error("private_reply_jwk", "decrypt_request") if PRIVATE_JWK_MEMBERS.any? { |member| value.key?(member) }

  raise error("invalid_reply_jwk", "decrypt_request") unless (value.keys - ALLOWED_REPLY_JWK_MEMBERS).empty?
  raise error("invalid_reply_jwk", "decrypt_request") unless value["kty"] == "EC" && value["crv"] == "P-256"

  %w[x y].each do |coordinate|
    candidate = value[coordinate]
    # 43 chars is exactly a base64url-encoded 32-byte P-256 coordinate.
    raise error("invalid_reply_jwk", "decrypt_request") unless candidate.is_a?(String) && candidate.length == 43
  end

  raise error("invalid_reply_jwk", "decrypt_request") if value.key?("use") && value["use"] != "enc"
  raise error("invalid_reply_jwk", "decrypt_request") if value.key?("alg") && value["alg"] != ALG

  kid = value["kid"]
  if !kid.nil? && (!kid.is_a?(String) || kid.strip.empty? || kid.length > 128)
    raise error("invalid_reply_jwk", "decrypt_request")
  end

  value
end

.xor_counter(block, counter) ⇒ Object



171
172
173
174
# File 'lib/zeroclick/sellers/encryption.rb', line 171

def xor_counter(block, counter)
  counter_bytes = [0, counter].pack("NN")
  block.bytes.each_with_index.map { |byte, i| byte ^ counter_bytes.getbyte(i) }.pack("C*")
end