diff --git a/README.md b/README.md index 0c1d7ce..f190bf2 100644 --- a/README.md +++ b/README.md @@ -599,6 +599,8 @@ it; none of them restates it. top of the quickstart; [REVIEW.md](REVIEW.md) has the review order and the gates a broadcast must prove in one cycle. - [Provenance contract](docs/PROVENANCE.md), every pin the shadow audit uses. +- [Versioned release sealing](docs/SN39_VERSIONED_RELEASES.md), the read-only + ceremony preflight, immutable generation paths, and external reproduction. - [CyberGym pre-launch E2E testing](docs/CYBERGYM_E2E_TESTING.md) - [SN39 v3 publisher cutover](docs/SN39_V3_PUBLISHER_CUTOVER.md), what the live publisher actually imports, the ordered steps to make it v3-capable, and how diff --git a/deploy/sn39/cathedral-sn39-release-launcher.py b/deploy/sn39/cathedral-sn39-release-launcher.py index 6a2cf0d..0fb66a7 100644 --- a/deploy/sn39/cathedral-sn39-release-launcher.py +++ b/deploy/sn39/cathedral-sn39-release-launcher.py @@ -32,7 +32,7 @@ # "local publisher publishes to the public feed; validator fetches it back". "continuous": INSTALL_ROOT / "validator-thin-sn39-relay.toml", } -MODES = frozenset({*CONFIGS, "status", "finalize"}) +MODES = frozenset({*CONFIGS, "status", "preflight", "finalize"}) JOURNAL_RE = re.compile(r"journal-[0-9a-f]{64}\.json") FINALIZER_CONTEXT_ENV = "CATHEDRAL_SN39_FINALIZER_CONTEXT" LEGACY_SERVICE_MASK = Path("/etc/systemd/system/cathedral-thin-validator.service") @@ -356,7 +356,11 @@ def _git_output(release: Path, *args: str) -> str: cwd=release, text=True, stderr=subprocess.DEVNULL, - env={"PATH": "/usr/bin:/bin", "LC_ALL": "C"}, + env={ + "PATH": "/usr/bin:/bin", + "LC_ALL": "C", + "GIT_OPTIONAL_LOCKS": "0", + }, ).strip() except (OSError, subprocess.CalledProcessError) as exc: raise InstallError("cannot verify immutable release checkout") from exc @@ -424,12 +428,16 @@ def _verify(mode: str) -> tuple[Path, Path, str]: def _finalizer_context_digest( *, + operation: str, release_sha: str, journal: Path, manifest_digest: str, ) -> str: + if operation not in {"preflight", "finalize"}: + raise InstallError("release finalizer operation is invalid") payload = ( - "cathedral-sn39-finalizer-context-v1\n" + "cathedral-sn39-finalizer-context-v2\n" + f"{operation}\n" f"{release_sha}\n" f"{manifest_digest}\n" f"{journal}\n" @@ -450,22 +458,23 @@ def _finalizer_journal(value: str) -> Path: def main(argv: list[str]) -> int: mode = argv[0] if argv else "" - finalize = mode == "finalize" + ceremony = mode in {"preflight", "finalize"} if ( mode not in MODES - or (finalize and len(argv) != 2) - or (not finalize and len(argv) != 1) + or (ceremony and len(argv) != 2) + or (not ceremony and len(argv) != 1) ): print( - "usage: cathedral-sn39-release {continuous|status|finalize JOURNAL}", + "usage: cathedral-sn39-release " + "{continuous|status|preflight JOURNAL|finalize JOURNAL}", file=sys.stderr, ) return 2 - if finalize and os.geteuid() != ROOT_UID: - print("SN39 finalize launcher must run as root", file=sys.stderr) + if ceremony and os.geteuid() != ROOT_UID: + print("SN39 release ceremony launcher must run as root", file=sys.stderr) return 1 try: - journal = _finalizer_journal(argv[1]) if finalize else None + journal = _finalizer_journal(argv[1]) if ceremony else None release, python, manifest_digest = _verify(mode) except InstallError as exc: print(f"SN39 immutable-install check failed: {exc}", file=sys.stderr) @@ -484,7 +493,7 @@ def main(argv: list[str]) -> int: # from a systemd drop-in, a shell, a compose file -- never reaches the # child; and the unit's digest is bound in the release manifest, so they # cannot edit it either. - if finalize: + if ceremony: assert journal is not None command = [ str(python), @@ -499,6 +508,8 @@ def main(argv: list[str]) -> int: "--journal", str(journal), ] + if mode == "preflight": + command.append("--preflight") elif mode == "status": command = [str(python), "-u", "scripts/publish_sn39_validator_status.py"] else: @@ -518,9 +529,10 @@ def main(argv: list[str]) -> int: release_sha=release.name, launch_config_sha256=_digest(config) if config is not None else None, ) - if finalize: + if ceremony: assert journal is not None environment[FINALIZER_CONTEXT_ENV] = _finalizer_context_digest( + operation=mode, release_sha=release.name, journal=journal, manifest_digest=manifest_digest, diff --git a/deploy/sn39/cathedral-sn39-validator.tmpfiles b/deploy/sn39/cathedral-sn39-validator.tmpfiles index 003afdb..93d07e7 100644 --- a/deploy/sn39/cathedral-sn39-validator.tmpfiles +++ b/deploy/sn39/cathedral-sn39-validator.tmpfiles @@ -35,6 +35,8 @@ d /var/lib/cathedral-public-evidence :0755 :root :root - d /var/lib/cathedral-public-evidence/blobs :0755 :root :root - d /var/lib/cathedral-public-evidence/blobs/sha256 :0755 :root :root - +d /var/lib/cathedral-public-evidence/releases :0755 :root :root - +d /var/lib/cathedral-public-evidence/releases/sha256 :0755 :root :root - d /var/lib/cathedral-public-evidence/epochs :0755 :root :root - d /var/lib/cathedral-public-evidence/pins :0755 :root :root - d /var/lib/cathedral-public-evidence/receipts :0755 :root :root - diff --git a/docs/SN39_VERSIONED_RELEASES.md b/docs/SN39_VERSIONED_RELEASES.md new file mode 100644 index 0000000..a233d96 --- /dev/null +++ b/docs/SN39_VERSIONED_RELEASES.md @@ -0,0 +1,87 @@ +# SN39 versioned release sealing + +The historical launch seal stays at `release.json` and `release.json.sig`. +Never replace or delete those files to publish another release. + +New seals use their exact release payload digest: + +```text +releases/sha256/.json +releases/sha256/.json.sig +``` + +The release digest printed by preflight and finalize is the complete release +identifier. Record it in the immutable release notes. There is no mutable +`latest` pointer in this protocol. + +## Read-only preflight + +Run preflight through the root-owned immutable-install launcher: + +```bash +sudo /usr/bin/python3 -I -E -s \ + /usr/local/libexec/cathedral-sn39-release preflight \ + /var/lib/cathedral-validator/journal-<64-hex-digest>.json +``` + +Preflight runs the same journal, archive, controlled-replay, release-key, and +publication-conflict checks as finalize. It does not create a publication lock, +staging file, replay blob, release, or signature file. Success prints +`SN39_PUBLIC_RELEASE_PREFLIGHT_PASS`, `"mutations": false`, the release digest, +and the two versioned artifact paths. + +Preflight does not reserve the result. Finalize rechecks every destination +under the publication lock before its first write. + +## Finalize + +Finalize is a separately approved mutation: + +```bash +sudo /usr/bin/python3 -I -E -s \ + /usr/local/libexec/cathedral-sn39-release finalize \ + /var/lib/cathedral-validator/journal-<64-hex-digest>.json +``` + +Finalize does not submit a chain transaction. It publishes the optional +content-addressed replay blob, the versioned release, and its detached +signature. It checks the complete publication plan for conflicts before the +first blob or release write. A process crash can leave an incomplete but +non-conflicting generation. An identical rerun completes it without replacing +different bytes. + +The producer may rotate +`/var/lib/cathedral-validator-controlled-sn39/current` between epochs. The +finalizer accepts only a root-owned leaf symlink to a direct sibling epoch +directory. It opens every ancestor and the selected epoch with `O_NOFOLLOW`, +then reads every envelope through the held directory descriptor. A later +rotation cannot mix evidence from two epochs. + +## External reproduction + +Use the digest printed by preflight or finalize: + +```bash +python -I -B -u scripts/run_sn39_public_reproduction.py \ + --release-sha256 sha256: +``` + +Omitting `--release-sha256` intentionally reproduces the historical root seal. +The versioned reproducer checks that the fetched release bytes match the digest +in the requested path before it verifies the detached signature. + +## Required gates + +A passing source test does not authorize a seal. Before preflight, prove: + +1. The producer and validator revisions and every release pin match the + immutable installation. +2. The active validator runs only through the manifest-bound launcher from a + pristine release tree and versioned environment. +3. The journal has no pending submission and contains the exact finalized + receipt being sealed. +4. A claimed replay checkpoint has matching controlled envelopes, public + evidence, candidate set, and pinned verifier bytes. +5. The shadow or authority provenance gate is currently passing. +6. An independent operator can reproduce the versioned release from a clean + checkout after publication. diff --git a/scaffold/sn39_public_reproduction.py b/scaffold/sn39_public_reproduction.py index ba6ffee..db61d11 100644 --- a/scaffold/sn39_public_reproduction.py +++ b/scaffold/sn39_public_reproduction.py @@ -2,6 +2,7 @@ from __future__ import annotations +import argparse import base64 import hashlib import json @@ -2132,7 +2133,21 @@ def load_blob(digest: str) -> bytes: } -def verify_public_release() -> dict[str, Any]: +def _release_artifact_paths(release_sha256: str | None) -> tuple[str, str]: + """Select the historical root seal or one immutable release generation.""" + if release_sha256 is None: + return "/release.json", "/release.json.sig" + if not _is_hash(release_sha256, prefix="sha256:"): + raise ReproductionError("versioned release digest is malformed") + name = release_sha256.split(":", 1)[1] + release_path = f"/releases/sha256/{name}.json" + return release_path, release_path + ".sig" + + +def verify_public_release( + *, + release_sha256: str | None = None, +) -> dict[str, Any]: from scaffold.provenance_audit import ( ProvenanceAuditError, ProvenanceSettings, @@ -2157,8 +2172,9 @@ def verify_public_release() -> dict[str, Any]: deadline=deadline, include_raw_fetch=True, ) - release_bytes = fetch_named("/release.json") - signature_bytes = fetch_named("/release.json.sig") + release_path, signature_path = _release_artifact_paths(release_sha256) + release_bytes = fetch_named(release_path) + signature_bytes = fetch_named(signature_path) if not isinstance(release_bytes, bytes) or not isinstance( signature_bytes, bytes ): @@ -2170,6 +2186,13 @@ def verify_public_release() -> dict[str, Any]: or len(signature_bytes) > MAX_RELEASE_BYTES ): raise ReproductionError("public release artifact exceeds its size cap") + if ( + release_sha256 is not None + and "sha256:" + hashlib.sha256(release_bytes).hexdigest() != release_sha256 + ): + raise ReproductionError( + "versioned release bytes differ from their requested digest" + ) result = verify_release_bytes( release_bytes, signature_bytes, @@ -2553,11 +2576,14 @@ def assert_current_dry_run( def assert_public_reproduction( *, + release_sha256: str | None = None, release_result: dict[str, Any] | None = None, ) -> dict[str, Any]: """Reproduce the immutable launch without consulting the mutable live feed.""" release_result = ( - verify_public_release() if release_result is None else release_result + verify_public_release(release_sha256=release_sha256) + if release_result is None + else release_result ) required = { "release_attestation": "signed release attestation", @@ -2573,9 +2599,7 @@ def assert_public_reproduction( # there is nothing to replay. The summary says so explicitly rather # than implying a replay happened. if release_result.get("evidence_scope") != "signed_feed_relay": - raise ReproductionError( - "unclaimed frozen evidence lacks the relay scope" - ) + raise ReproductionError("unclaimed frozen evidence lacks the relay scope") for field, label in evidence_fields.items(): if field in release_result: raise ReproductionError( @@ -2611,15 +2635,17 @@ def assert_public_reproduction( def main(argv: list[str] | None = None) -> int: - args = sys.argv[1:] if argv is None else argv - if args: - print( - "usage: assert_sn39_public_reproduction.py", - file=sys.stderr, - ) - return 2 + parser = argparse.ArgumentParser() + parser.add_argument( + "--release-sha256", + help=( + "reproduce a versioned release under /releases/sha256; " + "omit only for the historical root release" + ), + ) + args = parser.parse_args(sys.argv[1:] if argv is None else argv) try: - summary = assert_public_reproduction() + summary = assert_public_reproduction(release_sha256=args.release_sha256) except ReproductionNotProven as exc: print(f"SN39 public reproduction: NOT_PROVEN: {exc}", file=sys.stderr) return 3 diff --git a/scripts/finalize_sn39_public_release.py b/scripts/finalize_sn39_public_release.py index f8e82cc..9289b3d 100644 --- a/scripts/finalize_sn39_public_release.py +++ b/scripts/finalize_sn39_public_release.py @@ -17,7 +17,7 @@ from contextlib import contextmanager from datetime import UTC, datetime from pathlib import Path -from typing import Any +from typing import Any, NamedTuple # The root finalizer imports from the already-verified immutable checkout. # Never create ignored bytecode there after the pristine-tree gate passes. @@ -51,6 +51,16 @@ class ReleaseError(RuntimeError): """The irreversible launch cannot be sealed safely.""" +class PublicationItem(NamedTuple): + """One immutable file in a release publication transaction.""" + + path: Path + payload: bytes + size_cap: int + label: str + conflict: str + + def canonical_json(document: dict[str, Any]) -> bytes: return json.dumps( document, @@ -135,7 +145,11 @@ def git(release: Path, *arguments: str) -> str: cwd=release, text=True, stderr=subprocess.DEVNULL, - env={"PATH": "/usr/bin:/bin", "LC_ALL": "C"}, + env={ + "PATH": "/usr/bin:/bin", + "LC_ALL": "C", + "GIT_OPTIONAL_LOCKS": "0", + }, timeout=30, ).strip() except (OSError, subprocess.CalledProcessError, subprocess.TimeoutExpired) as exc: @@ -206,12 +220,16 @@ def _read_root_manifest_digest() -> str: def _launcher_context_digest( *, + operation: str, release_sha: str, journal: Path, manifest_digest: str, ) -> str: + if operation not in {"finalize", "preflight"}: + raise ReleaseError("release finalizer operation is invalid") payload = ( - "cathedral-sn39-finalizer-context-v1\n" + "cathedral-sn39-finalizer-context-v2\n" + f"{operation}\n" f"{release_sha}\n" f"{manifest_digest}\n" f"{journal}\n" @@ -219,11 +237,17 @@ def _launcher_context_digest( return "sha256:" + hashlib.sha256(payload).hexdigest() -def _require_launcher_context(*, release_sha: str, journal: Path) -> None: +def _require_launcher_context( + *, + operation: str, + release_sha: str, + journal: Path, +) -> None: if os.geteuid() != ROOT_UID: raise ReleaseError("release finalizer must run as root") manifest_digest = _read_root_manifest_digest() expected = _launcher_context_digest( + operation=operation, release_sha=release_sha, journal=journal, manifest_digest=manifest_digest, @@ -984,27 +1008,183 @@ def _validated_broadcast_intent( } -def _read_controlled_envelope(root: Path, digest: str) -> bytes: - if SHA256.fullmatch(digest) is None: - raise ReleaseError("controlled envelope digest is malformed") +def _open_trusted_directory(path: Path) -> int: + """Open an absolute directory without following any path-component symlink.""" + if not path.is_absolute(): + raise ReleaseError("controlled evidence path must be absolute") + flags = ( + os.O_RDONLY + | getattr(os, "O_DIRECTORY", 0) + | getattr(os, "O_NOFOLLOW", 0) + | getattr(os, "O_CLOEXEC", 0) + ) + descriptor = -1 try: - root_info = root.lstat() - except OSError as exc: - raise ReleaseError("controlled evidence directory is unavailable") from exc - if ( - stat.S_ISLNK(root_info.st_mode) - or not stat.S_ISDIR(root_info.st_mode) - or root_info.st_uid != ROOT_UID - or stat.S_IMODE(root_info.st_mode) & 0o022 - or stat.S_IMODE(root_info.st_mode) & 0o007 - ): - raise ReleaseError( - "controlled evidence directory is not private and root-controlled" + descriptor = os.open("/", flags) + root_info = os.fstat(descriptor) + if ( + not stat.S_ISDIR(root_info.st_mode) + or root_info.st_uid not in {0, ROOT_UID} + or stat.S_IMODE(root_info.st_mode) & 0o022 + ): + raise ReleaseError("controlled evidence ancestor is not root-controlled") + parts = path.parts[1:] + if any(part in {"", ".", ".."} for part in parts): + raise ReleaseError("controlled evidence ancestor path is malformed") + for index, part in enumerate(parts): + try: + child = os.open(part, flags, dir_fd=descriptor) + except OSError as exc: + raise ReleaseError( + "controlled evidence ancestor is unavailable or contains a symlink" + ) from exc + info = os.fstat(child) + mode = stat.S_IMODE(info.st_mode) + is_leaf = index == len(parts) - 1 + sticky_root_ancestor = ( + not is_leaf and info.st_uid == 0 and bool(mode & stat.S_ISVTX) + ) + if ( + not stat.S_ISDIR(info.st_mode) + or info.st_uid not in {0, ROOT_UID} + or (mode & 0o022 and not sticky_root_ancestor) + or (is_leaf and info.st_uid != ROOT_UID) + ): + os.close(child) + raise ReleaseError( + "controlled evidence ancestor is not owner-controlled" + ) + os.close(descriptor) + descriptor = child + return descriptor + except Exception: + if descriptor >= 0: + os.close(descriptor) + raise + + +@contextmanager +def _open_controlled_directory(root: Path): + """Bind one direct, root-controlled epoch selected by ``current``. + + The producer rotates ``current`` atomically. The finalizer permits that one + leaf symlink, but walks every ancestor with ``O_NOFOLLOW``, requires its + target to be a direct sibling, and holds the selected directory descriptor + for the entire replay. A later rotation therefore cannot mix envelopes + from different epochs. + """ + if not root.is_absolute() or root.name in {"", ".", ".."}: + raise ReleaseError("controlled evidence path is malformed") + parent_descriptor = _open_trusted_directory(root.parent) + selected_descriptor = -1 + try: + try: + selector_info = os.stat( + root.name, + dir_fd=parent_descriptor, + follow_symlinks=False, + ) + except OSError as exc: + raise ReleaseError("controlled evidence selector is unavailable") from exc + selected_name = root.name + selected_link: str | None = None + if stat.S_ISLNK(selector_info.st_mode): + if selector_info.st_uid != ROOT_UID or selector_info.st_nlink != 1: + raise ReleaseError( + "controlled evidence selector is not root-controlled" + ) + try: + selected_link = os.readlink(root.name, dir_fd=parent_descriptor) + except OSError as exc: + raise ReleaseError( + "controlled evidence selector is unavailable" + ) from exc + target = Path(selected_link) + if ( + target.is_absolute() + or len(target.parts) != 1 + or selected_link != target.name + or selected_link in {"", ".", "..", root.name} + ): + raise ReleaseError( + "controlled evidence selector must name one direct epoch directory" + ) + selected_name = selected_link + elif not stat.S_ISDIR(selector_info.st_mode): + raise ReleaseError("controlled evidence selector is not a directory") + + flags = ( + os.O_RDONLY + | getattr(os, "O_DIRECTORY", 0) + | getattr(os, "O_NOFOLLOW", 0) + | getattr(os, "O_CLOEXEC", 0) ) - path = root / f"{digest.split(':', 1)[1]}.json" + try: + selected_descriptor = os.open( + selected_name, + flags, + dir_fd=parent_descriptor, + ) + except OSError as exc: + raise ReleaseError( + "controlled evidence epoch is unavailable or is a symlink" + ) from exc + selected_info = os.fstat(selected_descriptor) + selected_mode = stat.S_IMODE(selected_info.st_mode) + if ( + not stat.S_ISDIR(selected_info.st_mode) + or selected_info.st_uid != ROOT_UID + or selected_mode & 0o022 + or selected_mode & 0o007 + ): + raise ReleaseError( + "controlled evidence epoch is not private and root-controlled" + ) + try: + selected_path_info = os.stat( + selected_name, + dir_fd=parent_descriptor, + follow_symlinks=False, + ) + selector_after = os.stat( + root.name, + dir_fd=parent_descriptor, + follow_symlinks=False, + ) + except OSError as exc: + raise ReleaseError("controlled evidence selector changed") from exc + if ( + not stat.S_ISDIR(selected_path_info.st_mode) + or selected_path_info.st_dev != selected_info.st_dev + or selected_path_info.st_ino != selected_info.st_ino + or selector_after.st_dev != selector_info.st_dev + or selector_after.st_ino != selector_info.st_ino + ): + raise ReleaseError("controlled evidence selector changed") + if selected_link is not None: + try: + selected_link_after = os.readlink( + root.name, + dir_fd=parent_descriptor, + ) + except OSError as exc: + raise ReleaseError("controlled evidence selector changed") from exc + if selected_link_after != selected_link: + raise ReleaseError("controlled evidence selector changed") + yield selected_descriptor + finally: + if selected_descriptor >= 0: + os.close(selected_descriptor) + os.close(parent_descriptor) + + +def _read_controlled_envelope(directory_descriptor: int, digest: str) -> bytes: + if SHA256.fullmatch(digest) is None: + raise ReleaseError("controlled envelope digest is malformed") + name = f"{digest.split(':', 1)[1]}.json" flags = os.O_RDONLY | getattr(os, "O_NOFOLLOW", 0) | getattr(os, "O_CLOEXEC", 0) try: - descriptor = os.open(path, flags) + descriptor = os.open(name, flags, dir_fd=directory_descriptor) except OSError as exc: raise ReleaseError("controlled replay envelope is unavailable") from exc try: @@ -1199,13 +1379,14 @@ def load_blob(digest: str) -> bytes: raise ReleaseError( "frozen positive receipt set differs from the rewarded launch set" ) - envelopes = { - hotkey: _read_controlled_envelope( - controlled_root, - str(bindings[hotkey]["envelope_digest"]), - ) - for hotkey in positive_hotkeys - } + with _open_controlled_directory(controlled_root) as controlled_directory: + envelopes = { + hotkey: _read_controlled_envelope( + controlled_directory, + str(bindings[hotkey]["envelope_digest"]), + ) + for hotkey in positive_hotkeys + } verifier_bytes = _read_verifier_binary(verifier_binary_path) candidate_snapshot = manifest["candidate_set"] replayed = provenance.replay_positive_miners( @@ -1713,6 +1894,119 @@ def _read_pending_publication( return payload, info +def _preflight_publish_once(item: PublicationItem) -> None: + """Validate one publication target without creating, deleting, or syncing.""" + path, payload, size_cap, label, conflict = item + if not payload or len(payload) > size_cap: + raise ReleaseError(f"{label} exceeds its size cap") + _safe_public_directory(path.parent) + pending = path.parent / f".{path.name}.pending" + try: + final_info = path.lstat() + except FileNotFoundError: + final_info = None + except OSError as exc: + raise ReleaseError(f"{label} is unavailable") from exc + + if final_info is None: + # A safe single-link staging inode is recoverable. Its bytes need not + # match because finalize replaces only this unpublished internal name. + _read_pending_publication( + pending, + size_cap=size_cap, + label=label, + allowed_links=frozenset({1}), + ) + return + + if ( + stat.S_ISLNK(final_info.st_mode) + or not stat.S_ISREG(final_info.st_mode) + or final_info.st_uid != os.geteuid() + or stat.S_IMODE(final_info.st_mode) & 0o022 + ): + raise ReleaseError(f"{label} is not an owner-controlled file") + if final_info.st_nlink == 2: + recovery = _read_pending_publication( + pending, + size_cap=size_cap, + label=label, + allowed_links=frozenset({2}), + ) + if recovery is None: + raise ReleaseError(f"{label} has an untrusted hardlink alias") + pending_bytes, pending_info = recovery + if ( + pending_info.st_dev != final_info.st_dev + or pending_info.st_ino != final_info.st_ino + or pending_bytes != payload + ): + raise ReleaseError(f"{label} has an untrusted hardlink alias") + elif final_info.st_nlink == 1: + _read_pending_publication( + pending, + size_cap=size_cap, + label=label, + allowed_links=frozenset({1}), + ) + else: + raise ReleaseError(f"{label} has an untrusted hardlink alias") + existing = _read_public_file(path, size_cap=size_cap, label=label) + if existing != payload: + raise ReleaseError(conflict) + + +def _preflight_publication( + items: tuple[PublicationItem, ...], + *, + public_root: Path, +) -> None: + """Check the complete publication transaction without taking a write lock.""" + if not items: + raise ReleaseError("release publication plan is empty") + _safe_public_directory(public_root) + allowed_parents = { + public_root / "blobs" / "sha256", + public_root / "releases" / "sha256", + } + seen: set[Path] = set() + for item in items: + if item.path in seen: + raise ReleaseError("release publication plan contains a duplicate path") + seen.add(item.path) + if item.path.parent not in allowed_parents: + raise ReleaseError( + "release publication target is outside the versioned tree" + ) + if item.path.parent == public_root / "blobs" / "sha256": + _safe_public_directory(public_root / "blobs") + else: + _safe_public_directory(public_root / "releases") + _preflight_publish_once(item) + + +def _publish_publication( + items: tuple[PublicationItem, ...], + *, + public_root: Path, +) -> None: + """Publish a prechecked generation under one root-scoped transaction lock.""" + _preflight_publication(items, public_root=public_root) + with _publication_lock(public_root): + # Recheck every destination after taking the lock and before the first + # blob or release write. A conflict therefore never leaves a partial + # generation merely because its conflicting file was ordered later. + _preflight_publication(items, public_root=public_root) + for item in items: + _publish_once_locked( + item.path, + item.payload, + size_cap=item.size_cap, + label=item.label, + conflict=item.conflict, + ) + + @contextmanager def _publication_lock(directory: Path): """Serialize recovery and publication; process death releases the lock.""" @@ -1885,44 +2179,75 @@ def _publish_once_locked( raise ReleaseError(conflict) -def _publish_once( - path: Path, - payload: bytes, +def _release_publication_plan( *, - size_cap: int, - label: str, - conflict: str, -) -> None: - """Crash-recoverable publish-once, serialized across finalizer processes.""" - if not payload or len(payload) > size_cap: - raise ReleaseError(f"{label} exceeds its size cap") - with _publication_lock(path.parent): - _publish_once_locked( - path, - payload, - size_cap=size_cap, - label=label, - conflict=conflict, + public_root: Path, + release_bytes: bytes, + signature_bytes: bytes, + replay_bytes: bytes, + checkpoint: dict[str, Any] | None, +) -> tuple[str, str | None, Path, Path, tuple[PublicationItem, ...]]: + """Build one content-addressed release generation without touching disk.""" + if not release_bytes or len(release_bytes) > MAX_RELEASE_BYTES: + raise ReleaseError("generated release exceeds its size cap") + if not signature_bytes or len(signature_bytes) > MAX_RELEASE_BYTES: + raise ReleaseError("generated release signature exceeds its size cap") + release_digest = "sha256:" + hashlib.sha256(release_bytes).hexdigest() + release_name = release_digest.split(":", 1)[1] + releases = public_root / "releases" / "sha256" + release_path = releases / f"{release_name}.json" + signature_path = releases / f"{release_name}.json.sig" + items: list[PublicationItem] = [] + replay_digest: str | None = None + if checkpoint is not None: + if not replay_bytes or len(replay_bytes) > MAX_PUBLIC_BLOB_BYTES: + raise ReleaseError("generated replay result exceeds its size cap") + replay_digest = "sha256:" + hashlib.sha256(replay_bytes).hexdigest() + if replay_digest != checkpoint.get("replay_result"): + raise ReleaseError("replay-result digest differs from the release") + items.append( + PublicationItem( + path=( + public_root / "blobs" / "sha256" / replay_digest.split(":", 1)[1] + ), + payload=replay_bytes, + size_cap=MAX_PUBLIC_BLOB_BYTES, + label="public replay-result blob", + conflict="public replay-result blob collides with other bytes", + ) + ) + elif replay_bytes: + raise ReleaseError("relay release unexpectedly generated replay bytes") + items.extend( + ( + PublicationItem( + path=release_path, + payload=release_bytes, + size_cap=MAX_RELEASE_BYTES, + label=f"versioned public release {release_digest}", + conflict=( + f"versioned public release {release_digest} has different bytes" + ), + ), + PublicationItem( + path=signature_path, + payload=signature_bytes, + size_cap=MAX_RELEASE_BYTES, + label=f"versioned public release signature {release_digest}", + conflict=( + "versioned public release signature " + f"{release_digest} has different bytes" + ), + ), ) - - -def put_blob(root: Path, payload: bytes) -> str: - if not payload or len(payload) > MAX_PUBLIC_BLOB_BYTES: - raise ReleaseError("public replay-result blob exceeds its size cap") - digest = "sha256:" + hashlib.sha256(payload).hexdigest() - directory = root / "blobs" / "sha256" - _safe_public_directory(root) - _safe_public_directory(root / "blobs") - _safe_public_directory(directory) - path = directory / digest.split(":", 1)[1] - _publish_once( - path, - payload, - size_cap=MAX_PUBLIC_BLOB_BYTES, - label="public replay-result blob", - conflict="public replay-result blob collides with other bytes", ) - return digest + return ( + release_digest, + replay_digest, + release_path, + signature_path, + tuple(items), + ) def verify_frozen_release_evidence( @@ -1976,17 +2301,6 @@ def load_blob(digest: str) -> bytes: raise ReleaseError("frozen public evidence did not reproduce") -def atomic_write(path: Path, payload: bytes) -> None: - """Durably publish immutable bytes, accepting only an idempotent rerun.""" - _publish_once( - path, - payload, - size_cap=MAX_RELEASE_BYTES, - label=f"public release artifact {path.name}", - conflict=(f"public release artifact {path.name} is already sealed differently"), - ) - - def _archive_subtensor() -> Any: try: import bittensor as bt @@ -2003,10 +2317,17 @@ def main() -> int: parser.add_argument("--release", type=Path, required=True) parser.add_argument("--release-sha", required=True) parser.add_argument("--journal", type=Path, required=True) + parser.add_argument( + "--preflight", + action="store_true", + help="run every release gate and publication conflict check without writes", + ) args = parser.parse_args() if not args.release.is_absolute(): raise ReleaseError("release checkout path must be absolute") + operation = "preflight" if args.preflight else "finalize" _require_launcher_context( + operation=operation, release_sha=args.release_sha, journal=args.journal, ) @@ -2044,25 +2365,39 @@ def main() -> int: release_root=release_root, ) checkpoint = release["attested_submission"].get("evidence_checkpoint") - actual_replay = None - if checkpoint is not None: - expected_replay = checkpoint["replay_result"] - actual_replay = put_blob(PUBLIC_ROOT, replay_bytes) - if actual_replay != expected_replay: - raise ReleaseError( - "published replay-result digest differs from the release" - ) - atomic_write(PUBLIC_ROOT / "release.json", release_bytes) - atomic_write(PUBLIC_ROOT / "release.json.sig", signature_bytes) + ( + release_digest, + replay_digest, + release_path, + signature_path, + publication, + ) = _release_publication_plan( + public_root=PUBLIC_ROOT, + release_bytes=release_bytes, + signature_bytes=signature_bytes, + replay_bytes=replay_bytes, + checkpoint=checkpoint, + ) + _preflight_publication(publication, public_root=PUBLIC_ROOT) + if not args.preflight: + _publish_publication(publication, public_root=PUBLIC_ROOT) print( json.dumps( { "extrinsic_hash": release["attested_submission"]["extrinsic"]["hash"], - "release_sha256": ( - "sha256:" + hashlib.sha256(release_bytes).hexdigest() + "mutations": not args.preflight, + "publication_generation": 2, + "release_path": "/" + release_path.relative_to(PUBLIC_ROOT).as_posix(), + "release_sha256": release_digest, + "replay_result": replay_digest, + "signature_path": ( + "/" + signature_path.relative_to(PUBLIC_ROOT).as_posix() + ), + "status": ( + "SN39_PUBLIC_RELEASE_PREFLIGHT_PASS" + if args.preflight + else "SN39_PUBLIC_RELEASE_GENERATION_PUBLISHED" ), - "replay_result": actual_replay, - "status": "SN39_PUBLIC_RELEASE_PUBLISHED", }, sort_keys=True, ) diff --git a/scripts/run_sn39_public_reproduction.py b/scripts/run_sn39_public_reproduction.py index 14d6097..4a1f6f6 100644 --- a/scripts/run_sn39_public_reproduction.py +++ b/scripts/run_sn39_public_reproduction.py @@ -3,6 +3,7 @@ from __future__ import annotations +import argparse import json import sys from pathlib import Path @@ -25,18 +26,28 @@ def run( *, + release_sha256: str | None = None, release_result: dict[str, Any] | None = None, ) -> dict[str, Any]: """Verify signed release, archive state, and frozen public evidence.""" - return assert_public_reproduction(release_result=release_result) + return assert_public_reproduction( + release_sha256=release_sha256, + release_result=release_result, + ) def main() -> int: - if len(sys.argv) != 1: - print("usage: run_sn39_public_reproduction.py", file=sys.stderr) - return 2 + parser = argparse.ArgumentParser() + parser.add_argument( + "--release-sha256", + help=( + "reproduce a versioned release under /releases/sha256; " + "omit only for the historical root release" + ), + ) + args = parser.parse_args() try: - result = run() + result = run(release_sha256=args.release_sha256) except ReproductionNotProven as exc: print(f"SN39 public reproduction: NOT_PROVEN: {exc}", file=sys.stderr) return 3 diff --git a/tests/thin/test_release_generation_finalizer.py b/tests/thin/test_release_generation_finalizer.py new file mode 100644 index 0000000..e4c6537 --- /dev/null +++ b/tests/thin/test_release_generation_finalizer.py @@ -0,0 +1,477 @@ +"""Adversarial boundaries for versioned SN39 release publication.""" + +from __future__ import annotations + +import hashlib +import importlib.util +import os +from pathlib import Path +import subprocess +import sys + +import pytest + + +_ROOT = Path(__file__).resolve().parents[2] + + +def _load_script(name: str, relative: str): + spec = importlib.util.spec_from_file_location(name, _ROOT / relative) + assert spec is not None and spec.loader is not None + module = importlib.util.module_from_spec(spec) + sys.modules[name] = module + spec.loader.exec_module(module) + return module + + +_finalizer = _load_script( + "_cathedral_release_generation_finalizer", + "scripts/finalize_sn39_public_release.py", +) +_launcher = _load_script( + "_cathedral_release_generation_launcher", + "deploy/sn39/cathedral-sn39-release-launcher.py", +) + + +def _private_directory(path: Path) -> Path: + path.mkdir() + path.chmod(0o750) + return path + + +def _controlled_epoch(base: Path, name: str, payload: bytes) -> tuple[Path, str]: + epoch = _private_directory(base / name) + digest = "sha256:" + hashlib.sha256(payload).hexdigest() + envelope = epoch / f"{digest.split(':', 1)[1]}.json" + envelope.write_bytes(payload) + envelope.chmod(0o640) + return epoch, digest + + +def _public_root(tmp_path: Path) -> Path: + root = tmp_path / "public" + for path in ( + root, + root / "blobs", + root / "blobs" / "sha256", + root / "releases", + root / "releases" / "sha256", + ): + path.mkdir() + path.chmod(0o755) + return root + + +def _tree_snapshot(root: Path) -> dict[str, tuple[str, bytes | str, int]]: + snapshot: dict[str, tuple[str, bytes | str, int]] = {} + for path in sorted(root.rglob("*")): + relative = path.relative_to(root).as_posix() + info = path.lstat() + if path.is_symlink(): + snapshot[relative] = ("symlink", os.readlink(path), info.st_nlink) + elif path.is_file(): + snapshot[relative] = ("file", path.read_bytes(), info.st_nlink) + else: + snapshot[relative] = ("directory", b"", info.st_nlink) + return snapshot + + +def _index_snapshot(path: Path) -> tuple[bytes, tuple[int, ...]]: + info = path.stat() + return path.read_bytes(), ( + info.st_dev, + info.st_ino, + info.st_mode, + info.st_uid, + info.st_gid, + info.st_size, + info.st_mtime_ns, + info.st_ctime_ns, + ) + + +def test_pristine_checks_do_not_refresh_the_git_index(tmp_path): + release = tmp_path / ("a" * 40) + release.mkdir() + tracked = release / "tracked.txt" + tracked.write_text("sealed bytes\n", encoding="utf-8") + git_env = { + "PATH": "/usr/bin:/bin", + "LC_ALL": "C", + "GIT_CONFIG_NOSYSTEM": "1", + "HOME": str(tmp_path), + } + for command in ( + ["/usr/bin/git", "init", "--quiet"], + ["/usr/bin/git", "add", "tracked.txt"], + [ + "/usr/bin/git", + "-c", + "user.name=Cathedral Test", + "-c", + "user.email=cathedral-test@example.invalid", + "commit", + "--quiet", + "-m", + "fixture", + ], + ): + subprocess.run(command, cwd=release, env=git_env, check=True) + + tracked_info = tracked.stat() + os.utime( + tracked, + ns=(tracked_info.st_atime_ns, tracked_info.st_mtime_ns + 2_000_000_000), + ) + index = release / ".git" / "index" + before = _index_snapshot(index) + + assert ( + _launcher._git_output( + release, + "status", + "--porcelain=v1", + "--untracked-files=all", + "--ignored=matching", + ) + == "" + ) + assert _index_snapshot(index) == before + assert ( + _finalizer.git( + release, + "status", + "--porcelain=v1", + "--untracked-files=all", + "--ignored=matching", + ) + == "" + ) + assert _index_snapshot(index) == before + + +def test_current_symlink_is_bound_to_one_direct_epoch(tmp_path, monkeypatch): + monkeypatch.setattr(_finalizer, "ROOT_UID", os.geteuid()) + base = _private_directory(tmp_path / "controlled") + _epoch_a, digest = _controlled_epoch(base, "epoch-a", b"epoch-a") + _epoch_b, _ = _controlled_epoch(base, "epoch-b", b"epoch-b") + current = base / "current" + current.symlink_to("epoch-a", target_is_directory=True) + + with _finalizer._open_controlled_directory(current) as descriptor: + current.unlink() + current.symlink_to("epoch-b", target_is_directory=True) + assert _finalizer._read_controlled_envelope(descriptor, digest) == b"epoch-a" + + +@pytest.mark.parametrize( + "target", + ["../outside", "/tmp/outside", "current", "./epoch-a", "epoch-a/"], +) +def test_current_symlink_cannot_escape_its_controlled_parent( + tmp_path, + monkeypatch, + target, +): + monkeypatch.setattr(_finalizer, "ROOT_UID", os.geteuid()) + base = _private_directory(tmp_path / "controlled") + current = base / "current" + current.symlink_to(target, target_is_directory=True) + with pytest.raises(_finalizer.ReleaseError, match="direct epoch"): + with _finalizer._open_controlled_directory(current): + pass + + +def test_controlled_path_rejects_a_symlinked_ancestor(tmp_path, monkeypatch): + monkeypatch.setattr(_finalizer, "ROOT_UID", os.geteuid()) + base = _private_directory(tmp_path / "controlled") + _controlled_epoch(base, "epoch-a", b"epoch-a") + (base / "current").symlink_to("epoch-a", target_is_directory=True) + alias = tmp_path / "controlled-alias" + alias.symlink_to(base, target_is_directory=True) + + with pytest.raises(_finalizer.ReleaseError, match="contains a symlink"): + with _finalizer._open_controlled_directory(alias / "current"): + pass + + +def test_controlled_selector_rejects_a_symlinked_epoch_target( + tmp_path, + monkeypatch, +): + monkeypatch.setattr(_finalizer, "ROOT_UID", os.geteuid()) + base = _private_directory(tmp_path / "controlled") + _controlled_epoch(base, "epoch-a", b"epoch-a") + (base / "epoch-alias").symlink_to("epoch-a", target_is_directory=True) + current = base / "current" + current.symlink_to("epoch-alias", target_is_directory=True) + + with pytest.raises(_finalizer.ReleaseError, match="is a symlink"): + with _finalizer._open_controlled_directory(current): + pass + + +def test_preflight_leaves_every_public_inode_unchanged(tmp_path): + root = _public_root(tmp_path) + release_bytes = b'{"generation":2}' + signature_bytes = b'{"signature":"test"}\n' + _, _, release_path, _, plan = _finalizer._release_publication_plan( + public_root=root, + release_bytes=release_bytes, + signature_bytes=signature_bytes, + replay_bytes=b"", + checkpoint=None, + ) + pending = release_path.parent / f".{release_path.name}.pending" + pending.write_bytes(b"recoverable-stale-stage") + pending.chmod(0o644) + historical = root / "release.json" + historical.write_bytes(b"historical-release") + historical.chmod(0o644) + before = _tree_snapshot(root) + + _finalizer._preflight_publication(plan, public_root=root) + + assert _tree_snapshot(root) == before + assert not (root / ".sn39-publication.lock").exists() + + +def test_versioned_publication_preserves_the_historical_root_release(tmp_path): + root = _public_root(tmp_path) + historical = root / "release.json" + historical_signature = root / "release.json.sig" + historical.write_bytes(b"historical-release") + historical_signature.write_bytes(b"historical-signature") + historical.chmod(0o644) + historical_signature.chmod(0o644) + release_bytes = b'{"generation":2}' + signature_bytes = b'{"signature":"test"}\n' + release_digest, _, release_path, signature_path, plan = ( + _finalizer._release_publication_plan( + public_root=root, + release_bytes=release_bytes, + signature_bytes=signature_bytes, + replay_bytes=b"", + checkpoint=None, + ) + ) + + _finalizer._publish_publication(plan, public_root=root) + + assert historical.read_bytes() == b"historical-release" + assert historical_signature.read_bytes() == b"historical-signature" + assert release_path.read_bytes() == release_bytes + assert signature_path.read_bytes() == signature_bytes + assert release_path.name == release_digest.split(":", 1)[1] + ".json" + + +def test_a_late_conflict_is_found_before_any_blob_or_release_write(tmp_path): + root = _public_root(tmp_path) + replay = b"replay" + replay_digest = "sha256:" + hashlib.sha256(replay).hexdigest() + checkpoint = {"replay_result": replay_digest} + _, _, release_path, _, plan = _finalizer._release_publication_plan( + public_root=root, + release_bytes=b'{"generation":2}', + signature_bytes=b'{"signature":"test"}\n', + replay_bytes=replay, + checkpoint=checkpoint, + ) + release_path.write_bytes(b"hostile-conflict") + release_path.chmod(0o644) + blob_path = root / "blobs" / "sha256" / replay_digest.split(":", 1)[1] + + with pytest.raises(_finalizer.ReleaseError, match="different bytes"): + _finalizer._publish_publication(plan, public_root=root) + + assert not blob_path.exists() + assert release_path.read_bytes() == b"hostile-conflict" + + +def test_preflight_rejects_a_release_hardlink_alias(tmp_path): + root = _public_root(tmp_path) + release_bytes = b'{"generation":2}' + _, _, release_path, _, plan = _finalizer._release_publication_plan( + public_root=root, + release_bytes=release_bytes, + signature_bytes=b'{"signature":"test"}\n', + replay_bytes=b"", + checkpoint=None, + ) + release_path.write_bytes(release_bytes) + release_path.chmod(0o644) + os.link(release_path, release_path.parent / "untrusted-alias") + + with pytest.raises(_finalizer.ReleaseError, match="hardlink alias"): + _finalizer._preflight_publication(plan, public_root=root) + + +def test_versioned_reproducer_path_is_digest_bound(): + digest = "sha256:" + "a" * 64 + assert _finalizer.SHA256.fullmatch(digest) + from scaffold import sn39_public_reproduction as reproduction + + assert reproduction._release_artifact_paths(None) == ( + "/release.json", + "/release.json.sig", + ) + assert reproduction._release_artifact_paths(digest) == ( + f"/releases/sha256/{'a' * 64}.json", + f"/releases/sha256/{'a' * 64}.json.sig", + ) + with pytest.raises(reproduction.ReproductionError, match="malformed"): + reproduction._release_artifact_paths("sha256:../release.json") + + +class _ExecveCalled(RuntimeError): + pass + + +def test_launcher_binds_preflight_context_and_adds_no_finalize_flag( + tmp_path, + monkeypatch, +): + release = tmp_path / ("a" * 40) + release.mkdir() + python = tmp_path / "python" + runtime = tmp_path / "runtime" + runtime.mkdir() + journal = runtime / f"journal-{'b' * 64}.json" + captured = {} + monkeypatch.setattr(_launcher, "RUNTIME_ROOT", runtime) + monkeypatch.setattr( + _launcher, + "_verify", + lambda mode: (release, python, "sha256:" + "c" * 64), + ) + monkeypatch.setattr(_launcher.os, "geteuid", lambda: 0) + monkeypatch.setattr(_launcher.os, "chdir", lambda _path: None) + + def capture_execve(executable, command, environment): + captured.update( + executable=executable, + command=command, + environment=environment, + ) + raise _ExecveCalled + + monkeypatch.setattr(_launcher.os, "execve", capture_execve) + + with pytest.raises(_ExecveCalled): + _launcher.main(["preflight", str(journal)]) + + assert captured["command"][-1] == "--preflight" + expected = _launcher._finalizer_context_digest( + operation="preflight", + release_sha=release.name, + journal=journal, + manifest_digest="sha256:" + "c" * 64, + ) + assert captured["environment"][_launcher.FINALIZER_CONTEXT_ENV] == expected + + +def test_preflight_context_cannot_authorize_finalize(tmp_path): + journal = tmp_path / f"journal-{'b' * 64}.json" + values = { + operation: _launcher._finalizer_context_digest( + operation=operation, + release_sha="a" * 40, + journal=journal, + manifest_digest="sha256:" + "c" * 64, + ) + for operation in ("preflight", "finalize") + } + assert values["preflight"] != values["finalize"] + + +@pytest.mark.parametrize("operation", ["preflight", "finalize"]) +def test_launcher_and_finalizer_context_contracts_match(tmp_path, operation): + journal = tmp_path / f"journal-{'b' * 64}.json" + arguments = { + "operation": operation, + "release_sha": "a" * 40, + "journal": journal, + "manifest_digest": "sha256:" + "c" * 64, + } + assert _launcher._finalizer_context_digest( + **arguments + ) == _finalizer._launcher_context_digest(**arguments) + + +def test_tmpfiles_provisions_the_versioned_release_parents(): + tmpfiles = (_ROOT / "deploy/sn39/cathedral-sn39-validator.tmpfiles").read_text( + "utf-8" + ) + assert ( + "d /var/lib/cathedral-public-evidence/releases :0755 :root :root -" in tmpfiles + ) + assert ( + "d /var/lib/cathedral-public-evidence/releases/sha256 " + ":0755 :root :root -" in tmpfiles + ) + + +def test_preflight_main_never_enters_the_publication_primitive( + tmp_path, + monkeypatch, +): + root = _public_root(tmp_path) + historical = root / "release.json" + historical.write_bytes(b"historical") + historical.chmod(0o644) + release = { + "attested_submission": { + "evidence_checkpoint": None, + "extrinsic": {"hash": "0x" + "d" * 64}, + } + } + monkeypatch.setattr(_finalizer, "PUBLIC_ROOT", root) + monkeypatch.setattr(_finalizer, "_require_launcher_context", lambda **_kw: None) + monkeypatch.setattr(_finalizer, "verify_release_checkout", lambda *_a: None) + monkeypatch.setattr(_finalizer, "read_launch_journal", lambda _path: {}) + monkeypatch.setattr(_finalizer, "_archive_subtensor", object) + monkeypatch.setattr( + _finalizer, + "build_release", + lambda *_a, **_kw: (release, b""), + ) + monkeypatch.setattr( + _finalizer, + "verify_frozen_release_evidence", + lambda *_a, **_kw: None, + ) + monkeypatch.setattr(_finalizer, "_read_root_seed", lambda _path: b"seed") + monkeypatch.setattr( + _finalizer, + "build_signature", + lambda *_a, **_kw: b"signature\n", + ) + monkeypatch.setattr( + _finalizer, + "_publish_publication", + lambda *_a, **_kw: (_ for _ in ()).throw( + AssertionError("preflight attempted publication") + ), + ) + journal = Path("/var/lib/cathedral-validator") / f"journal-{'b' * 64}.json" + monkeypatch.setattr( + sys, + "argv", + [ + str(Path(_finalizer.__file__)), + "--release", + str(_ROOT), + "--release-sha", + "a" * 40, + "--journal", + str(journal), + "--preflight", + ], + ) + before = _tree_snapshot(root) + + assert _finalizer.main() == 0 + + assert _tree_snapshot(root) == before + assert not (root / ".sn39-publication.lock").exists()