ra8-firmware 0.1.0
Bare-metal firmware for the Renesas RA8 family (RA8D2 / RA8P1)
Loading...
Searching...
No Matches
bench_lock_capability.py
Go to the documentation of this file.
1# SPDX-License-Identifier: MIT
2# Copyright (c) 2026 Brighton Sikarskie
3"""Authenticate the controller-side process capability for a bench hold."""
4
5from __future__ import annotations
6
7import base64
8import binascii
9import os
10import re
11import tempfile
12from collections.abc import Mapping
13from dataclasses import dataclass
14from pathlib import Path
15
16SSH_PREFIX = (
17 "-T",
18 "-o",
19 "BatchMode=yes",
20 "-o",
21 "ConnectTimeout=8",
22 "-o",
23 "ServerAliveInterval=15",
24 "-o",
25 "ServerAliveCountMax=4",
26)
27LOCK_ID_RE = re.compile(r"[0-9a-f]{16}")
28PID_RE = re.compile(r"[1-9][0-9]*")
29MAX_ANCESTORS = 64
30
31
32@dataclass(frozen=True)
33class Capability:
34 """Inherited facts which must bind to the live wrapper process tree."""
35
36 lock_id: str
37 holder_pid: int
38 holder_start_ticks: int
39 target: str
40
41
42@dataclass(frozen=True)
43class _SyntheticProcess:
44 """One synthetic proc identity used by the offline attacks."""
45
46 pid: int
47 parent: int
48 ticks: int
49 executable: str
50 argv: list[str]
51 cwd: Path | None = None
52
53
54@dataclass(frozen=True)
55class _Fixture:
56 """Controller process-tree authority for the offline attacks."""
57
58 repo: Path
59 proc: Path
60 capability: Capability
61 current_pid: int
62
63
64class CapabilityError(ValueError):
65 """Inherited capability facts did not match the trusted wrapper."""
66
67
68def _stat_fields(proc_root: Path, pid: int) -> tuple[int, int]:
69 """Return parent PID and start ticks from one proc stat record."""
70 try:
71 raw = (proc_root / str(pid) / "stat").read_text(encoding="ascii")
72 tail = raw[raw.rindex(")") + 2 :].split()
73 return int(tail[1]), int(tail[19])
74 except (OSError, ValueError, IndexError) as exc:
75 msg = f"cannot authenticate controller PID {pid}"
76 raise CapabilityError(msg) from exc
77
78
79def _argv(proc_root: Path, pid: int) -> list[str]:
80 """Read one process argv as strict UTF-8 fields."""
81 try:
82 raw = (proc_root / str(pid) / "cmdline").read_bytes()
83 return [field.decode("utf-8", "strict") for field in raw.split(b"\0") if field]
84 except (OSError, UnicodeError) as exc:
85 msg = f"cannot authenticate controller argv for PID {pid}"
86 raise CapabilityError(msg) from exc
87
88
89def _executable(proc_root: Path, pid: int) -> Path:
90 """Resolve one process executable or fail closed."""
91 try:
92 return (proc_root / str(pid) / "exe").resolve(strict=True)
93 except OSError as exc:
94 msg = f"cannot authenticate controller executable for PID {pid}"
95 raise CapabilityError(msg) from exc
96
97
98def _ancestors(proc_root: Path, pid: int) -> list[int]:
99 """Return the bounded live ancestry for one process."""
100 result: list[int] = []
101 seen: set[int] = set()
102 while pid > 1 and pid not in seen and len(result) < MAX_ANCESTORS:
103 seen.add(pid)
104 result.append(pid)
105 pid, _ticks = _stat_fields(proc_root, pid)
106 return result
107
108
109def _decoded_fields(remote: str) -> list[str]:
110 """Decode bounded base64 arguments embedded in the fixed remote command."""
111 decoded: list[str] = []
112 for candidate in re.findall(r"'([A-Za-z0-9+/=]{16,})'", remote):
113 value = _decode_field(candidate)
114 if value is not None:
115 decoded.append(value)
116 return decoded
117
118
119def _decode_field(candidate: str) -> str | None:
120 """Decode one bounded field without placing exception handling in the scan loop."""
121 try:
122 raw = base64.b64decode(candidate, validate=True)
123 return raw.decode("utf-8", "strict")
124 except (binascii.Error, UnicodeError):
125 return None
126
127
128def _holder_command(
129 proc_root: Path,
130 capability: Capability,
131 bench_host_source: bytes,
132) -> int:
133 """Require exact ssh transport, target, reviewed source, and lock fields."""
134 argv = _argv(proc_root, capability.holder_pid)
135 if _executable(proc_root, capability.holder_pid) != Path("/usr/bin/ssh"):
136 msg = "bench holder is not the fixed ssh executable"
137 raise CapabilityError(msg)
138 prefix_end = 1 + len(SSH_PREFIX)
139 if tuple(argv[1:prefix_end]) != SSH_PREFIX or len(argv) != prefix_end + 2:
140 msg = "bench holder ssh options are not exact"
141 raise CapabilityError(msg)
142 if argv[prefix_end] != capability.target:
143 msg = "bench holder ssh target does not match the declared target"
144 raise CapabilityError(msg)
145 remote = argv[prefix_end + 1]
146 encoded_source = base64.b64encode(bench_host_source).decode("ascii")
147 if encoded_source not in remote or "'hold' 'wrapped'" not in remote:
148 msg = "bench holder remote command is not the reviewed wrapped hold"
149 raise CapabilityError(msg)
150 fields = _decoded_fields(remote)
151 expected = f"lock_id={capability.lock_id}\n"
152 if not any(expected in value and "hold_kind=wrapped\n" in value for value in fields):
153 msg = "bench holder remote command does not bind this lock ID"
154 raise CapabilityError(msg)
155 parent, ticks = _stat_fields(proc_root, capability.holder_pid)
156 if ticks != capability.holder_start_ticks:
157 msg = "bench holder PID was reused"
158 raise CapabilityError(msg)
159 return parent
160
161
162def _wrapper_parent(proc_root: Path, parent: int, repo_root: Path, current_pid: int) -> None:
163 """Require the holder and current command beneath one reviewed bench.sh."""
164 if parent not in _ancestors(proc_root, current_pid):
165 msg = "bench holder is not a sibling in this wrapper transaction"
166 raise CapabilityError(msg)
167 if _executable(proc_root, parent).name != "bash":
168 msg = "bench wrapper parent is not Bash"
169 raise CapabilityError(msg)
170 argv = _argv(proc_root, parent)
171 try:
172 cwd = (proc_root / str(parent) / "cwd").resolve(strict=True)
173 except OSError as exc:
174 msg = "cannot authenticate bench wrapper working directory"
175 raise CapabilityError(msg) from exc
176 expected = (repo_root / "scripts/hil/bench.sh").resolve(strict=True)
177 candidates = [
178 (cwd / argument).resolve() for argument in argv[1:] if argument.endswith("bench.sh")
179 ]
180 if candidates != [expected] or "run" not in argv:
181 msg = "controller ancestry does not contain the reviewed bench wrapper"
182 raise CapabilityError(msg)
183
184
185def authenticate(
186 capability: Capability,
187 repo_root: Path,
188 proc_root: Path = Path("/proc"),
189 current_pid: int | None = None,
190) -> None:
191 """Authenticate one inherited hold against the exact local process tree."""
192 if LOCK_ID_RE.fullmatch(capability.lock_id) is None or capability.holder_pid <= 1:
193 msg = "bench capability identity is malformed"
194 raise CapabilityError(msg)
195 if (
196 capability.holder_start_ticks <= 0
197 or not capability.target
198 or any(character.isspace() for character in capability.target)
199 ):
200 msg = "bench capability transport is malformed"
201 raise CapabilityError(msg)
202 source = (repo_root / "scripts/hil/lib/bench_host.sh").read_bytes()
203 if not source:
204 msg = "bench holder source is empty"
205 raise CapabilityError(msg)
206 parent = _holder_command(proc_root, capability, source)
207 _wrapper_parent(proc_root, parent, repo_root, current_pid or os.getpid())
208
209
210def from_environment(environment: Mapping[str, str]) -> Capability:
211 """Parse the exact inherited capability fields without accepting aliases."""
212 pid = environment.get("RA8_BENCH_HOLDER_PID", "")
213 ticks = environment.get("RA8_BENCH_HOLDER_START_TICKS", "")
214 if PID_RE.fullmatch(pid) is None or PID_RE.fullmatch(ticks) is None:
215 msg = "bench holder process identity is absent or malformed"
216 raise CapabilityError(msg)
217 return Capability(
218 environment.get("RA8_BENCH_LOCK_ID", ""),
219 int(pid),
220 int(ticks),
221 environment.get("RA8_BENCH_HOLDER_TARGET", ""),
222 )
223
224
225def _write_process(
226 proc_root: Path,
227 spec: _SyntheticProcess,
228) -> None:
229 """Create one synthetic proc entry for the offline capability attacks."""
230 process = proc_root / str(spec.pid)
231 process.mkdir(parents=True)
232 tail = ["S", str(spec.parent), *(["0"] * 17), str(spec.ticks)]
233 (process / "stat").write_text(
234 f"{spec.pid} (fixture) " + " ".join(tail) + "\n", encoding="ascii"
235 )
236 (process / "cmdline").write_bytes(b"\0".join(item.encode() for item in spec.argv) + b"\0")
237 (process / "exe").symlink_to(spec.executable)
238 if spec.cwd is not None:
239 (process / "cwd").symlink_to(spec.cwd)
240
241
242def _fixture_remote(source: bytes, lock_id: str) -> str:
243 """Render the bounded fields consumed from the reviewed holder command."""
244 encoded_source = base64.b64encode(source).decode("ascii")
245 fields = base64.b64encode(
246 f"resource=bench\nlock_id={lock_id}\nhold_kind=wrapped\n".encode()
247 ).decode("ascii")
248 return f"printf %s '{encoded_source}' | base64 -d; 'hold' 'wrapped' '{fields}'"
249
250
251def _make_fixture(base: Path) -> _Fixture:
252 """Create one wrapper, exact holder, foreign sibling, and current child."""
253 repo = base / "repo"
254 proc = base / "proc"
255 host = repo / "scripts/hil/lib/bench_host.sh"
256 wrapper = repo / "scripts/hil/bench.sh"
257 host.parent.mkdir(parents=True)
258 host.write_bytes(b"#!/bin/bash\n# reviewed\n")
259 wrapper.write_bytes(b"#!/bin/bash\n")
260 wrapper_pid, holder_pid, foreign_pid, current_pid = 100, 101, 103, 102
261 _write_process(
262 proc,
263 _SyntheticProcess(wrapper_pid, 1, 500, "/bin/bash", ["bash", str(wrapper), "run"], repo),
264 )
265 capability = Capability("0123456789abcdef", holder_pid, 600, "star.local")
266 holder_argv = [
267 "/usr/bin/ssh",
268 *SSH_PREFIX,
269 capability.target,
270 _fixture_remote(host.read_bytes(), capability.lock_id),
271 ]
272 _write_process(
273 proc, _SyntheticProcess(holder_pid, wrapper_pid, 600, "/usr/bin/ssh", holder_argv)
274 )
275 foreign_argv = ["/usr/bin/ssh", *SSH_PREFIX, "foreign.local", "unreviewed"]
276 _write_process(
277 proc, _SyntheticProcess(foreign_pid, wrapper_pid, 601, "/usr/bin/ssh", foreign_argv)
278 )
279 _write_process(
280 proc, _SyntheticProcess(current_pid, wrapper_pid, 700, "/usr/bin/python3", ["python3"])
281 )
282 return _Fixture(repo, proc, capability, current_pid)
283
284
285def _expect_refusal(
286 fixture: _Fixture,
287 capability: Capability,
288 label: str,
289 failures: list[str],
290) -> None:
291 """Require a synthetic controller capability attack to fail closed."""
292 try:
293 authenticate(capability, fixture.repo, fixture.proc, fixture.current_pid)
294 except CapabilityError:
295 return
296 failures.append(f"{label} escaped controller capability authentication")
297
298
299def run_selftest() -> list[str]:
300 """Exercise PID, ancestry, target, source, and sibling binding offline."""
301 failures: list[str] = []
302 with tempfile.TemporaryDirectory(prefix="ra8-capability-") as raw:
303 fixture = _make_fixture(Path(raw))
304 capability = fixture.capability
305 authenticate(capability, fixture.repo, fixture.proc, fixture.current_pid)
306 reused = Capability(capability.lock_id, capability.holder_pid, 601, capability.target)
307 _expect_refusal(fixture, reused, "PID reuse", failures)
308 wrong_target = Capability(capability.lock_id, capability.holder_pid, 600, "other.local")
309 _expect_refusal(fixture, wrong_target, "remote target drift", failures)
310 foreign = Capability(capability.lock_id, 103, 601, "foreign.local")
311 _expect_refusal(fixture, foreign, "foreign same-parent ssh", failures)
312 (fixture.repo / "scripts/hil/lib/bench_host.sh").write_bytes(b"tampered\n")
313 _expect_refusal(fixture, capability, "reviewed holder tamper", failures)
314 (fixture.repo / "scripts/hil/lib/bench_host.sh").write_bytes(b"#!/bin/bash\n# reviewed\n")
315 detached_pid = 104
316 _write_process(
317 fixture.proc,
318 _SyntheticProcess(detached_pid, 1, 800, "/usr/bin/python3", ["python3"]),
319 )
320 detached = _Fixture(fixture.repo, fixture.proc, capability, detached_pid)
321 _expect_refusal(detached, capability, "foreign process tree", failures)
322 return failures