k9o 1.2.0-aarch64-linux → 1.5.2-aarch64-linux

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.
Files changed (62) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +123 -1
  3. data/README.md +1602 -180
  4. data/k9o.gemspec +2 -2
  5. data/lib/k9o/_ffi/kibo_kms_rest_ffi.rb +928 -25
  6. data/lib/k9o/_ffi/libkibo_kms_rest_ffi.so +0 -0
  7. data/lib/k9o/ai.rb +15 -0
  8. data/lib/k9o/analytics.rb +403 -0
  9. data/lib/k9o/approvals.rb +253 -0
  10. data/lib/k9o/audit.rb +162 -14
  11. data/lib/k9o/client.rb +28 -9
  12. data/lib/k9o/compute.rb +32 -15
  13. data/lib/k9o/crypto.rb +471 -0
  14. data/lib/k9o/custody.rb +28 -0
  15. data/lib/k9o/data.rb +125 -5
  16. data/lib/k9o/ed25519.rb +182 -0
  17. data/lib/k9o/end_users.rb +93 -0
  18. data/lib/k9o/envelope.rb +20 -5
  19. data/lib/k9o/functions.rb +127 -9
  20. data/lib/k9o/gate.rb +52 -22
  21. data/lib/k9o/http_transport.rb +78 -16
  22. data/lib/k9o/jcs.rb +117 -0
  23. data/lib/k9o/kerror.rb +57 -5
  24. data/lib/k9o/keys.rb +131 -0
  25. data/lib/k9o/kibo.rb +3 -3
  26. data/lib/k9o/kibo_client_core.rb +385 -0
  27. data/lib/k9o/kibo_ffi_core.rb +178 -0
  28. data/lib/k9o/locate.rb +118 -0
  29. data/lib/k9o/me.rb +27 -87
  30. data/lib/k9o/metadata.rb +342 -0
  31. data/lib/k9o/moat.rb +443 -0
  32. data/lib/k9o/org.rb +80 -68
  33. data/lib/k9o/orgs.rb +1 -0
  34. data/lib/k9o/policies.rb +25 -6
  35. data/lib/k9o/projects.rb +43 -8
  36. data/lib/k9o/query.rb +174 -0
  37. data/lib/k9o/rampart.rb +273 -0
  38. data/lib/k9o/realtime.rb +261 -32
  39. data/lib/k9o/region.rb +20 -18
  40. data/lib/k9o/schedules.rb +109 -0
  41. data/lib/k9o/schema.rb +35 -0
  42. data/lib/k9o/streams.rb +94 -0
  43. data/lib/k9o/studio.rb +21 -11
  44. data/lib/k9o/url_grammar.rb +46 -20
  45. data/lib/k9o/vault.rb +79 -84
  46. data/lib/k9o/vectors.rb +124 -0
  47. data/lib/k9o/version.rb +1 -1
  48. data/lib/k9o.rb +17 -0
  49. data/sig/k9o/ai.rbs +4 -0
  50. data/sig/k9o/approvals.rbs +71 -0
  51. data/sig/k9o/datasets.rbs +125 -0
  52. data/sig/k9o/gate.rbs +9 -6
  53. data/sig/k9o/kerror.rbs +24 -0
  54. data/sig/k9o/kibo_client_core.rbs +25 -0
  55. data/sig/k9o/metadata.rbs +54 -0
  56. data/sig/k9o/moat.rbs +29 -0
  57. data/sig/k9o/query.rbs +26 -0
  58. data/sig/k9o/rampart.rbs +59 -0
  59. data/sig/k9o/realtime.rbs +34 -0
  60. data/sig/k9o/region.rbs +14 -3
  61. data/sig/k9o/workload.rbs +64 -0
  62. metadata +33 -4
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