ace-herdr 0.4.0 → 0.5.1

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 (43) hide show
  1. checksums.yaml +4 -4
  2. data/.ace-defaults/herdr/config.yml +0 -4
  3. data/CHANGELOG.md +18 -0
  4. data/docs/usage.md +57 -35
  5. data/lib/ace/herdr/cli/commands/inbox.rb +83 -21
  6. data/lib/ace/herdr/cli/commands/native_source.rb +56 -0
  7. data/lib/ace/herdr/cli.rb +3 -0
  8. data/lib/ace/herdr/errors.rb +23 -0
  9. data/lib/ace/herdr/molecules/bounded_process.rb +261 -52
  10. data/lib/ace/herdr/molecules/delivery_record_store.rb +81 -6
  11. data/lib/ace/herdr/molecules/guarded_native_origin.rb +59 -0
  12. data/lib/ace/herdr/molecules/herdr_executor.rb +6 -3
  13. data/lib/ace/herdr/molecules/inbox_cli_input.rb +36 -0
  14. data/lib/ace/herdr/molecules/inbox_context_client.rb +88 -0
  15. data/lib/ace/herdr/molecules/inbox_context_completion_client.rb +119 -0
  16. data/lib/ace/herdr/molecules/inbox_context_key.rb +63 -0
  17. data/lib/ace/herdr/molecules/inbox_context_lifetime.rb +122 -0
  18. data/lib/ace/herdr/molecules/inbox_context_native_process.rb +92 -0
  19. data/lib/ace/herdr/molecules/inbox_context_service_configuration.rb +183 -0
  20. data/lib/ace/herdr/molecules/inbox_context_store.rb +159 -0
  21. data/lib/ace/herdr/molecules/inbox_context_wire.rb +76 -0
  22. data/lib/ace/herdr/molecules/inbox_direct_effect_binding.rb +92 -0
  23. data/lib/ace/herdr/molecules/inbox_direct_result.rb +65 -0
  24. data/lib/ace/herdr/molecules/native_queue_executor.rb +10 -21
  25. data/lib/ace/herdr/molecules/native_source.rb +189 -0
  26. data/lib/ace/herdr/molecules/protected_inbox_selection.rb +126 -0
  27. data/lib/ace/herdr/molecules/protected_native_control.rb +129 -4
  28. data/lib/ace/herdr/native_source/HERDR-LICENSE +201 -0
  29. data/lib/ace/herdr/native_source/guarded-prompt.patch +3493 -0
  30. data/lib/ace/herdr/native_source/selection.json +24 -0
  31. data/lib/ace/herdr/organisms/inbox.rb +293 -203
  32. data/lib/ace/herdr/organisms/inbox_context_direct_effects.rb +285 -0
  33. data/lib/ace/herdr/organisms/inbox_context_effects.rb +30 -0
  34. data/lib/ace/herdr/organisms/inbox_context_listener.rb +151 -0
  35. data/lib/ace/herdr/organisms/inbox_context_owner.rb +550 -0
  36. data/lib/ace/herdr/organisms/inbox_context_server.rb +70 -0
  37. data/lib/ace/herdr/organisms/inbox_context_service.rb +107 -0
  38. data/lib/ace/herdr/organisms/inbox_guarded_wake.rb +117 -0
  39. data/lib/ace/herdr/organisms/native_source_builder.rb +183 -0
  40. data/lib/ace/herdr/version.rb +1 -1
  41. data/lib/ace/herdr.rb +5 -28
  42. metadata +68 -9
  43. 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