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
-
.aes_ecb(key, mode) ⇒ Object
AES Key Wrap (RFC 3394), built on AES-ECB.
- .aes_key_unwrap(kek, ciphertext) ⇒ Object
- .aes_key_wrap(kek, plaintext) ⇒ Object
-
.b64u_decode(value) ⇒ Object
base64url, on core String/Array only.
- .b64u_encode(bytes) ⇒ Object
-
.concat_kdf(shared_secret, key_bits, algorithm_id) ⇒ Object
NIST SP 800-56A Concat KDF, the one primitive OpenSSL does not expose.
- .decode_protected_header(compact_jwe) ⇒ Object
-
.decrypt_request(body, resolve_private_key:) ⇒ Object
Decrypt a Compact JWE request body.
- .decrypt_segments(segments, header, private_jwk, kid) ⇒ Object
-
.ec_key_from_jwk(jwk, private: false) ⇒ Object
Build an OpenSSL EC key from a JWK.
- .encrypt_compact(protected_header, plaintext, recipient_jwk) ⇒ Object
-
.encrypt_response(response, envelope) ⇒ Object
Encrypt a response back to the buyer's reply key.
- .error(code, operation, **context) ⇒ Object
- .jwk_from_ec_public(key, extra = {}) ⇒ Object
- .validated_reply_jwk(header) ⇒ Object
- .xor_counter(block, counter) ⇒ Object
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
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
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.
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.) 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.) 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.) 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 |