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/kerror.rb CHANGED
@@ -8,8 +8,12 @@ module K9o
8
8
  # Code-narrowing: callers rescue K9o::KError and switch on e.code.
9
9
  class KError < StandardError
10
10
  attr_reader :code, :context, :recommendation, :status, :request_id
11
+ # Seconds the gateway asks the caller to wait before retrying, or nil.
12
+ # Informational: the SDK's own retry behaviour does not read it.
13
+ attr_reader :retry_after_sec
11
14
 
12
- def initialize(code:, message:, context: {}, recommendation: nil, status: nil, request_id: nil)
15
+ def initialize(code:, message:, context: {}, recommendation: nil, status: nil, request_id: nil,
16
+ retry_after_sec: nil)
13
17
  super(message)
14
18
  @code = code
15
19
  @message_text = message # avoid infinite recursion in to_s (Exception#message calls to_s)
@@ -17,13 +21,21 @@ module K9o
17
21
  @recommendation = recommendation
18
22
  @status = status
19
23
  @request_id = request_id
24
+ @retry_after_sec = retry_after_sec
25
+ end
26
+
27
+ # The step-up challenge a 401 K9O_STEP_UP_REQUIRED carries (context.stepUp) as a K9o::Keys::StepUpChallenge
28
+ # (+scope_set_root+, +nonce+, +min_tier+, +methods+, +max_age_sec+, +single_use+, +rp_id+), or nil.
29
+ def step_up
30
+ Keys::StepUp.challenge_from(@context)
20
31
  end
21
32
 
22
33
  def with_recommendation(rec)
23
34
  KError.new(
24
35
  code: @code, message: @message_text,
25
36
  context: @context, recommendation: rec,
26
- status: @status, request_id: @request_id
37
+ status: @status, request_id: @request_id,
38
+ retry_after_sec: @retry_after_sec
27
39
  )
28
40
  end
29
41
 
@@ -38,15 +50,55 @@ module K9o
38
50
  end
39
51
 
40
52
  # Rehydrate a wire KError payload into a typed K9o::KError.
41
- def self.from_wire(wire, status)
53
+ # +retry_after_header+ is the response's Retry-After header (whole seconds); it is used when the body's
54
+ # recommendation carries no +retryAfterSec+. +request_id_header+ is the response's x-k9o-request-id header;
55
+ # it is used when the body carries no request id.
56
+ def self.from_wire(wire, status, retry_after_header: nil, request_id_header: nil)
42
57
  KError.new(
43
58
  code: wire['code'] || 'K9O_INTERNAL_ERROR',
44
59
  message: wire['message'] || '',
45
60
  context: wire['context'] || {},
46
- recommendation: wire['recommendation'],
61
+ recommendation: recommendation_text(wire['recommendation']),
47
62
  status: status,
48
- request_id: wire['requestId'] || wire['request_id']
63
+ request_id: wire['requestId'] || wire['request_id'] || request_id_from_header(request_id_header),
64
+ retry_after_sec: retry_after(wire['recommendation']) || retry_after_from_header(retry_after_header)
49
65
  )
50
66
  end
67
+
68
+ # The gateway's request id from the x-k9o-request-id header value, or nil when absent or blank.
69
+ def self.request_id_from_header(value)
70
+ return nil unless value.is_a?(String) && !value.strip.empty?
71
+
72
+ value.strip
73
+ end
74
+
75
+ # Whole seconds from a Retry-After header value; an HTTP date or anything else gives nil.
76
+ def self.retry_after_from_header(value)
77
+ return nil unless value.is_a?(String) && value.strip.match?(/\A\d+\z/)
78
+
79
+ value.strip.to_i
80
+ end
81
+
82
+ # The gateway sends `recommendation` as null, a string, or an object such
83
+ # as {"action": "..."} or {"retryAfterSec": 30, "note": "..."}. A string is
84
+ # kept; an object gives its `action`, else its `note`, when a string;
85
+ # anything else gives nil.
86
+ def self.recommendation_text(raw)
87
+ return raw if raw.is_a?(String)
88
+ return nil unless raw.is_a?(Hash)
89
+
90
+ [raw['action'], raw['note']].find { |v| v.is_a?(String) }
91
+ end
92
+
93
+ # `retryAfterSec` from an object recommendation when a finite number >= 0.
94
+ def self.retry_after(raw)
95
+ return nil unless raw.is_a?(Hash)
96
+
97
+ n = raw['retryAfterSec']
98
+ return nil unless n.is_a?(Numeric) && !n.is_a?(Complex) && n >= 0
99
+ return nil if n.respond_to?(:finite?) && !n.finite?
100
+
101
+ n
102
+ end
51
103
  end
52
104
  end
data/lib/k9o/keys.rb ADDED
@@ -0,0 +1,131 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'json'
4
+
5
+ module K9o
6
+ # client.keys: the project's encryption keys. Metadata only: no key material is ever returned.
7
+ #
8
+ # list(status: nil) GET /@<slug>/vault/keys data: { "items" => [key] } (not paged)
9
+ # get(key_id) GET /@<slug>/vault/keys/<id> data: key
10
+ # rotate(key_id, step_up: nil) POST /@<slug>/vault/keys/<id>/rotate
11
+ #
12
+ # A key is { "id", "version", "status", "custodyMode", "createdAt", "rotatedAt", "byokRef", "hyokRef" }.
13
+ # Reading needs PROJECT_ADMIN or PROJECT_AUDITOR; rotating needs PROJECT_ADMIN and a fresh step-up proof.
14
+ # A person's credential only: service, workload, extension and end-user tokens are refused (403).
15
+ class Keys
16
+ STATUSES = %w[active all].freeze
17
+ STEP_UP_HEADER = 'x-k9o-step-up-proof'
18
+
19
+ def initialize(config)
20
+ @config = config
21
+ end
22
+
23
+ # The project's keys. Only the active key by default; +status:+ "all" adds the rotating and revoked versions.
24
+ # The list is not paged: +data+ is { "items" => [key] } and nothing else.
25
+ def list(status: nil)
26
+ query = {}
27
+ query['status'] = ApprovalChecks.enum!(status, STATUSES, 'status') unless status.nil?
28
+ envelope = call('GET', '', query: query)
29
+ data = envelope.data
30
+ return envelope unless data.is_a?(Hash) && data.key?('nextCursor')
31
+
32
+ Envelope.new(data: data.reject { |k, _| k == 'nextCursor' }, meta: envelope.meta)
33
+ end
34
+
35
+ def get(key_id)
36
+ call('GET', "/#{ApprovalChecks.id_segment(key_id, 'key')}")
37
+ end
38
+
39
+ # Rotate the active key: every data key is re-wrapped under a new version and the old version is revoked.
40
+ # +data+ is { "keyId", "previousVersion", "newVersion", "status" => "rotated" }.
41
+ #
42
+ # Rotation needs a fresh step-up proof. The first request answers 401 K9O_STEP_UP_REQUIRED with a challenge
43
+ # (K9o::KError#step_up, a K9o::Keys::StepUpChallenge). Pass +step_up:+ a callable and the SDK hands it the
44
+ # challenge, the HTTP method and the URL being called, expects { method:, proof: } back (+method+ is one of the
45
+ # challenge's +methods+; +proof+ is that method's own proof, for DPoP bound to this method and URL) and sends the
46
+ # same request again with the +x-k9o-step-up-proof+ header: base64url(JSON { scopeSetRoot, nonce, method, proof }).
47
+ # It retries exactly once. Without a hook the K9O_STEP_UP_REQUIRED error is raised and carries the challenge.
48
+ # An error from your hook, a second 401 or any other failure of the retry is raised as it is. The proof is never
49
+ # logged. Nothing in the platform mints the proof for you.
50
+ #
51
+ # Other errors (all K9o::KError): 409 K9O_STATE_CONFLICT with context reason "rotation_in_flight" or
52
+ # "key_not_active"; 429 K9O_RATE_LIMITED with reason "rotation_rate_cap" (at most 3 rotations per rolling 24 hours;
53
+ # +retry_after_sec+ says when the next one is possible) or "key_service_busy". A refused rotation does not use up
54
+ # a proof. +headers:+ adds request headers. The call is never retried otherwise.
55
+ def rotate(key_id, step_up: nil, headers: nil)
56
+ tail = "/#{ApprovalChecks.id_segment(key_id, 'key')}/rotate"
57
+ unless step_up.nil? || step_up.respond_to?(:call)
58
+ raise ApprovalChecks.invalid('step_up must respond to call(challenge, method, url)')
59
+ end
60
+
61
+ begin
62
+ call('POST', tail, headers: headers)
63
+ rescue KError => e
64
+ raise unless step_up && e.code == 'K9O_STEP_UP_REQUIRED' && e.status == 401
65
+
66
+ challenge = e.step_up
67
+ raise unless challenge
68
+
69
+ method = 'POST'
70
+ url = "#{Region.resolve_origin(@config).sub(%r{/\z}, '')}#{UrlGrammar.project_path(slug_for_keys, "vault/keys#{tail}")}"
71
+ answer = step_up.call(challenge, method, url)
72
+ proof_header = StepUp.header(challenge, answer)
73
+ call('POST', tail, headers: (headers || {}).merge(STEP_UP_HEADER => proof_header))
74
+ end
75
+ end
76
+
77
+ # The step-up challenge from a 401 K9O_STEP_UP_REQUIRED (context.stepUp).
78
+ StepUpChallenge = Struct.new(:scope_set_root, :nonce, :min_tier, :methods, :max_age_sec, :single_use, :rp_id,
79
+ keyword_init: true)
80
+
81
+ module StepUp
82
+ module_function
83
+
84
+ # The challenge in an error's context, or nil.
85
+ def challenge_from(context)
86
+ raw = context.is_a?(Hash) ? (context['stepUp'] || context[:stepUp]) : nil
87
+ return nil unless raw.is_a?(Hash)
88
+
89
+ root = raw['scopeSetRoot']
90
+ nonce = raw['nonce']
91
+ return nil unless root.is_a?(String) && nonce.is_a?(String)
92
+
93
+ StepUpChallenge.new(scope_set_root: root, nonce: nonce, min_tier: raw['minTier'],
94
+ methods: Array(raw['methods']), max_age_sec: raw['maxAgeSec'],
95
+ single_use: raw['singleUse'], rp_id: raw['rpId'])
96
+ end
97
+
98
+ # base64url (no padding) of JSON { scopeSetRoot, nonce, method, proof }, from the hook's answer.
99
+ def header(challenge, answer)
100
+ unless answer.is_a?(Hash)
101
+ raise ApprovalChecks.invalid('the step_up hook must return { method:, proof: }')
102
+ end
103
+
104
+ method = answer[:method] || answer['method']
105
+ proof = answer[:proof] || answer['proof']
106
+ unless method.is_a?(String) && !method.empty? && proof.is_a?(String) && !proof.empty?
107
+ raise ApprovalChecks.invalid('the step_up hook must return a method and a proof, both non-empty strings')
108
+ end
109
+ unless challenge.methods.empty? || challenge.methods.include?(method)
110
+ raise ApprovalChecks.invalid("step-up method must be one of: #{challenge.methods.join(', ')}")
111
+ end
112
+
113
+ json = JSON.generate('scopeSetRoot' => challenge.scope_set_root, 'nonce' => challenge.nonce,
114
+ 'method' => method, 'proof' => proof)
115
+ [json].pack('m0').tr('+/', '-_').delete('=')
116
+ end
117
+ end
118
+
119
+ private
120
+
121
+ def slug_for_keys
122
+ Studio.require_project_slug(@config, 'keys')
123
+ end
124
+
125
+ def call(method, tail, query: nil, headers: nil)
126
+ slug = slug_for_keys
127
+ HttpTransport.request(config: @config, method: method, path: UrlGrammar.project_path(slug, "vault/keys#{tail}"),
128
+ query: query, headers: headers)
129
+ end
130
+ end
131
+ end
data/lib/k9o/kibo.rb CHANGED
@@ -97,14 +97,14 @@ module K9o
97
97
  case e
98
98
  when KiboKmsRestFfi::ZkFfiUniffiError::PostureViolation
99
99
  K9o::KError.new(
100
- code: e.code, message: e.message,
100
+ code: e.code, message: e.detail,
101
101
  context: { dimension: e.dimension, requested: e.requested, declared: e.declared }
102
102
  )
103
103
  when KiboKmsRestFfi::ZkFfiUniffiError::Platform
104
- K9o::KError.new(code: e.code, message: e.message)
104
+ K9o::KError.new(code: e.code, message: e.detail)
105
105
  when KiboKmsRestFfi::ZkFfiUniffiError::UpstreamKms
106
106
  # Customer's own KMS failed — no K9O_* code (outside the platform error namespace).
107
- K9o::KError.new(code: 'UPSTREAM_KMS_ERROR', message: e.message)
107
+ K9o::KError.new(code: 'UPSTREAM_KMS_ERROR', message: e.detail)
108
108
  else
109
109
  K9o::KError.new(code: 'KIBO_FFI_ERROR', message: e.message)
110
110
  end
@@ -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