k9o 1.2.0-aarch64-linux → 1.5.0-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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +115 -1
- data/README.md +1575 -180
- data/k9o.gemspec +2 -2
- data/lib/k9o/_ffi/kibo_kms_rest_ffi.rb +928 -25
- data/lib/k9o/_ffi/libkibo_kms_rest_ffi.so +0 -0
- data/lib/k9o/ai.rb +15 -0
- data/lib/k9o/analytics.rb +403 -0
- data/lib/k9o/approvals.rb +253 -0
- data/lib/k9o/audit.rb +162 -14
- data/lib/k9o/client.rb +28 -9
- data/lib/k9o/compute.rb +32 -15
- data/lib/k9o/crypto.rb +471 -0
- data/lib/k9o/custody.rb +28 -0
- data/lib/k9o/data.rb +125 -5
- 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 +127 -9
- data/lib/k9o/gate.rb +52 -22
- data/lib/k9o/http_transport.rb +78 -16
- 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.rb +3 -3
- 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 +27 -87
- data/lib/k9o/metadata.rb +342 -0
- data/lib/k9o/moat.rb +443 -0
- data/lib/k9o/org.rb +81 -68
- data/lib/k9o/orgs.rb +1 -0
- data/lib/k9o/policies.rb +25 -6
- data/lib/k9o/projects.rb +43 -8
- data/lib/k9o/query.rb +174 -0
- data/lib/k9o/rampart.rb +273 -0
- data/lib/k9o/realtime.rb +261 -32
- data/lib/k9o/region.rb +20 -18
- data/lib/k9o/schedules.rb +109 -0
- data/lib/k9o/schema.rb +35 -0
- data/lib/k9o/streams.rb +94 -0
- data/lib/k9o/studio.rb +21 -11
- data/lib/k9o/url_grammar.rb +46 -20
- data/lib/k9o/vault.rb +79 -84
- data/lib/k9o/vectors.rb +124 -0
- data/lib/k9o/version.rb +1 -1
- data/lib/k9o.rb +17 -0
- data/sig/k9o/ai.rbs +4 -0
- data/sig/k9o/approvals.rbs +71 -0
- data/sig/k9o/datasets.rbs +125 -0
- data/sig/k9o/gate.rbs +9 -6
- 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/query.rbs +26 -0
- data/sig/k9o/rampart.rbs +59 -0
- data/sig/k9o/realtime.rbs +34 -0
- data/sig/k9o/region.rbs +14 -3
- data/sig/k9o/workload.rbs +64 -0
- 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
|
-
|
|
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.
|
|
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.
|
|
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.
|
|
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
|