ace-herdr 0.4.0 → 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/.ace-defaults/herdr/config.yml +0 -4
- data/CHANGELOG.md +12 -0
- data/docs/usage.md +57 -35
- data/lib/ace/herdr/cli/commands/inbox.rb +83 -21
- data/lib/ace/herdr/cli/commands/native_source.rb +56 -0
- data/lib/ace/herdr/cli.rb +3 -0
- data/lib/ace/herdr/errors.rb +23 -0
- data/lib/ace/herdr/molecules/bounded_process.rb +261 -52
- data/lib/ace/herdr/molecules/delivery_record_store.rb +81 -6
- data/lib/ace/herdr/molecules/guarded_native_origin.rb +59 -0
- data/lib/ace/herdr/molecules/herdr_executor.rb +6 -3
- data/lib/ace/herdr/molecules/inbox_cli_input.rb +36 -0
- data/lib/ace/herdr/molecules/inbox_context_client.rb +88 -0
- data/lib/ace/herdr/molecules/inbox_context_completion_client.rb +119 -0
- data/lib/ace/herdr/molecules/inbox_context_key.rb +63 -0
- data/lib/ace/herdr/molecules/inbox_context_lifetime.rb +122 -0
- data/lib/ace/herdr/molecules/inbox_context_native_process.rb +92 -0
- data/lib/ace/herdr/molecules/inbox_context_service_configuration.rb +183 -0
- data/lib/ace/herdr/molecules/inbox_context_store.rb +159 -0
- data/lib/ace/herdr/molecules/inbox_context_wire.rb +76 -0
- data/lib/ace/herdr/molecules/inbox_direct_effect_binding.rb +92 -0
- data/lib/ace/herdr/molecules/inbox_direct_result.rb +65 -0
- data/lib/ace/herdr/molecules/native_queue_executor.rb +10 -21
- data/lib/ace/herdr/molecules/native_source.rb +189 -0
- data/lib/ace/herdr/molecules/protected_inbox_selection.rb +126 -0
- data/lib/ace/herdr/molecules/protected_native_control.rb +129 -4
- data/lib/ace/herdr/native_source/HERDR-LICENSE +201 -0
- data/lib/ace/herdr/native_source/guarded-prompt.patch +3493 -0
- data/lib/ace/herdr/native_source/selection.json +24 -0
- data/lib/ace/herdr/organisms/inbox.rb +293 -203
- data/lib/ace/herdr/organisms/inbox_context_direct_effects.rb +285 -0
- data/lib/ace/herdr/organisms/inbox_context_effects.rb +30 -0
- data/lib/ace/herdr/organisms/inbox_context_listener.rb +151 -0
- data/lib/ace/herdr/organisms/inbox_context_owner.rb +550 -0
- data/lib/ace/herdr/organisms/inbox_context_server.rb +70 -0
- data/lib/ace/herdr/organisms/inbox_context_service.rb +107 -0
- data/lib/ace/herdr/organisms/inbox_guarded_wake.rb +117 -0
- data/lib/ace/herdr/organisms/native_source_builder.rb +183 -0
- data/lib/ace/herdr/version.rb +1 -1
- data/lib/ace/herdr.rb +5 -28
- metadata +64 -5
- data/lib/ace/herdr/organisms/protected_inbox.rb +0 -73
|
@@ -0,0 +1,550 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "securerandom"
|
|
4
|
+
require "ace/runtime/molecules/protected_linux"
|
|
5
|
+
require_relative "../molecules/inbox_context_store"
|
|
6
|
+
require_relative "../molecules/inbox_context_key"
|
|
7
|
+
require_relative "../molecules/inbox_context_lifetime"
|
|
8
|
+
require_relative "inbox"
|
|
9
|
+
require_relative "inbox_context_effects"
|
|
10
|
+
require_relative "inbox_context_direct_effects"
|
|
11
|
+
|
|
12
|
+
module Ace
|
|
13
|
+
module Herdr
|
|
14
|
+
module Organisms
|
|
15
|
+
# The existing inbox owner grants admission; this metadata never contains delivery outcomes.
|
|
16
|
+
class InboxContextOwner
|
|
17
|
+
include InboxContextEffects
|
|
18
|
+
include InboxContextDirectEffects
|
|
19
|
+
TOKEN = /\A[A-Za-z0-9][A-Za-z0-9._-]{0,127}\z/
|
|
20
|
+
DIGEST = /\A[0-9a-f]{64}\z/
|
|
21
|
+
ID = /\A[0-9a-f]{32}\z/
|
|
22
|
+
PURPOSES = %w[deliver enqueue maintenance_inventory].freeze
|
|
23
|
+
PEER_FIELDS = %w[gid groups host parent_pid pid started_at uid].freeze
|
|
24
|
+
KEY_FIELDS = %w[config_sha256 fingerprint key_generation public_key_sha256].freeze
|
|
25
|
+
OPERATION_LIMIT = 256
|
|
26
|
+
|
|
27
|
+
def initialize(context_id:, deliveries_dir:, grants:, store:, keys:, epoch:,
|
|
28
|
+
kernel: Ace::Runtime::Molecules::ProtectedLinux.new, inbox: nil, completion: nil)
|
|
29
|
+
token!(context_id)
|
|
30
|
+
@context_id, @deliveries_dir, @store, @keys, @kernel = context_id, deliveries_dir, store, keys, kernel
|
|
31
|
+
if inbox && (!inbox.is_a?(Inbox) || inbox.context_root != deliveries_dir)
|
|
32
|
+
raise ValidationError, "context Inbox selection differs"
|
|
33
|
+
end
|
|
34
|
+
@inbox, @completion, @effect_issuers = inbox, completion, {}
|
|
35
|
+
Molecules::InboxContextLifetime.validate_epoch!(epoch)
|
|
36
|
+
@epoch = immutable_effect(epoch)
|
|
37
|
+
@grants = JSON.parse(JSON.generate(grants))
|
|
38
|
+
validate_grants!
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# Called explicitly by the trusted provisioner. Missing state on restart is never empty.
|
|
42
|
+
def provision!
|
|
43
|
+
key = @keys.snapshot
|
|
44
|
+
validate_key!(key)
|
|
45
|
+
@store.provision!({"schema" => "ace.herdr.inbox-context-control/v3", "context_id" => @context_id,
|
|
46
|
+
"key" => key, "operations" => {}, "rotation" => nil, "last_rotation" => nil,
|
|
47
|
+
"owner_epoch" => @epoch, "owner_epoch_state" => "active", "owner_epoch_closure_sha256" => nil})
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# Startup never initializes missing state or replaces an active old
|
|
51
|
+
# owner. A closed old epoch is a retained recovery publication, not a
|
|
52
|
+
# claim inferred from process absence.
|
|
53
|
+
def activate_epoch!
|
|
54
|
+
@store.transaction do |state|
|
|
55
|
+
validate_state!(state, current_epoch: false)
|
|
56
|
+
key_current!(state)
|
|
57
|
+
if state.fetch("owner_epoch") == @epoch && state.fetch("owner_epoch_state") == "active"
|
|
58
|
+
next @epoch
|
|
59
|
+
end
|
|
60
|
+
unless state.fetch("owner_epoch_state") == "closed" && state.fetch("operations").empty? && state["rotation"].nil?
|
|
61
|
+
raise ValidationError, "context prior epoch has not been positively closed"
|
|
62
|
+
end
|
|
63
|
+
raise ValidationError, "context closed epoch cannot be reactivated" if state.fetch("owner_epoch") == @epoch
|
|
64
|
+
state["owner_epoch"], state["owner_epoch_state"], state["owner_epoch_closure_sha256"] = @epoch, "active", nil
|
|
65
|
+
validate_state!(state)
|
|
66
|
+
@epoch
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
# Initializer acknowledgement recovery is strictly read-only. A
|
|
71
|
+
# retained installation ticket cannot reactivate a closed old epoch or
|
|
72
|
+
# authorize another service incarnation.
|
|
73
|
+
def observe_initialized_epoch!
|
|
74
|
+
transaction do |state|
|
|
75
|
+
key_current!(state)
|
|
76
|
+
@epoch
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def status(peer:)
|
|
81
|
+
authorize!(peer)
|
|
82
|
+
transaction do |state|
|
|
83
|
+
projected = state["rotation"] ? "rotation_blocked" : state.fetch("operations").empty? ? "open" : "operations_active"
|
|
84
|
+
unless state["rotation"]
|
|
85
|
+
begin
|
|
86
|
+
projected = "key_unverifiable" unless @keys.snapshot == state.fetch("key")
|
|
87
|
+
rescue ValidationError
|
|
88
|
+
projected = "key_unverifiable"
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
{"context_id" => @context_id, "key_generation" => state.fetch("key").fetch("key_generation"),
|
|
92
|
+
"fingerprint" => state.fetch("key").fetch("fingerprint"),
|
|
93
|
+
"active_operations" => state.fetch("operations").size,
|
|
94
|
+
"state" => projected}
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
def begin_context_operation(context_id:, purpose:, event_id:, process_binding:, peer:, original: nil)
|
|
99
|
+
context!(context_id)
|
|
100
|
+
authorize!(peer, purpose: purpose)
|
|
101
|
+
token!(event_id)
|
|
102
|
+
validate_direct_original!(original) if %w[enqueue deliver].include?(purpose)
|
|
103
|
+
raise ValidationError, "non-direct admission cannot select original delivery" if !%w[enqueue deliver].include?(purpose) && original
|
|
104
|
+
peer!(process_binding)
|
|
105
|
+
raise ValidationError, "context process binding differs from kernel peer" unless @kernel.same?(process_binding, peer)
|
|
106
|
+
transaction do |state|
|
|
107
|
+
raise ValidationError, "context rotation blocks admission" if state["rotation"]
|
|
108
|
+
key_current!(state)
|
|
109
|
+
existing = state.fetch("operations").find do |_id, operation|
|
|
110
|
+
@kernel.same?(operation.fetch("peer"), peer) && operation.fetch("event_id") == event_id
|
|
111
|
+
end
|
|
112
|
+
if existing
|
|
113
|
+
id, operation = existing
|
|
114
|
+
raise ValidationError, "context admission retry binding differs" unless operation.fetch("purpose") == purpose && operation["original"] == original
|
|
115
|
+
direct_idle_readback!(state, operation) if operation["effect_binding"] && operation.fetch("in_flight").zero? && direct_binding?(operation)
|
|
116
|
+
next operation_projection(state, id, operation)
|
|
117
|
+
end
|
|
118
|
+
raise ValidationError, "context admission count exceeds bounds" if state.fetch("operations").size >= OPERATION_LIMIT
|
|
119
|
+
id = SecureRandom.hex(16)
|
|
120
|
+
state.fetch("operations")[id] = {"peer" => copy(peer), "purpose" => purpose, "event_id" => event_id,
|
|
121
|
+
"key_generation" => state.fetch("key").fetch("key_generation"), "in_flight" => 0, "original" => copy(original), "original_binding_digest" => nil,
|
|
122
|
+
"effect_binding" => nil, "completion" => nil, "issuer_state" => nil, "admitted_claim" => nil, "issuer_epoch" => nil}
|
|
123
|
+
operation_projection(state, id, state.fetch("operations").fetch(id))
|
|
124
|
+
end
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
# Exact caller retains this grant through signing and its downstream accepted response.
|
|
128
|
+
def end_context_operation(operation_id:, peer:)
|
|
129
|
+
authorize!(peer)
|
|
130
|
+
transaction do |state|
|
|
131
|
+
operation = operation!(state, operation_id, peer)
|
|
132
|
+
raise ValidationError, "context operation is still executing" unless operation.fetch("in_flight").zero?
|
|
133
|
+
direct_idle_readback!(state, operation) if direct_binding?(operation)
|
|
134
|
+
state.fetch("operations").delete(operation_id)
|
|
135
|
+
{"operation_id" => operation_id, "state" => "ended"}
|
|
136
|
+
end
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
# Source-owned effects must use this admission before any lifecycle/cache/event lock.
|
|
140
|
+
# No metadata mutex survives the effect or a remote response wait.
|
|
141
|
+
def with_operation(operation_id:, key_generation:, purpose:, event_id:, peer:)
|
|
142
|
+
generation!(key_generation)
|
|
143
|
+
authorize!(peer, purpose: purpose)
|
|
144
|
+
key = transaction do |state|
|
|
145
|
+
operation = operation!(state, operation_id, peer)
|
|
146
|
+
unless operation.values_at("key_generation", "purpose", "event_id") == [key_generation, purpose, event_id]
|
|
147
|
+
raise ValidationError, "context operation binding differs"
|
|
148
|
+
end
|
|
149
|
+
key_current!(state)
|
|
150
|
+
raise ValidationError, "context operation already executing" unless operation.fetch("in_flight").zero?
|
|
151
|
+
operation["in_flight"] = 1
|
|
152
|
+
copy(state.fetch("key"))
|
|
153
|
+
end
|
|
154
|
+
# A transport return/exception is not positive downstream completion.
|
|
155
|
+
# The integrating source handler must reauthenticate its canonical
|
|
156
|
+
# result before clearing this count; there is no generic callback grant.
|
|
157
|
+
yield key.freeze
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
def begin_rotation(context_id:, expected_key_generation:, peer:)
|
|
161
|
+
context!(context_id)
|
|
162
|
+
authorize!(peer, role: "maintenance")
|
|
163
|
+
transaction do |state|
|
|
164
|
+
generation!(expected_key_generation)
|
|
165
|
+
if state["rotation"]
|
|
166
|
+
rotation = rotation!(state, state.fetch("rotation").fetch("rotation_id"), expected_key_generation, peer)
|
|
167
|
+
next rotation_projection(rotation)
|
|
168
|
+
end
|
|
169
|
+
expected!(state, expected_key_generation)
|
|
170
|
+
key_current!(state)
|
|
171
|
+
raise ValidationError, "context operations have not positively ended" unless state.fetch("operations").empty?
|
|
172
|
+
Inbox.with_retained_records(deliveries_dir: @deliveries_dir) do |records|
|
|
173
|
+
unless records.all? { |record| completed_record?(record) }
|
|
174
|
+
raise ValidationError, "context contains unresolved events"
|
|
175
|
+
end
|
|
176
|
+
raise ValidationError, "context key generation is exhausted" if expected_key_generation == (1 << 63) - 1
|
|
177
|
+
state["rotation"] = {"rotation_id" => SecureRandom.hex(16), "peer" => copy(peer),
|
|
178
|
+
"old_key" => copy(state.fetch("key")), "challenge" => SecureRandom.hex(32), "attestation" => nil}
|
|
179
|
+
rotation_projection(state.fetch("rotation"))
|
|
180
|
+
end
|
|
181
|
+
end
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
# Only the actual fixed signer account can attest, never a maintainer's claimed signer UID.
|
|
185
|
+
def attest_rotation(rotation_id:, expected_key_generation:, new_public_key_bytes:,
|
|
186
|
+
installed_config_digest:, disposition:, signature:, peer:)
|
|
187
|
+
authorize!(peer, role: "signer")
|
|
188
|
+
proposed = proposed_key!(new_public_key_bytes, installed_config_digest)
|
|
189
|
+
transaction do |state|
|
|
190
|
+
rotation = rotation!(state, rotation_id, expected_key_generation, nil)
|
|
191
|
+
payload = attestation_payload(rotation, proposed, disposition)
|
|
192
|
+
bytes = JSON.generate(payload)
|
|
193
|
+
unless signature.is_a?(String) && signature.bytesize.between?(1, 16_384)
|
|
194
|
+
raise ValidationError, "context signer attestation exceeds bounds"
|
|
195
|
+
end
|
|
196
|
+
decoded = signature.unpack1("m0")
|
|
197
|
+
raise ValidationError, "context signer signature is noncanonical" unless [decoded].pack("m0") == signature
|
|
198
|
+
key = Molecules::InboxContextKey.public_key!(new_public_key_bytes)
|
|
199
|
+
unless key.verify(OpenSSL::Digest::SHA256.new, decoded, bytes)
|
|
200
|
+
raise ValidationError, "context signer pair attestation differs"
|
|
201
|
+
end
|
|
202
|
+
attestation = {"attestation_id" => SecureRandom.hex(16), "peer" => copy(peer),
|
|
203
|
+
"payload_sha256" => Digest::SHA256.hexdigest(bytes), "proposed_key" => proposed, "disposition" => disposition}
|
|
204
|
+
previous = rotation["attestation"]
|
|
205
|
+
if previous && !(previous.fetch("disposition") == "replacement" && disposition == "rollback")
|
|
206
|
+
unless previous.reject { |k, _| k == "attestation_id" } == attestation.reject { |k, _| k == "attestation_id" }
|
|
207
|
+
raise ValidationError, "context signer attestation conflicts"
|
|
208
|
+
end
|
|
209
|
+
next {"attestation_id" => previous.fetch("attestation_id")}
|
|
210
|
+
end
|
|
211
|
+
rotation["attestation"] = attestation
|
|
212
|
+
{"attestation_id" => attestation.fetch("attestation_id")}
|
|
213
|
+
end
|
|
214
|
+
rescue ArgumentError, OpenSSL::PKey::PKeyError
|
|
215
|
+
raise ValidationError, "context signer attestation is invalid"
|
|
216
|
+
end
|
|
217
|
+
|
|
218
|
+
def commit_rotation(rotation_id:, expected_key_generation:, new_public_key_bytes:,
|
|
219
|
+
installed_config_digest:, signer_keypair_attestation:, peer:)
|
|
220
|
+
authorize!(peer, role: "maintenance")
|
|
221
|
+
proposed = proposed_key!(new_public_key_bytes, installed_config_digest)
|
|
222
|
+
strict!(signer_keypair_attestation, %w[attestation_id])
|
|
223
|
+
id!(signer_keypair_attestation.fetch("attestation_id"))
|
|
224
|
+
transaction do |state|
|
|
225
|
+
if !state["rotation"]
|
|
226
|
+
next completed_rotation!(state, rotation_id, expected_key_generation, peer, "committed", proposed)
|
|
227
|
+
end
|
|
228
|
+
rotation = rotation!(state, rotation_id, expected_key_generation, peer)
|
|
229
|
+
attestation = rotation.fetch("attestation")
|
|
230
|
+
unless attestation && attestation.fetch("attestation_id") == signer_keypair_attestation.fetch("attestation_id") &&
|
|
231
|
+
attestation.fetch("proposed_key") == proposed && attestation.fetch("disposition") == "replacement"
|
|
232
|
+
raise ValidationError, "authenticated signer attestation is unavailable"
|
|
233
|
+
end
|
|
234
|
+
installed = @keys.snapshot
|
|
235
|
+
validate_key!(installed)
|
|
236
|
+
unless installed == proposed.merge("key_generation" => expected_key_generation + 1)
|
|
237
|
+
raise ValidationError, "installed replacement pair/config differs"
|
|
238
|
+
end
|
|
239
|
+
state["key"] = installed
|
|
240
|
+
finish_rotation!(state, rotation, "committed", installed)
|
|
241
|
+
end
|
|
242
|
+
end
|
|
243
|
+
|
|
244
|
+
def abort_rotation(rotation_id:, signer_keypair_attestation:, peer:)
|
|
245
|
+
authorize!(peer, role: "maintenance")
|
|
246
|
+
strict!(signer_keypair_attestation, %w[attestation_id])
|
|
247
|
+
id!(signer_keypair_attestation.fetch("attestation_id"))
|
|
248
|
+
transaction do |state|
|
|
249
|
+
if !state["rotation"]
|
|
250
|
+
last = state.fetch("last_rotation")
|
|
251
|
+
raise ValidationError, "context rotation is unavailable" unless last
|
|
252
|
+
next completed_rotation!(state, rotation_id, last.fetch("old_generation"), peer, "aborted", nil)
|
|
253
|
+
end
|
|
254
|
+
rotation = state.fetch("rotation")
|
|
255
|
+
rotation!(state, rotation_id, rotation.fetch("old_key").fetch("key_generation"), peer)
|
|
256
|
+
attestation = rotation["attestation"]
|
|
257
|
+
old = rotation.fetch("old_key").reject { |key, _| key == "key_generation" }
|
|
258
|
+
unless attestation && attestation.fetch("attestation_id") == signer_keypair_attestation.fetch("attestation_id") &&
|
|
259
|
+
attestation.fetch("disposition") == "rollback" && attestation.fetch("proposed_key") == old
|
|
260
|
+
raise ValidationError, "authenticated original signer pair rollback is unavailable"
|
|
261
|
+
end
|
|
262
|
+
unless @keys.snapshot == rotation.fetch("old_key")
|
|
263
|
+
raise ValidationError, "context original pair rollback is unverified"
|
|
264
|
+
end
|
|
265
|
+
finish_rotation!(state, rotation, "aborted", rotation.fetch("old_key"))
|
|
266
|
+
end
|
|
267
|
+
end
|
|
268
|
+
|
|
269
|
+
# Signer receives this exact closed challenge body; never signs arbitrary caller content.
|
|
270
|
+
def rotation_challenge(rotation_id:, new_public_key_bytes:, installed_config_digest:, disposition:, peer:)
|
|
271
|
+
authorize!(peer, role: "signer")
|
|
272
|
+
proposed = proposed_key!(new_public_key_bytes, installed_config_digest)
|
|
273
|
+
transaction do |state|
|
|
274
|
+
rotation = state.fetch("rotation")
|
|
275
|
+
raise ValidationError, "context rotation is unavailable" unless rotation && rotation["rotation_id"] == rotation_id
|
|
276
|
+
attestation_payload(rotation, proposed, disposition)
|
|
277
|
+
end
|
|
278
|
+
end
|
|
279
|
+
|
|
280
|
+
private
|
|
281
|
+
|
|
282
|
+
def transaction(&block)
|
|
283
|
+
@store.transaction do |state|
|
|
284
|
+
validate_state!(state)
|
|
285
|
+
result = block.call(state)
|
|
286
|
+
validate_state!(state)
|
|
287
|
+
copy(result)
|
|
288
|
+
end
|
|
289
|
+
end
|
|
290
|
+
|
|
291
|
+
def authorize!(peer, role: nil, purpose: nil)
|
|
292
|
+
peer!(peer)
|
|
293
|
+
@kernel.live!(peer)
|
|
294
|
+
grant = @grants.find { |item| item.values_at("uid", "gid", "groups") == peer.values_at("uid", "gid", "groups") }
|
|
295
|
+
unless grant && (!role || grant["role"] == role) && (!purpose || grant.fetch("purposes").include?(purpose))
|
|
296
|
+
raise ValidationError, "context peer is unauthorized"
|
|
297
|
+
end
|
|
298
|
+
grant
|
|
299
|
+
rescue Ace::Runtime::RuntimeUnavailableError
|
|
300
|
+
raise ValidationError, "context original peer is unobservable"
|
|
301
|
+
end
|
|
302
|
+
|
|
303
|
+
def operation!(state, id, peer, live: true)
|
|
304
|
+
id!(id)
|
|
305
|
+
operation = state.fetch("operations")[id]
|
|
306
|
+
unless operation && @kernel.same?(operation.fetch("peer"), peer) &&
|
|
307
|
+
operation.fetch("key_generation") == state.fetch("key").fetch("key_generation")
|
|
308
|
+
raise ValidationError, "context admission is stale or belongs to another process"
|
|
309
|
+
end
|
|
310
|
+
@kernel.live!(peer) if live
|
|
311
|
+
operation
|
|
312
|
+
end
|
|
313
|
+
|
|
314
|
+
def rotation!(state, id, generation, peer)
|
|
315
|
+
id!(id)
|
|
316
|
+
generation!(generation)
|
|
317
|
+
rotation = state["rotation"]
|
|
318
|
+
unless rotation && rotation["rotation_id"] == id && rotation.fetch("old_key").fetch("key_generation") == generation &&
|
|
319
|
+
(!peer || @kernel.same?(rotation.fetch("peer"), peer))
|
|
320
|
+
raise ValidationError, "context rotation is stale or belongs to another process"
|
|
321
|
+
end
|
|
322
|
+
rotation
|
|
323
|
+
end
|
|
324
|
+
|
|
325
|
+
def rotation_projection(rotation)
|
|
326
|
+
{"rotation_id" => rotation.fetch("rotation_id"), "key_generation" => rotation.fetch("old_key").fetch("key_generation"),
|
|
327
|
+
"fingerprint" => rotation.fetch("old_key").fetch("fingerprint")}
|
|
328
|
+
end
|
|
329
|
+
|
|
330
|
+
def operation_projection(state, id, operation)
|
|
331
|
+
{"operation_id" => id, "key_generation" => state.fetch("key").fetch("key_generation"),
|
|
332
|
+
"fingerprint" => state.fetch("key").fetch("fingerprint"),
|
|
333
|
+
"state" => operation.fetch("in_flight").zero? ? "admitted" : "unknown"}
|
|
334
|
+
end
|
|
335
|
+
|
|
336
|
+
def attestation_payload(rotation, proposed, disposition)
|
|
337
|
+
unless %w[replacement rollback].include?(disposition) &&
|
|
338
|
+
(disposition != "rollback" || proposed == rotation.fetch("old_key").reject { |k, _| k == "key_generation" })
|
|
339
|
+
raise ValidationError, "context pair attestation disposition differs"
|
|
340
|
+
end
|
|
341
|
+
{"schema" => "ace.herdr.inbox-key-attestation/v1", "context_id" => @context_id,
|
|
342
|
+
"rotation_id" => rotation.fetch("rotation_id"), "expected_key_generation" => rotation.fetch("old_key").fetch("key_generation"),
|
|
343
|
+
"new_key_generation" => rotation.fetch("old_key").fetch("key_generation") + (disposition == "replacement" ? 1 : 0),
|
|
344
|
+
"disposition" => disposition,
|
|
345
|
+
"challenge" => rotation.fetch("challenge"), "fingerprint" => proposed.fetch("fingerprint"),
|
|
346
|
+
"public_key_sha256" => proposed.fetch("public_key_sha256"), "installed_config_digest" => proposed.fetch("config_sha256")}
|
|
347
|
+
end
|
|
348
|
+
|
|
349
|
+
def proposed_key!(bytes, digest)
|
|
350
|
+
digest!(digest)
|
|
351
|
+
key = Molecules::InboxContextKey.public_key!(bytes)
|
|
352
|
+
{"fingerprint" => Digest::SHA256.hexdigest(key.public_to_der),
|
|
353
|
+
"public_key_sha256" => Digest::SHA256.hexdigest(bytes), "config_sha256" => digest}
|
|
354
|
+
end
|
|
355
|
+
|
|
356
|
+
def finish_rotation!(state, rotation, kind, key)
|
|
357
|
+
state["last_rotation"] = {"rotation_id" => rotation.fetch("rotation_id"), "peer" => rotation.fetch("peer"),
|
|
358
|
+
"old_generation" => rotation.fetch("old_key").fetch("key_generation"), "kind" => kind, "key" => copy(key)}
|
|
359
|
+
state["rotation"] = nil
|
|
360
|
+
{"rotation_id" => rotation.fetch("rotation_id"), "state" => kind,
|
|
361
|
+
"key_generation" => key.fetch("key_generation"), "fingerprint" => key.fetch("fingerprint")}
|
|
362
|
+
end
|
|
363
|
+
|
|
364
|
+
def completed_rotation!(state, id, generation, peer, kind, proposed)
|
|
365
|
+
id!(id)
|
|
366
|
+
generation!(generation)
|
|
367
|
+
last = state["last_rotation"]
|
|
368
|
+
unless last && last["rotation_id"] == id && last["old_generation"] == generation && last["kind"] == kind &&
|
|
369
|
+
@kernel.same?(last.fetch("peer"), peer) && (!proposed || last.fetch("key").reject { |k, _| k == "key_generation" } == proposed)
|
|
370
|
+
raise ValidationError, "context completed rotation conflicts"
|
|
371
|
+
end
|
|
372
|
+
{"rotation_id" => id, "state" => kind, "key_generation" => last.fetch("key").fetch("key_generation"),
|
|
373
|
+
"fingerprint" => last.fetch("key").fetch("fingerprint")}
|
|
374
|
+
end
|
|
375
|
+
|
|
376
|
+
def key_current!(state)
|
|
377
|
+
raise ValidationError, "installed context key/config changed outside admitted rotation" unless @keys.snapshot == state.fetch("key")
|
|
378
|
+
end
|
|
379
|
+
|
|
380
|
+
def completed_record?(record)
|
|
381
|
+
return false unless record.state == "delivered" && record.inbox.is_a?(Hash)
|
|
382
|
+
inbox = record.inbox
|
|
383
|
+
receipt = inbox["receipt"]
|
|
384
|
+
inbox["claim_generation"].is_a?(Integer) && inbox["claim_generation"].positive? &&
|
|
385
|
+
receipt.is_a?(Hash) && receipt.values_at("event_id", "attempt_id", "claim_generation", "payload_sha256", "binding") ==
|
|
386
|
+
[record.event_id, inbox["attempt_id"], inbox["claim_generation"], record.answer_digest, inbox["binding"]]
|
|
387
|
+
end
|
|
388
|
+
|
|
389
|
+
def expected!(state, expected)
|
|
390
|
+
raise ValidationError, "context key generation differs" unless state.fetch("key").fetch("key_generation") == expected
|
|
391
|
+
end
|
|
392
|
+
|
|
393
|
+
def validate_direct_original!(value)
|
|
394
|
+
fields = Molecules::InboxDirectEffectBinding::ORIGINAL_FIELDS
|
|
395
|
+
strict!(value, fields + ["attempt_id"])
|
|
396
|
+
Molecules::InboxDirectEffectBinding.original!(value.slice(*fields))
|
|
397
|
+
token!(value.fetch("attempt_id"))
|
|
398
|
+
context!(value.fetch("inbox_context_id"))
|
|
399
|
+
end
|
|
400
|
+
|
|
401
|
+
def validate_grants!
|
|
402
|
+
unless @grants.is_a?(Array) && @grants.size.between?(1, 64) && @grants.all? { |grant| grant.is_a?(Hash) } &&
|
|
403
|
+
@grants.map { |g| g["uid"] }.uniq.size == @grants.size
|
|
404
|
+
raise ValidationError, "context peer grants exceed bounds or overlap"
|
|
405
|
+
end
|
|
406
|
+
@grants.each do |grant|
|
|
407
|
+
strict!(grant, %w[gid groups purposes role uid])
|
|
408
|
+
unless %w[authority maintenance signer supervisor].include?(grant["role"]) &&
|
|
409
|
+
grant.values_at("uid", "gid").all? { |v| v.is_a?(Integer) && v >= 0 } && groups?(grant["groups"]) &&
|
|
410
|
+
grant["purposes"].is_a?(Array) && grant["purposes"] == grant["purposes"].sort.uniq &&
|
|
411
|
+
(grant["purposes"] - PURPOSES).empty? && (grant["role"] != "maintenance" || grant["purposes"] == ["maintenance_inventory"])
|
|
412
|
+
raise ValidationError, "context peer grant is invalid"
|
|
413
|
+
end
|
|
414
|
+
end
|
|
415
|
+
end
|
|
416
|
+
|
|
417
|
+
def validate_state!(state, current_epoch: true)
|
|
418
|
+
strict!(state, %w[context_id key last_rotation operations owner_epoch owner_epoch_closure_sha256 owner_epoch_state rotation schema])
|
|
419
|
+
unless state["schema"] == "ace.herdr.inbox-context-control/v3" && state["context_id"] == @context_id
|
|
420
|
+
raise ValidationError, "context metadata belongs to another context"
|
|
421
|
+
end
|
|
422
|
+
Molecules::InboxContextLifetime.validate_epoch!(state.fetch("owner_epoch"))
|
|
423
|
+
unless state["owner_epoch_state"] == "active" && state["owner_epoch_closure_sha256"].nil? ||
|
|
424
|
+
state["owner_epoch_state"] == "closed" && state["owner_epoch_closure_sha256"].is_a?(String) && DIGEST.match?(state["owner_epoch_closure_sha256"])
|
|
425
|
+
raise ValidationError, "context epoch disposition differs"
|
|
426
|
+
end
|
|
427
|
+
if current_epoch && (state.fetch("owner_epoch") != @epoch || state.fetch("owner_epoch_state") != "active")
|
|
428
|
+
raise ValidationError, "context owner epoch is not the current active incarnation"
|
|
429
|
+
end
|
|
430
|
+
validate_key!(state.fetch("key"))
|
|
431
|
+
operations = state.fetch("operations")
|
|
432
|
+
raise ValidationError, "context admission map exceeds bounds" unless operations.is_a?(Hash) && operations.size <= OPERATION_LIMIT
|
|
433
|
+
operations.each do |id, operation|
|
|
434
|
+
id!(id)
|
|
435
|
+
strict!(operation, %w[admitted_claim completion effect_binding event_id in_flight issuer_epoch issuer_state key_generation original original_binding_digest peer purpose])
|
|
436
|
+
peer!(operation.fetch("peer"))
|
|
437
|
+
token!(operation.fetch("event_id"))
|
|
438
|
+
if %w[enqueue deliver].include?(operation["purpose"])
|
|
439
|
+
validate_direct_original!(operation.fetch("original"))
|
|
440
|
+
elsif operation["original"]
|
|
441
|
+
raise ValidationError, "non-direct retained original selection differs"
|
|
442
|
+
end
|
|
443
|
+
unless PURPOSES.include?(operation["purpose"]) && [0, 1].include?(operation["in_flight"]) &&
|
|
444
|
+
operation["in_flight"].is_a?(Integer) && operation["key_generation"] == state.fetch("key").fetch("key_generation")
|
|
445
|
+
raise ValidationError, "context admission metadata differs"
|
|
446
|
+
end
|
|
447
|
+
if operation["effect_binding"]
|
|
448
|
+
if direct_binding?(operation)
|
|
449
|
+
unless operation["original_binding_digest"].is_a?(String) && DIGEST.match?(operation["original_binding_digest"])
|
|
450
|
+
raise ValidationError, "direct original record digest differs"
|
|
451
|
+
end
|
|
452
|
+
effect = Molecules::InboxDirectEffectBinding.verify!(operation.fetch("effect_binding"), key_generation: operation.fetch("key_generation"))
|
|
453
|
+
unless effect.values_at("purpose", "event_id") == operation.values_at("purpose", "event_id") &&
|
|
454
|
+
effect.slice(*Molecules::InboxDirectEffectBinding::ORIGINAL_FIELDS, "attempt_id") == operation.fetch("original") && operation["completion"].nil?
|
|
455
|
+
raise ValidationError, "context retained direct binding differs"
|
|
456
|
+
end
|
|
457
|
+
unless %w[running returned].include?(operation["issuer_state"])
|
|
458
|
+
raise ValidationError, "context direct issuer evidence differs"
|
|
459
|
+
end
|
|
460
|
+
unless operation["issuer_epoch"] == state.fetch("owner_epoch")
|
|
461
|
+
raise ValidationError, "context direct issuer epoch differs"
|
|
462
|
+
end
|
|
463
|
+
if (claim = operation["admitted_claim"])
|
|
464
|
+
strict!(claim, %w[kind claim_generation claim_owner])
|
|
465
|
+
expected = effect.fetch("selection").fetch("expected_claim_generation")
|
|
466
|
+
valid_queue = claim["kind"] == "queue" && claim["claim_generation"] == expected + 1 &&
|
|
467
|
+
claim["claim_owner"] == Digest::SHA256.hexdigest(JSON.generate([@context_id, id]))
|
|
468
|
+
valid_wake = claim["kind"] == "wake" && expected.positive? && claim["claim_generation"] == expected &&
|
|
469
|
+
claim["claim_owner"].is_a?(String) && DIGEST.match?(claim["claim_owner"])
|
|
470
|
+
unless effect["purpose"] == "deliver" && (valid_queue || valid_wake)
|
|
471
|
+
raise ValidationError, "context actual claim evidence differs"
|
|
472
|
+
end
|
|
473
|
+
end
|
|
474
|
+
next
|
|
475
|
+
end
|
|
476
|
+
raise ValidationError, "unsupported context effect binding"
|
|
477
|
+
end
|
|
478
|
+
unless operation["issuer_state"].nil? && operation["admitted_claim"].nil? && operation["issuer_epoch"].nil? && operation["original_binding_digest"].nil?
|
|
479
|
+
raise ValidationError, "context non-direct issuer evidence differs"
|
|
480
|
+
end
|
|
481
|
+
raise ValidationError, "non-direct completion is unavailable" if operation["completion"]
|
|
482
|
+
end
|
|
483
|
+
if (rotation = state["rotation"])
|
|
484
|
+
strict!(rotation, %w[attestation challenge old_key peer rotation_id])
|
|
485
|
+
id!(rotation.fetch("rotation_id")); digest!(rotation.fetch("challenge")); peer!(rotation.fetch("peer"))
|
|
486
|
+
validate_key!(rotation.fetch("old_key"))
|
|
487
|
+
unless operations.empty? && rotation.fetch("old_key") == state.fetch("key")
|
|
488
|
+
raise ValidationError, "context exclusive admission conflicts"
|
|
489
|
+
end
|
|
490
|
+
if (attestation = rotation["attestation"])
|
|
491
|
+
strict!(attestation, %w[attestation_id disposition payload_sha256 peer proposed_key])
|
|
492
|
+
id!(attestation.fetch("attestation_id")); digest!(attestation.fetch("payload_sha256")); peer!(attestation.fetch("peer"))
|
|
493
|
+
strict!(attestation.fetch("proposed_key"), KEY_FIELDS - ["key_generation"])
|
|
494
|
+
attestation.fetch("proposed_key").each_value { |value| digest!(value) }
|
|
495
|
+
unless Digest::SHA256.hexdigest(JSON.generate(attestation_payload(rotation,
|
|
496
|
+
attestation.fetch("proposed_key"), attestation.fetch("disposition")))) == attestation.fetch("payload_sha256")
|
|
497
|
+
raise ValidationError, "context retained attestation binding differs"
|
|
498
|
+
end
|
|
499
|
+
end
|
|
500
|
+
end
|
|
501
|
+
if (last = state["last_rotation"])
|
|
502
|
+
strict!(last, %w[key kind old_generation peer rotation_id])
|
|
503
|
+
id!(last.fetch("rotation_id")); peer!(last.fetch("peer")); generation!(last.fetch("old_generation")); validate_key!(last.fetch("key"))
|
|
504
|
+
unless %w[aborted committed].include?(last["kind"]) &&
|
|
505
|
+
last.fetch("key").fetch("key_generation") == last.fetch("old_generation") + (last["kind"] == "committed" ? 1 : 0)
|
|
506
|
+
raise ValidationError, "context completed rotation metadata differs"
|
|
507
|
+
end
|
|
508
|
+
end
|
|
509
|
+
end
|
|
510
|
+
|
|
511
|
+
def validate_key!(key)
|
|
512
|
+
strict!(key, KEY_FIELDS)
|
|
513
|
+
generation!(key.fetch("key_generation"))
|
|
514
|
+
(KEY_FIELDS - ["key_generation"]).each { |field| digest!(key.fetch(field)) }
|
|
515
|
+
end
|
|
516
|
+
|
|
517
|
+
def peer!(peer)
|
|
518
|
+
strict!(peer, PEER_FIELDS)
|
|
519
|
+
unless peer.values_at("pid", "parent_pid").all? { |v| v.is_a?(Integer) && v.positive? } &&
|
|
520
|
+
peer.values_at("uid", "gid").all? { |v| v.is_a?(Integer) && v >= 0 } && groups?(peer["groups"]) &&
|
|
521
|
+
peer["host"].is_a?(String) && peer["host"].bytesize.between?(1, 255) &&
|
|
522
|
+
peer["started_at"].is_a?(String) && peer["started_at"].match?(/\Alinux:[0-9a-f]{8}(?:-[0-9a-f]{4}){3}-[0-9a-f]{12}:[1-9][0-9]*\z/)
|
|
523
|
+
raise ValidationError, "context kernel peer is invalid"
|
|
524
|
+
end
|
|
525
|
+
end
|
|
526
|
+
|
|
527
|
+
def groups?(groups) = groups.is_a?(Array) && groups.size <= 64 && groups.all? { |v| v.is_a?(Integer) && v >= 0 } && groups == groups.sort.uniq
|
|
528
|
+
def strict!(value, fields)
|
|
529
|
+
raise ValidationError, "context fields differ" unless value.is_a?(Hash) && value.keys.all? { |key| key.is_a?(String) } && value.keys.sort == fields.sort
|
|
530
|
+
end
|
|
531
|
+
def token!(value)
|
|
532
|
+
raise ValidationError, "context selector is invalid" unless value.is_a?(String) && TOKEN.match?(value)
|
|
533
|
+
end
|
|
534
|
+
def id!(value)
|
|
535
|
+
raise ValidationError, "context admission id is invalid" unless value.is_a?(String) && ID.match?(value)
|
|
536
|
+
end
|
|
537
|
+
def digest!(value)
|
|
538
|
+
raise ValidationError, "context digest is invalid" unless value.is_a?(String) && DIGEST.match?(value)
|
|
539
|
+
end
|
|
540
|
+
def generation!(value)
|
|
541
|
+
raise ValidationError, "context generation is invalid" unless value.is_a?(Integer) && value.between?(1, (1 << 63) - 1)
|
|
542
|
+
end
|
|
543
|
+
def context!(value)
|
|
544
|
+
raise ValidationError, "context selector differs" unless value == @context_id
|
|
545
|
+
end
|
|
546
|
+
def copy(value) = JSON.parse(JSON.generate(value))
|
|
547
|
+
end
|
|
548
|
+
end
|
|
549
|
+
end
|
|
550
|
+
end
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "inbox_context_owner"
|
|
4
|
+
require_relative "../molecules/inbox_context_wire"
|
|
5
|
+
|
|
6
|
+
module Ace
|
|
7
|
+
module Herdr
|
|
8
|
+
module Organisms
|
|
9
|
+
# Fixed authenticated transport for the existing Herdr context owner.
|
|
10
|
+
# Lifecycle embedding selects endpoint/startup; public callers never select paths.
|
|
11
|
+
class InboxContextServer
|
|
12
|
+
FIELDS = {
|
|
13
|
+
"status" => [],
|
|
14
|
+
"enqueue_context" => %w[attempt_id event_id key_generation operation_id original payload_bytes payload_sha256 reverse],
|
|
15
|
+
"status_context" => %w[attempt_id event_id original],
|
|
16
|
+
"deliver_context" => %w[attempt_id event_id expected_claim_generation key_generation operation_id original],
|
|
17
|
+
"begin_context_operation" => %w[context_id event_id process_binding purpose],
|
|
18
|
+
"end_context_operation" => %w[operation_id],
|
|
19
|
+
"begin_rotation" => %w[context_id expected_key_generation],
|
|
20
|
+
"rotation_challenge" => %w[disposition installed_config_digest new_public_key_bytes rotation_id],
|
|
21
|
+
"attest_rotation" => %w[disposition expected_key_generation installed_config_digest new_public_key_bytes rotation_id signature],
|
|
22
|
+
"commit_rotation" => %w[expected_key_generation installed_config_digest new_public_key_bytes rotation_id signer_keypair_attestation],
|
|
23
|
+
"abort_rotation" => %w[rotation_id signer_keypair_attestation]
|
|
24
|
+
}.freeze
|
|
25
|
+
|
|
26
|
+
def initialize(owner:, context_id:, kernel: Ace::Runtime::Molecules::ProtectedLinux.new)
|
|
27
|
+
@owner, @context_id, @kernel = owner, context_id, kernel
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def fields_for(request)
|
|
31
|
+
fields = FIELDS.fetch(request.fetch("operation"))
|
|
32
|
+
if request["operation"] == "begin_context_operation" && %w[enqueue deliver].include?(request.dig("params", "purpose"))
|
|
33
|
+
(fields + ["original"]).sort
|
|
34
|
+
else
|
|
35
|
+
fields
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
private :fields_for
|
|
39
|
+
|
|
40
|
+
# One bounded exchange; EOF never releases an operation/rotation grant.
|
|
41
|
+
def handle(socket)
|
|
42
|
+
deadline = Molecules::InboxContextWire.deadline
|
|
43
|
+
peer = @kernel.peer(socket)
|
|
44
|
+
request = Molecules::InboxContextWire.read(socket, deadline: deadline)
|
|
45
|
+
unless request.is_a?(Hash) && request.keys.sort == %w[context_id operation params version] &&
|
|
46
|
+
request["version"].is_a?(Integer) && request["version"] == 1 && request["context_id"] == @context_id &&
|
|
47
|
+
FIELDS.key?(request["operation"]) && request["params"].is_a?(Hash) &&
|
|
48
|
+
request["params"].keys.sort == fields_for(request)
|
|
49
|
+
raise ValidationError, "context request fields differ"
|
|
50
|
+
end
|
|
51
|
+
options = request.fetch("params").transform_keys(&:to_sym)
|
|
52
|
+
if request.fetch("operation") == "enqueue_context"
|
|
53
|
+
options[:payload] = Molecules::InboxContextWire.read_payload(socket, size: options.fetch(:payload_bytes), deadline: deadline)
|
|
54
|
+
unless Digest::SHA256.hexdigest(options.fetch(:payload)) == options.fetch(:payload_sha256)
|
|
55
|
+
raise ValidationError, "context payload digest differs"
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
Molecules::InboxContextWire.require_eof!(socket, deadline: deadline)
|
|
59
|
+
result = @owner.public_send(request.fetch("operation"), **options, peer: peer)
|
|
60
|
+
Molecules::InboxContextWire.write(socket, {"version" => 1, "context_id" => @context_id, "result" => result}, deadline: deadline)
|
|
61
|
+
rescue ValidationError, Ace::Runtime::RuntimeUnavailableError
|
|
62
|
+
# Never echo caller key/signature/config/payload or filesystem errors.
|
|
63
|
+
Molecules::InboxContextWire.write(socket,
|
|
64
|
+
{"version" => 1, "context_id" => @context_id, "error" => {"code" => "context_blocked"}},
|
|
65
|
+
deadline: deadline || Molecules::InboxContextWire.deadline)
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
end
|