k9o 1.3.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.
Files changed (51) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +80 -1
  3. data/README.md +1516 -159
  4. data/k9o.gemspec +2 -2
  5. data/lib/k9o/ai.rb +15 -0
  6. data/lib/k9o/analytics.rb +403 -0
  7. data/lib/k9o/approvals.rb +253 -0
  8. data/lib/k9o/audit.rb +150 -0
  9. data/lib/k9o/client.rb +25 -4
  10. data/lib/k9o/crypto.rb +471 -0
  11. data/lib/k9o/custody.rb +28 -0
  12. data/lib/k9o/data.rb +125 -5
  13. data/lib/k9o/ed25519.rb +182 -0
  14. data/lib/k9o/end_users.rb +93 -0
  15. data/lib/k9o/envelope.rb +20 -5
  16. data/lib/k9o/functions.rb +82 -3
  17. data/lib/k9o/gate.rb +46 -7
  18. data/lib/k9o/http_transport.rb +71 -14
  19. data/lib/k9o/jcs.rb +117 -0
  20. data/lib/k9o/kerror.rb +57 -5
  21. data/lib/k9o/keys.rb +131 -0
  22. data/lib/k9o/kibo_client_core.rb +385 -0
  23. data/lib/k9o/kibo_ffi_core.rb +178 -0
  24. data/lib/k9o/locate.rb +118 -0
  25. data/lib/k9o/me.rb +1 -1
  26. data/lib/k9o/metadata.rb +342 -0
  27. data/lib/k9o/moat.rb +443 -0
  28. data/lib/k9o/org.rb +1 -1
  29. data/lib/k9o/query.rb +174 -0
  30. data/lib/k9o/rampart.rb +273 -0
  31. data/lib/k9o/realtime.rb +261 -10
  32. data/lib/k9o/schedules.rb +109 -0
  33. data/lib/k9o/schema.rb +35 -0
  34. data/lib/k9o/streams.rb +94 -0
  35. data/lib/k9o/vault.rb +3 -4
  36. data/lib/k9o/vectors.rb +124 -0
  37. data/lib/k9o/version.rb +1 -1
  38. data/lib/k9o.rb +17 -0
  39. data/sig/k9o/ai.rbs +4 -0
  40. data/sig/k9o/approvals.rbs +71 -0
  41. data/sig/k9o/datasets.rbs +125 -0
  42. data/sig/k9o/gate.rbs +2 -0
  43. data/sig/k9o/kerror.rbs +24 -0
  44. data/sig/k9o/kibo_client_core.rbs +25 -0
  45. data/sig/k9o/metadata.rbs +54 -0
  46. data/sig/k9o/moat.rbs +29 -0
  47. data/sig/k9o/query.rbs +26 -0
  48. data/sig/k9o/rampart.rbs +59 -0
  49. data/sig/k9o/realtime.rbs +34 -0
  50. data/sig/k9o/workload.rbs +64 -0
  51. metadata +33 -4
@@ -0,0 +1,385 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'timeout'
4
+ require_relative 'kerror'
5
+ require_relative 'crypto'
6
+
7
+ module K9o
8
+ # The client core for +client.crypto+ on a BYOK or HYOK project: it opens and seals data on this machine, against your
9
+ # own KMS or HSM, so the platform never sees plaintext or the key. There is no crypto here. Sealing, opening, the key
10
+ # pin and the token exchange all run in the audited Rust core (+kibo-kms-rest-ffi+, zk-bundle-v2.16.3 or later). This
11
+ # class only sequences them:
12
+ #
13
+ # federated token (platform) -> acquire_kms_credential (your cloud) -> app_crypto_seal / app_crypto_open (your KMS)
14
+ #
15
+ # Everything that picks a key, a provider, a role, a region or an endpoint comes from the configuration you pass here.
16
+ # Nothing comes from the platform's answer: the token response's +provider+ and +project_id+ are checked against your
17
+ # configuration, and a mismatch is refused, never followed.
18
+ #
19
+ # core = K9o::KiboClientCore.new(
20
+ # provider: :aws, posture: :byok,
21
+ # key_ref: { version: 1, external_ref: key_arn },
22
+ # provider_config: { aws_region: 'eu-central-1' },
23
+ # federation: { provider: 'aws', role_arn: role_arn, session_name: 'app', region: 'eu-central-1',
24
+ # key_arn: key_arn, duration_secs: 900 },
25
+ # expected_key_refs: [key_arn], # required
26
+ # federated_token: client.crypto.method(:federated_token),
27
+ # core: K9o::KiboFfiCore.new
28
+ # )
29
+ # client.crypto.use_client_core(core)
30
+ #
31
+ # +core:+ is the bound Kibo core: K9o::KiboFfiCore.new, which wraps the generated binding and the native library, or any
32
+ # object that responds to +app_crypto_seal_blocking+, +app_crypto_open_blocking+, +acquire_kms_credential_blocking+ and
33
+ # +core_capabilities+ (Ruby has no async, so it calls the +_blocking+ exports). Inputs are passed as Hashes with snake_case
34
+ # keys, the field names of the core's input types; an answer may be a Hash or an object with the same readers.
35
+ # Ciphertext is the base64 String of the container bytes.
36
+ class KiboClientCore
37
+ EXCHANGE_TIMEOUT = 'KIBO_EXCHANGE_TIMEOUT'
38
+ CORE_FUNCTIONS = %i[app_crypto_seal_blocking app_crypto_open_blocking acquire_kms_credential_blocking
39
+ core_capabilities].freeze
40
+ PROVIDER_WIRE = { aws: 'aws-kms', gcp: 'gcp-kms', azure: 'azure-kv', vault: 'hashicorp-vault' }.freeze
41
+ POSTURES = %i[byok hyok].freeze
42
+ OPS = %w[encrypt decrypt rewrap].freeze
43
+ # Refresh a credential this long before it expires.
44
+ SKEW_SECS = 60
45
+ DEFAULT_DEADLINE_SECS = 30
46
+
47
+ Cred = Struct.new(:secret, :expires_at_unix_secs) do
48
+ def inspect
49
+ "#<struct K9o::KiboClientCore::Cred secret=[redacted] expires_at_unix_secs=#{expires_at_unix_secs.inspect}>"
50
+ end
51
+
52
+ def to_s
53
+ inspect
54
+ end
55
+
56
+ def to_h
57
+ { secret: secret, expires_at_unix_secs: expires_at_unix_secs }
58
+ end
59
+ end
60
+ private_constant :Cred
61
+
62
+ # +provider+: :aws, :gcp, :azure or :vault, the same as +federation[:provider]+.
63
+ # +posture+: :byok or :hyok.
64
+ # +project_id+: the project id (+proj_...+), bound into what is sealed. Leave it out only if you want the token
65
+ # response's value; a value given here is never overridden.
66
+ # +key_ref+: the exact key this application uses, +{ version:, external_ref: }+.
67
+ # +provider_config+: +{ aws_region:, vault_address: }+ (only the ones you need).
68
+ # +federation+: how to exchange the platform's identity token for a credential on your cloud (the core's
69
+ # FfiFederationSpec, with a String +provider+ of "aws", "gcp", "azure" or "vault"). For "vault" it is
70
+ # +{ provider: 'vault', vault_addr:, jwt_auth_mount:, role: }+, where +jwt_auth_mount+ is the JWT auth mount: the name
71
+ # Vault serves the JWT login under, "jwt" for +auth/jwt+ (not the transit mount, which comes from the key reference).
72
+ # It is handed to the core as the core's own +mount+ field.
73
+ # +expected_key_refs+: the keys this application allows. Required and non-empty.
74
+ # +federated_token+: anything that answers +call(crypt, op)+ with a Crypto::FederatedToken, for example
75
+ # +client.crypto.method(:federated_token)+.
76
+ # +exchange_deadline+: seconds the exchange may take before it is abandoned. Default 30.
77
+ # +now+: the host clock, a callable answering Unix seconds.
78
+ def initialize(provider:, posture:, key_ref:, federation:, expected_key_refs:, federated_token:, core:,
79
+ project_id: nil, provider_config: {}, exchange_deadline: DEFAULT_DEADLINE_SECS,
80
+ now: -> { Time.now.to_i })
81
+ @provider = provider.respond_to?(:to_sym) ? provider.to_sym : provider
82
+ @federation = symbolize(federation)
83
+ check_vault_federation(@federation) if @federation[:provider].to_s == 'vault'
84
+ fed_provider = @federation[:provider].respond_to?(:to_sym) ? @federation[:provider].to_sym : @federation[:provider]
85
+ raise bad('provider and federation.provider must be the same cloud', 'provider') unless @provider == fed_provider
86
+ raise bad('provider must be :aws, :gcp, :azure or :vault', 'provider') unless PROVIDER_WIRE.key?(@provider)
87
+
88
+ @posture = posture.respond_to?(:to_sym) ? posture.to_sym : posture
89
+ raise bad('posture must be :byok or :hyok', 'posture') unless POSTURES.include?(@posture)
90
+
91
+ @pin = check_pin(expected_key_refs)
92
+ key_ref = symbolize(key_ref)
93
+ @key_ref = { version: key_ref[:version], external_ref: key_ref[:external_ref] }
94
+ unless @key_ref[:external_ref].is_a?(String) && Crypto.key_ref_matches?(@key_ref[:external_ref], @pin, provider: @provider)
95
+ raise bad('key_ref is not in expected_key_refs', 'keyRef')
96
+ end
97
+
98
+ raise bad('federated_token must respond to call(crypt, op)', 'federatedToken') unless federated_token.respond_to?(:call)
99
+
100
+ @project_id = project_id
101
+ @provider_config = symbolize(provider_config)
102
+ @federated_token = federated_token
103
+ @core = core
104
+ @deadline = exchange_deadline
105
+ @now = now
106
+ @creds = {}
107
+ @mutex = Mutex.new
108
+ @caps_checked = false
109
+ end
110
+
111
+ # Declares that this core enforces the key pin (see Crypto#use_client_core).
112
+ def enforces_expected_key_refs?
113
+ true
114
+ end
115
+
116
+ # Neither the cached credentials nor the callables appear here.
117
+ def inspect
118
+ "#<#{self.class.name} provider=#{@provider.inspect} posture=#{@posture.inspect}>"
119
+ end
120
+
121
+ def to_s
122
+ inspect
123
+ end
124
+
125
+ def pretty_print(pp)
126
+ pp.text(inspect)
127
+ end
128
+
129
+ # Seal each item's plaintext under the configured key. Items: +{ plaintext:, context: }+. Answers
130
+ # +{ 'items' => [{ 'ok' => true, 'ciphertext' => base64 } | { 'ok' => false, 'code', 'message' }] }+.
131
+ def encrypt(crypt, items, **options)
132
+ pin = effective_pin(options)
133
+ return refused(items.size) unless key_in_pin?(pin)
134
+
135
+ core = loaded_core
136
+ cred, project_id, secrets = credential(core, crypt, 'encrypt')
137
+ out = items.map do |it|
138
+ guard(secrets) do
139
+ { 'ok' => true, 'ciphertext' => seal(core, cred, project_id, crypt, it[:plaintext], it[:context], pin, options) }
140
+ end
141
+ end
142
+ { 'items' => out }
143
+ end
144
+
145
+ # Open each item's ciphertext. Answers +'plaintext'+ as a binary String (and +'text'+ when it is valid UTF-8).
146
+ def decrypt(crypt, items, **options)
147
+ pin = effective_pin(options)
148
+ return refused(items.size) unless key_in_pin?(pin)
149
+
150
+ core = loaded_core
151
+ cred, project_id, secrets = credential(core, crypt, 'decrypt')
152
+ out = items.map do |it|
153
+ guard(secrets) do
154
+ plain = open_item(core, cred, project_id, crypt, it[:ciphertext], it[:context], pin, options)
155
+ item = { 'ok' => true, 'plaintext' => plain }
156
+ text = plain.dup.force_encoding(Encoding::UTF_8)
157
+ item['text'] = text if text.valid_encoding?
158
+ item
159
+ end
160
+ end
161
+ { 'items' => out }
162
+ end
163
+
164
+ # Open each item, then seal it again under the configured key, with a "rewrap" federated token.
165
+ def rewrap(crypt, items, **options)
166
+ pin = effective_pin(options)
167
+ return refused(items.size) unless key_in_pin?(pin)
168
+
169
+ core = loaded_core
170
+ cred, project_id, secrets = credential(core, crypt, 'rewrap')
171
+ out = items.map do |it|
172
+ guard(secrets) do
173
+ plain = open_item(core, cred, project_id, crypt, it[:ciphertext], it[:context], pin, options)
174
+ { 'ok' => true, 'ciphertext' => seal(core, cred, project_id, crypt, plain, it[:context], pin, options) }
175
+ end
176
+ end
177
+ { 'items' => out }
178
+ end
179
+
180
+ private
181
+
182
+ def bad(message, field)
183
+ KError.new(code: 'K9O_INVALID_FIELD_VALUE', message: message, context: { 'field' => field })
184
+ end
185
+
186
+ def symbolize(hash)
187
+ raise bad('expected a Hash', 'config') unless hash.is_a?(Hash)
188
+
189
+ hash.each_with_object({}) { |(k, v), acc| acc[k.to_sym] = v }
190
+ end
191
+
192
+ def check_vault_federation(fed)
193
+ %i[vault_addr jwt_auth_mount role].each do |field|
194
+ next if fed[field].is_a?(String) && !fed[field].strip.empty?
195
+
196
+ raise bad("federation.#{field} is required for vault and must be a non-empty String", "federation.#{field}")
197
+ end
198
+ end
199
+
200
+ # The spec as the core names its fields: the SDK's +jwt_auth_mount+ is the core's +mount+.
201
+ def core_spec
202
+ spec = @federation.dup
203
+ spec[:mount] = spec.delete(:jwt_auth_mount) if spec.key?(:jwt_auth_mount)
204
+ spec
205
+ end
206
+
207
+ def check_pin(refs)
208
+ pin = Crypto.check_expected_key_refs(refs)
209
+ if pin.nil?
210
+ raise bad('expected_key_refs is required: this client never seals or opens without a key pin', 'expectedKeyRefs')
211
+ end
212
+
213
+ pin
214
+ end
215
+
216
+ def effective_pin(options)
217
+ p = options[:expected_key_refs].nil? ? @pin : options[:expected_key_refs]
218
+ unless p.is_a?(Array) && !p.empty?
219
+ raise bad('expected_key_refs is empty: refusing to call without a key pin', 'expectedKeyRefs')
220
+ end
221
+
222
+ p
223
+ end
224
+
225
+ # The call's own pin is matched against the configured key before any token is fetched or any credential is exchanged;
226
+ # the key version is ignored.
227
+ def key_in_pin?(pin)
228
+ Crypto.key_ref_matches?(@key_ref[:external_ref], pin, provider: @provider)
229
+ end
230
+
231
+ def refused(count)
232
+ item = { 'ok' => false, 'code' => 'K9O_CRYPTO_UNEXPECTED_KEY',
233
+ 'message' => "the configured key is not in this call's expected_key_refs; nothing was sent" }
234
+ { 'items' => Array.new(count) { item.dup } }
235
+ end
236
+
237
+ # The core, once it is known to be 2.16.3 or later and to declare that it enforces the pin. Nothing is fetched or sent
238
+ # before this passes.
239
+ def loaded_core
240
+ CORE_FUNCTIONS.each do |fn|
241
+ next if @core.respond_to?(fn)
242
+
243
+ raise KError.new(code: 'KIBO_WASM_ERROR',
244
+ message: "the Kibo core does not export #{fn}; it is older than zk-bundle-v2.16.3",
245
+ context: { 'missing' => fn.to_s },
246
+ recommendation: 'Use a build of the Kibo binding that ships zk-bundle-v2.16.3 or later.')
247
+ end
248
+ unless @caps_checked
249
+ unless Array(@core.core_capabilities).include?('expected_key_refs')
250
+ raise KError.new(code: Crypto::KEY_PIN_UNENFORCED,
251
+ message: 'the Kibo core does not declare that it enforces expected_key_refs; nothing was sent',
252
+ recommendation: 'Use a build of the Kibo binding that ships zk-bundle-v2.16.3 or later.')
253
+ end
254
+ @caps_checked = true
255
+ end
256
+ @core
257
+ end
258
+
259
+ # [credential, project id, secrets to scrub from messages]
260
+ def credential(core, crypt, op)
261
+ t = @federated_token.call(crypt, op)
262
+ named_provider = t.respond_to?(:provider) ? t.provider : nil
263
+ if !named_provider.nil? && named_provider.to_s != @provider.to_s
264
+ raise bad("the platform named provider \"#{named_provider}\"; this client is configured for \"#{@provider}\"", 'provider')
265
+ end
266
+
267
+ named = t.respond_to?(:project_id) ? t.project_id : nil
268
+ if !named.nil? && !@project_id.nil? && named != @project_id
269
+ raise bad('the platform named a different project than the one configured', 'projectId')
270
+ end
271
+
272
+ project_id = @project_id.nil? && named.is_a?(String) ? named : @project_id
273
+ raise bad('project_id is not configured and the platform did not name one', 'projectId') if project_id.nil?
274
+
275
+ key = [crypt, op, t.subject].join("\u0000")
276
+ # A token is fetched on every call (it is bound to the call's crypt and op); the credential from the exchange is
277
+ # reused until it is close to expiring.
278
+ cached = @mutex.synchronize { @creds[key] }
279
+ return [cached, project_id, [t.token, cached.secret]] if cached && cached.expires_at_unix_secs - SKEW_SECS > @now.call
280
+
281
+ out = exchange(core, t.token)
282
+ secret = read(out, :secret)
283
+ expiry = read(out, :expires_at_unix_secs)
284
+ unless secret.is_a?(String) && expiry.is_a?(Numeric)
285
+ raise KError.new(code: 'KIBO_WASM_DECODE_ERROR', message: 'the core returned a credential without secret or expiry')
286
+ end
287
+
288
+ cred = Cred.new(secret, expiry)
289
+ @mutex.synchronize { @creds[key] = cred }
290
+ [cred, project_id, [t.token, secret]]
291
+ end
292
+
293
+ # acquire_kms_credential_blocking under a deadline. A blocking native call cannot be interrupted, so it runs on its
294
+ # own thread and is abandoned when the deadline passes.
295
+ def exchange(core, token)
296
+ input = { spec: core_spec, identity_token: token, now_unix_secs: @now.call }
297
+ worker = Thread.new do
298
+ Thread.current.report_on_exception = false
299
+ core.acquire_kms_credential_blocking(input)
300
+ end
301
+ unless worker.join(@deadline)
302
+ worker.kill
303
+ raise KError.new(code: EXCHANGE_TIMEOUT,
304
+ message: "the token exchange with your cloud did not finish within #{@deadline} s",
305
+ context: { 'deadlineSecs' => @deadline },
306
+ recommendation: "Check that your cloud's token endpoint is reachable from this host.")
307
+ end
308
+ worker.value
309
+ rescue KError => e
310
+ raise scrub_error(e, [token])
311
+ rescue StandardError => e
312
+ raise map_core_error(e, [token])
313
+ end
314
+
315
+ def common(cred, project_id, crypt, context, pin, options)
316
+ {
317
+ kms_cred: cred.to_h,
318
+ provider_config: { aws_region: @provider_config[:aws_region], vault_address: @provider_config[:vault_address] },
319
+ project_id: project_id,
320
+ crypt: crypt,
321
+ context: context,
322
+ expected_key_refs: pin.dup
323
+ }
324
+ end
325
+
326
+ def seal(core, cred, project_id, crypt, plaintext, context, pin, options)
327
+ out = core.app_crypto_seal_blocking(
328
+ { provider: PROVIDER_WIRE[@provider], posture: @posture.to_s, key_ref: @key_ref.dup,
329
+ plaintext: plaintext.b }.merge(common(cred, project_id, crypt, context, pin, options))
330
+ )
331
+ [to_bytes(read(out, :container))].pack('m0')
332
+ end
333
+
334
+ def open_item(core, cred, project_id, crypt, ciphertext, context, pin, options)
335
+ container = ciphertext.to_s.unpack1('m0')
336
+ out = core.app_crypto_open_blocking(
337
+ { container: container }.merge(common(cred, project_id, crypt, context, pin, options))
338
+ )
339
+ to_bytes(read(out, :plaintext))
340
+ end
341
+
342
+ def read(obj, name)
343
+ if obj.is_a?(Hash)
344
+ obj.key?(name) ? obj[name] : obj[name.to_s]
345
+ elsif obj.respond_to?(name)
346
+ obj.public_send(name)
347
+ end
348
+ end
349
+
350
+ def to_bytes(value)
351
+ return value.b if value.is_a?(String)
352
+ return value.pack('C*') if value.is_a?(Array)
353
+
354
+ raise KError.new(code: 'KIBO_WASM_DECODE_ERROR', message: 'the core returned a non-byte value where bytes were expected')
355
+ end
356
+
357
+ # One item's failure stays its own: the core's code passes through ("<CODE>: <message>"; UPSTREAM_KMS_ERROR is your
358
+ # own KMS failing).
359
+ def guard(secrets)
360
+ yield
361
+ rescue StandardError => e
362
+ k = map_core_error(e, secrets)
363
+ { 'ok' => false, 'code' => k.code, 'message' => k.message }
364
+ end
365
+
366
+ def map_core_error(error, secrets)
367
+ return scrub_error(error, secrets) if error.is_a?(KError)
368
+
369
+ raw = scrub(error.message.to_s, secrets)
370
+ head = /\A([A-Z][A-Z0-9_]*):\s*(.*)\z/m.match(raw)
371
+ KError.new(code: head ? head[1] : 'KIBO_WASM_ERROR', message: head ? head[2] : raw)
372
+ end
373
+
374
+ def scrub_error(error, secrets)
375
+ message = scrub(error.message.to_s, secrets)
376
+ return error if message == error.message.to_s
377
+
378
+ KError.new(code: error.code, message: message, context: error.context, recommendation: error.recommendation)
379
+ end
380
+
381
+ def scrub(text, secrets)
382
+ secrets.compact.reduce(text) { |acc, s| s.to_s.empty? ? acc : acc.gsub(s.to_s, '[redacted]') }
383
+ end
384
+ end
385
+ end
@@ -0,0 +1,178 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'kerror'
4
+
5
+ module K9o
6
+ # The bound Kibo core for K9o::KiboClientCore: it hands the SDK's plain Hashes and Strings to the generated uniffi
7
+ # binding (+KiboKmsRestFfi+, zk-bundle-v2.16.3 or later) as the typed records, integer enums and federation variant
8
+ # the binding wants, and turns the binding's errors into the SDK's "CODE: detail" form. There is no cryptography here:
9
+ # sealing, opening, the key pin and the token exchange all run in the native core. Requiring +k9o+ does not load the
10
+ # binding; building this object does, and raises a K9o::KError saying what is missing when it cannot.
11
+ #
12
+ # core = K9o::KiboFfiCore.new # binding and library on the default paths
13
+ # core = K9o::KiboFfiCore.new(library: '/opt/kibo/libkibo_kms_rest_ffi.so') # library at an absolute path
14
+ #
15
+ # The generated binding opens the native library by its bare name, +kibo_kms_rest_ffi+. Ruby's +ffi+ gem looks for it in
16
+ # the system library paths (+/opt/homebrew/lib+, +/opt/local/lib+, +/usr/local/lib+, +/usr/lib+, and on Linux the
17
+ # loader paths such as +LD_LIBRARY_PATH+). macOS does not pass +DYLD_LIBRARY_PATH+ to the system Ruby, so on macOS put
18
+ # the library in one of those directories or pass +library:+ with its absolute path. +library:+ makes this adapter load
19
+ # the binding with that path in place of the bare name; the binding file itself is not changed.
20
+ class KiboFfiCore
21
+ # Raised from the binding's errors. The message is "<CODE>: <detail>", which KiboClientCore turns into the item's
22
+ # code and message.
23
+ class CoreError < StandardError; end
24
+
25
+ PROVIDERS = { 'aws-kms' => :AWS_KMS, 'gcp-kms' => :GCP_KMS, 'azure-kv' => :AZURE_KV,
26
+ 'hashicorp-vault' => :HASHICORP_VAULT }.freeze
27
+ POSTURES = { 'byok' => :BYOK, 'hyok' => :HYOK }.freeze
28
+ BINDING_LIB_LINE = /^(\s*)ffi_lib\s+'kibo_kms_rest_ffi'\s*$/.freeze
29
+ REQUIRED = %i[app_crypto_seal_blocking app_crypto_open_blocking acquire_kms_credential_blocking
30
+ core_capabilities].freeze
31
+
32
+ # +library+: absolute path of the native library, for a library that is not on the default paths.
33
+ # +binding_path+: path of the generated +kibo_kms_rest_ffi.rb+, when it is not on the load path. Needed only if
34
+ # +KiboKmsRestFfi+ is not already loaded and +require 'kibo_kms_rest_ffi'+ would not find it.
35
+ # +ffi+: the binding module itself, when you have already loaded it under another name.
36
+ def initialize(library: nil, binding_path: nil, ffi: nil)
37
+ @ffi = ffi || load_binding(library, binding_path)
38
+ REQUIRED.each do |fn|
39
+ next if @ffi.respond_to?(fn)
40
+
41
+ raise unavailable("the loaded KiboKmsRestFfi does not export #{fn}; it is older than zk-bundle-v2.16.3")
42
+ end
43
+ end
44
+
45
+ def core_capabilities
46
+ guard { @ffi.core_capabilities }
47
+ end
48
+
49
+ def acquire_kms_credential_blocking(input)
50
+ guard do
51
+ @ffi.acquire_kms_credential_blocking(
52
+ @ffi::FfiFederationInput.new(spec: spec(input[:spec]), identity_token: input[:identity_token],
53
+ now_unix_secs: input[:now_unix_secs])
54
+ )
55
+ end
56
+ end
57
+
58
+ def app_crypto_seal_blocking(input)
59
+ guard do
60
+ @ffi.app_crypto_seal_blocking(
61
+ @ffi::AppSealInput.new(
62
+ provider: enum(@ffi::KmsProvider, PROVIDERS, input[:provider], 'provider'),
63
+ posture: enum(@ffi::FfiPosture, POSTURES, input[:posture], 'posture'),
64
+ kms_cred: cred(input[:kms_cred]),
65
+ key_ref: @ffi::FfiKeyRef.new(version: input[:key_ref][:version], external_ref: input[:key_ref][:external_ref]),
66
+ provider_config: provider_config(input[:provider_config]),
67
+ project_id: input[:project_id], crypt: input[:crypt], context: input[:context],
68
+ plaintext: input[:plaintext], expected_key_refs: input[:expected_key_refs]
69
+ )
70
+ )
71
+ end
72
+ end
73
+
74
+ def app_crypto_open_blocking(input)
75
+ guard do
76
+ @ffi.app_crypto_open_blocking(
77
+ @ffi::AppOpenInput.new(
78
+ kms_cred: cred(input[:kms_cred]), provider_config: provider_config(input[:provider_config]),
79
+ project_id: input[:project_id], crypt: input[:crypt], context: input[:context],
80
+ container: input[:container], expected_key_refs: input[:expected_key_refs]
81
+ )
82
+ )
83
+ end
84
+ end
85
+
86
+ def inspect
87
+ "#<#{self.class.name}>"
88
+ end
89
+
90
+ private
91
+
92
+ def unavailable(message)
93
+ KError.new(code: 'KIBO_WASM_ERROR', message: message,
94
+ recommendation: 'Install the generated Kibo binding (kibo_kms_rest_ffi.rb) and the native library ' \
95
+ '(zk-bundle-v2.16.3 or later); see "Encrypt through client.crypto" in the README.')
96
+ end
97
+
98
+ def load_binding(library, binding_path)
99
+ require 'ffi'
100
+ existing = loaded_binding
101
+ return existing if existing
102
+ return require_binding(binding_path) if library.nil?
103
+
104
+ load_with_library(library, binding_path)
105
+ rescue LoadError => e
106
+ raise unavailable("the Kibo binding or its native library could not be loaded: #{e.message}")
107
+ end
108
+
109
+ def loaded_binding
110
+ return nil unless Object.const_defined?(:KiboKmsRestFfi)
111
+
112
+ mod = Object.const_get(:KiboKmsRestFfi)
113
+ mod.respond_to?(:app_crypto_seal_blocking) ? mod : nil
114
+ end
115
+
116
+ def require_binding(binding_path)
117
+ binding_path ? require(File.expand_path(binding_path)) : require('kibo_kms_rest_ffi')
118
+ Object.const_get(:KiboKmsRestFfi)
119
+ end
120
+
121
+ # The binding opens +ffi_lib 'kibo_kms_rest_ffi'+ at load. To use an absolute path, the file is read and loaded with
122
+ # that one line pointing at the path; nothing on disk changes.
123
+ def load_with_library(library, binding_path)
124
+ path = File.expand_path(library)
125
+ raise LoadError, "no native library at #{path}" unless File.file?(path)
126
+
127
+ file = binding_path ? File.expand_path(binding_path) : $LOAD_PATH.map { |d| File.join(d.to_s, 'kibo_kms_rest_ffi.rb') }.find { |f| File.file?(f) }
128
+ raise LoadError, 'cannot find kibo_kms_rest_ffi.rb; pass binding_path: with its path' unless file
129
+
130
+ source = File.read(file)
131
+ raise LoadError, "#{file} does not look like the generated Kibo binding" unless source.match?(BINDING_LIB_LINE)
132
+
133
+ # rubocop:disable Security/Eval
134
+ TOPLEVEL_BINDING.eval(source.sub(BINDING_LIB_LINE) { "#{Regexp.last_match(1)}ffi_lib #{path.inspect}" }, file)
135
+ # rubocop:enable Security/Eval
136
+ Object.const_get(:KiboKmsRestFfi)
137
+ end
138
+
139
+ def enum(mod, table, wire, field)
140
+ name = table[wire.to_s]
141
+ raise KError.new(code: 'K9O_INVALID_FIELD_VALUE', message: "unknown #{field} #{wire.inspect}", context: { 'field' => field }) unless name
142
+
143
+ mod.const_get(name)
144
+ end
145
+
146
+ def cred(h)
147
+ @ffi::FfiCredential.new(secret: h[:secret], expires_at_unix_secs: h[:expires_at_unix_secs])
148
+ end
149
+
150
+ def provider_config(h)
151
+ h ||= {}
152
+ @ffi::FfiProviderConfig.new(aws_region: h[:aws_region], vault_address: h[:vault_address])
153
+ end
154
+
155
+ # The core's federation spec is a variant class per cloud.
156
+ def spec(s)
157
+ v = @ffi::FfiFederationSpec
158
+ case s[:provider].to_s
159
+ when 'aws' then v::AWS.new(s[:role_arn], s[:session_name], s[:region], s[:key_arn], s[:duration_secs])
160
+ when 'gcp' then v::GCP.new(s[:audience])
161
+ when 'azure' then v::AZURE.new(s[:tenant_id], s[:client_id], s[:scope])
162
+ when 'vault' then v::VAULT.new(s[:vault_addr], s[:mount], s[:role])
163
+ else
164
+ raise KError.new(code: 'K9O_INVALID_FIELD_VALUE', message: "unknown federation provider #{s[:provider].inspect}",
165
+ context: { 'field' => 'federation.provider' })
166
+ end
167
+ end
168
+
169
+ # The binding's error classes keep the code and detail as readers; their +message+ is not "CODE: detail".
170
+ def guard
171
+ yield
172
+ rescue @ffi::ZkFfiUniffiError::Platform, @ffi::ZkFfiUniffiError::PostureViolation => e
173
+ raise CoreError, "#{e.code}: #{e.detail}"
174
+ rescue @ffi::ZkFfiUniffiError::UpstreamKms => e
175
+ raise CoreError, "UPSTREAM_KMS_ERROR: #{e.detail}"
176
+ end
177
+ end
178
+ end
data/lib/k9o/locate.rb ADDED
@@ -0,0 +1,118 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'uri'
4
+
5
+ module K9o
6
+ # Locating the end user's region for password sign-in.
7
+ #
8
+ # K9o::Region finds the org's regional API host. An end user can live in another region of the project, so
9
+ # +gate.sign_in+ first asks the project which region holds the address
10
+ # (+POST /@<project>/gate/locate { email }+ at that regional base, with no credential) and sends the password to the
11
+ # origin it names. The answer is the same for a known and an unknown address.
12
+ #
13
+ # Rules:
14
+ # - The located origin is used for that one attempt. It is never cached. The one thing kept is a hint per refresh
15
+ # token (at most 64, in memory), so that refresh and sign-out reach the region that issued the session; a stale
16
+ # hint gets a 401.
17
+ # - An origin is used only if it is https (http only when the base is itself http on a loopback host and the origin is
18
+ # loopback too), has no userinfo, port, path, query or fragment, and its host is exactly the base host's org,
19
+ # family and apex with the region code ([a-z]{2}\d{2}) added or swapped in the gateway label, and that code equals
20
+ # the answer's +region+. Anything else, or +origin: null+, means the base is used. A password never goes to a host
21
+ # that fails this.
22
+ module Locate
23
+ REGION_CODE = /\A[a-z]{2}\d{2}\z/.freeze
24
+ HINT_LIMIT = 64
25
+ HINT_LOCK = Mutex.new
26
+ HINTS = {}
27
+
28
+ module_function
29
+
30
+ def loopback?(host)
31
+ h = host.to_s.downcase
32
+ h == 'localhost' || h.end_with?('.localhost') || h == '127.0.0.1' || h == '[::1]' || h == '::1'
33
+ end
34
+
35
+ # The origin the credential goes to: the located one if it passes the rule, else +base+. Pure; never raises.
36
+ # +answer+ is the +data+ of the locate envelope.
37
+ def choose_origin(base, answer)
38
+ return base unless answer.is_a?(Hash)
39
+
40
+ region = answer['region'] || answer[:region]
41
+ origin = answer['origin'] || answer[:origin]
42
+ return base unless origin.is_a?(String) && region.is_a?(String) && REGION_CODE.match?(region)
43
+
44
+ b = URI.parse(base)
45
+ u = URI.parse(origin)
46
+ return base if b.host.nil? || u.host.nil? || !u.scheme.is_a?(String)
47
+ return base unless u.userinfo.nil? && u.query.nil? && u.fragment.nil?
48
+ return base unless u.port == u.default_port
49
+ return base unless u.path.empty? || u.path == '/'
50
+
51
+ bs = b.scheme.to_s.downcase
52
+ us = u.scheme.to_s.downcase
53
+ unless us == 'https' || (us == 'http' && bs == 'http' && loopback?(b.host) && loopback?(u.host))
54
+ return base
55
+ end
56
+
57
+ bh = b.host.downcase
58
+ uh = u.host.downcase
59
+ return "#{bs}://#{bh}#{b.port == b.default_port ? '' : ":#{b.port}"}" if us == bs && uh == bh && u.port == b.port
60
+
61
+ from = UrlGrammar.parse_api_host(bh)
62
+ to = UrlGrammar.parse_api_host(uh)
63
+ return base if from.nil? || to.nil? || to.region != region
64
+ return base unless from.org_slug == to.org_slug && from.family == to.family && from.apex == to.apex
65
+
66
+ "#{us}://#{uh}"
67
+ rescue URI::Error, ArgumentError
68
+ base
69
+ end
70
+
71
+ # Ask the project which region holds +email+ and return the origin the credential goes to. A 404 from the door
72
+ # means the locate door is off and the base is used. Any other non-2xx answer (429, 503, anything else) raises as any other gateway error does, and nothing is sent after it. A 2xx
73
+ # answer that cannot be read means the base is used.
74
+ def origin_for(config, email, path)
75
+ base = Region.resolve_origin(config).sub(%r{/+\z}, '')
76
+ begin
77
+ env = HttpTransport.request(config: config, method: 'POST', path: path, body: { 'email' => email },
78
+ authenticated: false)
79
+ rescue KError => e
80
+ # 404: the locate door is off, so the base is used.
81
+ raise unless e.status == 404 || (e.code == 'K9O_INTERNAL_ERROR' && e.status.to_i.between?(200, 299))
82
+
83
+ return base
84
+ end
85
+ choose_origin(base, env.data)
86
+ end
87
+
88
+ # Remember which origin issued the session with this refresh token (a hint, not authority).
89
+ def remember(refresh_token, origin)
90
+ return unless refresh_token.is_a?(String) && !refresh_token.empty?
91
+
92
+ HINT_LOCK.synchronize do
93
+ HINTS.delete(refresh_token)
94
+ HINTS[refresh_token] = origin
95
+ HINTS.delete(HINTS.keys.first) while HINTS.size > HINT_LIMIT
96
+ end
97
+ end
98
+
99
+ def hint(refresh_token)
100
+ HINT_LOCK.synchronize { HINTS[refresh_token] }
101
+ end
102
+
103
+ def forget(refresh_token)
104
+ HINT_LOCK.synchronize { HINTS.delete(refresh_token) }
105
+ end
106
+
107
+ # A refresh answers with a new refresh token and the old one stops working: the hint follows the session. Data
108
+ # without a new token changes nothing.
109
+ def rotate(old_refresh_token, data)
110
+ token = data.is_a?(Hash) ? data['refreshToken'] : nil
111
+ origin = hint(old_refresh_token)
112
+ return unless token.is_a?(String) && !token.empty? && origin
113
+
114
+ forget(old_refresh_token)
115
+ remember(token, origin)
116
+ end
117
+ end
118
+ end