ra8-firmware 0.1.0
Bare-metal firmware for the Renesas RA8 family (RA8D2 / RA8P1)
Loading...
Searching...
No Matches
bench_lock_verify.py
Go to the documentation of this file.
1# SPDX-License-Identifier: MIT
2# Copyright (c) 2026 Brighton Sikarskie
3"""Authenticate the canonical bench holder against Linux kernel state."""
4
5from __future__ import annotations
6
7import argparse
8import base64
9import hashlib
10import itertools
11import json
12import os
13import re
14import select
15import signal
16import stat
17import subprocess
18import sys
19import tempfile
20from collections.abc import Callable
21from dataclasses import dataclass, replace
22from pathlib import Path
23from unittest import mock
24
25CANONICAL_ROOT = Path("/var/lib/ra8-bench")
26LOCK_ID_RE = re.compile(r"[0-9a-f]{16}")
27SHA256_RE = re.compile(r"[0-9a-f]{64}")
28MAX_RECORD_BYTES = 8192
29MAX_SCRIPT_BYTES = 128 * 1024
30BROKER_ARGC = 11
31SECOND_LOCK_OBSERVATION = 2
32
33
34class LockProofError(ValueError):
35 """The advertised holder did not match the live canonical flock."""
36
37
38class _ProofDeadlineError(RuntimeError):
39 """A selftest descriptor proof exceeded its hard liveness deadline."""
40
41
42@dataclass(frozen=True)
43class ProofRequest:
44 """Expected identities and local authorities for one live proof."""
45
46 lock_id: str
47 kind: str
48 expected_script_sha256: str
49 expected_broker_sha256: str
50 root: Path = CANONICAL_ROOT
51 proc_root: Path = Path("/proc")
52
53
54@dataclass(frozen=True)
55class _ProofState:
56 """Descriptor-bound identities retained across the two proof phases."""
57
58 record: dict[str, object]
59 record_identity: os.stat_result
60 lock_identity: os.stat_result
61 holder_pid: int
62
63
64@dataclass(frozen=True)
65class _Fixture:
66 """Paths and identities for one synthetic proc/lock authority."""
67
68 root: Path
69 proc: Path
70 record: Path
71 lock: Path
72 script: Path
73 broker: Path
74 pid: int
75 ticks: int
76 host_pid: int
77 host_ticks: int
78 lock_id: str
79
80 @property
81 def digest(self) -> str:
82 """Return the reviewed holder fixture digest."""
83 return hashlib.sha256(self.script.read_bytes()).hexdigest()
84
85 @property
86 def broker_digest(self) -> str:
87 """Return the reviewed broker fixture digest."""
88 return hashlib.sha256(self.broker.read_bytes()).hexdigest()
89
90 @property
91 def request(self) -> ProofRequest:
92 """Return the valid proof request bound to this fixture."""
93 return ProofRequest(
94 self.lock_id,
95 "wrapped",
96 self.digest,
97 self.broker_digest,
98 self.root,
99 self.proc,
100 )
101
102
103def _read_regular(
104 path: Path,
105 limit: int,
106 post_read: Callable[[], None] | None = None,
107) -> tuple[bytes, os.stat_result]:
108 """Read one non-linked regular file through one stable descriptor."""
109 flags = os.O_RDONLY | os.O_CLOEXEC | getattr(os, "O_NOFOLLOW", 0)
110 try:
111 descriptor = os.open(path, flags)
112 except OSError as exc:
113 msg = f"cannot open {path}: {exc}"
114 raise LockProofError(msg) from exc
115 try:
116 before = os.fstat(descriptor)
117 if not stat.S_ISREG(before.st_mode):
118 msg = f"{path} is not a regular file"
119 raise LockProofError(msg)
120 raw = os.read(descriptor, limit + 1)
121 if post_read is not None:
122 post_read()
123 after = os.fstat(descriptor)
124 finally:
125 os.close(descriptor)
126 if len(raw) > limit or (before.st_dev, before.st_ino) != (
127 after.st_dev,
128 after.st_ino,
129 ):
130 msg = f"{path} changed or exceeded its size bound"
131 raise LockProofError(msg)
132 try:
133 current = path.stat(follow_symlinks=False)
134 except OSError as exc:
135 msg = f"cannot restat {path}: {exc}"
136 raise LockProofError(msg) from exc
137 if (current.st_dev, current.st_ino) != (before.st_dev, before.st_ino):
138 msg = f"{path} was replaced during authentication"
139 raise LockProofError(msg)
140 return raw, before
141
142
143def _record(path: Path, lock_id: str, kind: str) -> tuple[dict[str, object], os.stat_result]:
144 """Load and validate the holder record's capability-binding fields."""
145 raw, identity = _read_regular(path, MAX_RECORD_BYTES)
146 try:
147 value = json.loads(raw.decode("utf-8", "strict"))
148 except (UnicodeError, json.JSONDecodeError) as exc:
149 msg = "holder record is malformed"
150 raise LockProofError(msg) from exc
151 if not isinstance(value, dict):
152 msg = "holder record is not a mapping"
153 raise LockProofError(msg)
154 required = {
155 "resource",
156 "lock_id",
157 "hold_kind",
158 "pid",
159 "pid_start_ticks",
160 "host_pid",
161 "host_start_ticks",
162 "boot_id",
163 }
164 if not required <= value.keys():
165 msg = "holder record omits live-capability fields"
166 raise LockProofError(msg)
167 if value["resource"] != "bench" or value["lock_id"] != lock_id:
168 msg = "holder record does not name this bench transaction"
169 raise LockProofError(msg)
170 if value["hold_kind"] != kind:
171 msg = "holder record has the wrong hold kind"
172 raise LockProofError(msg)
173 if type(value["pid"]) is not int or int(value["pid"]) <= 1:
174 msg = "holder record PID is invalid"
175 raise LockProofError(msg)
176 for field in ("pid_start_ticks", "host_pid", "host_start_ticks"):
177 if type(value[field]) is not int or int(value[field]) <= 0:
178 msg = f"holder record {field} is invalid"
179 raise LockProofError(msg)
180 return value, identity
181
182
183def _start_ticks(proc_root: Path, pid: int) -> int:
184 """Read one process start time without confusing spaces in comm."""
185 try:
186 raw = (proc_root / str(pid) / "stat").read_text(encoding="ascii")
187 tail = raw[raw.rindex(")") + 2 :].split()
188 return int(tail[19])
189 except (OSError, ValueError, IndexError) as exc:
190 msg = "cannot authenticate holder PID start time"
191 raise LockProofError(msg) from exc
192
193
194def _lock_owner_pids(proc_root: Path, lock_stat: os.stat_result) -> set[int]:
195 """Return active kernel FLOCK owners for one exact device and inode."""
196 try:
197 lines = (proc_root / "locks").read_text(encoding="ascii").splitlines()
198 except OSError as exc:
199 msg = "cannot read kernel lock authority"
200 raise LockProofError(msg) from exc
201 owners: set[int] = set()
202 for line in lines:
203 fields = line.split()
204 if "FLOCK" not in fields or "->" in fields:
205 continue
206 for index, field in enumerate(fields):
207 match = re.fullmatch(r"([0-9a-fA-F]+):([0-9a-fA-F]+):(\d+)", field)
208 if match is None or index == 0 or not fields[index - 1].isdigit():
209 continue
210 major = int(match.group(1), 16)
211 minor = int(match.group(2), 16)
212 inode = int(match.group(3))
213 if (major, minor, inode) == (
214 os.major(lock_stat.st_dev),
215 os.minor(lock_stat.st_dev),
216 lock_stat.st_ino,
217 ):
218 owners.add(int(fields[index - 1]))
219 return owners
220
221
222def _holder_fds(proc_root: Path, pid: int) -> list[Path]:
223 """Return a stable snapshot of the holder's visible file descriptors."""
224 try:
225 return sorted((proc_root / str(pid) / "fd").iterdir(), key=lambda path: path.name)
226 except OSError as exc:
227 msg = "cannot enumerate holder file descriptors"
228 raise LockProofError(msg) from exc
229
230
231def _has_locked_inode(fds: list[Path], lock_stat: os.stat_result) -> bool:
232 """Return whether one holder descriptor references the canonical inode."""
233 for path in fds:
234 try:
235 observed = path.stat()
236 except OSError:
237 continue
238 if (observed.st_dev, observed.st_ino) == (lock_stat.st_dev, lock_stat.st_ino):
239 return True
240 return False
241
242
243def _has_script_digest(fds: list[Path], expected: str) -> bool:
244 """Bind the holder to an open descriptor containing reviewed host bytes."""
245 if not hasattr(os, "O_PATH"):
246 return False
247 for path in fds:
248 try:
249 anchor = os.open(path, os.O_PATH | os.O_CLOEXEC)
250 try:
251 before = os.fstat(anchor)
252 if not stat.S_ISREG(before.st_mode):
253 continue
254 descriptor = os.open(
255 Path("/proc/self/fd") / str(anchor),
256 os.O_RDONLY | os.O_CLOEXEC | os.O_NONBLOCK,
257 )
258 try:
259 descriptor_before = os.fstat(descriptor)
260 raw = os.read(descriptor, MAX_SCRIPT_BYTES + 1)
261 descriptor_after = os.fstat(descriptor)
262 finally:
263 os.close(descriptor)
264 anchored_after = os.fstat(anchor)
265 finally:
266 os.close(anchor)
267 except OSError:
268 continue
269 identities = {
270 (value.st_dev, value.st_ino)
271 for value in (before, descriptor_before, descriptor_after, anchored_after)
272 }
273 stable = len(identities) == 1 and all(
274 stat.S_ISREG(value.st_mode)
275 for value in (before, descriptor_before, descriptor_after, anchored_after)
276 )
277 if stable and len(raw) <= MAX_SCRIPT_BYTES and hashlib.sha256(raw).hexdigest() == expected:
278 return True
279 return False
280
281
282def _process_argv(proc_root: Path, pid: int) -> tuple[Path, list[str]]:
283 """Return one strict executable/argv pair from proc."""
284 try:
285 executable = (proc_root / str(pid) / "exe").resolve(strict=True)
286 argv = (proc_root / str(pid) / "cmdline").read_bytes().split(b"\0")
287 except OSError as exc:
288 msg = "cannot authenticate holder executable"
289 raise LockProofError(msg) from exc
290 return executable, [item.decode("utf-8", "strict") for item in argv if item]
291
292
293def _broker_shape(
294 proc_root: Path,
295 root: Path,
296 record: dict[str, object],
297 expected_broker_sha256: str,
298) -> None:
299 """Bind the kernel owner to exact broker code and the reviewed Bash parent."""
300 pid = int(record["pid"])
301 host_pid = int(record["host_pid"])
302 executable, argv = _process_argv(proc_root, pid)
303 expected_python = Path("/usr/bin/python3").resolve(strict=True)
304 if executable != expected_python or len(argv) != BROKER_ARGC or argv[1:4] != ["-I", "-S", "-c"]:
305 msg = "kernel owner is not the fixed isolated broker"
306 raise LockProofError(msg)
307 if hashlib.sha256(argv[4].encode()).hexdigest() != expected_broker_sha256:
308 msg = "kernel owner is not running the reviewed broker bytes"
309 raise LockProofError(msg)
310 if argv[5:7] != [str(root / "board.lock"), str(root / "holder.json")]:
311 msg = "broker command is not bound to the canonical lock and record"
312 raise LockProofError(msg)
313 if argv[-2:] != [str(host_pid), str(record["host_start_ticks"])]:
314 msg = "broker command is not bound to its recorded Bash parent"
315 raise LockProofError(msg)
316 parent, _ticks = _stat_fields(proc_root, pid)
317 if parent != host_pid:
318 msg = "broker is not the direct child of the reviewed Bash host"
319 raise LockProofError(msg)
320
321
322def _host_shape(proc_root: Path, record: dict[str, object], kind: str) -> None:
323 """Require the broker parent to be reviewed Bash host bytes without lock fd."""
324 host_pid = int(record["host_pid"])
325 executable, argv = _process_argv(proc_root, host_pid)
326 if executable.name != "bash" or not any(
327 left == "hold" and right == kind for left, right in itertools.pairwise(argv)
328 ):
329 msg = "broker parent is not the reviewed host hold process"
330 raise LockProofError(msg)
331
332
333def _stat_fields(proc_root: Path, pid: int) -> tuple[int, int]:
334 """Return one process parent and start time from proc stat."""
335 try:
336 raw = (proc_root / str(pid) / "stat").read_text(encoding="ascii")
337 tail = raw[raw.rindex(")") + 2 :].split()
338 return int(tail[1]), int(tail[19])
339 except (OSError, ValueError, IndexError) as exc:
340 msg = "cannot authenticate process identity"
341 raise LockProofError(msg) from exc
342
343
344def _validate_request(request: ProofRequest) -> None:
345 """Reject malformed or noncanonical proof authorities."""
346 if LOCK_ID_RE.fullmatch(request.lock_id) is None or request.kind not in {"wrapped", "detached"}:
347 msg = "requested lock identity is malformed"
348 raise LockProofError(msg)
349 if any(
350 SHA256_RE.fullmatch(digest) is None
351 for digest in (request.expected_script_sha256, request.expected_broker_sha256)
352 ):
353 msg = "reviewed holder digest is malformed"
354 raise LockProofError(msg)
355 if request.root != CANONICAL_ROOT and os.environ.get("RA8_LOCK_VERIFY_SELFTEST") != "1":
356 msg = "production verification is bound to the canonical lock root"
357 raise LockProofError(msg)
358
359
360def _initial_proof(request: ProofRequest) -> _ProofState:
361 """Bind the record, live PIDs, canonical flock, and reviewed source bytes."""
362 record, record_identity = _record(request.root / "holder.json", request.lock_id, request.kind)
363 pid = int(record["pid"])
364 if _start_ticks(request.proc_root, pid) != int(record["pid_start_ticks"]):
365 msg = "holder PID was reused or the record is stale"
366 raise LockProofError(msg)
367 try:
368 boot = (request.proc_root / "sys/kernel/random/boot_id").read_text(encoding="ascii").strip()
369 lock_stat = (request.root / "board.lock").stat(follow_symlinks=False)
370 except OSError as exc:
371 msg = "canonical lock or boot identity is unavailable"
372 raise LockProofError(msg) from exc
373 if stat.S_ISLNK(lock_stat.st_mode) or not stat.S_ISREG(lock_stat.st_mode):
374 msg = "canonical board lock is not a regular inode"
375 raise LockProofError(msg)
376 if record["boot_id"] != boot or _lock_owner_pids(request.proc_root, lock_stat) != {pid}:
377 msg = "record does not match one unambiguous live kernel lock owner"
378 raise LockProofError(msg)
379 broker_fds = _holder_fds(request.proc_root, pid)
380 host_fds = _holder_fds(request.proc_root, int(record["host_pid"]))
381 if not _has_locked_inode(broker_fds, lock_stat):
382 msg = "broker lacks the canonical lock descriptor"
383 raise LockProofError(msg)
384 if _has_script_digest(broker_fds, request.expected_script_sha256):
385 msg = "broker inherited the reviewed host descriptor"
386 raise LockProofError(msg)
387 if _has_locked_inode(host_fds, lock_stat) or not _has_script_digest(
388 host_fds, request.expected_script_sha256
389 ):
390 msg = "reviewed host is missing or inherited the lock descriptor"
391 raise LockProofError(msg)
392 _broker_shape(request.proc_root, request.root, record, request.expected_broker_sha256)
393 _host_shape(request.proc_root, record, request.kind)
394 return _ProofState(record, record_identity, lock_stat, pid)
395
396
397def _final_proof(request: ProofRequest, proof: _ProofState) -> None:
398 """Recheck every replaceable identity before returning a held verdict."""
399 final_record = (request.root / "holder.json").stat(follow_symlinks=False)
400 if (final_record.st_dev, final_record.st_ino) != (
401 proof.record_identity.st_dev,
402 proof.record_identity.st_ino,
403 ):
404 msg = "holder record changed before proof completion"
405 raise LockProofError(msg)
406 if _start_ticks(request.proc_root, proof.holder_pid) != int(proof.record["pid_start_ticks"]):
407 msg = "holder exited before proof completion"
408 raise LockProofError(msg)
409 if _start_ticks(request.proc_root, int(proof.record["host_pid"])) != int(
410 proof.record["host_start_ticks"]
411 ):
412 msg = "reviewed host exited before proof completion"
413 raise LockProofError(msg)
414 if _lock_owner_pids(request.proc_root, proof.lock_identity) != {proof.holder_pid}:
415 msg = "kernel lock changed before proof completion"
416 raise LockProofError(msg)
417 final_lock = (request.root / "board.lock").stat(follow_symlinks=False)
418 if (final_lock.st_dev, final_lock.st_ino) != (
419 proof.lock_identity.st_dev,
420 proof.lock_identity.st_ino,
421 ):
422 msg = "canonical lock inode changed before proof completion"
423 raise LockProofError(msg)
424
425
426def verify(request: ProofRequest) -> None:
427 """Prove record, process, script, and canonical flock are one live holder."""
428 _validate_request(request)
429 _final_proof(request, _initial_proof(request))
430
431
432def _replacement_selftest() -> list[str]:
433 """Prove descriptor reads reject a path replacement after the read."""
434 failures: list[str] = []
435 with tempfile.TemporaryDirectory(prefix="ra8-lock-proof-") as raw:
436 path = Path(raw) / "holder.json"
437 replacement = path.with_suffix(".new")
438 path.write_bytes(b"old")
439 replacement.write_bytes(b"new")
440
441 def replace() -> None:
442 replacement.replace(path)
443
444 try:
445 _read_regular(path, 16, replace)
446 except LockProofError:
447 pass
448 else:
449 failures.append("holder record replacement escaped descriptor binding")
450 return failures
451
452
453def _stat_line(pid: int, parent: int, ticks: int) -> str:
454 """Render the proc stat fields consumed by this verifier."""
455 return f"{pid} (holder) S {parent} " + " ".join(["0"] * 17 + [str(ticks)]) + "\n"
456
457
458def _write_lock_line(fixture: _Fixture, owners: tuple[int, ...]) -> None:
459 """Render active FLOCK records for the canonical fixture inode."""
460 observed = fixture.lock.stat()
461 device = f"{os.major(observed.st_dev):x}:{os.minor(observed.st_dev):x}:{observed.st_ino}"
462 lines = [
463 f"{index}: FLOCK ADVISORY WRITE {pid} {device} 0 EOF" for index, pid in enumerate(owners, 1)
464 ]
465 (fixture.proc / "locks").write_text("\n".join(lines) + "\n", encoding="ascii")
466
467
468def _write_broker_process(fixture: _Fixture) -> None:
469 """Create the synthetic isolated broker process and its locked fd."""
470 argv = [
471 "/usr/bin/python3",
472 "-I",
473 "-S",
474 "-c",
475 fixture.broker.read_text(encoding="utf-8"),
476 str(fixture.lock),
477 str(fixture.record),
478 "0",
479 "fields",
480 str(fixture.host_pid),
481 str(fixture.host_ticks),
482 ]
483 process = fixture.proc / str(fixture.pid)
484 (process / "stat").write_text(
485 _stat_line(fixture.pid, fixture.host_pid, fixture.ticks), encoding="ascii"
486 )
487 (process / "cmdline").write_bytes(b"\0".join(item.encode() for item in argv) + b"\0")
488 (process / "exe").symlink_to("/usr/bin/python3")
489 (process / "fd/3").symlink_to(fixture.lock)
490
491
492def _write_host_process(proc: Path, script: Path, pid: int, ticks: int) -> None:
493 """Create the reviewed Bash parent without a lock descriptor."""
494 (proc / str(pid) / "stat").write_text(_stat_line(pid, 12, ticks), encoding="ascii")
495 (proc / str(pid) / "cmdline").write_bytes(b"bash\0bench_host.sh\0hold\0wrapped\0")
496 (proc / str(pid) / "exe").symlink_to("/bin/bash")
497 (proc / str(pid) / "fd/4").symlink_to(script)
498
499
500def _make_fixture(base: Path) -> _Fixture:
501 """Create a complete synthetic Linux proc and canonical lock authority."""
502 root = base / "bench"
503 proc = base / "proc"
504 pid = 321
505 ticks = 98765
506 host_pid = 320
507 host_ticks = 87654
508 lock_id = "0123456789abcdef"
509 (proc / str(pid) / "fd").mkdir(parents=True)
510 (proc / str(host_pid) / "fd").mkdir(parents=True)
511 (proc / "sys/kernel/random").mkdir(parents=True)
512 root.mkdir()
513 lock = root / "board.lock"
514 script = root / "bench_host.sh"
515 broker = root / "bench_lock_broker.py"
516 record = root / "holder.json"
517 lock.write_bytes(b"")
518 script.write_bytes(b"#!/bin/bash\n")
519 broker.write_bytes(b"# broker fixture\n")
520 boot_id = "12345678-1234-1234-1234-123456789abc"
521 (proc / "sys/kernel/random/boot_id").write_text(boot_id + "\n", encoding="ascii")
522 record.write_text(
523 json.dumps(
524 {
525 "resource": "bench",
526 "lock_id": lock_id,
527 "hold_kind": "wrapped",
528 "pid": pid,
529 "pid_start_ticks": ticks,
530 "host_pid": host_pid,
531 "host_start_ticks": host_ticks,
532 "boot_id": boot_id,
533 }
534 ),
535 encoding="utf-8",
536 )
537 fixture = _Fixture(
538 root,
539 proc,
540 record,
541 lock,
542 script,
543 broker,
544 pid,
545 ticks,
546 host_pid,
547 host_ticks,
548 lock_id,
549 )
550 _write_broker_process(fixture)
551 _write_host_process(proc, script, host_pid, host_ticks)
552 _write_lock_line(fixture, (pid,))
553 return fixture
554
555
556def _expect_refusal(fixture: _Fixture, label: str, failures: list[str]) -> None:
557 """Require the current fixture state to fail authentication."""
558 try:
559 verify(fixture.request)
560 except LockProofError:
561 return
562 failures.append(f"{label} escaped live lock authentication")
563
564
565def _authority_selftest(fixture: _Fixture) -> list[str]:
566 """Attack kernel ownership, record identity, PID reuse, and script binding."""
567 failures: list[str] = []
568 verify(fixture.request)
569 _write_lock_line(fixture, ())
570 _expect_refusal(fixture, "forged record without flock", failures)
571 _write_lock_line(fixture, (fixture.pid + 1,))
572 _expect_refusal(fixture, "unrelated flock", failures)
573 _write_lock_line(fixture, (fixture.pid, fixture.pid + 1))
574 _expect_refusal(fixture, "ambiguous flock ownership", failures)
575 _write_lock_line(fixture, (fixture.pid,))
576 stat_path = fixture.proc / str(fixture.pid) / "stat"
577 stat_path.write_text(_stat_line(fixture.pid, 12, fixture.ticks + 1), encoding="ascii")
578 _expect_refusal(fixture, "reused holder PID", failures)
579 stat_path.write_text(_stat_line(fixture.pid, 12, fixture.ticks), encoding="ascii")
580 try:
581 verify(replace(fixture.request, expected_script_sha256="f" * 64))
582 except LockProofError:
583 pass
584 else:
585 failures.append("tampered reviewed-holder digest was accepted")
586 try:
587 verify(replace(fixture.request, expected_broker_sha256="e" * 64))
588 except LockProofError:
589 pass
590 else:
591 failures.append("tampered reviewed-broker digest was accepted")
592 leaked = fixture.proc / str(fixture.host_pid) / "fd/9"
593 leaked.symlink_to(fixture.lock)
594 _expect_refusal(fixture, "lock descriptor leaked to Bash host", failures)
595 leaked.unlink()
596 broker_leak = fixture.proc / str(fixture.pid) / "fd/4"
597 broker_leak.symlink_to(fixture.script)
598 _expect_refusal(fixture, "reviewed host descriptor leaked to broker", failures)
599 broker_leak.unlink()
600 return failures
601
602
603def _race_selftest(fixture: _Fixture) -> list[str]:
604 """Attack record/lock replacement and holder exit between proof phases."""
605 failures: list[str] = []
606 real_ticks = _start_ticks
607
608 def replace_record(proc_root: Path, pid: int) -> int:
609 if fixture.record.exists():
610 replacement = fixture.record.with_suffix(".replacement")
611 replacement.write_bytes(fixture.record.read_bytes())
612 replacement.replace(fixture.record)
613 return real_ticks(proc_root, pid)
614
615 with mock.patch.object(sys.modules[__name__], "_start_ticks", side_effect=replace_record):
616 _expect_refusal(fixture, "holder record inode replacement", failures)
617 # Restore the record to a stable inode before later race attacks.
618 fixture.record.write_bytes(fixture.record.read_bytes())
619 with mock.patch.object(
620 sys.modules[__name__],
621 "_start_ticks",
622 side_effect=[fixture.ticks, LockProofError("holder exited")],
623 ):
624 _expect_refusal(fixture, "holder exit during proof", failures)
625 real_owners = _lock_owner_pids
626 calls = 0
627
628 def replace_lock(proc_root: Path, observed: os.stat_result) -> set[int]:
629 nonlocal calls
630 calls += 1
631 result = real_owners(proc_root, observed)
632 if calls == SECOND_LOCK_OBSERVATION:
633 replacement = fixture.lock.with_suffix(".replacement")
634 replacement.write_bytes(b"")
635 replacement.replace(fixture.lock)
636 return result
637
638 with mock.patch.object(sys.modules[__name__], "_lock_owner_pids", side_effect=replace_lock):
639 _expect_refusal(fixture, "canonical lock inode replacement", failures)
640 return failures
641
642
643def _live_fields(lock_id: str) -> str:
644 """Return one valid wrapped request for the real-process descriptor test."""
645 fields = {
646 "resource": "bench",
647 "lock_id": lock_id,
648 "holder_class": "agent",
649 "holder_name": "selftest",
650 "intent": "offline live descriptor selftest",
651 "max_hold_s": "30",
652 "hold_kind": "wrapped",
653 "break_glass": "false",
654 "origin": "selftest",
655 "git_ref": "dev",
656 }
657 raw = "".join(f"{key}={value}\n" for key, value in fields.items()).encode()
658 return base64.b64encode(raw).decode("ascii")
659
660
661def _start_live_host(base: Path, lock_id: str) -> tuple[subprocess.Popen[str], ProofRequest]:
662 """Start the actual Bash host and Python broker against a throwaway lock."""
663 source_dir = Path(__file__).resolve().parent
664 host_source = (source_dir / "bench_host.sh").read_bytes()
665 broker_source = (source_dir / "bench_lock_broker.py").read_bytes()
666 host = base / "bench_host.sh"
667 broker = base / "bench_lock_broker.py"
668 root = base / "bench"
669 host.write_bytes(host_source)
670 broker.write_bytes(broker_source)
671 environment = {
672 "PATH": "/usr/bin:/bin",
673 "RA8_BENCH_DIR": str(root),
674 "RA8_BENCH_BROKER_SRC": str(broker),
675 }
676 process = subprocess.Popen( # noqa: S603 -- exact reviewed selftest host
677 ["/bin/bash", str(host), "hold", "wrapped", "0", _live_fields(lock_id)],
678 env=environment,
679 stdin=subprocess.PIPE,
680 stdout=subprocess.PIPE,
681 stderr=subprocess.PIPE,
682 text=True,
683 )
684 request = ProofRequest(
685 lock_id,
686 "wrapped",
687 hashlib.sha256(host_source).hexdigest(),
688 hashlib.sha256(broker_source).hexdigest(),
689 root,
690 )
691 return process, request
692
693
694def _proof_timeout(_signum: int, _frame: object) -> None:
695 """Interrupt a verifier that reads the wrapped liveness pipe."""
696 message = "live descriptor proof blocked"
697 raise _ProofDeadlineError(message)
698
699
700def _verify_bounded(request: ProofRequest) -> None:
701 """Run one real-process proof with a hard nonblocking deadline."""
702 previous_handler = signal.signal(signal.SIGALRM, _proof_timeout)
703 previous_timer = signal.setitimer(signal.ITIMER_REAL, 5.0)
704 try:
705 verify(request)
706 finally:
707 signal.setitimer(signal.ITIMER_REAL, *previous_timer)
708 signal.signal(signal.SIGALRM, previous_handler)
709
710
711def _stop_live_host(process: subprocess.Popen[str]) -> None:
712 """Close the liveness pipe and reap only this throwaway holder."""
713 if process.stdin is not None:
714 process.stdin.close()
715 try:
716 process.wait(timeout=5)
717 except subprocess.TimeoutExpired:
718 process.kill()
719 process.wait(timeout=5)
720
721
722def _digest_scan_bounded(fds: list[Path], expected: str) -> bool:
723 """Run one descriptor digest scan with a hard liveness deadline."""
724 previous_handler = signal.signal(signal.SIGALRM, _proof_timeout)
725 previous_timer = signal.setitimer(signal.ITIMER_REAL, 2.0)
726 try:
727 return _has_script_digest(fds, expected)
728 finally:
729 signal.setitimer(signal.ITIMER_REAL, *previous_timer)
730 signal.signal(signal.SIGALRM, previous_handler)
731
732
733def _prefilled_pipe_scan(script: Path, expected: str, payload: bytes) -> tuple[bool, bytes]:
734 """Scan a pipe before a regular file and return its unconsumed payload."""
735 read_fd, write_fd = os.pipe()
736 try:
737 os.write(write_fd, payload)
738 matched = _has_script_digest([Path(f"/proc/self/fd/{read_fd}"), script], expected)
739 os.set_blocking(read_fd, False)
740 try:
741 unread = os.read(read_fd, len(payload))
742 except BlockingIOError:
743 unread = b""
744 finally:
745 os.close(read_fd)
746 os.close(write_fd)
747 return matched, unread
748
749
750def _pipe_descriptor_selftest() -> list[str]:
751 """Prove descriptor classification never consumes a pipe before a file."""
752 failures: list[str] = []
753 with tempfile.TemporaryDirectory(prefix="ra8-lock-pipe-fd-") as raw:
754 root = Path(raw)
755 script = root / "bench_host.sh"
756 script.write_bytes(b"#!/bin/bash\n# reviewed host\n")
757 expected = hashlib.sha256(script.read_bytes()).hexdigest()
758 payload = b"pipe bytes are not reviewed host source"
759 pipe_digest = hashlib.sha256(payload).hexdigest()
760 false_match, false_unread = _prefilled_pipe_scan(script, pipe_digest, payload)
761 if false_match or false_unread != payload:
762 failures.append("non-regular fd supplied or consumed a matching digest")
763 true_match, true_unread = _prefilled_pipe_scan(script, expected, payload)
764 if not true_match or true_unread != payload:
765 failures.append("non-regular fd was consumed before the reviewed regular fd")
766 fifo = root / "blocking.fifo"
767 os.mkfifo(fifo)
768 try:
769 fifo_match = _digest_scan_bounded([fifo, script], expected)
770 except _ProofDeadlineError:
771 failures.append("opening a non-regular fd blocked before the reviewed regular fd")
772 else:
773 if not fifo_match:
774 failures.append("a skipped FIFO prevented the reviewed regular fd from matching")
775 return failures
776
777
778def _live_digest_controls(request: ProofRequest, host_fds: list[Path], base: Path) -> list[str]:
779 """Attack a real holder's digest and its deleted script pathname."""
780 failures: list[str] = []
781 try:
782 _verify_bounded(replace(request, expected_script_sha256="f" * 64))
783 except LockProofError:
784 pass
785 else:
786 failures.append("real Bash host accepted the wrong reviewed script digest")
787 replacement_payload = b"#!/bin/bash\n# path replacement\n"
788 replacement_path = base / "bench_host.sh"
789 replacement_path.write_bytes(replacement_payload)
790 try:
791 _verify_bounded(request)
792 except (LockProofError, OSError, _ProofDeadlineError) as exc:
793 failures.append(f"script path replacement displaced the open host inode: {exc}")
794 replacement_digest = hashlib.sha256(replacement_payload).hexdigest()
795 if _has_script_digest(host_fds, replacement_digest):
796 failures.append("script path replacement redirected the open host descriptor")
797 return failures
798
799
800def _live_descriptor_selftest() -> list[str]:
801 """Prove the verifier never reads the real host's liveness pipe."""
802 if not Path("/proc/self/fd").is_dir():
803 return []
804 failures: list[str] = []
805 with tempfile.TemporaryDirectory(prefix="ra8-lock-live-") as raw:
806 process, request = _start_live_host(Path(raw), "fedcba9876543210")
807 try:
808 if process.stdout is None or not select.select([process.stdout], [], [], 5)[0]:
809 return ["real Bash host did not acknowledge its throwaway lock"]
810 if process.stdout.readline() != "bench: ACQUIRED fedcba9876543210\n":
811 return ["real Bash host returned the wrong acquisition identity"]
812 try:
813 _verify_bounded(request)
814 except (LockProofError, OSError, _ProofDeadlineError) as exc:
815 failures.append(f"real Bash host proof failed: {exc}")
816 return failures
817 record, _identity = _record(request.root / "holder.json", request.lock_id, request.kind)
818 lock_stat = (request.root / "board.lock").stat(follow_symlinks=False)
819 broker_fds = _holder_fds(request.proc_root, int(record["pid"]))
820 host_fds = _holder_fds(request.proc_root, int(record["host_pid"]))
821 if not _has_locked_inode(broker_fds, lock_stat):
822 failures.append("real broker lost its lock descriptor")
823 if _has_locked_inode(host_fds, lock_stat):
824 failures.append("real Bash host inherited the lock descriptor")
825 if not _has_script_digest(host_fds, request.expected_script_sha256):
826 failures.append("real Bash host lost the reviewed script descriptor")
827 if _has_script_digest(broker_fds, request.expected_script_sha256):
828 failures.append("real broker inherited the reviewed script descriptor")
829 failures.extend(_live_digest_controls(request, host_fds, Path(raw)))
830 finally:
831 _stop_live_host(process)
832 return failures
833
834
835def run_selftest() -> list[str]:
836 """Run deterministic offline attacks against every lock proof boundary."""
837 previous = os.environ.get("RA8_LOCK_VERIFY_SELFTEST")
838 os.environ["RA8_LOCK_VERIFY_SELFTEST"] = "1"
839 try:
840 with tempfile.TemporaryDirectory(prefix="ra8-lock-authority-") as raw:
841 fixture = _make_fixture(Path(raw))
842 return (
843 _replacement_selftest()
844 + _authority_selftest(fixture)
845 + _race_selftest(fixture)
846 + _pipe_descriptor_selftest()
847 + _live_descriptor_selftest()
848 )
849 finally:
850 if previous is None:
851 os.environ.pop("RA8_LOCK_VERIFY_SELFTEST", None)
852 else:
853 os.environ["RA8_LOCK_VERIFY_SELFTEST"] = previous
854
855
856def main() -> int:
857 """Parse one fixed verification request and report a fail-closed verdict."""
858 if sys.argv[1:] == ["--selftest"]:
859 failures = run_selftest()
860 for failure in failures:
861 print(f"bench-lock-verify selftest: {failure}", file=sys.stderr)
862 return 1 if failures else 0
863 parser = argparse.ArgumentParser(description=__doc__)
864 parser.add_argument("lock_id")
865 parser.add_argument("hold_kind", choices=("wrapped", "detached"))
866 parser.add_argument("expected_script_sha256")
867 parser.add_argument("expected_broker_sha256")
868 args = parser.parse_args()
869 try:
870 verify(
871 ProofRequest(
872 args.lock_id,
873 args.hold_kind,
874 args.expected_script_sha256,
875 args.expected_broker_sha256,
876 )
877 )
878 except (LockProofError, OSError, UnicodeError) as exc:
879 print(f"bench-lock-verify: {exc}", file=sys.stderr)
880 return 3
881 print("bench-lock-verify: HELD")
882 return 0
883
884
885if __name__ == "__main__":
886 raise SystemExit(main())
void main(void)
The application entry point Reset_Handler hands control to.
Definition main.c:298