diff --git a/backend/cortex_backend/execution/manifest.py b/backend/cortex_backend/execution/manifest.py index cdcb42c..edfda83 100644 --- a/backend/cortex_backend/execution/manifest.py +++ b/backend/cortex_backend/execution/manifest.py @@ -23,11 +23,18 @@ MAX_MANIFEST_BYTES = 256 * 1024 -MAX_MANIFEST_ENTRIES = 128 +# A PyInstaller one-folder dependency closure can contain hundreds of ordinary +# files (DLLs, extension modules, and package metadata). Keep the bound explicit +# while allowing the signed worker package to describe its complete tree. +MAX_MANIFEST_ENTRIES = 1024 MAX_BUNDLE_ENTRY_BYTES = 128 * 1024 * 1024 _SAFE_KEY_ID = re.compile(r"^[a-z0-9][a-z0-9._-]{0,63}$") _SAFE_RECIPE_ID = re.compile(r"^[a-z0-9][a-z0-9._-]{0,63}$") -_SAFE_BUNDLE_PATH = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._/-]{0,255}$") +# PyInstaller one-folder packages use the ordinary `_internal/` directory and +# may carry package metadata such as `Lorem ipsum.txt` or timezone names with +# `+`. These are safe path characters; dot segments and traversal are still +# rejected explicitly by the validator below. +_SAFE_BUNDLE_PATH = re.compile(r"^[A-Za-z0-9_][A-Za-z0-9._/+ -]{0,255}$") _SHA256 = re.compile(r"^[0-9a-f]{64}$") _SEMVER = re.compile(r"^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$") @@ -57,7 +64,7 @@ def canonical_json(self) -> str: class ManifestEntry(_ManifestModel): recipe_id: str bundle_path: str - entrypoint: Literal["image_transform", "calculation", "check"] + entrypoint: Literal["image_transform", "calculation", "check", "resource"] version: str size: int = Field(strict=True, ge=1, le=MAX_BUNDLE_ENTRY_BYTES) sha256: str diff --git a/backend/cortex_backend/execution/worker_release.py b/backend/cortex_backend/execution/worker_release.py new file mode 100644 index 0000000..ff2b9d3 --- /dev/null +++ b/backend/cortex_backend/execution/worker_release.py @@ -0,0 +1,269 @@ +"""Fail-closed signing metadata for the packaged recipe worker. + +This module is a release-tool boundary, not a runtime capability. It turns an +already-built one-folder worker into a signed ``recipe.manifest.v1`` payload by +hashing every ordinary package file. Only ``recipe_worker.exe`` receives the +``image_transform`` role; all other files are inert ``resource`` entries so the +installer's exact-tree check can preserve the PyInstaller dependency closure. +Private key bytes are accepted only from an external caller and are never +persisted or returned. +""" + +from __future__ import annotations + +from dataclasses import dataclass +import base64 +from hashlib import sha256 +import json +import os +from pathlib import Path +import re +import stat +from typing import Any, Final, Mapping + +from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey +from cryptography.hazmat.primitives import serialization +from pydantic import ValidationError + +from .manifest import ( + MAX_BUNDLE_ENTRY_BYTES, + MAX_MANIFEST_ENTRIES, + ManifestEntry, + TrustedRecipeKeys, + parse_signed_manifest, + verify_manifest_signature, +) + + +EXPECTED_WORKER_PATH: Final[str] = "recipe_worker.exe" +_SAFE_CODE = re.compile(r"^[a-z][a-z0-9_]{0,63}$") +_SAFE_KEY_ID = re.compile(r"^[a-z0-9][a-z0-9._-]{0,63}$") +_SAFE_VERSION = re.compile(r"^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$") + + +class WorkerReleaseError(ValueError): + """Stable release failure category without paths or key material.""" + + def __init__(self, code: str) -> None: + if _SAFE_CODE.fullmatch(code) is None: + raise ValueError("invalid worker release code") + self.code = code + super().__init__("The recipe worker release artifact failed closed.") + + +@dataclass(frozen=True, slots=True) +class SignedWorkerRelease: + """Safe release metadata; private key material is intentionally absent.""" + + manifest: dict[str, Any] + manifest_digest: str + entry_count: int + worker_size: int + worker_sha256: str + + +def _is_reparse_point(path: Path) -> bool: + if path.is_symlink(): + return True + is_junction = getattr(path, "is_junction", None) + return bool(is_junction is not None and is_junction()) + + +def _path_has_reparse_component(path: Path) -> bool: + """Reject symlink/junction parents, not only a reparse leaf.""" + + absolute = Path(os.path.abspath(os.fspath(path))) + current = Path(absolute.anchor) + for component in absolute.parts[1:]: + current /= component + if _is_reparse_point(current): + return True + return False + + +def _ordinary_root(source_root: str | os.PathLike[str]) -> Path: + try: + candidate = Path(source_root) + except (TypeError, ValueError): + raise WorkerReleaseError("release_root_invalid") from None + if _path_has_reparse_component(candidate): + raise WorkerReleaseError("release_root_invalid") + try: + root = candidate.resolve(strict=True) + except (OSError, RuntimeError): + raise WorkerReleaseError("release_root_unavailable") from None + if _is_reparse_point(root) or not root.is_dir(): + raise WorkerReleaseError("release_root_invalid") + return root + + +def _ordinary_file(path: Path) -> tuple[int, int, int, int, int]: + if _path_has_reparse_component(path): + raise WorkerReleaseError("release_entry_reparse") + try: + stat_result = path.lstat() + except OSError: + raise WorkerReleaseError("release_entry_unavailable") from None + if stat.S_ISLNK(stat_result.st_mode) or not stat.S_ISREG(stat_result.st_mode): + raise WorkerReleaseError("release_entry_invalid") + if int(getattr(stat_result, "st_nlink", 1)) != 1: + raise WorkerReleaseError("release_entry_invalid") + if stat_result.st_size < 1 or stat_result.st_size > MAX_BUNDLE_ENTRY_BYTES: + raise WorkerReleaseError("release_entry_size_invalid") + return ( + int(stat_result.st_size), + int(stat_result.st_mtime_ns), + int(stat_result.st_ctime_ns), + int(getattr(stat_result, "st_ino", 0)), + int(getattr(stat_result, "st_dev", 0)), + ) + + +def _digest_file(path: Path, expected_identity: tuple[int, int, int, int, int]) -> str: + expected_size = expected_identity[0] + digest = sha256() + total = 0 + try: + with path.open("rb") as stream: + while True: + chunk = stream.read(min(1024 * 1024, expected_size + 1 - total)) + if not chunk: + break + total += len(chunk) + if total > expected_size: + raise WorkerReleaseError("release_entry_changed") + digest.update(chunk) + except WorkerReleaseError: + raise + except OSError: + raise WorkerReleaseError("release_entry_unavailable") from None + after = _ordinary_file(path) + if after != expected_identity or total != expected_size: + raise WorkerReleaseError("release_entry_changed") + return digest.hexdigest() + + +def _enumerate_files(root: Path) -> list[tuple[str, Path, int, str]]: + files: list[tuple[str, Path, int, str]] = [] + try: + candidates = sorted(root.rglob("*"), key=lambda path: path.relative_to(root).as_posix()) + except (OSError, RuntimeError): + raise WorkerReleaseError("release_tree_unavailable") from None + for candidate in candidates: + if _is_reparse_point(candidate): + raise WorkerReleaseError("release_entry_reparse") + if not candidate.is_file(): + continue + relative = candidate.relative_to(root).as_posix() + if relative == "manifest.json": + raise WorkerReleaseError("release_manifest_reserved") + stat_identity = _ordinary_file(candidate) + files.append((relative, candidate, stat_identity[0], _digest_file(candidate, stat_identity))) + if not files or not any(relative == EXPECTED_WORKER_PATH for relative, *_ in files): + raise WorkerReleaseError("release_worker_missing") + if len(files) > MAX_MANIFEST_ENTRIES: + raise WorkerReleaseError("release_entry_count_exceeded") + return files + + +def _canonical_signed_payload(payload: Mapping[str, Any]) -> bytes: + try: + return json.dumps( + payload, + ensure_ascii=True, + sort_keys=True, + separators=(",", ":"), + allow_nan=False, + ).encode("ascii") + except (TypeError, ValueError, UnicodeEncodeError, OverflowError): + raise WorkerReleaseError("release_manifest_invalid") from None + + +def build_signed_worker_manifest( + source_root: str | os.PathLike[str], + *, + private_key_bytes: bytes, + key_id: str, + bundle_version: str, + sequence: int, +) -> SignedWorkerRelease: + """Hash every package file and return a self-verified signed manifest.""" + + if not isinstance(private_key_bytes, bytes) or len(private_key_bytes) != 32: + raise WorkerReleaseError("release_signing_key_invalid") + if not isinstance(key_id, str) or _SAFE_KEY_ID.fullmatch(key_id) is None: + raise WorkerReleaseError("release_key_id_invalid") + if not isinstance(bundle_version, str) or _SAFE_VERSION.fullmatch(bundle_version) is None: + raise WorkerReleaseError("release_version_invalid") + if type(sequence) is not int or sequence < 1: + raise WorkerReleaseError("release_sequence_invalid") + try: + signer = Ed25519PrivateKey.from_private_bytes(private_key_bytes) + except (TypeError, ValueError): + raise WorkerReleaseError("release_signing_key_invalid") from None + + root = _ordinary_root(source_root) + files = _enumerate_files(root) + entries: list[ManifestEntry] = [] + worker_size = 0 + worker_sha256 = "" + try: + for index, (relative, _path, size, digest) in enumerate(files, start=1): + is_worker = relative == EXPECTED_WORKER_PATH + if is_worker: + worker_size = size + worker_sha256 = digest + entries.append( + ManifestEntry( + recipe_id="image_transform" if is_worker else f"resource-{index:06d}", + bundle_path=relative, + entrypoint="image_transform" if is_worker else "resource", + version=bundle_version, + size=size, + sha256=digest, + ) + ) + except (TypeError, ValueError, ValidationError): + raise WorkerReleaseError("release_manifest_invalid") from None + unsigned = { + "schema_version": "recipe.manifest.v1", + "key_id": key_id, + "sequence": sequence, + "bundle_version": bundle_version, + "rollback_of": None, + "entries": [entry.model_dump(mode="json") for entry in entries], + } + signature = base64.urlsafe_b64encode(signer.sign(_canonical_signed_payload(unsigned))).decode( + "ascii" + ).rstrip("=") + payload = {**unsigned, "signature": signature} + try: + # Use the same guarded parser as the runtime verifier. The wire format + # carries ``entries`` as a JSON array while the immutable model stores + # it as a tuple; validating through the parser keeps release and runtime + # canonicalization exactly aligned. + manifest = parse_signed_manifest(payload) + public_key = signer.public_key().public_bytes( + serialization.Encoding.Raw, + serialization.PublicFormat.Raw, + ) + verified = verify_manifest_signature(payload, TrustedRecipeKeys({key_id: public_key})) + except Exception: + raise WorkerReleaseError("release_manifest_self_check_failed") from None + if verified.manifest != manifest: + raise WorkerReleaseError("release_manifest_self_check_failed") + return SignedWorkerRelease( + manifest=manifest.model_dump(mode="json"), + manifest_digest=verified.digest, + entry_count=len(entries), + worker_size=worker_size, + worker_sha256=worker_sha256, + ) + + +__all__ = [ + "EXPECTED_WORKER_PATH", + "SignedWorkerRelease", + "WorkerReleaseError", + "build_signed_worker_manifest", +] diff --git a/docs/adr/0001-capability-tiered-agentic-execution-harness.md b/docs/adr/0001-capability-tiered-agentic-execution-harness.md index a1f24d8..fd12149 100644 --- a/docs/adr/0001-capability-tiered-agentic-execution-harness.md +++ b/docs/adr/0001-capability-tiered-agentic-execution-harness.md @@ -949,7 +949,9 @@ pass without executing code. worker entrypoint, and the disposable launcher now qualifies pre-resume Job Object resource policy. The reviewed Win32 suspended factory and launcher-side broker PID/AppContainer-token binder are now qualified, and the packaged worker-side - authenticated broker loop is implemented; signed-worker end-to-end broker + authenticated broker loop is implemented. The release-only signer now covers the + complete one-folder dependency closure with inert resource entries and refuses to + produce metadata without an external key; signed-worker end-to-end broker execution remains blocked. Collect opt-in aggregate reliability metrics, never content, only after the provider is sandbox-qualified. diff --git a/docs/adr/0001-phase2-bundle-installation.md b/docs/adr/0001-phase2-bundle-installation.md index 2976961..f5bbc83 100644 --- a/docs/adr/0001-phase2-bundle-installation.md +++ b/docs/adr/0001-phase2-bundle-installation.md @@ -31,6 +31,11 @@ digest changes, and source identity changes during the copy. Only declared files copied; `manifest.json` is reserved for the canonical signed payload. Staged files are flushed before the generation is renamed into the same-volume bundle directory. +One-folder worker dependencies are represented by manifest entries with the inert +`resource` entrypoint; the installer copies them for exact-tree integrity but never +selects them as executable roles. Worker-role selection remains the separate +`verify_active_worker()` provenance check. + Activation is a two-step commit. First, the complete generation is durably written and renamed without replacing an existing digest directory. Second, the small `state.json` pointer is written to a unique temporary file, flushed, and atomically diff --git a/docs/adr/0001-phase2-evidence.md b/docs/adr/0001-phase2-evidence.md index 059482e..2ac5c8b 100644 --- a/docs/adr/0001-phase2-evidence.md +++ b/docs/adr/0001-phase2-evidence.md @@ -17,6 +17,7 @@ | Canonical plan identity | **Complete** | Validated plans expose stable canonical JSON and SHA-256 digests for future idempotency/signature binding. | | Signed recipe manifest | **Complete (verification only)** | Ed25519 signature verification uses a pinned key-id allowlist; every declared bundle entry is path-, size-, and SHA-256-verified; monotonic updates and explicit rollback authorization are enforced. | | Signed bundle installation/update | **Complete (storage-only)** | Digest-named immutable generations, exclusive staging, atomic activation state, chained keyring rotation, explicit rollback authorization, and previous-generation recovery are covered by installer tests. No provider is loaded. | +| Signed worker release generation | **Complete (release-only)** | `worker_release.py` and `tools/sign_recipe_worker.py` hash every one-folder file, mark only `recipe_worker.exe` as `image_transform`, classify dependencies as inert `resource` entries, self-verify the Ed25519 signature, reject ambiguous/mutable packages, and never persist private key material. Installation still requires the pinned public trust root. | | Signed worker provenance binding | **Complete (storage-only)** | `verify_active_worker()` rechecks the active signed generation, binds exactly one `image_transform` role to `recipe_worker.exe`, revalidates byte identity, and rejects missing/ambiguous/mismatched/tampered/reparse entries without launching. | | Fixed worker protocol and package closure | **Complete (qualification-only)** | `worker_protocol.py` and `worker_runtime.py` enforce bounded prepare/chunk/complete/cancel/collect state, authenticated envelope identity, concurrent cancellation, redacted output/errors, and no-capability bodies. `packaging/recipe_worker/recipe_worker.spec` builds the fixed `recipe_worker.exe` (Windows build verified 2026-07-23); the entrypoint accepts only the fixed native-broker identity arguments and returns `78` on direct or failed launches. | | Authenticated broker contract | **Complete (transport-neutral)** | Bounded versioned frames, direction-specific HMAC keys, canonical messages, peer ACL/integrity policy, and owner-scoped authorization are covered by adversarial tests. | @@ -86,6 +87,9 @@ 23. The disposable launcher applies all required Job Object policy before resume, queries the configured limits/accounting, never grants breakaway, and reports the absent worker/broker gates as blocking. +24. Release signing reads an external raw private key only for the signing operation, + self-verifies the canonical manifest, rejects reparse/hardlink/mutable package + inputs, and never treats a generated manifest as launch authorization. ## Re-run target @@ -98,6 +102,7 @@ python -m pytest tests/test_phase2_bundle_installer.py -q python -m pytest tests/test_phase2_artifact_boundary.py -q python -m pytest tests/test_phase2_recipe_provider.py -q python -m pytest tests/test_phase2_worker_provenance.py -q +python -m pytest tests/test_phase2_worker_release.py -q python -m pytest tests/test_native_launcher_qualification.py -q python -m pytest tests/test_recipe_sandbox_qualification.py -q python tools/execution_spikes/native_launcher_qualification.py @@ -114,14 +119,15 @@ npm.cmd test --prefix frontend -- --run **Validation result (2026-07-23):** 16 Phase 2 contract tests, 9 signed-manifest tests, 7 broker-contract tests, 9 native-broker tests, 7 bundle-installer tests, 16 artifact-boundary tests, 17 recipe-provider tests, 6 worker-provenance tests, 7 -worker-protocol tests, 16 native-launcher/factory tests, 4 -native-launcher tests, and -5 sandbox-qualification tests -passed; 9 worker-runtime tests passed; the full Python suite -passed (251 tests total) with one +worker-protocol tests, 7 worker-release tests, 16 native-launcher/factory tests, +4 native-launcher tests, and 5 sandbox-qualification tests passed; 9 worker-runtime +tests passed; the full Python suite passed (258 tests total) with one native-platform skip and one pre-existing `pytest-asyncio` deprecation warning. Frontend lint, typecheck, production build, and all 39 frontend tests passed. Contract generation, compileall, and `git diff --check` passed. No production execution provider is enabled. The sandbox qualification command passed its AppContainer, Job Object, cancellation, and fixed decoder checks but returned the expected -fail-closed `blocked` status because the signed worker bundle is not shipped. +fail-closed `blocked` status because the signed worker bundle is not shipped. The +Windows PyInstaller package built successfully, and an external-key smoke signed and +verified its complete 822-file closure (one `image_transform` role plus 821 inert +`resource` entries); no key or signed artifact was retained. diff --git a/docs/adr/0001-phase2-signed-manifest.md b/docs/adr/0001-phase2-signed-manifest.md index 428e6d0..f561258 100644 --- a/docs/adr/0001-phase2-signed-manifest.md +++ b/docs/adr/0001-phase2-signed-manifest.md @@ -13,6 +13,11 @@ semantic bundle version, optional rollback target, and a bounded list of entries entry pins an opaque recipe id, safe relative bundle path, typed entrypoint, version, exact byte size, and lowercase SHA-256 digest. +The `resource` entrypoint is reserved for inert files in a one-folder worker +dependency closure; it is never a launch role. Exactly one `image_transform` entry at +the fixed `recipe_worker.exe` path is required by the separate worker-provenance +boundary. + The signature is Ed25519 over canonical UTF-8 JSON with sorted keys, compact separators, and the signature field removed. Verification uses a packaged allowlist of raw 32-byte public keys selected by a bounded key id. Unknown or revoked keys are rejected. The @@ -49,6 +54,23 @@ It reads at most the hard per-entry ceiling and never imports, decodes, executes publishes the bytes. The future provider must load only entries that were verified by this manifest; extra files are not implicitly trusted. +## Release signing boundary + +`backend/cortex_backend/execution/worker_release.py` and +`tools/sign_recipe_worker.py` form a release-only signing boundary. They enumerate +every ordinary file in the built one-folder package, reject reparse points, +hardlinks, empty/oversized files, source mutation, reserved `manifest.json`, and a +missing fixed worker, then sign the canonical manifest with an externally supplied +raw Ed25519 private key. The key is read only for the signing operation and is never +written to the repository, returned in metadata, or printed. The builder self-verifies +the resulting signature before returning, while installation still requires the +independently pinned public-key trust root and `SignedBundleInstaller`. + +`packaging/build_recipe_worker.ps1` remains unsigned by default. Supplying all signing +parameters opts into the release tool; a package is not launch-authorized merely +because a manifest was generated. No private key, signed production artifact, or +trust-root update is committed by this ADR stage. + ## Failure contract Failures are reduced to stable categories: `manifest_invalid`, `manifest_too_large`, diff --git a/docs/adr/0001-phase2-worker-protocol.md b/docs/adr/0001-phase2-worker-protocol.md index e6bc0dd..e4fa407 100644 --- a/docs/adr/0001-phase2-worker-protocol.md +++ b/docs/adr/0001-phase2-worker-protocol.md @@ -41,8 +41,12 @@ serves the bounded worker loop. Direct launches, malformed arguments, missing broker identity, provider startup failure, transport failure, and message-budget exhaustion return the safe refusal status (`78`); there is no stdio, shell, path, or host-process fallback. The PyInstaller definition and Windows build script -qualify dependency closure and the fixed `recipe_worker.exe` path only; they do -not sign, install, or authorize the worker. +qualify dependency closure and the fixed `recipe_worker.exe` path only. The +release-only signing tool hashes every ordinary dependency file as an inert +`resource` manifest entry, assigns the sole `image_transform` role to +`recipe_worker.exe`, and self-verifies the canonical signature. It does not sign, +install, or authorize the worker without the external key and pinned public trust +root. ## Security invariants @@ -55,10 +59,10 @@ not sign, install, or authorize the worker. cancellation or terminal failure. - Output metadata is private and content-addressed; publication remains outside the worker session. -- A package build is not a signed bundle. The release pipeline must create the - exact `recipe.manifest.v1` entry for `image_transform`/`recipe_worker.exe`, sign - it with the pinned key, install it through `SignedBundleInstaller`, and re-run - `verify_active_worker()`. +- A package build is not a signed bundle. The release pipeline must enumerate and + sign the complete one-folder closure, install the resulting manifest through + `SignedBundleInstaller`, and re-run `verify_active_worker()`; only then may the + native launch gates be evaluated. ## Evidence diff --git a/docs/adr/0001-phase2-worker-provenance.md b/docs/adr/0001-phase2-worker-provenance.md index cea8806..07a2d59 100644 --- a/docs/adr/0001-phase2-worker-provenance.md +++ b/docs/adr/0001-phase2-worker-provenance.md @@ -30,6 +30,10 @@ multiple image roles, a changed worker, or a reparse/hardlink entry cannot be treated as the worker. Stable `WorkerProvenanceError.code` values cross the boundary; paths, bytes, OS errors, and signatures never do. +The release signer may declare the remaining one-folder files as inert `resource` +entries so the installer can enforce an exact dependency tree. Those entries are +copied and rehashed but are never eligible for worker-role selection. + ## Failure categories The verifier uses bounded categories including `worker_bundle_unavailable`, diff --git a/packaging/build_recipe_worker.ps1 b/packaging/build_recipe_worker.ps1 index 807c088..44ccc6c 100644 --- a/packaging/build_recipe_worker.ps1 +++ b/packaging/build_recipe_worker.ps1 @@ -1,5 +1,10 @@ param( - [switch]$SkipDependencyInstall + [switch]$SkipDependencyInstall, + [string]$SigningKeyPath, + [string]$SigningKeyId, + [string]$BundleVersion, + [int]$Sequence, + [string]$ManifestOutput ) $ErrorActionPreference = "Stop" @@ -25,5 +30,48 @@ if (-not (Test-Path -LiteralPath $Executable -PathType Leaf)) { throw "The recipe worker package did not produce recipe_worker.exe." } +# PyInstaller emits an empty PEP 376 REQUESTED marker for some transitive +# distributions. It carries no runtime bytes and cannot be represented by the +# positive-size signed manifest, so remove only that known generated marker. +Get-ChildItem -LiteralPath (Split-Path -Parent $Executable) -Recurse -File -Filter "REQUESTED" | + Where-Object { $_.Length -eq 0 -and $_.DirectoryName -like "*.dist-info" } | + ForEach-Object { Remove-Item -LiteralPath $_.FullName -Force } + Write-Host "Recipe worker contract package ready: $Executable" -Write-Host "This output is intentionally unsigned and not launch-authorized." + +$SigningRequested = -not [string]::IsNullOrWhiteSpace($SigningKeyPath) +$SigningOptionsPresent = $SigningRequested -or + -not [string]::IsNullOrWhiteSpace($SigningKeyId) -or + -not [string]::IsNullOrWhiteSpace($BundleVersion) -or + $Sequence -ne 0 -or + -not [string]::IsNullOrWhiteSpace($ManifestOutput) +if (-not $SigningRequested) { + if ($SigningOptionsPresent) { + throw "SigningKeyPath is required when any signing option is supplied." + } + Write-Host "This output is intentionally unsigned and not launch-authorized." + exit 0 +} +if ([string]::IsNullOrWhiteSpace($SigningKeyId) -or + [string]::IsNullOrWhiteSpace($BundleVersion) -or + $Sequence -lt 1) { + throw "Signing requires SigningKeyId, BundleVersion, and a positive Sequence." +} +if (-not (Test-Path -LiteralPath $SigningKeyPath -PathType Leaf)) { + throw "The external signing key file was not found." +} +if ([string]::IsNullOrWhiteSpace($ManifestOutput)) { + $ManifestOutput = Join-Path $RepositoryRoot "dist\recipe-runtime.manifest.json" +} +python tools/sign_recipe_worker.py ` + --source-root (Split-Path -Parent $Executable) ` + --private-key $SigningKeyPath ` + --key-id $SigningKeyId ` + --bundle-version $BundleVersion ` + --sequence $Sequence ` + --output-manifest $ManifestOutput +if ($LASTEXITCODE -ne 0) { + throw "The recipe worker manifest signing gate failed." +} +Write-Host "Signed recipe worker manifest ready: $ManifestOutput" +Write-Host "Installation still requires the pinned public-key trust root and SignedBundleInstaller." diff --git a/packaging/recipe_worker/README.md b/packaging/recipe_worker/README.md index d9c474c..47330c7 100644 --- a/packaging/recipe_worker/README.md +++ b/packaging/recipe_worker/README.md @@ -14,14 +14,28 @@ powershell -ExecutionPolicy Bypass -File packaging/build_recipe_worker.ps1 The output is `dist/recipe-runtime/recipe_worker.exe`. It is a dependency-closure artifact for qualification only and is **not signed or launch-authorized** by this -repository. Release packaging must, outside source control: +repository. The build remains unsigned by default. A release build may opt into the +external-key signing boundary: + +```powershell +powershell -ExecutionPolicy Bypass -File packaging/build_recipe_worker.ps1 ` + -SigningKeyPath C:\secure\recipe-worker-release.key ` + -SigningKeyId release-2026 ` + -BundleVersion 1.0.0 ` + -Sequence 1 +``` + +The key file must be an external raw 32-byte Ed25519 private key. The signer: 1. hash every immutable package entry; -2. create the exact `recipe.manifest.v1` entry with role `image_transform` and - path `recipe_worker.exe`; -3. sign the canonical manifest with the pinned release key; and -4. install it through `SignedBundleInstaller` before `verify_active_worker()` can - be considered. +2. creates one `recipe.manifest.v1` entry per file, marking `recipe_worker.exe` as + the sole `image_transform` role and all dependencies as inert `resource` entries; +3. signs and self-verifies the canonical manifest with the supplied release key; and +4. leaves installation to `SignedBundleInstaller` with its independently pinned + public-key trust root before `verify_active_worker()` can be considered. + +The private key is never persisted, emitted, or committed. A generated manifest is +release metadata, not launch authorization. The native launcher and live broker PID/AppContainer-token binding remain required before this package can perform any transform. The worker loop itself is covered diff --git a/tests/test_phase2_worker_release.py b/tests/test_phase2_worker_release.py new file mode 100644 index 0000000..00fe2dd --- /dev/null +++ b/tests/test_phase2_worker_release.py @@ -0,0 +1,185 @@ +"""Signed one-folder worker release and installer gate tests.""" + +from __future__ import annotations + +from hashlib import sha256 +import json +from pathlib import Path + +import pytest +from cryptography.hazmat.primitives import serialization +from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey + +from cortex_backend.execution.bundle_installer import SignedBundleInstaller +from cortex_backend.execution.manifest import TrustedRecipeKeys, verify_manifest_signature +from cortex_backend.execution.worker_provenance import verify_active_worker +from cortex_backend.execution.worker_release import ( + WorkerReleaseError, + build_signed_worker_manifest, +) +from tools.sign_recipe_worker import main as sign_worker_main + + +def _public_key(signer: Ed25519PrivateKey) -> bytes: + return signer.public_key().public_bytes( + serialization.Encoding.Raw, + serialization.PublicFormat.Raw, + ) + + +def test_release_manifest_covers_one_folder_resources_and_installs_exactly(tmp_path: Path): + signer = Ed25519PrivateKey.generate() + source = tmp_path / "recipe-runtime" + (source / "lib" / "nested").mkdir(parents=True) + (source / "recipe_worker.exe").write_bytes(b"signed worker bytes") + (source / "lib" / "codec.pyd").write_bytes(b"codec dependency") + (source / "lib" / "nested" / "metadata.dat").write_bytes(b"resource") + (source / "_internal" / "tzdata" / "GMT+0").parent.mkdir(parents=True) + (source / "_internal" / "tzdata" / "GMT+0").write_bytes(b"timezone resource") + + release = build_signed_worker_manifest( + source, + private_key_bytes=signer.private_bytes( + serialization.Encoding.Raw, + serialization.PrivateFormat.Raw, + serialization.NoEncryption(), + ), + key_id="release-1", + bundle_version="1.2.3", + sequence=1, + ) + + verified = verify_manifest_signature(release.manifest, TrustedRecipeKeys({"release-1": _public_key(signer)})) + assert verified.digest == release.manifest_digest + assert release.entry_count == 4 + assert {entry["entrypoint"] for entry in release.manifest["entries"]} == { + "image_transform", + "resource", + } + installer = SignedBundleInstaller( + tmp_path / "store", + TrustedRecipeKeys({"release-1": _public_key(signer)}), + ) + installed = installer.install(release.manifest, source) + worker = verify_active_worker(installer) + assert worker.worker_path == "recipe_worker.exe" + assert worker.worker_size == len(b"signed worker bytes") + assert worker.worker_sha256 == sha256(b"signed worker bytes").hexdigest() + assert (installed.bundle_root / "lib" / "codec.pyd").read_bytes() == b"codec dependency" + assert (installed.bundle_root / "lib" / "nested" / "metadata.dat").read_bytes() == b"resource" + assert (installed.bundle_root / "_internal" / "tzdata" / "GMT+0").read_bytes() == b"timezone resource" + + +@pytest.mark.parametrize( + ("mutator", "code"), + [ + (lambda root: (root / "manifest.json").write_text("{}", encoding="ascii"), "release_manifest_reserved"), + (lambda root: (root / "recipe_worker.exe").unlink(), "release_worker_missing"), + (lambda root: (root / "empty.dat").write_bytes(b""), "release_entry_size_invalid"), + ], +) +def test_release_manifest_rejects_incomplete_or_ambiguous_package( + tmp_path: Path, + mutator, + code: str, +): + signer = Ed25519PrivateKey.generate() + source = tmp_path / "recipe-runtime" + source.mkdir() + (source / "recipe_worker.exe").write_bytes(b"worker") + mutator(source) + with pytest.raises(WorkerReleaseError) as error: + build_signed_worker_manifest( + source, + private_key_bytes=signer.private_bytes( + serialization.Encoding.Raw, + serialization.PrivateFormat.Raw, + serialization.NoEncryption(), + ), + key_id="release-1", + bundle_version="1.0.0", + sequence=1, + ) + assert error.value.code == code + + +def test_release_manifest_rejects_invalid_external_key_material(tmp_path: Path): + source = tmp_path / "recipe-runtime" + source.mkdir() + (source / "recipe_worker.exe").write_bytes(b"worker") + with pytest.raises(WorkerReleaseError) as error: + build_signed_worker_manifest( + source, + private_key_bytes=b"not-a-private-key", + key_id="release-1", + bundle_version="1.0.0", + sequence=1, + ) + assert error.value.code == "release_signing_key_invalid" + + +def test_release_cli_writes_only_signed_manifest_metadata(tmp_path: Path, capsys): + signer = Ed25519PrivateKey.generate() + private_key = tmp_path / "release.key" + private_key.write_bytes( + signer.private_bytes( + serialization.Encoding.Raw, + serialization.PrivateFormat.Raw, + serialization.NoEncryption(), + ) + ) + source = tmp_path / "recipe-runtime" + source.mkdir() + (source / "recipe_worker.exe").write_bytes(b"worker") + output = tmp_path / "out" / "recipe-runtime.manifest.json" + + assert sign_worker_main( + [ + "--source-root", + str(source), + "--private-key", + str(private_key), + "--key-id", + "release-1", + "--bundle-version", + "1.0.0", + "--sequence", + "1", + "--output-manifest", + str(output), + "--json", + ] + ) == 0 + result = capsys.readouterr() + assert json.loads(result.out)["status"] == "signed" + assert private_key.read_bytes().hex() not in result.out + verified = verify_manifest_signature( + json.loads(output.read_text(encoding="ascii")), + TrustedRecipeKeys({"release-1": _public_key(signer)}), + ) + assert verified.manifest.entries[0].bundle_path == "recipe_worker.exe" + + +def test_release_root_reparse_alias_is_rejected_when_supported(tmp_path: Path): + source = tmp_path / "recipe-runtime" + source.mkdir() + (source / "recipe_worker.exe").write_bytes(b"worker") + alias = tmp_path / "alias" + try: + alias.symlink_to(source, target_is_directory=True) + except (OSError, NotImplementedError): + pytest.skip("directory symlinks are unavailable on this host") + signer = Ed25519PrivateKey.generate() + with pytest.raises(WorkerReleaseError) as error: + build_signed_worker_manifest( + alias, + private_key_bytes=signer.private_bytes( + serialization.Encoding.Raw, + serialization.PrivateFormat.Raw, + serialization.NoEncryption(), + ), + key_id="release-1", + bundle_version="1.0.0", + sequence=1, + ) + assert error.value.code == "release_root_invalid" diff --git a/tools/sign_recipe_worker.py b/tools/sign_recipe_worker.py new file mode 100644 index 0000000..c0bbd5e --- /dev/null +++ b/tools/sign_recipe_worker.py @@ -0,0 +1,142 @@ +"""Sign a built one-folder recipe worker without persisting private key material. + +The caller supplies an external raw Ed25519 private key file. The tool emits only +the signed manifest and bounded release metadata; installation still belongs to +``SignedBundleInstaller`` with an independently pinned public-key trust root. +""" + +from __future__ import annotations + +import argparse +import json +import os +from pathlib import Path +import sys +from typing import Any + +ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(ROOT / "backend")) + +from cortex_backend.execution.worker_release import ( # noqa: E402 + WorkerReleaseError, + _path_has_reparse_component, + build_signed_worker_manifest, +) + + +def _read_private_key(path: Path) -> bytes: + if _path_has_reparse_component(path): + raise WorkerReleaseError("release_signing_key_invalid") + try: + stat = path.stat() + if not path.is_file() or stat.st_size != 32 or int(getattr(stat, "st_nlink", 1)) != 1: + raise WorkerReleaseError("release_signing_key_invalid") + identity = ( + int(stat.st_size), + int(stat.st_mtime_ns), + int(stat.st_ctime_ns), + int(getattr(stat, "st_ino", 0)), + int(getattr(stat, "st_dev", 0)), + ) + key = path.read_bytes() + after = path.stat() + except WorkerReleaseError: + raise + except OSError: + raise WorkerReleaseError("release_signing_key_invalid") from None + if ( + len(key) != 32 + or identity + != ( + int(after.st_size), + int(after.st_mtime_ns), + int(after.st_ctime_ns), + int(getattr(after, "st_ino", 0)), + int(getattr(after, "st_dev", 0)), + ) + ): + raise WorkerReleaseError("release_signing_key_changed") + return key + + +def _write_manifest(path: Path, payload: dict[str, Any]) -> None: + if _path_has_reparse_component(path): + raise WorkerReleaseError("release_manifest_write_failed") + if path.exists() or path.is_symlink() or getattr(path, "is_junction", lambda: False)(): + raise WorkerReleaseError("release_manifest_exists") + temporary: Path | None = None + created = False + try: + path.parent.mkdir(parents=True, exist_ok=True) + if _path_has_reparse_component(path): + raise WorkerReleaseError("release_manifest_write_failed") + temporary = path.with_name(f".{path.name}.staging-{os.getpid()}") + if temporary.exists() or temporary.is_symlink(): + raise WorkerReleaseError("release_manifest_exists") + created = True + temporary.write_text( + json.dumps(payload, ensure_ascii=True, sort_keys=True, separators=(",", ":")), + encoding="ascii", + ) + os.replace(temporary, path) + except WorkerReleaseError: + raise + except OSError: + raise WorkerReleaseError("release_manifest_write_failed") from None + finally: + if created and temporary is not None and temporary.exists(): + try: + temporary.unlink() + except OSError: + pass + + +def main(argv: list[str] | None = None) -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--source-root", type=Path, required=True) + parser.add_argument("--private-key", type=Path, required=True) + parser.add_argument("--key-id", required=True) + parser.add_argument("--bundle-version", required=True) + parser.add_argument("--sequence", type=int, required=True) + parser.add_argument("--output-manifest", type=Path, required=True) + parser.add_argument("--json", action="store_true", help="Emit bounded metadata as JSON.") + args = parser.parse_args(argv) + try: + source_root = args.source_root + if _path_has_reparse_component(args.output_manifest): + raise WorkerReleaseError("release_manifest_write_failed") + try: + source_resolved = source_root.resolve(strict=True) + output_manifest = args.output_manifest.resolve() + except (OSError, RuntimeError): + raise WorkerReleaseError("release_root_unavailable") from None + if output_manifest == source_resolved or output_manifest.is_relative_to(source_resolved): + raise WorkerReleaseError("release_manifest_inside_source") + release = build_signed_worker_manifest( + source_root, + private_key_bytes=_read_private_key(args.private_key), + key_id=args.key_id, + bundle_version=args.bundle_version, + sequence=args.sequence, + ) + _write_manifest(output_manifest, release.manifest) + except WorkerReleaseError as error: + if args.json: + print(json.dumps({"status": "blocked", "code": error.code}, separators=(",", ":"))) + else: + print(f"release blocked: {error.code}", file=sys.stderr) + return 2 + result = { + "status": "signed", + "entry_count": release.entry_count, + "manifest_digest": release.manifest_digest, + "worker_size": release.worker_size, + "worker_sha256": release.worker_sha256, + "manifest": str(output_manifest), + } + print(json.dumps(result, separators=(",", ":") if args.json else None, sort_keys=True)) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main())