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,88 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "ace/runtime/molecules/protected_linux"
|
|
4
|
+
require_relative "inbox_context_wire"
|
|
5
|
+
require_relative "inbox_direct_result"
|
|
6
|
+
|
|
7
|
+
module Ace
|
|
8
|
+
module Herdr
|
|
9
|
+
module Molecules
|
|
10
|
+
# Constructor inputs come only from the verified installed context map.
|
|
11
|
+
class InboxContextClient
|
|
12
|
+
def self.selected(context_id:, socket_path:, owner_credentials:, **options)
|
|
13
|
+
new(context_id: context_id, socket_path: socket_path, owner_identity: nil,
|
|
14
|
+
owner_credentials: owner_credentials, **options)
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def initialize(context_id:, socket_path:, owner_identity:, owner_credentials: nil, kernel: Ace::Runtime::Molecules::ProtectedLinux.new,
|
|
18
|
+
wire: Ace::Runtime::Molecules::ProtectedSocket)
|
|
19
|
+
@context_id, @socket_path, @identity, @kernel, @wire = context_id, socket_path, owner_identity, kernel, wire
|
|
20
|
+
@credentials = owner_credentials || owner_identity&.slice("uid", "gid", "groups")
|
|
21
|
+
unless @credentials.is_a?(Hash) && @credentials.keys.sort == %w[gid groups uid] &&
|
|
22
|
+
@credentials.values_at("uid", "gid").all? { |value| value.is_a?(Integer) && value.positive? } &&
|
|
23
|
+
@credentials["groups"].is_a?(Array) && @credentials["groups"].size <= 64 &&
|
|
24
|
+
@credentials["groups"].all? { |value| value.is_a?(Integer) && value.positive? } && @credentials["groups"] == @credentials["groups"].sort.uniq
|
|
25
|
+
raise ValidationError, "context fixed owner credentials differ"
|
|
26
|
+
end
|
|
27
|
+
@selection_mutex = Mutex.new
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def request(operation, params, payload: nil)
|
|
31
|
+
if operation == "enqueue_context"
|
|
32
|
+
unless payload.is_a?(String) && payload.bytesize.between?(1, 65_536) &&
|
|
33
|
+
params["payload_bytes"] == payload.bytesize && params["payload_sha256"] == Digest::SHA256.hexdigest(payload)
|
|
34
|
+
raise ValidationError, "context payload differs"
|
|
35
|
+
end
|
|
36
|
+
elsif payload
|
|
37
|
+
raise ValidationError, "context operation does not accept payload"
|
|
38
|
+
end
|
|
39
|
+
@wire.root_path!(File.dirname(@socket_path), directory: true, owner: @credentials.fetch("uid"))
|
|
40
|
+
before = @wire.socket_identity(@socket_path)
|
|
41
|
+
raise ValidationError, "context socket owner differs" unless before[2] == @credentials.fetch("uid")
|
|
42
|
+
@kernel.live!(@identity) if @identity
|
|
43
|
+
deadline = InboxContextWire.deadline
|
|
44
|
+
@wire.connect(@socket_path, deadline: deadline) do |socket|
|
|
45
|
+
observed = @kernel.peer(socket)
|
|
46
|
+
unless observed.values_at("uid", "gid", "groups") == @credentials.values_at("uid", "gid", "groups")
|
|
47
|
+
raise ValidationError, "context fixed owner principal differs"
|
|
48
|
+
end
|
|
49
|
+
@selection_mutex.synchronize { @identity ||= JSON.parse(JSON.generate(observed)).freeze }
|
|
50
|
+
@kernel.live!(@identity)
|
|
51
|
+
unless @kernel.same?(@identity, observed) && @wire.socket_identity(@socket_path) == before
|
|
52
|
+
raise ValidationError, "context owner incarnation differs"
|
|
53
|
+
end
|
|
54
|
+
InboxContextWire.write(socket, {"version" => 1, "context_id" => @context_id,
|
|
55
|
+
"operation" => operation, "params" => params}, deadline: deadline)
|
|
56
|
+
if payload
|
|
57
|
+
# Same bounded writer; a one-part raw ingress has the larger native payload bound.
|
|
58
|
+
offset = 0
|
|
59
|
+
while offset < payload.bytesize
|
|
60
|
+
remaining = deadline - Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
61
|
+
raise ValidationError, "context payload deadline expired" unless remaining.positive? && IO.select(nil, [socket], nil, remaining)
|
|
62
|
+
sent = socket.write_nonblock(payload.byteslice(offset..), exception: false)
|
|
63
|
+
next if sent == :wait_writable
|
|
64
|
+
offset += sent
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
socket.shutdown(Socket::SHUT_WR)
|
|
68
|
+
result = InboxContextWire.read(socket, deadline: deadline)
|
|
69
|
+
unless @kernel.same?(@identity, @kernel.peer(socket)) && @wire.socket_identity(@socket_path) == before
|
|
70
|
+
raise ValidationError, "context owner incarnation changed"
|
|
71
|
+
end
|
|
72
|
+
unless result.is_a?(Hash) && result["version"].is_a?(Integer) && result["version"] == 1 &&
|
|
73
|
+
result["context_id"] == @context_id && result.keys.sort == %w[context_id result version] && result["result"].is_a?(Hash)
|
|
74
|
+
raise ValidationError, "context operation is blocked or unverifiable"
|
|
75
|
+
end
|
|
76
|
+
data = result.fetch("result")
|
|
77
|
+
if %w[enqueue_context deliver_context status_context].include?(operation)
|
|
78
|
+
InboxDirectResult.verify!(data, operation: operation, event_id: params.fetch("event_id"), attempt_id: params.fetch("attempt_id"), original: params.fetch("original"))
|
|
79
|
+
end
|
|
80
|
+
data
|
|
81
|
+
end
|
|
82
|
+
rescue Ace::Runtime::RuntimeUnavailableError, SystemCallError
|
|
83
|
+
raise ValidationError, "fixed context endpoint is unavailable"
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
end
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "ace/runtime/molecules/protected_linux"
|
|
4
|
+
require "ace/runtime/molecules/protected_socket"
|
|
5
|
+
require_relative "inbox_direct_effect_binding"
|
|
6
|
+
require_relative "guarded_native_origin"
|
|
7
|
+
|
|
8
|
+
module Ace
|
|
9
|
+
module Herdr
|
|
10
|
+
module Molecules
|
|
11
|
+
# Fixed installed authority selection; there is no caller-selected verifier
|
|
12
|
+
# callback, generic finished flag, current Inbox lookup or local fallback.
|
|
13
|
+
class InboxContextCompletionClient
|
|
14
|
+
LIMIT = 16_384
|
|
15
|
+
|
|
16
|
+
def initialize(authority:, project_id:, mapping_id:, kernel: Ace::Runtime::Molecules::ProtectedLinux.new,
|
|
17
|
+
wire: Ace::Runtime::Molecules::ProtectedSocket)
|
|
18
|
+
@authority, @project_id, @mapping_id, @kernel, @wire = authority, project_id, mapping_id, kernel, wire
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
# Identity evidence only: callers must independently own admission and
|
|
24
|
+
# exact native context/thread selection before any native effect.
|
|
25
|
+
def original!(assignment_id:, attempt_id:, event_id:, inbox_context_id:, purpose:, payload_sha256:)
|
|
26
|
+
params = {"mapping_id" => @mapping_id, "assignment_id" => assignment_id, "attempt_id" => attempt_id,
|
|
27
|
+
"event_id" => event_id, "inbox_context_id" => inbox_context_id, "purpose" => purpose,
|
|
28
|
+
"payload_sha256" => payload_sha256}
|
|
29
|
+
unless %w[assignment_id attempt_id event_id inbox_context_id mapping_id].all? { |key|
|
|
30
|
+
params[key].is_a?(String) && params[key].match?(/\A[A-Za-z0-9][A-Za-z0-9._-]{0,127}\z/) } &&
|
|
31
|
+
%w[enqueue deliver].include?(purpose) && %w[payload_sha256].all? { |key| (params[key].is_a?(String) && InboxDirectEffectBinding::SHA.match?(params[key])) }
|
|
32
|
+
raise ValidationError, "original context selection differs"
|
|
33
|
+
end
|
|
34
|
+
query!(operation: "inbox_context_original", params: params) { |data| verify_original!(data, params) }
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
private
|
|
38
|
+
|
|
39
|
+
def query!(operation:, params:)
|
|
40
|
+
path = @authority.fetch("socket_path")
|
|
41
|
+
@wire.root_path!(File.dirname(path), directory: true, owner: @authority.fetch("uid"))
|
|
42
|
+
endpoint = @wire.socket_identity(path)
|
|
43
|
+
raise ValidationError, "context completion authority endpoint differs" unless endpoint.last == @authority.fetch("uid")
|
|
44
|
+
deadline = @wire.deadline(30)
|
|
45
|
+
@wire.connect(path, deadline: deadline) do |socket|
|
|
46
|
+
original = @kernel.peer(socket)
|
|
47
|
+
unless original.values_at("uid", "gid", "groups") == @authority.values_at("uid", "gid", "groups") && @wire.socket_identity(path) == endpoint
|
|
48
|
+
raise ValidationError, "context completion authority principal differs"
|
|
49
|
+
end
|
|
50
|
+
@kernel.live!(original)
|
|
51
|
+
@wire.write(socket, {"version" => 1, "project_id" => @project_id, "operation" => operation,
|
|
52
|
+
"mutation_id" => nil, "params" => params}, deadline: deadline, limit: LIMIT)
|
|
53
|
+
socket.shutdown(Socket::SHUT_WR)
|
|
54
|
+
reply = @wire.read(socket, deadline: deadline, limit: LIMIT)
|
|
55
|
+
unless @kernel.same?(original, @kernel.peer(socket)) && @wire.socket_identity(path) == endpoint
|
|
56
|
+
raise ValidationError, "context completion authority incarnation changed"
|
|
57
|
+
end
|
|
58
|
+
@kernel.live!(original)
|
|
59
|
+
unless reply.is_a?(Hash) && reply.keys.sort == %w[data status transport] && reply["status"] == "ok" &&
|
|
60
|
+
reply["transport"] == {"replayed" => false}
|
|
61
|
+
raise ValidationError, "context completion canonical query refused"
|
|
62
|
+
end
|
|
63
|
+
yield reply.fetch("data")
|
|
64
|
+
end
|
|
65
|
+
rescue KeyError, TypeError, ArgumentError, Ace::Runtime::RuntimeUnavailableError, SystemCallError, IOError
|
|
66
|
+
raise ValidationError, "context canonical query is unavailable"
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def verify_original!(data, params)
|
|
70
|
+
fields = %w[assignment_id attempt_id commit event_id guarded_origin inbox_context_id mapping_id native_binding native_channel original_binding_digest process_binding project_id purpose registered registration schema]
|
|
71
|
+
registration = params.slice("event_id", "attempt_id", "payload_sha256")
|
|
72
|
+
unless data.is_a?(Hash) && data.keys.sort == fields && data["schema"] == "ace.assign.inbox-context-original/v1" &&
|
|
73
|
+
data.slice("assignment_id", "attempt_id", "event_id", "inbox_context_id", "mapping_id", "purpose") ==
|
|
74
|
+
params.slice("assignment_id", "attempt_id", "event_id", "inbox_context_id", "mapping_id", "purpose") &&
|
|
75
|
+
data["project_id"] == @project_id && data["registration"] == registration &&
|
|
76
|
+
[true, false].include?(data["registered"]) && (params.fetch("purpose") != "deliver" || data["registered"]) &&
|
|
77
|
+
data["commit"].is_a?(String) && data["commit"].match?(/\A[0-9a-f]{40}\z/) &&
|
|
78
|
+
(data["original_binding_digest"].is_a?(String) && InboxDirectEffectBinding::SHA.match?(data["original_binding_digest"])) &&
|
|
79
|
+
data["process_binding"].is_a?(Hash) && data["native_binding"].is_a?(Hash)
|
|
80
|
+
raise ValidationError, "original context projection differs"
|
|
81
|
+
end
|
|
82
|
+
process = data.fetch("process_binding")
|
|
83
|
+
native = data.fetch("native_binding")
|
|
84
|
+
channel = data.fetch("native_channel")
|
|
85
|
+
path = channel["socket_path"] if channel.is_a?(Hash)
|
|
86
|
+
unless channel.is_a?(Hash) && channel.keys.sort == %w[socket_path version] && channel["version"] == "0.9.3" &&
|
|
87
|
+
path.is_a?(String) && path.valid_encoding? && path.bytesize.between?(1, 107) && path.start_with?("/") &&
|
|
88
|
+
!path.include?("\0") && File.expand_path(path) == path
|
|
89
|
+
raise ValidationError, "original context native channel differs"
|
|
90
|
+
end
|
|
91
|
+
origin = process["native_origin"]
|
|
92
|
+
unless process.keys.sort == %w[native_origin pane process_identity runtime session shell_identity terminal_id] &&
|
|
93
|
+
native.keys.sort == %w[scope_binding_event_id scope_generation server_identity service_invocation_id socket_identity workspace_id] &&
|
|
94
|
+
origin.is_a?(Hash) && origin.keys.sort == %w[command cwd pane server_identity socket_identity tab workspace] &&
|
|
95
|
+
process["runtime"] == "herdr" && process["shell_identity"] == process["process_identity"] &&
|
|
96
|
+
process["session"] == native["workspace_id"] && origin["workspace"] == process["session"] &&
|
|
97
|
+
origin["pane"] == process["pane"] && origin["server_identity"] == native["server_identity"] &&
|
|
98
|
+
origin["socket_identity"] == native["socket_identity"] && native["server_identity"].is_a?(Hash) &&
|
|
99
|
+
process.dig("process_identity", "parent_pid") == native.dig("server_identity", "pid")
|
|
100
|
+
raise ValidationError, "original context native association differs"
|
|
101
|
+
end
|
|
102
|
+
GuardedNativeOrigin.verify!(data.fetch("guarded_origin"), terminal_id: process.fetch("terminal_id"), child: process.fetch("process_identity"))
|
|
103
|
+
immutable(data)
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def immutable(value)
|
|
109
|
+
case value
|
|
110
|
+
when Hash then value.to_h { |key, item| [key.dup.freeze, immutable(item)] }.freeze
|
|
111
|
+
when Array then value.map { |item| immutable(item) }.freeze
|
|
112
|
+
when String then value.dup.freeze
|
|
113
|
+
else value.freeze
|
|
114
|
+
end
|
|
115
|
+
end
|
|
116
|
+
end
|
|
117
|
+
end
|
|
118
|
+
end
|
|
119
|
+
end
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "openssl"
|
|
4
|
+
require "digest"
|
|
5
|
+
require "ace/runtime/molecules/protected_artifact_set"
|
|
6
|
+
require_relative "inbox_context_store"
|
|
7
|
+
|
|
8
|
+
module Ace
|
|
9
|
+
module Herdr
|
|
10
|
+
module Molecules
|
|
11
|
+
# Both fixed installer artifacts are read from held protected descriptors.
|
|
12
|
+
class InboxContextKey
|
|
13
|
+
LIMIT = 16_384
|
|
14
|
+
def initialize(context_id:, public_key_path:, config_path:, artifacts: Ace::Runtime::Molecules::ProtectedArtifactSet.new)
|
|
15
|
+
@context_id, @key_path, @config_path, @artifacts = context_id, public_key_path, config_path, artifacts
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def snapshot = selected.fetch(:snapshot)
|
|
19
|
+
|
|
20
|
+
# The fresh verifier key and its metadata come from the same held pair.
|
|
21
|
+
# Rotation never leaves an Inbox collaborator pinned to an obsolete key.
|
|
22
|
+
def selected
|
|
23
|
+
@artifacts.with do |reader|
|
|
24
|
+
key_bytes, key_ref = reader.read_path!(@key_path, limit: LIMIT)
|
|
25
|
+
config_bytes, config_ref = reader.read_path!(@config_path, limit: LIMIT)
|
|
26
|
+
config = InboxContextStore.decode(config_bytes, limit: LIMIT)
|
|
27
|
+
unless config.is_a?(Hash) && config.keys.sort == %w[context_id key_generation public_key_sha256 schema] &&
|
|
28
|
+
config["schema"] == "ace.herdr.inbox-key/v1" && config["context_id"] == @context_id &&
|
|
29
|
+
config["key_generation"].is_a?(Integer) && config["key_generation"].between?(1, (1 << 63) - 1) &&
|
|
30
|
+
config["public_key_sha256"] == key_ref.fetch("sha256")
|
|
31
|
+
raise ValidationError, "installed context key configuration differs"
|
|
32
|
+
end
|
|
33
|
+
key = public_key!(key_bytes)
|
|
34
|
+
reader.verify_unchanged!
|
|
35
|
+
snapshot = {"key_generation" => config.fetch("key_generation"),
|
|
36
|
+
"fingerprint" => Digest::SHA256.hexdigest(key.public_to_der),
|
|
37
|
+
"public_key_sha256" => key_ref.fetch("sha256"), "config_sha256" => config_ref.fetch("sha256")}
|
|
38
|
+
{snapshot: snapshot.freeze, key: key.freeze}.freeze
|
|
39
|
+
end
|
|
40
|
+
rescue Ace::Runtime::RuntimeUnavailableError, OpenSSL::PKey::PKeyError, KeyError
|
|
41
|
+
raise ValidationError, "installed context key evidence is unavailable"
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def self.public_key!(bytes)
|
|
45
|
+
unless bytes.is_a?(String) && bytes.bytesize.between?(1, LIMIT)
|
|
46
|
+
raise ValidationError, "context public key exceeds bounds"
|
|
47
|
+
end
|
|
48
|
+
key = OpenSSL::PKey.read(bytes)
|
|
49
|
+
unless key.is_a?(OpenSSL::PKey::RSA) && !key.private? && key.n.num_bits >= 2048
|
|
50
|
+
raise ValidationError, "context key must be public RSA"
|
|
51
|
+
end
|
|
52
|
+
key
|
|
53
|
+
rescue OpenSSL::PKey::PKeyError
|
|
54
|
+
raise ValidationError, "context public key is invalid"
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
private
|
|
58
|
+
|
|
59
|
+
def public_key!(bytes) = self.class.public_key!(bytes)
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
end
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "ace/runtime/molecules/execution_unit_installation"
|
|
4
|
+
require "ace/runtime/molecules/cgroup_observation"
|
|
5
|
+
require "ace/runtime/molecules/protected_linux"
|
|
6
|
+
require_relative "inbox_context_service_configuration"
|
|
7
|
+
|
|
8
|
+
module Ace
|
|
9
|
+
module Herdr
|
|
10
|
+
module Molecules
|
|
11
|
+
# An epoch is an observed service incarnation, not a caller grant. The
|
|
12
|
+
# full effective unit is verified before and after the pinned observation.
|
|
13
|
+
class InboxContextLifetime
|
|
14
|
+
EPOCH_FIELDS = %w[boot_id cgroup_identity installation_sha256 process_identity schema service_invocation_id service_unit].freeze
|
|
15
|
+
UUID = /\A[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\z/
|
|
16
|
+
class KernelFiles
|
|
17
|
+
def boot_id
|
|
18
|
+
bytes = File.read("/proc/sys/kernel/random/boot_id", 128).strip
|
|
19
|
+
unless bytes.match?(/\A[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\z/)
|
|
20
|
+
raise ValidationError, "context boot identity is unavailable"
|
|
21
|
+
end
|
|
22
|
+
bytes
|
|
23
|
+
rescue SystemCallError, IOError
|
|
24
|
+
raise ValidationError, "context boot identity is unavailable"
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
attr_reader :epoch
|
|
29
|
+
|
|
30
|
+
# Persisted shape validation alone never establishes stopped writers.
|
|
31
|
+
def self.validate_epoch!(epoch)
|
|
32
|
+
unless epoch.is_a?(Hash) && epoch.keys.sort == EPOCH_FIELDS && epoch["schema"] == "ace.herdr.inbox-owner-epoch/v1" &&
|
|
33
|
+
epoch["installation_sha256"].is_a?(String) && epoch["installation_sha256"].match?(/\A[0-9a-f]{64}\z/) &&
|
|
34
|
+
epoch["boot_id"].is_a?(String) && UUID.match?(epoch["boot_id"]) &&
|
|
35
|
+
epoch["service_invocation_id"].is_a?(String) && epoch["service_invocation_id"].match?(/\A[0-9a-f]{32}\z/) &&
|
|
36
|
+
epoch["service_unit"].is_a?(String) && epoch["service_unit"].match?(/\A[A-Za-z0-9_.@-]+\.service\z/)
|
|
37
|
+
raise ValidationError, "context epoch fields differ"
|
|
38
|
+
end
|
|
39
|
+
process = epoch.fetch("process_identity")
|
|
40
|
+
unless process.is_a?(Hash) && process.keys.sort == %w[gid groups host parent_pid pid started_at uid] &&
|
|
41
|
+
process.values_at("pid", "parent_pid").all? { |value| value.is_a?(Integer) && value.between?(1, (1 << 31) - 1) } &&
|
|
42
|
+
process.values_at("uid", "gid").all? { |value| value.is_a?(Integer) && value.between?(1, InboxContextServiceConfiguration::MAX_ID) } &&
|
|
43
|
+
process["groups"].is_a?(Array) && process["groups"].size <= 64 && process["groups"] == process["groups"].sort.uniq &&
|
|
44
|
+
process["groups"].all? { |value| value.is_a?(Integer) && value.between?(0, InboxContextServiceConfiguration::MAX_ID) } &&
|
|
45
|
+
process["host"].is_a?(String) && process["host"].bytesize.between?(1, 255) &&
|
|
46
|
+
process["started_at"].is_a?(String) && process["started_at"].match?(/\Alinux:#{Regexp.escape(epoch.fetch('boot_id'))}:[1-9][0-9]*\z/)
|
|
47
|
+
raise ValidationError, "context epoch process differs"
|
|
48
|
+
end
|
|
49
|
+
cgroup = epoch.fetch("cgroup_identity")
|
|
50
|
+
root = Ace::Runtime::Molecules::CgroupObservation::ROOT
|
|
51
|
+
unless cgroup.is_a?(Hash) && cgroup.keys.sort == %w[device filesystem_type inode mount_id path] &&
|
|
52
|
+
cgroup["filesystem_type"] == "cgroup2" && cgroup["path"].is_a?(String) &&
|
|
53
|
+
cgroup["path"].bytesize <= 4096 && !cgroup["path"].match?(/[\s\0]/) && cgroup["path"].start_with?(root + "/") &&
|
|
54
|
+
File.expand_path(cgroup["path"]) == cgroup["path"] &&
|
|
55
|
+
cgroup.values_at("device", "inode", "mount_id").all? { |value| value.is_a?(Integer) && value.between?(0, (1 << 64) - 1) } &&
|
|
56
|
+
JSON.generate(epoch).bytesize <= 4096
|
|
57
|
+
raise ValidationError, "context epoch scope differs"
|
|
58
|
+
end
|
|
59
|
+
true
|
|
60
|
+
rescue KeyError, TypeError, ArgumentError
|
|
61
|
+
raise ValidationError, "context epoch is malformed"
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def initialize(installation:, manager:, configuration:, kernel: Ace::Runtime::Molecules::ProtectedLinux.new,
|
|
65
|
+
cgroups: Ace::Runtime::Molecules::CgroupObservation.new, files: KernelFiles.new)
|
|
66
|
+
unless installation.is_a?(Ace::Runtime::Molecules::ExecutionUnitInstallation) &&
|
|
67
|
+
configuration.is_a?(InboxContextServiceConfiguration)
|
|
68
|
+
raise ValidationError, "context lifetime selection is not held"
|
|
69
|
+
end
|
|
70
|
+
@installation, @manager, @configuration, @kernel, @cgroups, @files = installation, manager, configuration, kernel, cgroups, files
|
|
71
|
+
unless installation.inbox_context_configuration_reference == configuration.reference
|
|
72
|
+
raise ValidationError, "context lifetime configuration association differs"
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def capture_epoch!
|
|
77
|
+
raise ValidationError, "context epoch is already observed" if @epoch
|
|
78
|
+
scope = @installation.inbox_context_scope
|
|
79
|
+
@installation.verify!(manager: @manager)
|
|
80
|
+
boot = @files.boot_id
|
|
81
|
+
before = @manager.inspect_activation.fetch("service")
|
|
82
|
+
peer = @kernel.capture(Process.pid)
|
|
83
|
+
unless peer.values_at("uid", "gid", "groups") == @configuration.data.fetch("owner_credentials").values_at("uid", "gid", "groups") &&
|
|
84
|
+
before.fetch("Id") == scope.fetch("service_unit") && before.fetch("MainPID") == Process.pid &&
|
|
85
|
+
before.fetch("ControlPID").zero? && before.fetch("Job") == [0, "/"] &&
|
|
86
|
+
before.fetch("ActiveState") == "active" && before.fetch("SubState") == "running" &&
|
|
87
|
+
before.fetch("InvocationID").is_a?(String) && before.fetch("InvocationID").match?(/\A[0-9a-f]{32}\z/)
|
|
88
|
+
raise ValidationError, "context service incarnation differs"
|
|
89
|
+
end
|
|
90
|
+
path = Ace::Runtime::Molecules::CgroupObservation::ROOT + before.fetch("ControlGroup")
|
|
91
|
+
pinned = @cgroups.pin(path)
|
|
92
|
+
@cgroups.member!(peer, pinned: pinned, kernel: @kernel)
|
|
93
|
+
unless @cgroups.observe(pinned).fetch("populated") == 1 && @kernel.same?(peer, @kernel.capture(Process.pid)) &&
|
|
94
|
+
@manager.inspect_activation.fetch("service") == before && @files.boot_id == boot
|
|
95
|
+
raise ValidationError, "context service observation changed"
|
|
96
|
+
end
|
|
97
|
+
@installation.verify!(manager: @manager)
|
|
98
|
+
observed = {"schema" => "ace.herdr.inbox-owner-epoch/v1", "installation_sha256" => scope.fetch("unit_manifest_sha256"),
|
|
99
|
+
"boot_id" => boot, "service_unit" => scope.fetch("service_unit"), "service_invocation_id" => before.fetch("InvocationID"),
|
|
100
|
+
"cgroup_identity" => pinned.fetch(:identity), "process_identity" => peer}
|
|
101
|
+
self.class.validate_epoch!(observed)
|
|
102
|
+
@epoch = immutable(observed)
|
|
103
|
+
rescue KeyError, TypeError, Ace::Runtime::RuntimeUnavailableError, SystemCallError, IOError
|
|
104
|
+
raise ValidationError, "context service epoch is unavailable"
|
|
105
|
+
ensure
|
|
106
|
+
pinned&.fetch(:handle)&.close
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
private
|
|
110
|
+
|
|
111
|
+
def immutable(value)
|
|
112
|
+
case value
|
|
113
|
+
when Hash then value.to_h { |key, item| [key.dup.freeze, immutable(item)] }.freeze
|
|
114
|
+
when Array then value.map { |item| immutable(item) }.freeze
|
|
115
|
+
when String then value.dup.freeze
|
|
116
|
+
else value.freeze
|
|
117
|
+
end
|
|
118
|
+
end
|
|
119
|
+
end
|
|
120
|
+
end
|
|
121
|
+
end
|
|
122
|
+
end
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "ace/runtime/molecules/protected_artifact_set"
|
|
4
|
+
require_relative "inbox_context_service_configuration"
|
|
5
|
+
require_relative "bounded_process"
|
|
6
|
+
|
|
7
|
+
module Ace
|
|
8
|
+
module Herdr
|
|
9
|
+
module Molecules
|
|
10
|
+
# Exact accepted executable inode, fixed environment and startup closure.
|
|
11
|
+
# This is process construction, not original-target authorization.
|
|
12
|
+
class InboxContextNativeProcess
|
|
13
|
+
def initialize(configuration:, artifacts_factory: nil, process: BoundedProcess)
|
|
14
|
+
unless configuration.is_a?(InboxContextServiceConfiguration)
|
|
15
|
+
raise ValidationError, "context native selection is not held"
|
|
16
|
+
end
|
|
17
|
+
@selection = configuration.data.fetch("native_clients")
|
|
18
|
+
@credentials = configuration.data.fetch("owner_credentials")
|
|
19
|
+
@artifacts_factory = artifacts_factory || -> { Ace::Runtime::Molecules::ProtectedArtifactSet.new(
|
|
20
|
+
file_limit: 268_435_456, total_limit: 268_435_456, count_limit: 512) }
|
|
21
|
+
@process = process
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def verify!
|
|
25
|
+
@artifacts_factory.call.with do |reader|
|
|
26
|
+
references.each { |ref| reader.read!(ref) }
|
|
27
|
+
@selection.values_at("codex", "pi", "herdr").each do |ref|
|
|
28
|
+
reader.with_readonly_handle!(ref) { |handle| verify_executable!(handle) }
|
|
29
|
+
end
|
|
30
|
+
resource_snapshot!
|
|
31
|
+
reader.verify_unchanged!
|
|
32
|
+
end
|
|
33
|
+
true
|
|
34
|
+
rescue Ace::Runtime::RuntimeUnavailableError, SystemCallError, IOError
|
|
35
|
+
raise ValidationError, "context native release is unavailable"
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def call(argv, stdin_data:, timeout_s:, output_limit: 65_536)
|
|
39
|
+
selected = @selection.values_at("codex", "pi", "herdr").find { |ref| ref.fetch("path") == argv.first }
|
|
40
|
+
raise ValidationError, "context native executable is not selected" unless selected
|
|
41
|
+
@artifacts_factory.call.with do |reader|
|
|
42
|
+
references.each { |ref| reader.read!(ref) }
|
|
43
|
+
resources = resource_snapshot!
|
|
44
|
+
reader.with_readonly_handle!(selected) do |handle|
|
|
45
|
+
verify_executable!(handle)
|
|
46
|
+
result = @process.call(["/proc/self/fd/5", *argv.drop(1)], stdin_data: stdin_data, timeout_s: timeout_s,
|
|
47
|
+
output_limit: output_limit, environment: @selection.fetch("environment"),
|
|
48
|
+
chdir: @selection.fetch("cwd"), descriptor_mapping: {5 => handle})
|
|
49
|
+
raise ValidationError, "context native resource changed" unless resource_snapshot! == resources
|
|
50
|
+
result
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
rescue Ace::Runtime::RuntimeUnavailableError, SystemCallError, IOError
|
|
54
|
+
raise ValidationError, "context native executable evidence is unavailable"
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
private
|
|
58
|
+
|
|
59
|
+
def references = @selection.values_at("codex", "pi", "herdr") + @selection.fetch("dependencies")
|
|
60
|
+
|
|
61
|
+
def access_bits(stat)
|
|
62
|
+
if stat.uid == @credentials.fetch("uid")
|
|
63
|
+
(stat.mode >> 6) & 7
|
|
64
|
+
elsif (@credentials.fetch("groups") + [@credentials.fetch("gid")]).include?(stat.gid)
|
|
65
|
+
(stat.mode >> 3) & 7
|
|
66
|
+
else
|
|
67
|
+
stat.mode & 7
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def verify_executable!(handle)
|
|
72
|
+
raise ValidationError, "context native executable mode differs" unless (access_bits(handle.stat) & 1) == 1
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def resource_snapshot!
|
|
76
|
+
@selection.fetch("resources").map do |row|
|
|
77
|
+
stat = File.lstat(row.fetch("path"))
|
|
78
|
+
kind = row.fetch("kind") == "directory" ? stat.directory? : stat.socket?
|
|
79
|
+
unless kind && !stat.symlink? && stat.uid == row.fetch("uid") && stat.gid == row.fetch("gid") &&
|
|
80
|
+
(stat.mode & 0o7777) == row.fetch("mode")
|
|
81
|
+
raise ValidationError, "context native resource placement differs"
|
|
82
|
+
end
|
|
83
|
+
bits = access_bits(stat)
|
|
84
|
+
required = row.fetch("kind") == "socket" ? 2 : row.fetch("access") == "write" ? 3 : 5
|
|
85
|
+
raise ValidationError, "context native resource access differs" unless (bits & required) == required
|
|
86
|
+
[stat.dev, stat.ino, stat.uid, stat.gid, stat.mode]
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
end
|