zeroclick-sellers 0.1.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 06b9bd195b7dc0b39d12660feeb511f4260c2b6627270d8dc643576e69b005a0
4
- data.tar.gz: d5e7a552b95d6f7664a96185de1446a0d215735fdcdf40e18479de90c7c30d1b
3
+ metadata.gz: 21a687776a099cffab368909929c6181de3425e83d6fe94bcfaba52cc40e76a8
4
+ data.tar.gz: 15b009873e6410a7703a6385ebdfc0d737f1be8d3d475b18ed22e96683117904
5
5
  SHA512:
6
- metadata.gz: 117c713bf87542ba54035ce2fa1d256716716f2297cd2cbab53739da2350c7b9ae9fa30e9f252ade7f660666bf93a5e61871e11cf976205cf66bb6559cf1cc25
7
- data.tar.gz: 01b80f728b183fa5d9d55aed65c25592c40bf77649bae4e6917ad0300e27c2ae3021aea45110bb708919175be2d74ca75c92e79b345a11b925d365d7127bd010
6
+ metadata.gz: 10cc75f795e921fa55d77b5dce9f101d1f3c4afadb191fe70546db9bd01dd309fe8453cfedd24de1d1991e1716567de26378b17cb87fb8338866f1132d4e96bd
7
+ data.tar.gz: 82a1860e38b1a2b53195b1aa0c17830b60f2c7ad5cdc80efc280c6547678f74771ed799a42ddabaf405c1df3f00f36de6344402e3c104c03078cc94e2d00f90e
data/README.md CHANGED
@@ -161,6 +161,80 @@ The idempotency key is yours to derive and must be stable for the request. The
161
161
  SDK never invents one and never retries on your behalf — a retry that changed
162
162
  the key would double-bill.
163
163
 
164
+ ## Encrypted bodies
165
+
166
+ When ZeroClick encrypts a request body to your public key, decrypt it before
167
+ doing anything else — the signature covers the *ciphertext*, so verify first,
168
+ decrypt second.
169
+
170
+ ```ruby
171
+ require "zeroclick/sellers/encryption"
172
+
173
+ envelope = ZeroClick::Sellers.decrypt_request(
174
+ request.body,
175
+ resolve_private_key: ->(kid) { MY_JWKS[kid] }
176
+ )
177
+
178
+ envelope.plaintext # the decrypted body, UTF-8
179
+ envelope.cty # the plaintext's media type, when declared
180
+ envelope.reply_jwk # present when the buyer wants an encrypted reply
181
+
182
+ # Returns the response unchanged when no reply key was offered.
183
+ ZeroClick::Sellers.encrypt_response(response, envelope)
184
+ ```
185
+
186
+ The suite is pinned to `ECDH-ES+A256KW` / `A256GCM`, and anything else is
187
+ refused rather than negotiated — accepting another algorithm is how an attacker
188
+ downgrades to one they can break. A reply JWK carrying private material
189
+ (`d`, `p`, `q`, …) is rejected outright with its own error code.
190
+
191
+ **Still no runtime dependencies.** This is built on OpenSSL from the standard
192
+ library; the one primitive it lacks, Concat KDF, is a short SHA-256 loop. Even
193
+ base64 is done with core `pack`/`unpack1` rather than the `base64` stdlib,
194
+ which stops being a default gem in Ruby 3.4.
195
+
196
+ ## Stateful sellers
197
+
198
+ If purchases provision a standing account — credits, a subscription, API keys —
199
+ rather than settling one call, `require "zeroclick/sellers/stateful"`.
200
+
201
+ ```ruby
202
+ response = ZeroClick::Sellers::Stateful.handle_access_request(
203
+ request,
204
+ base_path: "/zeroclick/access",
205
+ remint_policy: "additive", # or "rotating" — one live key at a time
206
+ signing_secrets: ZeroClick::Sellers.secrets_from_env,
207
+ on_write: ->(input) { ... ZeroClick::Sellers::Stateful::WriteResult.new(lifecycle: "active") },
208
+ on_mint: ->(input) { ZeroClick::Sellers::Stateful::MintKey.new(api_key: "sk_...") }
209
+ )
210
+
211
+ # nil means the route is not ours — serve it normally.
212
+ return serve_normally if response.nil?
213
+ ```
214
+
215
+ The credit arithmetic is **cumulative, not incremental**: both grants and
216
+ reversals are monotonic totals, so a delivery that arrives twice, out of order,
217
+ or after a gap converges on the same purse.
218
+
219
+ ```ruby
220
+ delta = ZeroClick::Sellers::Stateful::Entitlement.derive_credit_delta(stored, entitlement)
221
+ delta.outcome # "credit", "debit", "replay", or "no_credit_dimension"
222
+ delta.delta_usd # what to move
223
+ delta.next # persist ATOMICALLY with the purse, or a retry double-applies
224
+ ```
225
+
226
+ Three properties worth internalising before you wire it up:
227
+
228
+ - **The signature is a different one.** Stateful requests sign a *seven*-field
229
+ canonical string whose trailing purpose segment comes from the matched route.
230
+ An ordinary proxy signature is invalid here, and vice versa — deliberately.
231
+ - **A matched route always answers.** A handler that raises becomes the `503`
232
+ ZeroClick retries, never an exception escaping into your request path.
233
+ - **Money is a six-decimal string, capped at 2^53−1.** Ruby's Integer is
234
+ arbitrary precision, so that ceiling is a contract rule here rather than a
235
+ language limit — without it Ruby would accept amounts the TypeScript SDK
236
+ cannot represent.
237
+
164
238
  ## Behaviour worth knowing
165
239
 
166
240
  - **Verify first, always.** Every entry point verifies `zc-signature` before
@@ -0,0 +1,392 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "openssl"
5
+ require "securerandom"
6
+
7
+ require_relative "contracts"
8
+ require_relative "errors"
9
+
10
+ module ZeroClick
11
+ module Sellers
12
+ # Encrypted request and response bodies.
13
+ #
14
+ # ZeroClick can encrypt a buyer's request body to the seller's public key
15
+ # and ask for the reply encrypted back. The suite is pinned —
16
+ # `ECDH-ES+A256KW` key management, `A256GCM` content encryption — and the
17
+ # proxy carries its reply key in a custom protected-header parameter.
18
+ #
19
+ # Implemented on the standard library alone: OpenSSL provides P-256 ECDH,
20
+ # AES key wrap (RFC 3394) and AES-256-GCM, and the one missing piece —
21
+ # NIST SP 800-56A Concat KDF — is a short SHA-256 loop. That matters: the
22
+ # gem promises zero runtime dependencies and CI fails the build if one
23
+ # appears, so reaching for a JOSE gem here would have cost the property
24
+ # outright. (The Go SDK could not manage this and isolates go-jose in a
25
+ # separate package to keep its own core clean.)
26
+ #
27
+ # Interoperates with @zeroclickai/sellers; test/vectors/jwe-vectors.json is
28
+ # real TypeScript output and is what keeps them that way.
29
+ module Encryption
30
+ ALG = "ECDH-ES+A256KW"
31
+ ENC = "A256GCM"
32
+ REPLY_JWK_PARAM = "https://zeroclick.io/jwe/reply-jwk"
33
+
34
+ CURVE = "prime256v1"
35
+ # RFC 3394's fixed initial value for AES key wrap.
36
+ KEY_WRAP_IV = ["A6A6A6A6A6A6A6A6"].pack("H*")
37
+ KEY_WRAP_ROUNDS = 6
38
+
39
+ # Members whose presence means a JWK carries private key material. A
40
+ # reply key arriving with any of them is a protocol violation, not a key
41
+ # to quietly use.
42
+ PRIVATE_JWK_MEMBERS = %w[d p q dp dq qi oth k].freeze
43
+ ALLOWED_REPLY_JWK_MEMBERS = %w[kty crv kid x y use alg].freeze
44
+
45
+ MESSAGES = {
46
+ "invalid_compact_jwe" => "The request body is not a valid Compact JWE",
47
+ "unsupported_jwe_suite" => "The Compact JWE uses an unsupported algorithm suite",
48
+ "jwe_kid_required" => "The Compact JWE protected header requires a key ID",
49
+ "invalid_reply_jwk" => "The reply JWK is not a public P-256 key",
50
+ "private_reply_jwk" => "The reply JWK must not contain private key material",
51
+ "private_key_not_found" => "No private key was found for the Compact JWE key ID",
52
+ "private_key_resolution_failed" => "The private key could not be resolved",
53
+ "decryption_failed" => "The encrypted request could not be decrypted",
54
+ "encryption_failed" => "The response could not be encrypted"
55
+ }.freeze
56
+
57
+ # A decrypted request, plus what is needed to encrypt the reply.
58
+ class Envelope
59
+ attr_reader :plaintext, :protected_header, :cty, :reply_jwk
60
+
61
+ def initialize(plaintext:, protected_header:, cty: nil, reply_jwk: nil)
62
+ @plaintext = plaintext
63
+ @protected_header = protected_header.freeze
64
+ @cty = cty
65
+ @reply_jwk = reply_jwk&.freeze
66
+ freeze
67
+ end
68
+ end
69
+
70
+ module_function
71
+
72
+ def error(code, operation, **context)
73
+ Error.new(code, operation: operation, message: MESSAGES.fetch(code), **context)
74
+ end
75
+
76
+ # base64url, on core String/Array only.
77
+ #
78
+ # Deliberately NOT the base64 stdlib: it stops being a default gem in
79
+ # Ruby 3.4, so requiring it would force this gem to declare a runtime
80
+ # dependency — and the point of implementing JWE here rather than behind
81
+ # a JOSE gem is that it needs none.
82
+ def b64u_decode(value)
83
+ raise ArgumentError, "not base64url" unless value.is_a?(String)
84
+ raise ArgumentError, "not base64url" unless value.match?(/\A[A-Za-z0-9_-]*\z/)
85
+
86
+ padded = value.tr("-_", "+/")
87
+ padded += "=" * ((4 - (padded.length % 4)) % 4)
88
+ decoded = padded.unpack1("m0")
89
+ raise ArgumentError, "not base64url" if decoded.nil?
90
+
91
+ decoded
92
+ end
93
+
94
+ def b64u_encode(bytes)
95
+ [bytes].pack("m0").tr("+/", "-_").delete("=")
96
+ end
97
+
98
+ # NIST SP 800-56A Concat KDF, the one primitive OpenSSL does not expose.
99
+ #
100
+ # Single round only: the suite derives 256 bits and SHA-256 produces
101
+ # exactly that, so the counter never advances past 1. A wider key would
102
+ # need the loop.
103
+ def concat_kdf(shared_secret, key_bits, algorithm_id)
104
+ other_info = [algorithm_id.bytesize].pack("N") + algorithm_id +
105
+ [0].pack("N") + [0].pack("N") + [key_bits].pack("N")
106
+ OpenSSL::Digest::SHA256.digest([1].pack("N") + shared_secret + other_info)[0, key_bits / 8]
107
+ end
108
+
109
+ # AES Key Wrap (RFC 3394), built on AES-ECB.
110
+ #
111
+ # NOT OpenSSL's "aes-256-wrap" cipher: that is absent from some builds —
112
+ # it works on macOS and raises `unsupported cipher algorithm` on the
113
+ # Ubuntu runners — so relying on it made the SDK's portability depend on
114
+ # how the host happened to compile OpenSSL. AES-ECB is everywhere, and
115
+ # the wrapping itself is a short, fully specified loop.
116
+ #
117
+ # ECB is safe here precisely because RFC 3394 is what supplies the
118
+ # structure: each block is chained through the A register, and the fixed
119
+ # IV check on unwrap is what authenticates the result.
120
+ def aes_ecb(key, mode)
121
+ cipher = OpenSSL::Cipher.new("aes-256-ecb")
122
+ mode == :encrypt ? cipher.encrypt : cipher.decrypt
123
+ cipher.key = key
124
+ cipher.padding = 0
125
+ cipher
126
+ end
127
+
128
+ def aes_key_wrap(kek, plaintext)
129
+ raise ArgumentError, "key to wrap must be a multiple of 8 bytes" unless (plaintext.bytesize % 8).zero?
130
+
131
+ blocks = plaintext.scan(/.{8}/m)
132
+ a = KEY_WRAP_IV
133
+ cipher = aes_ecb(kek, :encrypt)
134
+
135
+ KEY_WRAP_ROUNDS.times do |round|
136
+ blocks.each_with_index do |block, index|
137
+ b = cipher.update(a + block) + cipher.final
138
+ counter = (blocks.length * round) + index + 1
139
+ a = xor_counter(b[0, 8], counter)
140
+ blocks[index] = b[8, 8]
141
+ end
142
+ end
143
+
144
+ a + blocks.join
145
+ end
146
+
147
+ def aes_key_unwrap(kek, ciphertext)
148
+ raise ArgumentError, "wrapped key must be a multiple of 8 bytes" unless (ciphertext.bytesize % 8).zero?
149
+
150
+ blocks = ciphertext.scan(/.{8}/m)
151
+ a = blocks.shift
152
+ cipher = aes_ecb(kek, :decrypt)
153
+
154
+ (KEY_WRAP_ROUNDS - 1).downto(0) do |round|
155
+ (blocks.length - 1).downto(0) do |index|
156
+ counter = (blocks.length * round) + index + 1
157
+ b = cipher.update(xor_counter(a, counter) + blocks[index]) + cipher.final
158
+ a = b[0, 8]
159
+ blocks[index] = b[8, 8]
160
+ end
161
+ end
162
+
163
+ # The fixed IV is the integrity check: a wrong KEK produces a different
164
+ # A, so this is what makes unwrapping fail closed rather than return
165
+ # plausible garbage.
166
+ raise ArgumentError, "key unwrap integrity check failed" unless a == KEY_WRAP_IV
167
+
168
+ blocks.join
169
+ end
170
+
171
+ def xor_counter(block, counter)
172
+ counter_bytes = [0, counter].pack("NN")
173
+ block.bytes.each_with_index.map { |byte, i| byte ^ counter_bytes.getbyte(i) }.pack("C*")
174
+ end
175
+
176
+ # Build an OpenSSL EC key from a JWK.
177
+ #
178
+ # Ruby 3.x has no JWK importer and EC keys are immutable, so the key is
179
+ # assembled as ASN.1 and parsed back — the only route from raw
180
+ # coordinates to a usable key.
181
+ def ec_key_from_jwk(jwk, private: false)
182
+ group = OpenSSL::PKey::EC::Group.new(CURVE)
183
+ x = b64u_decode(jwk["x"] || jwk[:x])
184
+ y = b64u_decode(jwk["y"] || jwk[:y])
185
+ point = OpenSSL::PKey::EC::Point.new(
186
+ group, OpenSSL::BN.new("04#{x.unpack1("H*")}#{y.unpack1("H*")}", 16)
187
+ )
188
+
189
+ unless private
190
+ sequence = OpenSSL::ASN1::Sequence([
191
+ OpenSSL::ASN1::Sequence([
192
+ OpenSSL::ASN1::ObjectId("id-ecPublicKey"),
193
+ OpenSSL::ASN1::ObjectId(CURVE)
194
+ ]),
195
+ OpenSSL::ASN1::BitString(point.to_octet_string(:uncompressed))
196
+ ])
197
+ return OpenSSL::PKey::EC.new(sequence.to_der)
198
+ end
199
+
200
+ d = b64u_decode(jwk["d"] || jwk[:d])
201
+ sequence = OpenSSL::ASN1::Sequence([
202
+ OpenSSL::ASN1::Integer(1),
203
+ OpenSSL::ASN1::OctetString(d),
204
+ OpenSSL::ASN1::ObjectId(CURVE, 0, :EXPLICIT),
205
+ OpenSSL::ASN1::BitString(point.to_octet_string(:uncompressed), 1, :EXPLICIT)
206
+ ])
207
+ OpenSSL::PKey::EC.new(sequence.to_der)
208
+ end
209
+
210
+ def jwk_from_ec_public(key, extra = {})
211
+ point = key.public_key.to_octet_string(:uncompressed)
212
+ # Uncompressed point: 0x04 || X || Y, 32 bytes each for P-256.
213
+ { "kty" => "EC", "crv" => "P-256",
214
+ "x" => b64u_encode(point[1, 32]), "y" => b64u_encode(point[33, 32]) }.merge(extra)
215
+ end
216
+
217
+ def decode_protected_header(compact_jwe)
218
+ segments = compact_jwe.split(".", -1)
219
+ # The ciphertext segment (index 3) may legitimately be empty — an empty
220
+ # plaintext is a real case. No other segment may be.
221
+ if segments.length != 5 || segments.each_with_index.any? { |seg, i| seg.empty? && i != 3 }
222
+ raise error("invalid_compact_jwe", "decrypt_request")
223
+ end
224
+
225
+ header = JSON.parse(b64u_decode(segments[0]))
226
+ raise error("invalid_compact_jwe", "decrypt_request") unless header.is_a?(Hash)
227
+
228
+ [header, segments]
229
+ rescue ArgumentError, JSON::ParserError
230
+ raise error("invalid_compact_jwe", "decrypt_request")
231
+ end
232
+
233
+ def validated_reply_jwk(header)
234
+ value = header[REPLY_JWK_PARAM]
235
+ return nil if value.nil?
236
+ raise error("invalid_reply_jwk", "decrypt_request") unless value.is_a?(Hash)
237
+
238
+ # Checked before shape: a key carrying private material is a different
239
+ # and more serious problem than a malformed one, and earns its own code.
240
+ raise error("private_reply_jwk", "decrypt_request") if PRIVATE_JWK_MEMBERS.any? { |member| value.key?(member) }
241
+
242
+ raise error("invalid_reply_jwk", "decrypt_request") unless (value.keys - ALLOWED_REPLY_JWK_MEMBERS).empty?
243
+ raise error("invalid_reply_jwk", "decrypt_request") unless value["kty"] == "EC" && value["crv"] == "P-256"
244
+
245
+ %w[x y].each do |coordinate|
246
+ candidate = value[coordinate]
247
+ # 43 chars is exactly a base64url-encoded 32-byte P-256 coordinate.
248
+ raise error("invalid_reply_jwk", "decrypt_request") unless candidate.is_a?(String) && candidate.length == 43
249
+ end
250
+
251
+ raise error("invalid_reply_jwk", "decrypt_request") if value.key?("use") && value["use"] != "enc"
252
+ raise error("invalid_reply_jwk", "decrypt_request") if value.key?("alg") && value["alg"] != ALG
253
+
254
+ kid = value["kid"]
255
+ if !kid.nil? && (!kid.is_a?(String) || kid.strip.empty? || kid.length > 128)
256
+ raise error("invalid_reply_jwk", "decrypt_request")
257
+ end
258
+
259
+ value
260
+ end
261
+
262
+ # Decrypt a Compact JWE request body.
263
+ #
264
+ # +body+ is the raw request bytes, which for an encrypted request are the
265
+ # ASCII Compact JWE rather than JSON.
266
+ #
267
+ # +resolve_private_key+ is a callable taking the header's `kid` and
268
+ # returning that key's JWK (or nil when the kid is unknown).
269
+ def decrypt_request(body, resolve_private_key:)
270
+ compact_jwe = body.is_a?(String) ? body.dup.force_encoding(Encoding::UTF_8) : body.to_s
271
+ raise error("invalid_compact_jwe", "decrypt_request") unless compact_jwe.valid_encoding?
272
+
273
+ header, segments = decode_protected_header(compact_jwe)
274
+
275
+ raise error("unsupported_jwe_suite", "decrypt_request") unless header["alg"] == ALG && header["enc"] == ENC
276
+
277
+ kid = header["kid"]
278
+ raise error("jwe_kid_required", "decrypt_request") unless kid.is_a?(String) && !kid.empty?
279
+
280
+ cty = header["cty"]
281
+ raise error("invalid_compact_jwe", "decrypt_request") if !cty.nil? && !cty.is_a?(String)
282
+
283
+ reply_jwk = validated_reply_jwk(header)
284
+
285
+ begin
286
+ private_jwk = resolve_private_key.call(kid)
287
+ rescue StandardError => e
288
+ raise error("private_key_resolution_failed", "decrypt_request", kid: kid, cause: e.message)
289
+ end
290
+ raise error("private_key_not_found", "decrypt_request", kid: kid) if private_jwk.nil?
291
+
292
+ plaintext = decrypt_segments(segments, header, private_jwk, kid)
293
+
294
+ Envelope.new(plaintext: plaintext, protected_header: header, cty: cty, reply_jwk: reply_jwk)
295
+ end
296
+
297
+ def decrypt_segments(segments, header, private_jwk, kid)
298
+ header_b64, encrypted_key, iv, ciphertext, tag = segments
299
+
300
+ private_key = ec_key_from_jwk(private_jwk, private: true)
301
+ epk = header["epk"]
302
+ raise error("decryption_failed", "decrypt_request", kid: kid) unless epk.is_a?(Hash)
303
+
304
+ shared = private_key.dh_compute_key(ec_key_from_jwk(epk).public_key)
305
+ kek = concat_kdf(shared, 256, ALG)
306
+
307
+ cek = aes_key_unwrap(kek, b64u_decode(encrypted_key))
308
+
309
+ decipher = OpenSSL::Cipher.new("aes-256-gcm").decrypt
310
+ decipher.key = cek
311
+ decipher.iv = b64u_decode(iv)
312
+ decipher.auth_tag = b64u_decode(tag)
313
+ # The AAD is the RAW protected header segment, not a re-encoding of it.
314
+ decipher.auth_data = header_b64
315
+ # An empty ciphertext is a real case (the empty_plaintext vector), and
316
+ # OpenSSL::Cipher#update rejects an empty string on older openssl gems
317
+ # — Ruby 3.1 raises where 3.3 does not. Skip straight to #final, which
318
+ # still verifies the GCM tag.
319
+ encrypted_bytes = b64u_decode(ciphertext)
320
+ raw = encrypted_bytes.empty? ? decipher.final : decipher.update(encrypted_bytes) + decipher.final
321
+
322
+ # OpenSSL hands back ASCII-8BIT. The plaintext is a request body, and
323
+ # leaving it binary makes `==` against any UTF-8 string false for every
324
+ # non-ASCII byte — which is exactly how the unicode vector fails while
325
+ # the bytes are identical.
326
+ raw.force_encoding(Encoding::UTF_8)
327
+ raw.valid_encoding? ? raw : raw.force_encoding(Encoding::BINARY)
328
+ rescue OpenSSL::OpenSSLError, ArgumentError => e
329
+ raise error("decryption_failed", "decrypt_request", kid: kid, cause: e.message)
330
+ end
331
+
332
+ # Encrypt a response back to the buyer's reply key.
333
+ #
334
+ # When the request carried no reply key the response is returned
335
+ # unchanged — the buyer did not ask for an encrypted reply.
336
+ def encrypt_response(response, envelope)
337
+ return response if envelope.reply_jwk.nil?
338
+
339
+ protected_header = { "alg" => ALG, "enc" => ENC }
340
+ content_type = response.headers["content-type"] || response.headers["Content-Type"]
341
+ protected_header["cty"] = content_type if content_type
342
+
343
+ compact = encrypt_compact(protected_header, response.body, envelope.reply_jwk)
344
+
345
+ headers = response.headers.reject { |name, _| %w[content-type content-length].include?(name.to_s.downcase) }
346
+ headers["content-type"] = "application/jose"
347
+
348
+ Response.new(status: response.status, body: compact, headers: headers)
349
+ end
350
+
351
+ def encrypt_compact(protected_header, plaintext, recipient_jwk)
352
+ recipient = ec_key_from_jwk(recipient_jwk)
353
+
354
+ # A fresh ephemeral key per message: ECDH-ES derives the KEK from it,
355
+ # so reusing one would reuse the KEK across messages.
356
+ ephemeral = OpenSSL::PKey::EC.generate(CURVE)
357
+ shared = ephemeral.dh_compute_key(recipient.public_key)
358
+ kek = concat_kdf(shared, 256, ALG)
359
+
360
+ cek = SecureRandom.bytes(32)
361
+ encrypted_key = aes_key_wrap(kek, cek)
362
+
363
+ header = protected_header.merge("epk" => jwk_from_ec_public(ephemeral))
364
+ header_b64 = b64u_encode(JSON.generate(header))
365
+
366
+ iv = SecureRandom.bytes(12)
367
+ cipher = OpenSSL::Cipher.new("aes-256-gcm").encrypt
368
+ cipher.key = cek
369
+ cipher.iv = iv
370
+ cipher.auth_data = header_b64
371
+ # Same empty-input constraint as the decrypt path above.
372
+ plain_bytes = plaintext.to_s.dup.force_encoding(Encoding::BINARY)
373
+ ciphertext = plain_bytes.empty? ? cipher.final : cipher.update(plain_bytes) + cipher.final
374
+
375
+ [header_b64, b64u_encode(encrypted_key), b64u_encode(iv),
376
+ b64u_encode(ciphertext), b64u_encode(cipher.auth_tag)].join(".")
377
+ rescue OpenSSL::OpenSSLError, ArgumentError => e
378
+ raise error("encryption_failed", "encrypt_response", cause: e.message)
379
+ end
380
+ end
381
+
382
+ # Decrypt a Compact JWE request body. See Encryption.decrypt_request.
383
+ def self.decrypt_request(body, resolve_private_key:)
384
+ Encryption.decrypt_request(body, resolve_private_key: resolve_private_key)
385
+ end
386
+
387
+ # Encrypt a response to the buyer's reply key. See Encryption.encrypt_response.
388
+ def self.encrypt_response(response, envelope)
389
+ Encryption.encrypt_response(response, envelope)
390
+ end
391
+ end
392
+ end
@@ -0,0 +1,141 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../errors"
4
+ require_relative "money"
5
+
6
+ module ZeroClick
7
+ module Sellers
8
+ module Stateful
9
+ # suspended is recoverable; closed is terminal — revoke live keys, stop
10
+ # serving, keep your records.
11
+ ACCESS_STATUSES = %w[active suspended closed].freeze
12
+
13
+ # zod v4's `iso.datetime({offset: true})` grammar, transcribed verbatim —
14
+ # as the Python and Go SDKs also transcribe it. Seconds and the fraction
15
+ # are optional, the calendar is validated (leap years included), and the
16
+ # zone must be Z or ±HH:MM.
17
+ #
18
+ # Copied rather than approximated on purpose: an SDK that accepts a
19
+ # timestamp ZeroClick rejects (or the reverse) turns a valid entitlement
20
+ # into a 400 nobody can reproduce.
21
+ ISO_DATETIME_RE = /
22
+ \A(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00
23
+ |[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])
24
+ |(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))
25
+ T(?:(?:[01]\d|2[0-3]):[0-5]\d(?::[0-5]\d(?:\.\d+)?)?
26
+ (?:Z|(?:[+-](?:[01]\d|2[0-3]):[0-5]\d)))\z
27
+ /x
28
+
29
+ AccessPeriod = Struct.new(:start, :end, keyword_init: true)
30
+ EntitlementPlan = Struct.new(:slug, :name, :billing_mode, :interval, :base_price_usd, keyword_init: true)
31
+ MintKeyRequest = Struct.new(:agent_id, :buyer_id, keyword_init: true)
32
+
33
+ ParsedEntitlement = Struct.new(
34
+ :access_id, :agent_id, :buyer_id, :buyer_email, :idempotency_key,
35
+ :state_version, :plan, :period, :status,
36
+ :lifetime_credit_granted_usd, :lifetime_credit_reversed_usd,
37
+ :base_price_usd_micros, :lifetime_credit_granted_usd_micros, :lifetime_credit_reversed_usd_micros,
38
+ keyword_init: true
39
+ )
40
+
41
+ module Contracts
42
+ module_function
43
+
44
+ def iso_datetime?(value)
45
+ value.is_a?(String) && ISO_DATETIME_RE.match?(value)
46
+ end
47
+
48
+ def non_empty_string?(value)
49
+ value.is_a?(String) && !value.empty?
50
+ end
51
+
52
+ # A non-negative integer. JSON `1.0` counts; `true` and `"1"` do not.
53
+ def as_version(value)
54
+ return nil if value.is_a?(TrueClass) || value.is_a?(FalseClass)
55
+ return value.negative? ? nil : value if value.is_a?(Integer)
56
+
57
+ if value.is_a?(Float) && value.finite? && (value % 1).zero?
58
+ version = value.to_i
59
+ return version.negative? ? nil : version
60
+ end
61
+
62
+ nil
63
+ end
64
+
65
+ # Required but nullable: a missing key is an error, null is not.
66
+ # Returns [value, ok].
67
+ def nullable_id(container, key)
68
+ return [nil, false] unless container.key?(key)
69
+
70
+ value = container[key]
71
+ return [nil, true] if value.nil?
72
+ return [value, true] if non_empty_string?(value)
73
+
74
+ [nil, false]
75
+ end
76
+
77
+ def parse_mint_key_request(body)
78
+ return nil unless body.is_a?(Hash) && non_empty_string?(body["agentId"])
79
+
80
+ buyer_id, ok = nullable_id(body, "buyerId")
81
+ return nil unless ok
82
+
83
+ MintKeyRequest.new(agent_id: body["agentId"], buyer_id: buyer_id)
84
+ end
85
+
86
+ def parse_plan(value, issues)
87
+ unless value.is_a?(Hash)
88
+ issues << "plan: expected an object"
89
+ return nil
90
+ end
91
+
92
+ before = issues.length
93
+ %w[slug name billingMode interval].each do |key|
94
+ issues << "plan.#{key}: expected a string" unless value[key].is_a?(String)
95
+ end
96
+ issues << "plan.basePriceUsd: expected a six-decimal USD string" unless Money.money_usd?(value["basePriceUsd"])
97
+ return nil if issues.length != before
98
+
99
+ EntitlementPlan.new(
100
+ slug: value["slug"], name: value["name"], billing_mode: value["billingMode"],
101
+ interval: value["interval"], base_price_usd: value["basePriceUsd"]
102
+ )
103
+ end
104
+
105
+ def parse_period(value, issues)
106
+ unless value.is_a?(Hash)
107
+ issues << "state.period: expected an object"
108
+ return nil
109
+ end
110
+
111
+ before = issues.length
112
+ start = value["start"]
113
+ issues << "state.period.start: expected an ISO datetime" unless iso_datetime?(start)
114
+ # Required but nullable: absence is a different mistake from null,
115
+ # and only absence is an error.
116
+ issues << "state.period.end: required (may be null)" unless value.key?("end")
117
+ finish = value["end"]
118
+ issues << "state.period.end: expected an ISO datetime or null" if !finish.nil? && !iso_datetime?(finish)
119
+
120
+ return nil if issues.length != before
121
+
122
+ AccessPeriod.new(start: start, end: finish)
123
+ end
124
+
125
+ def parse_money_field(container, key, issues:)
126
+ unless container.key?(key)
127
+ issues << "state.#{key}: required (may be null)"
128
+ return nil
129
+ end
130
+
131
+ value = container[key]
132
+ return nil if value.nil?
133
+ return value if Money.money_usd?(value)
134
+
135
+ issues << "state.#{key}: expected a six-decimal USD string or null"
136
+ nil
137
+ end
138
+ end
139
+ end
140
+ end
141
+ end