k9o 1.4.0 → 1.5.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.
data/lib/k9o/crypto.rb ADDED
@@ -0,0 +1,471 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'json'
4
+
5
+ module K9o
6
+ # client.crypto: application encryption with the project's key. The caller never names a key. The calls route by
7
+ # custody (see "Routing" below): a platform-managed key is used at the API; a key in the customer's own KMS or HSM
8
+ # (BYOK or HYOK) is used by a registered client core, so the platform never sees plaintext.
9
+ #
10
+ # encrypt(crypt, items) POST /@<slug>/crypto/encrypt items: [{ plaintext:, context: }]
11
+ # decrypt(crypt, items) POST /@<slug>/crypto/decrypt items: [{ ciphertext:, context: }]
12
+ # rewrap(crypt, items) POST /@<slug>/crypto/rewrap items: [{ ciphertext:, context: }]
13
+ # federated_token(crypt, op) POST /@<slug>/crypto/federated-token op: "encrypt", "decrypt" or "rewrap"
14
+ # crypts_list GET /@<slug>/crypto/crypts
15
+ # crypts_create(name) POST /@<slug>/crypto/crypts body: { name: }
16
+ # crypts_retire(name) POST /@<slug>/crypto/crypts/<name>/retire no body
17
+ #
18
+ # An item is a Hash (symbol or string keys) or, for brevity, a bare String (the plaintext, or the ciphertext).
19
+ # Plaintext goes in as a String of bytes (any encoding; its bytes are sent) and comes back as a binary String;
20
+ # the SDK does the base64 on both sides. A ciphertext is the base64 String the gateway gave you: one opaque container
21
+ # that the SDK never parses. +context+ is an optional String of at most 256 bytes; the same +crypt+ and +context+
22
+ # must be given on decrypt.
23
+ #
24
+ # The answer is an Envelope whose +data+ is { "items" => [...] } in the order sent. Each item is
25
+ # { "ciphertext" } (encrypt, rewrap), { "plaintext" } (decrypt, decoded to bytes) or { "code", "message" } for an item
26
+ # that failed on its own; fields the SDK does not know are kept as they are. A call-level failure raises K9o::KError:
27
+ # 400 K9O_ENVELOPE_INTEGRITY_FAILURE (every cannot-open, with no reason, on purpose), 409
28
+ # K9O_SEALED_UNDER_RETIRED_KEY (+context["resealableUntil"]+ while #rewrap can still fix it), 413
29
+ # K9O_PAYLOAD_TOO_LARGE, 429 K9O_RATE_LIMITED (+retry_after_sec+), 503 K9O_KEY_SERVICE_UNAVAILABLE (fails the whole
30
+ # call).
31
+ #
32
+ # Routing. #encrypt checks the project's custody mode on every call (GET crypto/mode, never cached), then sends
33
+ # plaintext to the API only for a platform-managed, confidential-workload or SKR project (the body carries the
34
+ # +custodyEpoch+ from the check). A BYOK or HYOK project goes to the client core; an unknown mode or a failed check
35
+ # sends nothing. If the API answers 409 K9O_ZK_STORE_FLOW_REQUIRED (custody moved between check and send), the mode
36
+ # is checked again and the call is routed once more; a second 409 is raised. #decrypt and #rewrap route by the
37
+ # ciphertext: they go to the API first, and a 409 K9O_ZK_STORE_FLOW_REQUIRED whose +context["route"]+ is "client"
38
+ # reroutes the same call to the client core. With no client core registered (#use_client_core), a call that needs
39
+ # one raises K9o::KError K9O_ZK_CLIENT_CORE_UNAVAILABLE (501).
40
+ # The SDK checks the crypt, the item count and the sizes before sending, with the same codes. It never retries
41
+ # these calls on its own.
42
+ class Crypto
43
+ CRYPT_PATTERN = /\A(?!actions\z)[a-z0-9-]{1,64}\z/.freeze
44
+ MAX_ITEMS = 100
45
+ MAX_ITEM_BYTES = 64 * 1024
46
+ MAX_CALL_BYTES = 1024 * 1024
47
+ MAX_CONTEXT_BYTES = 256
48
+
49
+ ZK_STORE_FLOW_REQUIRED = 'K9O_ZK_STORE_FLOW_REQUIRED'
50
+ ZK_CLIENT_CORE_UNAVAILABLE = 'K9O_ZK_CLIENT_CORE_UNAVAILABLE'
51
+ CUSTODY_MODE_UNKNOWN = 'K9O_CRYPTO_CUSTODY_UNKNOWN'
52
+ KEY_PIN_UNENFORCED = 'K9O_CRYPTO_KEY_PIN_UNENFORCED'
53
+ # 404 on encrypt: the crypt name is not registered. 409 on encrypt: the crypt name is retired. Both pass through as KError.
54
+ CRYPT_NOT_FOUND = 'K9O_CRYPT_NOT_FOUND'
55
+ CRYPT_RETIRED = 'K9O_CRYPT_RETIRED'
56
+ # Gateway refusals of crypts_create and crypts_retire; they pass through as KError.
57
+ CRYPT_ALREADY_EXISTS = 'K9O_CRYPT_ALREADY_EXISTS'
58
+ CRYPT_LIMIT_EXCEEDED = 'K9O_CRYPT_LIMIT_EXCEEDED'
59
+ CRYPT_RULE = 'crypt must be 1 to 64 characters of a-z, 0-9 and "-", and not "actions"'
60
+ CRYPT_STATUSES = %w[active retired].freeze
61
+ SERVER_MODES = %w[PLATFORM_MANAGED CONFIDENTIAL_WORKLOAD SKR].freeze
62
+ CLIENT_MODES = %w[BYOK HYOK].freeze
63
+ CLIENT_CORE_METHODS = %i[encrypt decrypt rewrap].freeze
64
+
65
+ # The answer of GET crypto/mode: the project's custody mode and the opaque epoch that changes with every custody
66
+ # transition. +route+ is "client" or "server" when the gateway named one, otherwise nil (an older gateway, or a value this
67
+ # SDK does not know): follow it, do not infer the route from +custody_mode+.
68
+ Mode = Struct.new(:custody_mode, :epoch, :route)
69
+
70
+ TOKEN_OPS = %w[encrypt decrypt rewrap].freeze
71
+ TOKEN_FIELDS = %w[token expiresAt issuer subject audience kid].freeze
72
+
73
+ # The answer of POST crypto/federated-token: a short-lived RS256 JWT (+token+, valid for at most 15 minutes) for
74
+ # the crypt and operation asked, with +expires_at+ (date-time String), +issuer+, +subject+, +audience+ and the
75
+ # signing +kid+. When the gateway names them, +provider+ (the cloud, for example "aws") and +project_id+ (the +projectId+
76
+ # field) are decoded too, otherwise they are nil; they are only ever checked against your configuration, never followed.
77
+ # The token is a credential: +inspect+, +to_s+ and +pp+ leave it out.
78
+ FederatedToken = Struct.new(:token, :expires_at, :issuer, :subject, :audience, :kid, :provider, :project_id) do
79
+ def inspect
80
+ extra = +''
81
+ extra << " provider=#{provider.inspect}" unless provider.nil?
82
+ extra << " project_id=#{project_id.inspect}" unless project_id.nil?
83
+ "#<struct K9o::Crypto::FederatedToken token=[redacted] expires_at=#{expires_at.inspect} issuer=#{issuer.inspect} " \
84
+ "subject=#{subject.inspect} audience=#{audience.inspect} kid=#{kid.inspect}#{extra}>"
85
+ end
86
+
87
+ def to_s
88
+ inspect
89
+ end
90
+
91
+ def pretty_print(pp)
92
+ pp.text(inspect)
93
+ end
94
+ end
95
+
96
+ def initialize(config)
97
+ @config = config
98
+ @client_core = nil
99
+ end
100
+
101
+ # Register the client core: an object with +encrypt(crypt, items, **options)+, +decrypt(crypt, items, **options)+ and
102
+ # +rewrap(crypt, items, **options)+. This is the hook the Ruby Kibo binding fills. When the client was built with
103
+ # +expected_key_refs:+ the router passes it as the keyword +expected_key_refs:+ (a frozen Array of String) on every
104
+ # call, and the core must refuse a key whose reference (version ignored, see Crypto.key_ref_matches?) is not in it,
105
+ # before it uses any token or makes any network call. The keyword is absent when the option was not set. A core
106
+ # declares that it does this with a method +enforces_expected_key_refs?+ that returns true; with the option set and
107
+ # no such declaration the call raises K9o::KError K9O_CRYPTO_KEY_PIN_UNENFORCED before the core is called. Items are Hashes with symbol keys
108
+ # ({ plaintext:, context: } with binary plaintext bytes, or { ciphertext:, context: }; +context+ only when given).
109
+ # Each method answers the per-item results (an Array of item Hashes in the order given, or a Hash or Envelope whose
110
+ # data holds "items"), with plaintext as bytes and ciphertext as the base64 String. Pass nil to remove it. Returns the core.
111
+ def use_client_core(core)
112
+ unless core.nil? || CLIENT_CORE_METHODS.all? { |m| core.respond_to?(m) }
113
+ raise invalid('client core must respond to encrypt, decrypt and rewrap')
114
+ end
115
+
116
+ @client_core = core
117
+ end
118
+
119
+ # The project's custody mode and epoch right now (GET crypto/mode), as a Mode with +custody_mode+, +epoch+ and +route+.
120
+ # With +crypt:+ the gateway authorizes the caller on that crypt (a personal access token or workload policy scoped to
121
+ # named crypts is refused without one); the crypt is checked first (K9O_INVALID_FIELD_VALUE) and nothing is sent if invalid.
122
+ def mode(crypt: nil)
123
+ slug = Studio.require_project_slug(@config, 'crypto')
124
+ unless crypt.nil? || (crypt.is_a?(String) && CRYPT_PATTERN.match?(crypt))
125
+ raise invalid('crypt must be 1 to 64 characters of a-z, 0-9 and -, and not "actions"', crypt: crypt)
126
+ end
127
+ data = mode_answer(slug, crypt)
128
+ route = %w[client server].include?(data['route']) ? data['route'] : nil
129
+ Mode.new(data['custodyMode'], data['epoch'], route)
130
+ end
131
+
132
+ # Encrypt each item's plaintext (up to 64 KiB each and 1 MiB per call). A crypt is a registered name. The gateway can
133
+ # answer 404 K9O_CRYPT_NOT_FOUND (name not registered) or 409 K9O_CRYPT_RETIRED (name retired); both raise K9o::KError
134
+ # unchanged. Decrypt and rewrap are not refused for either.
135
+ def encrypt(crypt, items)
136
+ route_encrypt(*prepare('encrypt', crypt, items, :plaintext))
137
+ end
138
+
139
+ # Open each item's ciphertext. +data["items"][n]["plaintext"]+ is a binary String.
140
+ def decrypt(crypt, items)
141
+ route_by_ciphertext('decrypt', *prepare('decrypt', crypt, items, :ciphertext))
142
+ end
143
+
144
+ # Open and re-seal each item's ciphertext under the project's current key version in one call; the plaintext never
145
+ # leaves the platform. It is the fix for 409 K9O_SEALED_UNDER_RETIRED_KEY while the window is open.
146
+ def rewrap(crypt, items)
147
+ route_by_ciphertext('rewrap', *prepare('rewrap', crypt, items, :ciphertext))
148
+ end
149
+
150
+ # Ask the gateway for a short-lived federated token (POST crypto/federated-token) for +op+ ("encrypt", "decrypt" or
151
+ # "rewrap") on +crypt+, as a FederatedToken. The SDK checks both before sending, with K9O_INVALID_FIELD_VALUE. The
152
+ # call is never retried. The token is a credential: keep it out of logs and error reports. A refusal raises
153
+ # K9o::KError (400 K9O_INVALID_FIELD_VALUE, 401, 403, 409 K9O_CUSTODY_SERVER_ROUTE (context['route'] is 'server' or 'client-idp'), 429 K9O_RATE_LIMITED, 503
154
+ # K9O_KEY_SERVICE_UNAVAILABLE).
155
+ def federated_token(crypt, op)
156
+ slug = Studio.require_project_slug(@config, 'crypto')
157
+ unless crypt.is_a?(String) && CRYPT_PATTERN.match?(crypt)
158
+ raise invalid('crypt must be 1 to 64 characters of a-z, 0-9 and -, and not "actions"', crypt: crypt)
159
+ end
160
+ raise invalid('op must be encrypt, decrypt or rewrap', op: op.is_a?(String) ? op : nil) unless TOKEN_OPS.include?(op)
161
+
162
+ data = HttpTransport.request(config: @config, method: 'POST', path: UrlGrammar.project_path(slug, 'crypto/federated-token'),
163
+ body: { crypt: crypt, op: op }).data
164
+ unless data.is_a?(Hash) && TOKEN_FIELDS.all? { |f| data[f].is_a?(String) && !data[f].empty? }
165
+ raise KError.new(code: 'K9O_INTERNAL_ERROR', message: 'Malformed crypto/federated-token response: expected token, expiresAt, issuer, subject, audience and kid')
166
+ end
167
+
168
+ optional = %w[provider projectId].map { |f| data[f].is_a?(String) && !data[f].empty? ? data[f] : nil }
169
+ FederatedToken.new(*TOKEN_FIELDS.map { |f| data[f] }, *optional)
170
+ end
171
+
172
+ # The crypts you hold (GET crypto/crypts): +data["crypts"]+ is an Array of { "name", "status" ("active" or "retired"),
173
+ # "createdAt", "retiredAt" (String or nil) }. Every crypt if you have a +*+ grant on crypts, none if you are a member
174
+ # with no grant. A malformed answer raises K9O_INTERNAL_ERROR. Gateway refusals (K9O_ACCESS_DENIED) raise K9o::KError.
175
+ def crypts_list
176
+ slug = Studio.require_project_slug(@config, 'crypto')
177
+ envelope = HttpTransport.request(config: @config, method: 'GET', path: UrlGrammar.project_path(slug, 'crypto/crypts'))
178
+ data = envelope.data
179
+ raise malformed_crypt('list', 'without crypts') unless data.is_a?(Hash) && data['crypts'].is_a?(Array)
180
+
181
+ Envelope.new(data: { 'crypts' => data['crypts'].map { |c| crypt_of(c, 'list') } }, meta: envelope.meta)
182
+ end
183
+
184
+ # Register a crypt so #encrypt accepts it (POST crypto/crypts, body { name: } and nothing else). +name+ is 1 to 64
185
+ # characters of a-z, 0-9 and -, and not "actions"; a bad name raises K9O_INVALID_FIELD_VALUE before anything is sent.
186
+ # Needs crypto:manage_crypts (project admins by default). Gateway refusals: 409 K9O_CRYPT_ALREADY_EXISTS, 409
187
+ # K9O_CRYPT_LIMIT_EXCEEDED (256 per project, retired ones counted), 409 K9O_CRYPT_RETIRED; all raise K9o::KError.
188
+ def crypts_create(name)
189
+ slug = Studio.require_project_slug(@config, 'crypto')
190
+ check_crypt_name(name, 'create')
191
+ envelope = HttpTransport.request(config: @config, method: 'POST', path: UrlGrammar.project_path(slug, 'crypto/crypts'),
192
+ body: { name: name })
193
+ Envelope.new(data: crypt_of(envelope.data, 'create'), meta: envelope.meta)
194
+ end
195
+
196
+ # Retire a crypt (POST crypto/crypts/<name>/retire, no body): #encrypt then refuses the name. #decrypt and #rewrap
197
+ # never read the registry, so data stays readable. This does not isolate, enforce or destroy anything. Needs
198
+ # crypto:manage_crypts; a personal access token is refused (K9O_RBAC_DENY). Gateway refusals: 404 K9O_CRYPT_NOT_FOUND,
199
+ # 409 K9O_CRYPT_RETIRED.
200
+ def crypts_retire(name)
201
+ slug = Studio.require_project_slug(@config, 'crypto')
202
+ check_crypt_name(name, 'retire')
203
+ envelope = HttpTransport.request(config: @config, method: 'POST',
204
+ path: UrlGrammar.project_path(slug, "crypto/crypts/#{name}/retire"))
205
+ Envelope.new(data: crypt_of(envelope.data, 'retire'), meta: envelope.meta)
206
+ end
207
+
208
+ # Check the +expected_key_refs:+ client option and return a frozen copy (nil when not set). It must be a non-empty
209
+ # Array of non-blank Strings without whitespace, and none may contain "*", since a wildcard pins nothing. Raises
210
+ # K9o::KError K9O_INVALID_FIELD_VALUE (+context["field"]+ is "expectedKeyRefs") otherwise.
211
+ def self.check_expected_key_refs(refs)
212
+ return nil if refs.nil?
213
+
214
+ bad = lambda do |message|
215
+ KError.new(code: 'K9O_INVALID_FIELD_VALUE', message: "expectedKeyRefs: #{message}", context: { 'field' => 'expectedKeyRefs' })
216
+ end
217
+ raise bad.call('pass a non-empty Array of key identifiers, or leave it out') unless refs.is_a?(Array) && !refs.empty?
218
+
219
+ refs.each do |r|
220
+ raise bad.call('each entry must be a key identifier without spaces') unless r.is_a?(String) && !r.strip.empty? && !r.match?(/\s/)
221
+ raise bad.call('an entry cannot contain "*"; name the exact key') if r.include?('*')
222
+ end
223
+ refs.map { |r| r.dup.freeze }.freeze
224
+ end
225
+
226
+ # Whether +key_ref+ is one of +expected+, ignoring the key version, by the rule the Kibo client core applies: AWS
227
+ # compares the whole reference; GCP drops +/cryptoKeyVersions/<n>+ and what follows; Azure compares the first five
228
+ # +/+-separated parts (+https://<host>/keys/<key>+) in lower case, so a version and the case of the host and key name
229
+ # are ignored; Vault compares +<path>/<key>+ without a +:<version>+ suffix. +provider:+ is required (:aws, :gcp,
230
+ # :azure or :vault) and is never inferred from the reference, because a Vault reference can look like a URL. Any
231
+ # other value raises K9O_INVALID_FIELD_VALUE. For authors of a client core; the SDK does not call it.
232
+ def self.key_ref_matches?(key_ref, expected, provider:)
233
+ k = key_identity(key_ref.to_s, provider)
234
+ Array(expected).any? { |e| key_identity(e.to_s, provider) == k }
235
+ end
236
+
237
+ def self.key_identity(ref, provider)
238
+ case provider.respond_to?(:to_sym) ? provider.to_sym : provider
239
+ when :aws then ref
240
+ when :gcp then ref.split('/cryptoKeyVersions/', -1).first.to_s
241
+ when :azure then ref.split('/', -1).first(5).join('/').downcase
242
+ when :vault
243
+ cut = ref.rindex('/')
244
+ path = cut.nil? ? '' : ref[0...cut]
245
+ key = cut.nil? ? ref : ref[(cut + 1)..]
246
+ "#{path}/#{key.split(':', 2).first.to_s}"
247
+ else
248
+ raise KError.new(code: 'K9O_INVALID_FIELD_VALUE', message: 'provider must be :aws, :gcp, :azure or :vault',
249
+ context: { 'field' => 'provider' })
250
+ end
251
+ end
252
+ private_class_method :key_identity
253
+
254
+ # The text of a decrypted item as a UTF-8 String (nil for an item without plaintext).
255
+ def self.text(item)
256
+ plain = item.is_a?(Hash) ? item['plaintext'] : nil
257
+ plain.is_a?(String) ? plain.dup.force_encoding(Encoding::UTF_8) : nil
258
+ end
259
+
260
+ private
261
+
262
+ def check_crypt_name(name, op)
263
+ return name if name.is_a?(String) && CRYPT_PATTERN.match?(name)
264
+
265
+ raise invalid("crypto.crypts.#{op}: #{CRYPT_RULE.sub('crypt must', 'name must')}", field: 'name')
266
+ end
267
+
268
+ def malformed_crypt(op, why)
269
+ KError.new(code: 'K9O_INTERNAL_ERROR', message: "crypto.crypts.#{op}: the gateway answered #{why}")
270
+ end
271
+
272
+ def crypt_of(item, op)
273
+ raise malformed_crypt(op, 'without a crypt') unless item.is_a?(Hash)
274
+ raise malformed_crypt(op, 'without a name') unless item['name'].is_a?(String) && !item['name'].empty?
275
+ raise malformed_crypt(op, 'with an unknown status') unless CRYPT_STATUSES.include?(item['status'])
276
+ raise malformed_crypt(op, 'without createdAt') unless item['createdAt'].is_a?(String) && !item['createdAt'].empty?
277
+ unless item['retiredAt'].nil? || item['retiredAt'].is_a?(String)
278
+ raise malformed_crypt(op, 'with a retiredAt that is neither a date nor null')
279
+ end
280
+
281
+ { 'name' => item['name'], 'status' => item['status'], 'createdAt' => item['createdAt'], 'retiredAt' => item['retiredAt'] }
282
+ end
283
+
284
+ def invalid(message, context = {})
285
+ ApprovalChecks.invalid(message, context)
286
+ end
287
+
288
+ def too_large(message, context)
289
+ KError.new(code: 'K9O_PAYLOAD_TOO_LARGE', message: message, context: context)
290
+ end
291
+
292
+ # Validate, then [crypt, wire items, client-core items]. Nothing is sent here.
293
+ def prepare(_op, crypt, items, field)
294
+ unless crypt.is_a?(String) && CRYPT_PATTERN.match?(crypt)
295
+ raise invalid('crypt must be 1 to 64 characters of a-z, 0-9 and -, and not "actions"', crypt: crypt)
296
+ end
297
+ unless items.is_a?(Array) && items.size >= 1 && items.size <= MAX_ITEMS
298
+ raise invalid("items must be an Array of 1 to #{MAX_ITEMS} items", count: items.is_a?(Array) ? items.size : nil)
299
+ end
300
+
301
+ total = 0
302
+ wire = []
303
+ plain = []
304
+ items.each_with_index do |item, i|
305
+ entry, size, core_item = wire_item(item, i, field)
306
+ total += size
307
+ wire << entry
308
+ plain << core_item
309
+ end
310
+ if field == :plaintext && total > MAX_CALL_BYTES
311
+ raise too_large("plaintext is at most #{MAX_CALL_BYTES} bytes per call", limit: MAX_CALL_BYTES, bytes: total)
312
+ end
313
+
314
+ [crypt, wire, plain]
315
+ end
316
+
317
+ def route_encrypt(crypt, wire, plain)
318
+ attempts = 0
319
+ loop do
320
+ attempts += 1
321
+ answer = mode_answer(Studio.require_project_slug(@config, 'crypto'), crypt)
322
+ custody = answer['custodyMode']
323
+ epoch = answer['epoch']
324
+ route = answer['route']
325
+ has_epoch = epoch.is_a?(String) && !epoch.empty?
326
+ # An unknown route refuses before anything is sent, in every custody mode.
327
+ # A route that is present but JSON null counts as unknown; only an absent key is "no route".
328
+ if answer.key?('route') && route != 'server' && route != 'client'
329
+ raise custody_unknown(custody, route)
330
+ end
331
+ if CLIENT_MODES.include?(custody)
332
+ return client_call('encrypt', crypt, plain, custody) unless route == 'server'
333
+ raise custody_unknown(custody, route) unless has_epoch
334
+ elsif !(SERVER_MODES.include?(custody) && has_epoch)
335
+ raise custody_unknown(custody, route)
336
+ end
337
+
338
+ begin
339
+ return post('encrypt', 'crypt' => crypt, 'items' => wire, 'custodyEpoch' => epoch)
340
+ rescue KError => e
341
+ raise unless attempts < 2 && zk_flow_required?(e)
342
+ end
343
+ end
344
+ end
345
+
346
+ # The raw GET crypto/mode answer as a Hash. Read fresh on every call; never cached.
347
+ # A non-nil +crypt+ goes out as ?crypt= so the gateway authorizes the caller on that crypt (encrypt's custody check).
348
+ def mode_answer(slug, crypt = nil)
349
+ data = HttpTransport.request(config: @config, method: 'GET', path: UrlGrammar.project_path(slug, 'crypto/mode'),
350
+ query: crypt.nil? ? nil : { 'crypt' => crypt }).data
351
+ data.is_a?(Hash) ? data : {}
352
+ end
353
+
354
+ def custody_unknown(custody, route)
355
+ context = { 'custodyMode' => custody }
356
+ context['route'] = route if route.is_a?(String)
357
+ KError.new(code: CUSTODY_MODE_UNKNOWN, status: nil,
358
+ message: 'the project custody mode or route is unknown, so nothing was sent; plaintext is never sent when the route is unclear',
359
+ context: context)
360
+ end
361
+
362
+ def route_by_ciphertext(op, crypt, wire, plain)
363
+ post(op, 'crypt' => crypt, 'items' => wire)
364
+ rescue KError => e
365
+ raise unless zk_flow_required?(e) && e.context.is_a?(Hash) && e.context['route'] == 'client'
366
+
367
+ client_call(op, crypt, plain, e.context['custodyMode'])
368
+ end
369
+
370
+ def zk_flow_required?(error)
371
+ error.status == 409 && error.code == ZK_STORE_FLOW_REQUIRED
372
+ end
373
+
374
+ def post(op, body)
375
+ slug = Studio.require_project_slug(@config, 'crypto')
376
+ envelope = HttpTransport.request(config: @config, method: 'POST',
377
+ path: UrlGrammar.project_path(slug, "crypto/#{op}"), body: body)
378
+ Envelope.new(data: decode(envelope.data), meta: envelope.meta)
379
+ end
380
+
381
+ def client_call(op, crypt, items, custody)
382
+ if @client_core.nil?
383
+ raise KError.new(code: ZK_CLIENT_CORE_UNAVAILABLE, status: 501,
384
+ message: "this project's key is held in your own KMS; install and register the Kibo client core " \
385
+ 'for this language (crypto.use_client_core), or call kibo.encrypt/decrypt directly',
386
+ context: { 'custodyMode' => custody, 'requirement' => 'kibo-client-core' })
387
+ end
388
+
389
+ refs = @config.expected_key_refs
390
+ unless refs.nil? || (@client_core.respond_to?(:enforces_expected_key_refs?) && @client_core.enforces_expected_key_refs? == true)
391
+ raise KError.new(code: KEY_PIN_UNENFORCED, status: nil,
392
+ message: 'expectedKeyRefs is set but the client core does not declare that it enforces it; nothing was sent',
393
+ context: { 'custodyMode' => custody, 'requirement' => 'client-core-enforces-expected-key-refs' },
394
+ recommendation: 'Use a client core that enforces the pin (enforces_expected_key_refs? returns true), ' \
395
+ 'or leave expected_key_refs out.')
396
+ end
397
+
398
+ opts = {}
399
+ opts[:expected_key_refs] = refs unless refs.nil?
400
+ result = @client_core.public_send(op, crypt, items, **opts)
401
+ result = result.data if result.is_a?(Envelope)
402
+ result = { 'items' => result } if result.is_a?(Array)
403
+ Envelope.new(data: result, meta: Meta.from_wire({}))
404
+ end
405
+
406
+ # [wire item, plaintext bytes, client-core item]
407
+ def wire_item(item, index, field)
408
+ item = { field => item } if item.is_a?(String)
409
+ unless item.is_a?(Hash)
410
+ raise invalid("items[#{index}] must be a Hash or a String", index: index)
411
+ end
412
+
413
+ value = item[field] || item[field.to_s]
414
+ unless value.is_a?(String) && (field == :plaintext || !value.empty?)
415
+ raise invalid("items[#{index}].#{field} is required and must be a String", index: index)
416
+ end
417
+ other = field == :plaintext ? :ciphertext : :plaintext
418
+ if item.key?(other) || item.key?(other.to_s)
419
+ raise invalid("items[#{index}] takes #{field}, not #{other}", index: index)
420
+ end
421
+
422
+ size = 0
423
+ entry = {}
424
+ core_item = {}
425
+ if field == :plaintext
426
+ bytes = value.b
427
+ size = bytes.bytesize
428
+ if size > MAX_ITEM_BYTES
429
+ raise too_large("plaintext is at most #{MAX_ITEM_BYTES} bytes per item", limit: MAX_ITEM_BYTES, index: index,
430
+ bytes: size)
431
+ end
432
+ entry['plaintext'] = [bytes].pack('m0')
433
+ core_item[:plaintext] = bytes
434
+ else
435
+ entry['ciphertext'] = value
436
+ core_item[:ciphertext] = value
437
+ end
438
+
439
+ context = item.key?(:context) ? item[:context] : item['context']
440
+ unless context.nil?
441
+ unless context.is_a?(String) && context.bytesize <= MAX_CONTEXT_BYTES
442
+ raise invalid("items[#{index}].context must be a String of at most #{MAX_CONTEXT_BYTES} bytes", index: index)
443
+ end
444
+ unless context.dup.force_encoding(Encoding::UTF_8).valid_encoding?
445
+ raise invalid("items[#{index}].context must be valid UTF-8 text", index: index)
446
+ end
447
+
448
+ entry['context'] = context
449
+ core_item[:context] = context
450
+ end
451
+ [entry, size, core_item]
452
+ end
453
+
454
+ # Decode each plaintext back to bytes; every other field stays as the gateway sent it.
455
+ def decode(data)
456
+ return data unless data.is_a?(Hash) && data['items'].is_a?(Array)
457
+
458
+ items = data['items'].map do |item|
459
+ next item unless item.is_a?(Hash) && item['plaintext'].is_a?(String)
460
+
461
+ bytes = begin
462
+ item['plaintext'].unpack1('m0')
463
+ rescue ArgumentError
464
+ nil
465
+ end
466
+ bytes.nil? ? item : item.merge('plaintext' => bytes)
467
+ end
468
+ data.merge('items' => items)
469
+ end
470
+ end
471
+ end
@@ -0,0 +1,28 @@
1
+ # frozen_string_literal: true
2
+
3
+ module K9o
4
+ # Key custody: k9o.custody.* reads how the project's keys are held. Changing custody is a Console action.
5
+ #
6
+ # status() GET /@<slug>/custody/status (project admin or auditor)
7
+ #
8
+ # +data+ is { 'mode', 'attached', 'provider', 'lastJob', 'hsm' }: +mode+ is a string such as "PLATFORM_MANAGED",
9
+ # +attached+ a boolean, +provider+ "platform", "external_kms", "hsm" or nil, +lastJob+ nil or
10
+ # { 'kind', 'status', 'completedAt' }. +hsm+ is nil unless the provider is "hsm"; then it is
11
+ # { 'vendor', 'clusterId', 'region', 'attestation' => { 'status', 'verifiedAt' }, 'keys' }, where
12
+ # +attestation+ values and +keys+ are nil for now. An answer without an +hsm+ key reads as +hsm+ nil.
13
+ # Errors: 403 for another role, 404 when the caller is not a project member, 502 K9O_UPSTREAM_UNAVAILABLE when the
14
+ # key manager cannot answer for an attached project.
15
+ class Custody
16
+ def initialize(config)
17
+ @config = config
18
+ end
19
+
20
+ def status
21
+ path = UrlGrammar.project_path(Studio.require_project_slug(@config, 'custody'), 'custody/status')
22
+ envelope = HttpTransport.request(config: @config, method: 'GET', path: path)
23
+ data = envelope.data
24
+ data['hsm'] = nil if data.is_a?(Hash) && !data.key?('hsm')
25
+ envelope
26
+ end
27
+ end
28
+ end
data/lib/k9o/data.rb CHANGED
@@ -19,12 +19,22 @@ module K9o
19
19
  SelectBuilder.new(@config, @collection)
20
20
  end
21
21
 
22
- def insert(record)
22
+ # Insert one record. With +require_approval: true+ the write is sent as +?requireApproval=true+ and waits for
23
+ # approval; the answer is then a queued change (HTTP 202) or, when an +auto_approve+ policy covers the write,
24
+ # the inserted row. Tell them apart with K9o.queued_write?.
25
+ def insert(record = nil, require_approval: false, **fields)
26
+ record = fields if record.nil? # a braceless hash literal arrives as keywords on Ruby 3
23
27
  HttpTransport.request(
24
- config: @config, method: 'POST', path: FromBuilder.collection_path(@config, @collection), body: record
28
+ config: @config, method: 'POST', path: FromBuilder.collection_path(@config, @collection), body: record,
29
+ query: FromBuilder.approval_query(require_approval)
25
30
  )
26
31
  end
27
32
 
33
+ # +{ 'requireApproval' => 'true' }+ for a write that asks to be queued, +nil+ (no query) otherwise.
34
+ def self.approval_query(require_approval)
35
+ require_approval ? { 'requireApproval' => 'true' } : nil
36
+ end
37
+
28
38
  def update(patch)
29
39
  UpdateBuilder.new(@config, @collection, patch)
30
40
  end
@@ -59,6 +69,7 @@ module K9o
59
69
  @collection = collection
60
70
  @filters = []
61
71
  @limit_val = nil
72
+ @require_approval = false
62
73
  @cursor_val = nil
63
74
  @count_mode = nil
64
75
  end
@@ -123,6 +134,7 @@ module K9o
123
134
  def initialize(config, collection)
124
135
  super
125
136
  @search = {}
137
+ @vector = nil
126
138
  end
127
139
 
128
140
  # Full-text search over one text column. +type+ is "plain", "phrase" or "websearch".
@@ -144,8 +156,11 @@ module K9o
144
156
  self
145
157
  end
146
158
 
147
- # Nearest-neighbour search over one vector column; results come back closest first.
148
- # +metric+ is "cosine", "euclidean" or "dot"; +limit+ is 1 to 1000 (server default 10).
159
+ # Nearest-neighbour search over one vector column; results come back closest first. The embedding travels in
160
+ # the request body (POST data/<collection>/search), so a real embedding of any length works.
161
+ # +metric+ is "cosine", "euclidean" or "dot"; +limit+ is 1 to 1000 (server default 10). The rows returned are
162
+ # the smaller of this +limit+ and the query's +limit+ (default 50): to get N rows set both.
163
+ # It cannot be combined with +text_search+.
149
164
  def vector_search(column, embedding, metric: 'cosine', limit: nil)
150
165
  check_search_column(column)
151
166
  unless embedding.is_a?(Array) && !embedding.empty? &&
@@ -159,15 +174,15 @@ module K9o
159
174
  raise search_error('vector limit must be an integer from 1 to 1000')
160
175
  end
161
176
 
162
- @search.delete('vectorMetric')
163
- @search.delete('vectorLimit')
164
- @search['vectorSearch'] = "#{column}:#{JSON.generate(embedding)}"
165
- @search['vectorMetric'] = metric unless metric == 'cosine'
166
- @search['vectorLimit'] = limit.to_s unless limit.nil?
177
+ @vector = { 'column' => column, 'vector' => embedding }
178
+ @vector['metric'] = metric unless metric == 'cosine'
179
+ @vector['limit'] = limit unless limit.nil?
167
180
  self
168
181
  end
169
182
 
170
183
  def execute
184
+ return execute_vector_search if @vector
185
+
171
186
  HttpTransport.request(
172
187
  config: @config, method: 'GET',
173
188
  path: FromBuilder.collection_path(@config, @collection),
@@ -177,6 +192,29 @@ module K9o
177
192
 
178
193
  private
179
194
 
195
+ BODY_KEYS = %w[select order limit cursor count or].freeze
196
+
197
+ # POST data/<collection>/search: the vector search entry becomes an object, filters go under +filter+, and the
198
+ # list keys (select, order, limit, cursor, count, or) stay at the top level.
199
+ def execute_vector_search
200
+ raise search_error('text_search cannot be combined with vector_search') if @search.key?('textSearch')
201
+
202
+ body = { 'vectorSearch' => @vector }
203
+ filter = {}
204
+ to_query.each do |key, value|
205
+ if BODY_KEYS.include?(key)
206
+ body[key] = key == 'limit' ? @limit_val : value
207
+ else
208
+ filter[key] = value
209
+ end
210
+ end
211
+ body['filter'] = filter unless filter.empty?
212
+ HttpTransport.request(
213
+ config: @config, method: 'POST',
214
+ path: "#{FromBuilder.collection_path(@config, @collection)}/search", body: body
215
+ )
216
+ end
217
+
180
218
  def search_error(message)
181
219
  KError.new(code: 'K9O_INVALID_FIELD_VALUE', message: message, context: { collection: @collection })
182
220
  end
@@ -194,6 +232,13 @@ module K9o
194
232
  @patch = patch
195
233
  end
196
234
 
235
+ # Ask for this update to wait for approval (+?requireApproval=true+). The answer is then a queued change or, when an
236
+ # +auto_approve+ policy covers the write, the updated row: check it with K9o.queued_write?.
237
+ def require_approval
238
+ @require_approval = true
239
+ self
240
+ end
241
+
197
242
  def execute
198
243
  id_filter = @filters.find { |col, op, _| col == 'id' && op == 'eq' }
199
244
  raise KError.new(
@@ -203,11 +248,19 @@ module K9o
203
248
  ) unless id_filter
204
249
 
205
250
  path = "#{FromBuilder.collection_path(@config, @collection)}/#{id_filter[2]}"
206
- HttpTransport.request(config: @config, method: 'PATCH', path: path, body: @patch)
251
+ HttpTransport.request(config: @config, method: 'PATCH', path: path, body: @patch,
252
+ query: FromBuilder.approval_query(@require_approval))
207
253
  end
208
254
  end
209
255
 
210
256
  class DeleteBuilder < BaseBuilder
257
+ # Ask for this delete to wait for approval (+?requireApproval=true+). The answer is then a queued change or, when an
258
+ # +auto_approve+ policy covers the write, the deleted record: check it with K9o.queued_write?.
259
+ def require_approval
260
+ @require_approval = true
261
+ self
262
+ end
263
+
211
264
  def execute
212
265
  id_filter = @filters.find { |col, op, _| col == 'id' && op == 'eq' }
213
266
  raise KError.new(
@@ -217,7 +270,8 @@ module K9o
217
270
  ) unless id_filter
218
271
 
219
272
  path = "#{FromBuilder.collection_path(@config, @collection)}/#{id_filter[2]}"
220
- HttpTransport.request(config: @config, method: 'DELETE', path: path)
273
+ HttpTransport.request(config: @config, method: 'DELETE', path: path,
274
+ query: FromBuilder.approval_query(@require_approval))
221
275
  end
222
276
  end
223
277
  end