k9o 1.4.0 → 1.5.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +69 -1
- data/README.md +1273 -142
- data/k9o.gemspec +2 -2
- data/lib/k9o/analytics.rb +403 -0
- data/lib/k9o/approvals.rb +102 -8
- data/lib/k9o/audit.rb +150 -0
- data/lib/k9o/client.rb +18 -3
- data/lib/k9o/crypto.rb +471 -0
- data/lib/k9o/custody.rb +28 -0
- data/lib/k9o/data.rb +65 -11
- data/lib/k9o/ed25519.rb +182 -0
- data/lib/k9o/end_users.rb +93 -0
- data/lib/k9o/envelope.rb +20 -5
- data/lib/k9o/functions.rb +82 -3
- data/lib/k9o/gate.rb +46 -7
- data/lib/k9o/http_transport.rb +71 -14
- data/lib/k9o/jcs.rb +117 -0
- data/lib/k9o/kerror.rb +57 -5
- data/lib/k9o/keys.rb +131 -0
- data/lib/k9o/kibo_client_core.rb +385 -0
- data/lib/k9o/kibo_ffi_core.rb +178 -0
- data/lib/k9o/locate.rb +118 -0
- data/lib/k9o/me.rb +1 -1
- data/lib/k9o/metadata.rb +342 -0
- data/lib/k9o/moat.rb +443 -0
- data/lib/k9o/org.rb +4 -5
- data/lib/k9o/rampart.rb +273 -0
- data/lib/k9o/realtime.rb +261 -10
- data/lib/k9o/schedules.rb +109 -0
- data/lib/k9o/vault.rb +3 -4
- data/lib/k9o/vectors.rb +124 -0
- data/lib/k9o/version.rb +1 -1
- data/lib/k9o.rb +13 -0
- data/sig/k9o/approvals.rbs +25 -3
- data/sig/k9o/datasets.rbs +125 -0
- data/sig/k9o/gate.rbs +2 -0
- data/sig/k9o/kerror.rbs +24 -0
- data/sig/k9o/kibo_client_core.rbs +25 -0
- data/sig/k9o/metadata.rbs +54 -0
- data/sig/k9o/moat.rbs +29 -0
- data/sig/k9o/rampart.rbs +59 -0
- data/sig/k9o/realtime.rbs +34 -0
- data/sig/k9o/workload.rbs +64 -0
- metadata +27 -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
|