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 +4 -4
- data/README.md +74 -0
- data/lib/zeroclick/sellers/encryption.rb +392 -0
- data/lib/zeroclick/sellers/stateful/contracts.rb +141 -0
- data/lib/zeroclick/sellers/stateful/dispatch.rb +230 -0
- data/lib/zeroclick/sellers/stateful/entitlement.rb +219 -0
- data/lib/zeroclick/sellers/stateful/money.rb +52 -0
- data/lib/zeroclick/sellers/stateful/responses.rb +76 -0
- data/lib/zeroclick/sellers/stateful/verify.rb +137 -0
- data/lib/zeroclick/sellers/stateful.rb +20 -0
- data/lib/zeroclick/sellers/version.rb +1 -1
- metadata +9 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 21a687776a099cffab368909929c6181de3425e83d6fe94bcfaba52cc40e76a8
|
|
4
|
+
data.tar.gz: 15b009873e6410a7703a6385ebdfc0d737f1be8d3d475b18ed22e96683117904
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|