diff --git a/backend/tests/test_delivery.py b/backend/tests/test_delivery.py index 161bc42..363f0d7 100644 --- a/backend/tests/test_delivery.py +++ b/backend/tests/test_delivery.py @@ -518,3 +518,27 @@ def test_factory_composition_keeps_privileged_boundary_and_finite_lifetime(): assert "EnvironmentFile" not in contents and "LoadCredential" not in contents assert "env -i" in contents and "MemorySwapMax=0" in contents assert receipt["configuration_id"] in contents + + +def test_factory_install_receipt_cannot_claim_execution_or_move_outside_root_batch(): + receipt = { + "schema_version": 1, + "source_sha": "123456789abc" + "d" * 28, + "configuration_id": "cfg-" + "e" * 24, + "configuration_receipt_sha256": "f" * 64, + "unit_sha256": "b" * 64, + "installed": False, + "services_started": False, + "execution_verified": False, + } + factory_service.validate_receipt(receipt) + assert str(factory_service.batch_directory(receipt)) == ( + "/etc/kf-judge/units/sd-123456789abc.cfg-" + "e" * 24 + ) + receipt["execution_verified"] = True + with pytest.raises(policy.DeliveryError, match="factory unit binding"): + factory_service.validate_receipt(receipt) + receipt["execution_verified"] = False + receipt["configuration_id"] = "../foreign" + with pytest.raises(policy.DeliveryError, match="factory unit binding"): + factory_service.validate_receipt(receipt) diff --git a/scripts/release/README.md b/scripts/release/README.md index 7f370cf..47d3562 100644 --- a/scripts/release/README.md +++ b/scripts/release/README.md @@ -165,3 +165,10 @@ same-boot loss or conflicting record refuses. New-boot recreation requires empty persistent resources. No journal/lease is deleted and preparation grants no execution permit. Neither composition nor lexical tests are Root/kernel/service acceptance; install, restart, failure recovery and actual execution remain VM-only. +The separate operator step `factory_service --install DIRECTORY --receipt-sha256 SHA` +requires that generated receipt, the selected protected configuration and matching +installer bytes. It keeps immutable Root-unit batches under `/etc/kf-judge/units/`, +updates only recorded Root-owned pointers with all app units stopped, and checks +loaded/inactive systemd metadata. Foreign files/links and partial unknown batches +are refused, old batches remain retained. This step neither starts the factory nor +prepares its cgroup; only the later bounded startup invokes Sandbox preparation. diff --git a/scripts/release/factory_service.py b/scripts/release/factory_service.py index 2501134..1985996 100644 --- a/scripts/release/factory_service.py +++ b/scripts/release/factory_service.py @@ -3,10 +3,106 @@ import argparse import json import os +import re +import secrets +import stat +import subprocess from pathlib import Path from . import configuration, install, preflight, service_install, services -from .policy import DeliveryError, canonical, digest +from .policy import DeliveryError, canonical, digest, fields, read_json, sha256 + +UNIT = "kf-sandbox-factory.service" +UNITS = Path("/etc/kf-judge/units") + + +def validate_receipt(value: dict) -> dict: + fields( + value, + { + "schema_version", + "source_sha", + "configuration_id", + "configuration_receipt_sha256", + "unit_sha256", + "installed", + "services_started", + "execution_verified", + }, + "factory unit receipt", + ) + if ( + type(value["schema_version"]) is not int + or value["schema_version"] != 1 + or not isinstance(value["source_sha"], str) + or not re.fullmatch(r"[0-9a-f]{40}", value["source_sha"]) + or not isinstance(value["configuration_id"], str) + or not re.fullmatch(r"cfg-[0-9a-f]{24}", value["configuration_id"]) + or any( + value[name] is not False + for name in ( + "installed", + "services_started", + "execution_verified", + ) + ) + ): + raise DeliveryError("factory unit binding differs") + sha256(value["configuration_receipt_sha256"]) + sha256(value["unit_sha256"]) + return value + + +def batch_directory(receipt: dict) -> Path: + value = validate_receipt(receipt) + return UNITS / ("sd-" + value["source_sha"][:12] + "." + value["configuration_id"]) + + +def check_batch(directory: Path, receipt: dict) -> None: + preflight.protected(directory, directory=True) + if stat.S_IMODE(directory.stat().st_mode) != 0o700: + raise DeliveryError("private factory unit batch required") + if {p.name for p in directory.iterdir()} != {UNIT, "receipt.json"}: + raise DeliveryError("unknown factory batch member") + path = directory / "receipt.json" + preflight.protected(path) + configuration.private(path) + if canonical(validate_receipt(read_json(path, 8192))) != canonical(receipt): + raise DeliveryError("factory batch receipt differs") + path = directory / UNIT + preflight.protected(path) + if ( + path.stat().st_size > 32768 + or stat.S_IMODE(path.stat().st_mode) != 0o644 + or digest(path) != receipt["unit_sha256"] + ): + raise DeliveryError("factory batch bytes/mode differ") + + +def known_link(path: Path) -> Path | None: + try: + info = path.lstat() + except FileNotFoundError: + return None + if not stat.S_ISLNK(info.st_mode) or info.st_uid != 0 or info.st_gid != 0: + raise DeliveryError("unknown factory unit; refused overwrite") + target = os.readlink(path) + candidate = Path(target) + if ( + candidate.as_posix() != target + or candidate.name != UNIT + or candidate.parent.parent != UNITS + or not re.fullmatch(r"sd-[0-9a-f]{12}\.cfg-[0-9a-f]{24}", candidate.parent.name) + ): + raise DeliveryError("unknown factory unit pointer") + receipt_path = candidate.parent / "receipt.json" + preflight.protected(receipt_path) + configuration.private(receipt_path) + receipt = validate_receipt(read_json(receipt_path, 8192)) + if batch_directory(receipt) != candidate.parent: + raise DeliveryError("factory pointer/receipt binding differs") + check_batch(candidate.parent, receipt) + return candidate.parent def render(receipt: dict) -> bytes: @@ -96,13 +192,122 @@ def generate(output: Path) -> dict: return result +def install_unit(source: Path, expected: str) -> dict: + if os.getuid() != 0 or os.geteuid() != 0: + raise DeliveryError("factory unit installation requires root operator") + preflight.protected(source, directory=True) + path = source / "receipt.json" + preflight.protected(path) + configuration.private(path) + if digest(path) != sha256(expected): + raise DeliveryError("factory source receipt differs") + receipt = validate_receipt(read_json(path, 8192)) + selected, original = service_install.current_receipt() + if ( + receipt["source_sha"] != original["source_sha"] + or receipt["configuration_id"] != selected["configuration_id"] + or receipt["configuration_receipt_sha256"] != selected["receipt_sha256"] + ): + raise DeliveryError("factory unit does not belong to selected configuration") + payload = install.RELEASES / selected["release_id"] / "payload" + for name in ("factory_service.py", "services.py"): + shipped = payload / "delivery/scripts/release" / name + preflight.protected(shipped) + if digest(shipped) != digest(Path(__file__).parent / name): + raise DeliveryError("factory installer differs from selected delivery") + if {p.name for p in source.iterdir()} != {UNIT, "receipt.json"}: + raise DeliveryError("unknown factory source member") + path = source / UNIT + preflight.protected(path) + configuration.private(path) + contents = render(original) + if ( + path.stat().st_size > 32768 + or digest(path) != receipt["unit_sha256"] + or path.read_bytes() != contents + ): + raise DeliveryError("factory source is not the fixed privileged composition") + install.require_stopped() + system = Path("/etc/systemd/system") + preflight.protected(system, directory=True) + link = system / UNIT + known_link(link) + install.directory(UNITS, 0o700) + directory = batch_directory(receipt) + install.directory(directory, 0o700) + for p in directory.iterdir(): + if p.name not in {UNIT, "receipt.json"}: + raise DeliveryError("unknown partial factory batch member") + preflight.protected(p) + if p.stat().st_size > (8192 if p.name == "receipt.json" else 32768): + raise DeliveryError("partial factory member exceeds bound") + install.exclusive_file(directory / UNIT, contents, 0o644) + install.exclusive_file(directory / "receipt.json", canonical(receipt), 0o600) + check_batch(directory, receipt) + if install.selection() != selected: + raise DeliveryError("selected configuration changed during factory preparation") + install.require_stopped() + if known_link(link) != directory: + temporary = system / (".kf-factory-" + secrets.token_hex(16)) + created = None + try: + temporary.symlink_to(directory / UNIT) + info = temporary.lstat() + created = (info.st_dev, info.st_ino) + os.replace(temporary, link) + install.sync_directory(system) + finally: + if created is not None and temporary.is_symlink(): + info = temporary.lstat() + if (info.st_dev, info.st_ino) == created: + temporary.unlink() + service_install.systemctl("daemon-reload") + loaded = dict( + line.split("=", 1) + for line in service_install.systemctl( + "show", + UNIT, + "--property=LoadState,ActiveState,MainPID,FragmentPath", + ).splitlines() + if "=" in line + ) + if ( + loaded.get("LoadState") != "loaded" + or loaded.get("ActiveState") != "inactive" + or loaded.get("MainPID") != "0" + or loaded.get("FragmentPath") not in {str(link), str(directory / UNIT)} + or known_link(link) != directory + or install.selection() != selected + ): + raise DeliveryError("factory unit load readback differs") + return { + "source_sha": receipt["source_sha"], + "configuration_id": receipt["configuration_id"], + "unit_installed": True, + "unit_manager_readback": loaded, + "services_started": False, + "execution_verified": False, + } + + def main() -> None: parser = argparse.ArgumentParser(description="Protected Root factory unit composition") - parser.add_argument("--output", required=True, type=Path) + action = parser.add_mutually_exclusive_group(required=True) + action.add_argument("--output", type=Path) + action.add_argument("--install", type=Path) + parser.add_argument("--receipt-sha256") args = parser.parse_args() try: - print(json.dumps(generate(args.output), sort_keys=True)) - except (OSError, ValueError) as error: + if args.output is not None: + if args.receipt_sha256 is not None: + raise DeliveryError("unexpected generator receipt override") + result = generate(args.output) + else: + if args.receipt_sha256 is None: + raise DeliveryError("explicit factory receipt fingerprint required") + result = install_unit(args.install, args.receipt_sha256) + print(json.dumps(result, sort_keys=True)) + except (OSError, ValueError, subprocess.SubprocessError) as error: parser.exit( 1, "Factory composition refused: "