ace-runtime 0.2.0 → 0.3.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.
@@ -0,0 +1,94 @@
1
+ # frozen_string_literal: true
2
+ require_relative "protected_artifact_set"
3
+ require "rbconfig"
4
+
5
+ module Ace
6
+ module Runtime
7
+ module Molecules
8
+ # The private hook installs this guard before loading its ACE dependencies.
9
+ # Every executable file is verified before require can evaluate it.
10
+ class ReadinessRuntime
11
+ BUILTINS = %w[enumerator.so thread.rb fiber.so rational.so complex.so ruby2_keywords.rb].freeze
12
+
13
+ def initialize(configuration:, artifacts: ProtectedArtifactSet.new)
14
+ @runtime, @artifacts = configuration.fetch("runtime"), artifacts
15
+ @references = @runtime.fetch("dependencies").to_h { |ref| [ref.fetch("path"), ref] }.freeze
16
+ end
17
+
18
+ def activate!(features: $LOADED_FEATURES, load_paths: $LOAD_PATH)
19
+ verify_loaded!(features)
20
+ @runtime.fetch("load_paths").each do |path|
21
+ ProtectedSocket.root_path!(path, directory: true)
22
+ end
23
+ load_paths.replace(@runtime.fetch("load_paths"))
24
+ @initialized_builtins ||= BUILTINS.select { |feature| features.include?(feature) }.freeze
25
+ owner = self
26
+ guard = Module.new do
27
+ define_method(:require) do |feature|
28
+ return false if owner.builtin_loaded?(feature)
29
+ path = owner.resolve!(feature)
30
+ owner.verify_file!(path)
31
+ super(path)
32
+ end
33
+ define_method(:require_relative) do |feature|
34
+ caller_file = caller_locations(1, 1).first.absolute_path
35
+ path = owner.resolve!(File.expand_path(feature, File.dirname(caller_file)))
36
+ owner.verify_file!(path)
37
+ require(path)
38
+ end
39
+ private :require, :require_relative
40
+ end
41
+ Kernel.prepend(guard)
42
+ true
43
+ end
44
+
45
+ def verify_loaded!(features)
46
+ features.each do |feature|
47
+ next if BUILTINS.include?(feature)
48
+ unless feature.is_a?(String) && feature.start_with?("/") && @references.key?(feature)
49
+ raise RuntimeUnavailableError, "readiness startup loaded undeclared code"
50
+ end
51
+ verify_file!(feature)
52
+ end
53
+ true
54
+ end
55
+
56
+ def builtin_loaded?(feature)
57
+ return false unless feature.is_a?(String) && @initialized_builtins
58
+ @initialized_builtins.any? do |builtin|
59
+ feature == builtin || feature == builtin.sub(/\.(?:rb|so)\z/, "")
60
+ end
61
+ end
62
+
63
+ def resolve!(feature)
64
+ unless feature.is_a?(String) && !feature.include?("\0")
65
+ raise RuntimeUnavailableError, "readiness require selection differs"
66
+ end
67
+ bases = feature.start_with?("/") ? [feature] : @runtime.fetch("load_paths").map { |path| File.join(path, feature) }
68
+ extension = "." + RbConfig::CONFIG.fetch("DLEXT")
69
+ candidates = bases.flat_map do |path|
70
+ # Ruby maps explicit .so/.o requests to its own platform extension.
71
+ if path.end_with?(".so", ".o")
72
+ [path.sub(/\.(?:so|o)\z/, extension)]
73
+ elsif path.end_with?(".rb", extension)
74
+ [path]
75
+ else
76
+ [path, path + ".rb", path + extension]
77
+ end
78
+ end
79
+ selected = candidates.find { |path| @references.key?(path) }
80
+ raise RuntimeUnavailableError, "readiness require is outside protected closure" unless selected
81
+ selected
82
+ end
83
+
84
+ def verify_file!(path)
85
+ @artifacts.with do |reader|
86
+ reader.read!(@references.fetch(path))
87
+ reader.verify_unchanged!
88
+ end
89
+ true
90
+ end
91
+ end
92
+ end
93
+ end
94
+ end
@@ -0,0 +1,183 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fiddle"
4
+ require "rbconfig"
5
+ require_relative "linux_mount_info"
6
+ require_relative "../errors"
7
+
8
+ module Ace
9
+ module Runtime
10
+ module Molecules
11
+ # Observes declared reachable objects in a pinned server root. This does
12
+ # not enter or modify a namespace and carries no admission authority.
13
+ class ServerResourceObservation
14
+ VIEW_PATHS = %w[/proc /proc/sys /sys /sys/fs/cgroup /dev /dev/pts /dev/mqueue /dev/shm /tmp /var/tmp].freeze
15
+ MOUNT_FIELDS = %w[mount_id parent_id major_minor root mountpoint options filesystem_type].freeze
16
+ class Files
17
+ OPENAT2 = 437
18
+ RESOLVE = 0x10 | 0x04 # IN_ROOT | NO_SYMLINKS; bind mounts are allowed.
19
+
20
+ def supported!
21
+ unless RUBY_PLATFORM.include?("linux") && %w[x86_64 aarch64 arm64].include?(RbConfig::CONFIG.fetch("host_cpu"))
22
+ raise RuntimeUnavailableError, "contained server-root observation is unsupported"
23
+ end
24
+ end
25
+
26
+ def namespace(pid, kind = "mnt")
27
+ File.open("/proc/#{pid}/ns/#{kind}", File::RDONLY) { |file| identity(file.stat) }
28
+ end
29
+
30
+ def observe(pid, entries, authority_socket:)
31
+ supported!
32
+ root = File.open("/proc/#{pid}/root", File::RDONLY)
33
+ namespace = File.open("/proc/#{pid}/ns/mnt", File::RDONLY)
34
+ network = File.open("/proc/#{pid}/ns/net", File::RDONLY)
35
+ network_identity = network_identity!(network)
36
+ ipc = File.open("/proc/#{pid}/ns/ipc", File::RDONLY)
37
+ hook_ipc = File.open("/proc/self/ns/ipc", File::RDONLY)
38
+ root_identity, namespace_identity = identity(root.stat), identity(namespace.stat)
39
+ ipc_identity, hook_ipc_identity = identity(ipc.stat), identity(hook_ipc.stat)
40
+ table = LinuxMountInfo.new(File.read("/proc/#{pid}/mountinfo", LinuxMountInfo::LIMIT + 1))
41
+ raise RuntimeUnavailableError, "server mount inventory exceeds bound" if table.records.size > 256
42
+ topology = []
43
+ result = entries.map do |entry|
44
+ handle = open_inside(root, entry.fetch("view_path"))
45
+ begin
46
+ stat = handle.stat
47
+ raise RuntimeUnavailableError, "declared server resource is not a directory" unless stat.directory?
48
+ rows = File.read("/proc/self/fdinfo/#{handle.fileno}", 16_385).lines.filter_map do |line|
49
+ /\Amnt_id:\s+([0-9]+)\s*\z/.match(line)&.[](1)
50
+ end
51
+ raise RuntimeUnavailableError, "server resource mount is ambiguous" unless rows.size == 1
52
+ mount = table.by_id(Integer(rows.first, 10))
53
+ topology << entry.slice("host_path", "view_path").merge(mount.slice("mountpoint", "root", "major_minor", "options"))
54
+ entry.slice("host_path", "view_path").merge("device" => stat.dev, "inode" => stat.ino,
55
+ "uid" => stat.uid, "gid" => stat.gid, "filesystem_type" => mount.fetch("filesystem_type"),
56
+ "mount_id" => mount.fetch("mount_id"))
57
+ ensure
58
+ handle.close
59
+ end
60
+ end
61
+ views = VIEW_PATHS.map { |path| pinned_view(root, path, table: table) }
62
+ socket_identity = pinned_socket(root, authority_socket)
63
+ ptmx_identity, ptmx_link = pinned_ptmx(root, table: table)
64
+ repeated = LinuxMountInfo.new(File.read("/proc/#{pid}/mountinfo", LinuxMountInfo::LIMIT + 1))
65
+ unless namespace_identity == self.namespace(pid) && root_identity == identity(File.stat("/proc/#{pid}/root")) &&
66
+ network_identity == self.namespace(pid, "net") &&
67
+ ipc_identity == self.namespace(pid, "ipc") && hook_ipc_identity == self.namespace("self", "ipc") &&
68
+ table.records == repeated.records &&
69
+ views == VIEW_PATHS.map { |path| pinned_view(root, path, table: repeated) } &&
70
+ socket_identity == pinned_socket(root, authority_socket) &&
71
+ [ptmx_identity, ptmx_link] == pinned_ptmx(root, table: repeated)
72
+ raise RuntimeUnavailableError, "server root or namespace changed during observation"
73
+ end
74
+ {"mount_namespace_identity" => namespace_identity, "network_namespace_identity" => network_identity, "resource_identities" => result, "resource_topology" => topology,
75
+ "kernel_view_topology" => {"ipc_namespace_identity" => ipc_identity, "hook_ipc_namespace_identity" => hook_ipc_identity,
76
+ "mounts" => table.records.map { |row| row.slice(*MOUNT_FIELDS) }, "views" => views, "authority_socket_identity" => socket_identity,
77
+ "ptmx_identity" => ptmx_identity, "ptmx_link" => ptmx_link}}
78
+ ensure
79
+ root&.close
80
+ namespace&.close
81
+ network&.close
82
+ ipc&.close
83
+ hook_ipc&.close
84
+ end
85
+
86
+ private
87
+
88
+ # NS_GET_NSTYPE is an nsfs ioctl. Pin the original server's object,
89
+ # require CLONE_NEWNET, and retain it across the repeated path read.
90
+ def network_identity!(handle)
91
+ unless handle.ioctl(0xb703) == 0x40000000
92
+ raise RuntimeUnavailableError, "server object is not a network namespace"
93
+ end
94
+ identity(handle.stat)
95
+ rescue SystemCallError, IOError
96
+ raise RuntimeUnavailableError, "server network namespace type is unavailable"
97
+ end
98
+
99
+ def identity(stat) = {"device" => stat.dev, "inode" => stat.ino}
100
+
101
+ def pinned_view(root, path, table:)
102
+ handle = open_inside(root, path, flags: 0x200000 | File::NOFOLLOW) # O_PATH, no data access.
103
+ stat = handle.stat
104
+ raise RuntimeUnavailableError, "fixed server view is not a directory" unless stat.directory?
105
+ rows = File.read("/proc/self/fdinfo/#{handle.fileno}", 16_385).lines.filter_map { |line| /\Amnt_id:\s+([0-9]+)\s*\z/.match(line)&.[](1) }
106
+ raise RuntimeUnavailableError, "fixed view mount is ambiguous" unless rows.size == 1
107
+ mount = table.by_id(Integer(rows.first, 10))
108
+ {"path" => path, "mount_id" => mount.fetch("mount_id"), "device" => stat.dev, "inode" => stat.ino, "type" => "directory"}
109
+ ensure
110
+ handle&.close
111
+ end
112
+
113
+ def pinned_socket(root, path)
114
+ handle = open_inside(root, path, flags: 0x200000 | File::NOFOLLOW)
115
+ stat = handle.stat
116
+ raise RuntimeUnavailableError, "authority projection is not a socket" unless stat.socket?
117
+ [stat.dev, stat.ino, stat.uid]
118
+ ensure
119
+ handle&.close
120
+ end
121
+
122
+ def pinned_ptmx(root, table:)
123
+ dev = open_inside(root, "/dev", flags: 0x200000 | File::NOFOLLOW)
124
+ link = read_ptmx_link(dev)
125
+ raise RuntimeUnavailableError, "selected ptmx link differs" unless link == "pts/ptmx"
126
+ node = open_inside(root, "/dev/pts/ptmx", flags: 0x200000 | File::NOFOLLOW)
127
+ stat = node.stat
128
+ raise RuntimeUnavailableError, "selected ptmx is not a character device" unless stat.chardev? && stat.rdev_major == 5 && stat.rdev_minor == 2
129
+ rows = File.read("/proc/self/fdinfo/#{node.fileno}", 16_385).lines.filter_map { |line| /\Amnt_id:\s+([0-9]+)\s*\z/.match(line)&.[](1) }
130
+ raise RuntimeUnavailableError, "selected ptmx mount is ambiguous" unless rows.size == 1
131
+ mount = table.by_id(Integer(rows.first, 10))
132
+ [{"device" => stat.dev, "inode" => stat.ino, "mount_id" => mount.fetch("mount_id"), "type" => "character",
133
+ "rdev_major" => stat.rdev_major, "rdev_minor" => stat.rdev_minor}, link]
134
+ ensure
135
+ dev&.close
136
+ node&.close
137
+ end
138
+
139
+ def read_ptmx_link(dev)
140
+ call = Fiddle::Function.new(Fiddle::Handle::DEFAULT["readlinkat"],
141
+ [Fiddle::TYPE_INT, Fiddle::TYPE_VOIDP, Fiddle::TYPE_VOIDP, Fiddle::TYPE_SIZE_T], Fiddle::TYPE_SSIZE_T)
142
+ buffer = Fiddle::Pointer.malloc(64)
143
+ count = call.call(dev.fileno, "ptmx\0", buffer, 64)
144
+ raise RuntimeUnavailableError, "selected ptmx link cannot be observed" unless count.between?(1, 63)
145
+ buffer.to_s(count)
146
+ end
147
+
148
+ def open_inside(root, path, flags: File::RDONLY | File::NONBLOCK | File::NOFOLLOW)
149
+ unless path.is_a?(String) && path.bytesize.between?(1, 4096) && path.start_with?("/") &&
150
+ !path.include?("\0") && File.expand_path(path) == path
151
+ raise RuntimeUnavailableError, "server resource path differs"
152
+ end
153
+ syscall = Fiddle::Function.new(Fiddle::Handle::DEFAULT["syscall"],
154
+ [Fiddle::TYPE_LONG, Fiddle::TYPE_INT, Fiddle::TYPE_VOIDP, Fiddle::TYPE_VOIDP, Fiddle::TYPE_SIZE_T],
155
+ Fiddle::TYPE_LONG)
156
+ how = [flags, 0, RESOLVE].pack("Q<Q<Q<")
157
+ descriptor = syscall.call(OPENAT2, root.fileno, path + "\0", how, how.bytesize)
158
+ raise RuntimeUnavailableError, "contained server resource cannot be opened" if descriptor.negative?
159
+ IO.for_fd(descriptor, autoclose: true)
160
+ end
161
+ end
162
+
163
+ def initialize(kernel:, files: Files.new)
164
+ @kernel, @files = kernel, files
165
+ end
166
+
167
+ def observe!(server:, entries:, authority_socket:)
168
+ @kernel.live!(server)
169
+ before = @kernel.capture(server.fetch("pid"))
170
+ unless @kernel.same?(before, server)
171
+ raise RuntimeUnavailableError, "server incarnation differs"
172
+ end
173
+ result = @files.observe(server.fetch("pid"), entries, authority_socket: authority_socket)
174
+ @kernel.live!(server)
175
+ unless @kernel.same?(@kernel.capture(server.fetch("pid")), before)
176
+ raise RuntimeUnavailableError, "server credentials changed during resource observation"
177
+ end
178
+ result
179
+ end
180
+ end
181
+ end
182
+ end
183
+ end
@@ -23,25 +23,31 @@ module Ace
23
23
  PropagatesStopTo StopPropagatedFrom JoinsNamespaceOf RequiresMountsFor WantsMountsFor].to_h { |key| [key, "as"] }
24
24
  }.freeze
25
25
  SERVICE_EXEC_SIGNATURES = {
26
- **%w[Type User Group Restart KillMode ProtectSystem RootDirectory RootImage NetworkNamespacePath Slice StandardOutput StandardError WorkingDirectory RuntimeDirectoryPreserve].to_h { |key| [key, "s"] },
26
+ **%w[Type User Group Restart KillMode ProtectSystem RootDirectory RootImage NetworkNamespacePath Slice StandardInput TTYPath StandardOutput StandardError WorkingDirectory RuntimeDirectoryPreserve DevicePolicy PAMName].to_h { |key| [key, "s"] },
27
27
  **%w[SupplementaryGroups ReadWritePaths ReadOnlyPaths Environment PassEnvironment UnsetEnvironment Sockets InaccessiblePaths ExtensionDirectories RuntimeDirectory].to_h { |key| [key, "as"] },
28
- **%w[SendSIGKILL Delegate ProtectControlGroups NoNewPrivileges PrivateIPC PrivateDevices DynamicUser RootEphemeral MountAPIVFS].to_h { |key| [key, "b"] },
28
+ **%w[SendSIGKILL Delegate ProtectControlGroups NoNewPrivileges PrivateIPC PrivateDevices DynamicUser RootEphemeral MountAPIVFS PrivateTmp ProtectKernelTunables BindLogSockets].to_h { |key| [key, "b"] },
29
29
  **%w[CapabilityBoundingSet AmbientCapabilities RestrictNamespaces].to_h { |key| [key, "t"] },
30
30
  "RuntimeDirectoryMode" => "u", "UMask" => "u", "RootImageOptions" => "a(ss)", "TemporaryFileSystem" => "a(ss)", "MountImages" => "a(ssba(ss))",
31
31
  "ExtensionImages" => "a(sba(ss))", "EnvironmentFiles" => "a(sb)", "RestartForceExitStatus" => "(aiai)", "RestrictAddressFamilies" => "(bas)",
32
- "BindPaths" => "a(ssbt)", "BindReadOnlyPaths" => "a(ssbt)",
32
+ "DeviceAllow" => "a(ss)", "BindPaths" => "a(ssbt)", "BindReadOnlyPaths" => "a(ssbt)",
33
33
  **%w[ExecConditionEx ExecStartPreEx ExecStartEx ExecStartPostEx ExecReloadEx ExecStopEx ExecStopPostEx].to_h { |key| [key, "a(sasasttttuii)"] }
34
34
  }.freeze
35
35
 
36
36
  UNIT_STATE_SIGNATURES = {"Id" => "s", "LoadState" => "s", "ActiveState" => "s", "SubState" => "s", "Job" => "(uo)"}.freeze
37
37
  MOUNT_SIGNATURES = {"Where" => "s"}.freeze
38
- ACTIVATION_UNIT_SIGNATURES = UNIT_STATE_SIGNATURES.merge("InvocationID" => "s", "ControlGroup" => "s").freeze
38
+ ACTIVATION_UNIT_SIGNATURES = UNIT_STATE_SIGNATURES.merge("InvocationID" => "ay", "ControlGroup" => "s").freeze
39
39
  ACTIVATION_SERVICE_SIGNATURES = {"MainPID" => "u", "ControlPID" => "u", "Slice" => "s"}.freeze
40
+ INBOX_CONTEXT_SIGNATURES = {"TimeoutStopUSec" => "t"}.freeze
41
+
42
+ PIDFD_ARGV = [BUSCTL, "--system", "--no-pager", "--json=short", "--auto-start=no",
43
+ "--allow-interactive-authorization=no", "--timeout=5", "call", "org.freedesktop.systemd1",
44
+ "/org/freedesktop/systemd1", "org.freedesktop.systemd1.Manager", "GetUnitByPIDFD", "h", "3"].freeze
40
45
 
41
46
  class Command
42
47
  LIMIT = 65_536
43
48
 
44
- def call(argv, timeout:)
49
+ def call(argv, timeout:, pidfd: nil)
50
+ inherited = inherited_descriptor_options(argv, pidfd)
45
51
  unless RUBY_PLATFORM.include?("linux") && File.directory?("/run/systemd/system")
46
52
  raise RuntimeUnavailableError, "execution scope requires the Linux system systemd manager"
47
53
  end
@@ -56,7 +62,7 @@ module Ace
56
62
  output_reader, output_writer = IO.pipe
57
63
  error_reader, error_writer = IO.pipe
58
64
  pid = Process.spawn({"PATH" => "/usr/bin:/bin", "LANG" => "C", "LC_ALL" => "C"}, *argv,
59
- in: File::NULL, out: output_writer, err: error_writer, unsetenv_others: true, close_others: true)
65
+ in: File::NULL, out: output_writer, err: error_writer, unsetenv_others: true, close_others: true, **inherited)
60
66
  output_writer.close
61
67
  error_writer.close
62
68
  deadline = ProtectedSocket.deadline(timeout)
@@ -112,6 +118,15 @@ module Ace
112
118
  end
113
119
  end
114
120
  end
121
+ private
122
+
123
+ def inherited_descriptor_options(argv, pidfd)
124
+ return {} unless pidfd || argv == PIDFD_ARGV
125
+ unless argv == PIDFD_ARGV && pidfd.is_a?(IO) && !pidfd.closed? && pidfd.fileno >= 3
126
+ raise ArgumentError, "pidfd transport requires the fixed manager method and held IO"
127
+ end
128
+ {3 => pidfd}
129
+ end
115
130
  end
116
131
 
117
132
  def initialize(slice_unit:, service_unit:, command: Command.new)
@@ -132,6 +147,25 @@ module Ace
132
147
  end
133
148
  end
134
149
 
150
+ # The root identity owner supplies its already policy-checked pinned
151
+ # lifetime. A wire descriptor number never selects this capability.
152
+ def unit_for_pidfd(handle:, timeout: 5)
153
+ unless handle.is_a?(IO) && !handle.closed?
154
+ raise ArgumentError, "manager identity requires a held process lifetime"
155
+ end
156
+ bytes = @command.call(PIDFD_ARGV, timeout: bounded_timeout(timeout), pidfd: handle)
157
+ value = strict_typed_json(bytes)
158
+ data = value["data"]
159
+ unless value.keys.sort == %w[data type] && value["type"] == "osay" &&
160
+ data.is_a?(Array) && data.size == 3 && data[0] == unit_object(@service_unit) &&
161
+ data[1] == @service_unit && invocation_bytes?(data[2]) && data[2].any?(&:positive?)
162
+ raise RuntimeUnavailableError, "manager lifetime belongs to another unit or invocation"
163
+ end
164
+ {"unit" => @service_unit, "invocation_id" => data[2].pack("C*").unpack1("H*")}.freeze
165
+ rescue JSON::ParserError, KeyError, TypeError
166
+ raise RuntimeUnavailableError, "typed manager lifetime response is malformed"
167
+ end
168
+
135
169
  def inspect_units
136
170
  slice = show(@slice_unit)
137
171
  service = show(@service_unit, properties: SERVICE_PROPERTIES)
@@ -168,7 +202,34 @@ module Ace
168
202
  show(unit)
169
203
  [unit, typed_properties(unit: unit, interface: "Unit", signatures: UNIT_GRAPH_SIGNATURES)]
170
204
  end
171
- {"slice" => slice, "service" => service, "ancestors" => ancestors, "unit_paths" => unit_paths, "prerequisites" => inspect_prerequisites(service)}
205
+ {"slice" => slice, "service" => service, "ancestors" => ancestors, "unit_paths" => unit_paths,
206
+ "manager_environment" => manager_environment, "prerequisites" => inspect_prerequisites(service)}
207
+ end
208
+
209
+ def inspect_inbox_context_profile
210
+ profile = inspect_profile
211
+ profile.fetch("service").merge!(typed_properties(unit: @service_unit, interface: "Service",
212
+ signatures: INBOX_CONTEXT_SIGNATURES))
213
+ profile
214
+ end
215
+
216
+ def manager_environment(timeout: 5)
217
+ bytes = @command.call([BUSCTL, "--system", "--no-pager", "--json=short", "--auto-start=no",
218
+ "--allow-interactive-authorization=no", "get-property", "org.freedesktop.systemd1",
219
+ "/org/freedesktop/systemd1", "org.freedesktop.systemd1.Manager", "Environment"], timeout: bounded_timeout(timeout))
220
+ unless bytes.is_a?(String) && bytes.bytesize.between?(1, 65_536) && bytes.lines.size == 1
221
+ raise RuntimeUnavailableError, "effective manager environment is unavailable"
222
+ end
223
+ value = JSON.parse(bytes, create_additions: false, allow_duplicate_key: false, allow_comments: false, max_nesting: 4)
224
+ unless value.is_a?(Hash) && value.keys.sort == %w[data type] && value["type"] == "as" &&
225
+ value["data"].is_a?(Array) && value["data"].size <= 256 &&
226
+ value["data"].all? { |entry| entry.is_a?(String) && entry.bytesize.between?(1, 4096) && !entry.include?("\0") && entry.match?(/\A[A-Za-z_][A-Za-z_0-9]*=.*\z/) } &&
227
+ value["data"].map { |entry| entry.split("=", 2).first }.uniq.size == value["data"].size
228
+ raise RuntimeUnavailableError, "effective manager environment differs"
229
+ end
230
+ value.fetch("data")
231
+ rescue JSON::ParserError, ArgumentError
232
+ raise RuntimeUnavailableError, "effective manager environment is malformed"
172
233
  end
173
234
 
174
235
  def unit_paths
@@ -191,25 +252,23 @@ module Ace
191
252
 
192
253
  # Typed reads only, against the existing system manager. Property sets
193
254
  # and unit identities are owner-selected, never request-controlled.
194
- def typed_properties(unit:, interface:, signatures:)
255
+ def typed_properties(unit:, interface:, signatures:, timeout: 5)
195
256
  unless [@slice_unit, @service_unit, *@slice_ancestors, *@prerequisite_units.to_a].include?(unit) &&
196
257
  %w[Unit Service Mount].include?(interface) && (interface == "Unit" || interface == "Service" && unit == @service_unit || interface == "Mount" && @prerequisite_units.to_a.include?(unit) && unit.end_with?(".mount")) && signatures.is_a?(Hash) &&
197
258
  signatures.all? { |key, value|
198
259
  allowed = case interface
199
260
  when "Unit" then UNIT_GRAPH_SIGNATURES.merge(ACTIVATION_UNIT_SIGNATURES)
200
- when "Service" then SERVICE_EXEC_SIGNATURES.merge(ACTIVATION_SERVICE_SIGNATURES)
261
+ when "Service" then SERVICE_EXEC_SIGNATURES.merge(ACTIVATION_SERVICE_SIGNATURES).merge(INBOX_CONTEXT_SIGNATURES)
201
262
  when "Mount" then MOUNT_SIGNATURES
202
263
  end
203
264
  allowed[key] == value
204
265
  } && !signatures.empty?
205
266
  raise ArgumentError, "typed inspection requires fixed unit properties"
206
267
  end
207
- object = "/org/freedesktop/systemd1/unit/" + unit.bytes.map { |byte|
208
- ((byte >= 65 && byte <= 90) || (byte >= 97 && byte <= 122) || (byte >= 48 && byte <= 57)) ? byte.chr : "_%02x" % byte
209
- }.join
268
+ object = unit_object(unit)
210
269
  bytes = @command.call([BUSCTL, "--system", "--no-pager", "--json=short", "--auto-start=no",
211
270
  "--allow-interactive-authorization=no", "get-property", "org.freedesktop.systemd1", object,
212
- "org.freedesktop.systemd1.#{interface}", *signatures.keys], timeout: 5)
271
+ "org.freedesktop.systemd1.#{interface}", *signatures.keys], timeout: bounded_timeout(timeout))
213
272
  unless bytes.is_a?(String) && bytes.bytesize.between?(1, Command::LIMIT)
214
273
  raise RuntimeUnavailableError, "typed manager properties are unavailable"
215
274
  end
@@ -218,11 +277,11 @@ module Ace
218
277
  raise RuntimeUnavailableError, "typed manager property set is incomplete"
219
278
  end
220
279
  signatures.to_a.zip(lines).to_h do |(key, signature), line|
221
- value = JSON.parse(line)
280
+ value = strict_typed_json(line)
222
281
  unless value.is_a?(Hash) && value.keys.sort == %w[data type] && value["type"] == signature && typed_value?(signature, value["data"])
223
282
  raise RuntimeUnavailableError, "typed manager property signature differs"
224
283
  end
225
- [key, value.fetch("data")]
284
+ [key, key == "InvocationID" ? value.fetch("data").pack("C*").unpack1("H*") : value.fetch("data")]
226
285
  end
227
286
  rescue JSON::ParserError, KeyError
228
287
  raise RuntimeUnavailableError, "typed manager properties are malformed"
@@ -255,12 +314,41 @@ module Ace
255
314
  end
256
315
  end
257
316
 
317
+ def bounded_timeout(value)
318
+ unless value.is_a?(Numeric) && value.finite? && value.positive? && value <= 5
319
+ raise ArgumentError, "manager observation deadline exceeds its fixed budget"
320
+ end
321
+ value
322
+ end
323
+
324
+ def unit_object(unit)
325
+ "/org/freedesktop/systemd1/unit/" + unit.bytes.map { |byte|
326
+ ((byte >= 65 && byte <= 90) || (byte >= 97 && byte <= 122) || (byte >= 48 && byte <= 57)) ? byte.chr : "_%02x" % byte
327
+ }.join
328
+ end
329
+
330
+ def strict_typed_json(bytes)
331
+ unless bytes.is_a?(String) && bytes.bytesize.between?(1, Command::LIMIT) &&
332
+ bytes.dup.force_encoding(Encoding::UTF_8).valid_encoding?
333
+ raise RuntimeUnavailableError, "typed manager response is unavailable"
334
+ end
335
+ value = JSON.parse(bytes, create_additions: false, max_nesting: 32,
336
+ allow_duplicate_key: false, allow_comments: false)
337
+ raise RuntimeUnavailableError, "typed manager response is malformed" unless value.is_a?(Hash)
338
+ value
339
+ end
340
+
341
+ def invocation_bytes?(value)
342
+ value.is_a?(Array) && value.size == 16 && value.all? { |byte| byte.is_a?(Integer) && byte.between?(0, 255) }
343
+ end
344
+
258
345
  def typed_value?(signature, value)
259
346
  string = ->(item) { item.is_a?(String) && !item.include?("\0") }
260
347
  unsigned = ->(item, bits) { item.is_a?(Integer) && item >= 0 && item < (1 << bits) }
261
348
  signed = ->(item) { item.is_a?(Integer) && item >= -(1 << 31) && item < (1 << 31) }
262
349
  strings = ->(items) { items.is_a?(Array) && items.all? { |item| string.call(item) } }
263
350
  case signature
351
+ when "ay" then invocation_bytes?(value)
264
352
  when "s" then string.call(value)
265
353
  when "(uo)" then value.is_a?(Array) && value.size == 2 && unsigned.call(value.first, 32) && string.call(value.last) && value.last.start_with?("/")
266
354
  when "a(ss)"
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Ace
4
4
  module Runtime
5
- VERSION = "0.2.0"
5
+ VERSION = "0.3.0"
6
6
  end
7
7
  end
data/lib/ace/runtime.rb CHANGED
@@ -13,6 +13,7 @@ require_relative "runtime/registry"
13
13
  require_relative "runtime/molecules/runtime_selector"
14
14
  require_relative "runtime/molecules/process_identity"
15
15
  require_relative "runtime/molecules/network_installation_evidence"
16
+ require_relative "runtime/molecules/execution_network_selection"
16
17
 
17
18
  module Ace
18
19
  module Runtime
data/native/README.md CHANGED
@@ -2,13 +2,13 @@
2
2
 
3
3
  The `ace-runtime` gem ships `native/worker_gate.c` and `native/build-worker-gate`.
4
4
  This is a Linux executable, not a Ruby interpreter wrapper. Build on the target
5
- architecture from the installed gem with the system compiler and json-c parser.
5
+ architecture from the installed gem with the system compiler, json-c parser and OpenSSL 3 libcrypto.
6
6
  Supported initial architectures are Linux x86_64 and aarch64 with little-endian
7
7
  ELF64. A bootstrap built on another architecture fails deployment preflight.
8
8
 
9
9
  On Debian bookworm install the distribution's `build-essential`, `pkg-config`
10
- and `libjson-c-dev` build packages. The executable requires the matching system
11
- `libjson-c5`, libc and ELF loader at runtime. Use the same distribution/architecture
10
+ `libjson-c-dev` and `libssl-dev` build packages. The executable requires the matching system
11
+ `libjson-c5`, OpenSSL 3 `libcrypto.so.3` (`libssl3`), libc and ELF loader at runtime. Use the same distribution/architecture
12
12
  for build and deployment; retain package versions, source SHA, compiler version,
13
13
  ELF machine, `readelf -d` dependencies and output SHA256 in installer evidence.
14
14
  These libraries and their loader search directories must remain root-owned and
@@ -33,8 +33,9 @@ All installation ancestors are root-owned and non-worker-writable. The fixed
33
33
  root-owned `/etc/ace/assignment-authorities.json` records `bootstrap` and its exact
34
34
  `bootstrap_sha256`. ACE verifies digest, ELF architecture, executable permissions,
35
35
  ownership and ancestry before reservation. The native layout uses only this
36
- absolute executable, installed mapping ID and correlation ticket; payload argv,
37
- cwd and environment come from the root-controlled mapping after authority release.
36
+ absolute executable, installed mapping ID and correlation ticket; cwd and environment come from the root-controlled mapping after authority release.
37
+ The mandatory closed `worker_entry` pins interpreter and wrapper references; arbitrary
38
+ worker argv and shebang/PATH execution are not admitted.
38
39
  The map grants no UID switching and the binary is never privileged.
39
40
 
40
41
  The root-installed authority, launcher and native server units require:
@@ -55,15 +56,69 @@ mandatory; an empty capability set alone does not stop same-UID injection.
55
56
 
56
57
  The bootstrap sets non-dumpable before connecting, requires enforced Yama
57
58
  `ptrace_scope=2`, requires empty Inh/Prm/Eff/Bnd/Amb capability sets and NoNewPrivs=1, verifies
58
- worker IDs/groups and exact pinned server birth/parent, authenticates authority
59
+ worker IDs/groups and its retained actual kernel parent birth, authenticates authority
59
60
  SO_PEERCRED and protected socket ancestry, and waits at most 30 seconds on one
60
61
  stream. It never reconnects or executes after pre-release EOF, malformed input
61
62
  or timeout. A committed release whose response is lost remains potentially
62
63
  executed; public inspection must retain ownership rather than resend release.
63
64
 
65
+ The gate accepts only the maintained `ace.assign.authorities/v2` mapping. It
66
+ captures its actual parent PID and kernel boot/start-tick birth before reading
67
+ the map, checks that same birth before and after parent credentials, and repeats
68
+ the check before authority admission and immediately before exec. The canonical
69
+ `gate_ready` authority owner associates the actual gate peer and its parent with
70
+ the original recorded child/server; the map supplies no `server_identity`.
71
+ Changed parent birth, reparenting, inaccessible credentials or changed policy
72
+ refuses. The original retained mapping supplies `worker_entry={interpreter:REF,wrapper:REF}`
73
+ in the authenticated release, rather than selecting code from the current map.
74
+ Interpreter references are bounded to 32MiB and wrapper references to 1MiB.
75
+ Both root-protected regular held files are hashed and their metadata rechecked.
76
+ The interpreter must be an executable native ELF. The verified wrapper is copied
77
+ into a memfd sealed against writes/growth/shrinkage, reopened read-only, and the
78
+ writable descriptor dropped. Selected held interpreter FD4 and code FD5 survive
79
+ `fexecve(4, ["/proc/self/fd/4", "-I", "-S", "-B", "/proc/self/fd/5", "authority", "worker"], env)`.
80
+ Inherited stdin/stdout/stderr remain untouched; no provider lifetime timeout is added.
81
+ The retained original source owner inside the wrapper separately validates and
82
+ executes the accepted Ruby snapshot and its package closure.
83
+
84
+ Release is one compact JSON record containing the original ticket/assignment/
85
+ attempt/generation/commit plus `prepared_input`. That closed object pins the
86
+ original registration generation/commit, definition and original binding digest,
87
+ prepared-work reference, original worker entry pair and exact journal bundle reference/length/SHA256. The
88
+ authority verifies the original bundle before durable issue; subsequent prepared
89
+ fetch authenticates the accepted release pin before returning body bytes. The
90
+ gate validates the closed ten-field pin, including the original workspace exclusion,
91
+ digests and unchanged bounds before exec. The exclusion has five closed fields:
92
+ key, authority UID/GID, readonly root resource and original cwd resource. Each
93
+ resource has the existing eight identity fields. The gate checks integer/path/
94
+ filesystem bounds and exact mapping/view/key association; the retained Ruby owner
95
+ verifies the canonical cwd digest and held filesystem identity before acquiring
96
+ the lifetime reader. This metadata does not itself grant filesystem access.
97
+ The complete encoded release is bounded to16KiB before durable issue and at the gate. It
98
+ does not receive inline work or propagate an input FD to providers. The worker
99
+ uses the existing authenticated original prepared fetch after release.
100
+
64
101
  An ordinary Docker kernel with Yama=0 can verify compilation and fail-closed
65
102
  policy rejection. It cannot demonstrate protected launch acceptance. A real
66
103
  isolated Linux deployment enforcing the complete policy is required for that
67
104
  positive proof; installing this gem does not change host sysctls or install OS
68
105
  service/account policy. Downstream gad.8 provisions those requirements and
69
106
  consumes this shipped build mechanism.
107
+
108
+ ## Fixed digest initialization and acceptance limits
109
+
110
+ Before any OpenSSL API call the gate selects `OPENSSL_INIT_NO_LOAD_CONFIG`.
111
+ It creates a private library context, fixes its provider search path to `/dev/null`
112
+ (a non-directory), explicitly loads only `default`, and fetches `SHA256` with
113
+ `provider=default`. Failure refuses execution; no config/engine/module fallback
114
+ is provided. [Initialization semantics](https://docs.openssl.org/3.0/man3/OPENSSL_init_crypto/),
115
+ [provider search path](https://docs.openssl.org/3.0/man3/OSSL_PROVIDER/), and
116
+ [fixed provider property](https://docs.openssl.org/3.0/man7/OSSL_PROVIDER-default/) and
117
+ [libcrypto built-in default provider](https://docs.openssl.org/3.0/man7/crypto/#default-provider)
118
+ are the maintained API basis. The system libcrypto/loader remain in the accepted
119
+ root-controlled OS artifact closure; this does not attest an arbitrary library build.
120
+
121
+ This source checkpoint does not compile or execute native code. Real ELF,
122
+ OpenSSL linkage/provider availability, memfd seals, retained FD execution and
123
+ Python script/stdlib behavior require installed Linux acceptance. The trusted
124
+ provisioned OS/Python premise remains; no separate Python runtime tree is introduced.
@@ -8,8 +8,10 @@ case "$1" in /*) ;; *) echo 'output must be absolute' >&2; exit 2;; esac
8
8
  source_dir=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)
9
9
  build_dir=$(mktemp -d)
10
10
  trap 'rm -rf -- "$build_dir"' EXIT HUP INT TERM
11
- cflags=$(env -i PATH=/usr/bin:/bin /usr/bin/pkg-config --cflags json-c)
12
- libs=$(env -i PATH=/usr/bin:/bin /usr/bin/pkg-config --libs json-c)
11
+ env -i PATH=/usr/bin:/bin /usr/bin/pkg-config --atleast-version=3.0.0 libcrypto
12
+ env -i PATH=/usr/bin:/bin /usr/bin/pkg-config --atleast-version=0.15 json-c
13
+ cflags=$(env -i PATH=/usr/bin:/bin /usr/bin/pkg-config --cflags json-c libcrypto)
14
+ libs=$(env -i PATH=/usr/bin:/bin /usr/bin/pkg-config --libs json-c libcrypto)
13
15
  # pkg-config values come only from the installed system development package.
14
16
  # shellcheck disable=SC2086
15
17
  env -i PATH=/usr/bin:/bin /usr/bin/cc -std=c11 -O2 -D_FORTIFY_SOURCE=3 \