diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6991b07f..487a688e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -558,10 +558,42 @@ jobs: - name: Test upgrade exercise validator run: python3 -m unittest release/scripts/test_validate_upgrade_exercise.py - - name: Validate upgrade exercise template + - name: Test upgrade exercise asset preparation + run: python3 -m unittest release/scripts/test_prepare_upgrade_exercise_assets.py + + - name: Prepare committed upgrade candidate assets + id: upgrade-assets + env: + GH_TOKEN: ${{ github.token }} run: >- - python3 release/scripts/validate-upgrade-exercise.py --template - release/exercises/upgrade-exercise-v1.template.json + python3 release/scripts/prepare-upgrade-exercise-assets.py + --discover release/exercises + --asset-root target/upgrade-exercise-assets + --github-output "${GITHUB_OUTPUT}" + + - name: Install cosign for committed upgrade evidence + if: steps.upgrade-assets.outputs.has_candidates == 'true' + uses: sigstore/cosign-installer@398d4b0eeef1380460a10c8013a76f728fb906ac # v3 + + - name: Install SLSA verifier for committed upgrade evidence + if: steps.upgrade-assets.outputs.has_candidates == 'true' + env: + SLSA_VERIFIER_SHA256: 946dbec729094195e88ef78e1734324a27869f03e2c6bd2f61cbc06bd5350339 + SLSA_VERIFIER_VERSION: v2.7.1 + shell: bash + run: | + set -euo pipefail + tools_dir="${RUNNER_TEMP}/upgrade-evidence-tools" + mkdir -p "${tools_dir}" + curl --proto '=https' --tlsv1.2 --fail --location --retry 3 \ + --output "${tools_dir}/slsa-verifier" \ + "https://github.com/slsa-framework/slsa-verifier/releases/download/${SLSA_VERIFIER_VERSION}/slsa-verifier-linux-amd64" + echo "${SLSA_VERIFIER_SHA256} ${tools_dir}/slsa-verifier" | sha256sum --check + chmod 0755 "${tools_dir}/slsa-verifier" + echo "${tools_dir}" >> "${GITHUB_PATH}" + + - name: Validate committed upgrade exercise records + run: python3 release/scripts/validate-upgrade-exercise.py --discover release/exercises --candidate-asset-root target/upgrade-exercise-assets - name: Stable surface compatibility env: diff --git a/release/conformance/openid/README.md b/release/conformance/openid/README.md index 19b010f8..99dcb766 100644 --- a/release/conformance/openid/README.md +++ b/release/conformance/openid/README.md @@ -22,8 +22,9 @@ themselves: - The supported Registry Notary topology must use a frozen release-candidate image pinned by digest and checked-in non-secret configuration. -- The full OID4VCI issuer plan needs an adapter that sends the issuer-initiated - credential offer to the suite's `/credential_offer` callback. +- The owner-only `submit-offer` adapter can send the real issuer-initiated + pre-authorized offer to the suite's `/credential_offer` callback without + exposing it in process arguments or command output. - The upstream full-plan shape currently selects DPoP. Registry Notary 1.0 does not support or claim DPoP, wallet attestation, PAR, EUDI, HAIP, an authorization-code wallet grant, or ES256 holder proof. @@ -45,9 +46,10 @@ and unmodified result status without retaining secrets. Notary's registry-backed OID4VCI issuer. It runs `oid4vci-1_0-issuer-test-plan` with only `oid4vci-1_0-issuer-metadata-test`. -- `notary-oid4vci-issuer-full` is mapped but blocked until a topology adapter - bridges a Notary pre-authorized offer into the suite callback and the suite - path matches the supported Registry Notary profile. +- `notary-oid4vci-issuer-full` is mapped but blocked until the suite path + matches the supported Registry Notary profile. The offer adapter closes only + the callback transport gap; it does not add DPoP, attestation, batch, + notification, or other unsupported product behavior. The suite's `sender_constrain=dpop` selector is required by the upstream plan shape. The metadata-only module does not exercise DPoP, and the selector must @@ -91,6 +93,33 @@ Candidate-only scenarios are directly runnable. `--allow-blocked` is reserved for deliberate investigation of scenarios whose status is explicitly blocked; it does not turn their output into release evidence. +For an issuer-initiated suite module, store the exact +`openid-credential-offer` URI rendered after Notary completes its authenticated +`/oid4vci/offer/callback` in an owner-only file. After `up`, export the exact +self-signed certificate generated for the suite's Nginx service from +`/etc/ssl/certs/nginx-selfsigned.crt`, then submit the offer: + +```bash +release/scripts/openid-conformance-runner.py export-suite-ca \ + --output target/openid-conformance/conformance-suite-ca.pem + +chmod 600 /private/path/notary-offer.txt +release/scripts/openid-conformance-runner.py submit-offer \ + --offer-file /private/path/notary-offer.txt \ + --issuer-url https://issuer.example.test \ + --suite-offer-endpoint 'https://localhost.emobix.co.uk:8443//credential_offer' \ + --suite-ca-certificate target/openid-conformance/conformance-suite-ca.pem +``` + +The adapter accepts only an inline Notary offer with the pre-authorized-code +grant, sends it once to the pinned suite origin without proxies or redirects, +and prints no offer content. TLS uses normal hostname and certificate +validation. The checked-in certificate recipe covers +`localhost.emobix.co.uk`, `localhost`, `127.0.0.1`, and `::1`. The optional CA +file is read once without following symlinks and adds only that explicitly +captured local trust anchor. The export command refuses to overwrite an +existing output. A fabricated offer is not candidate evidence. + Set `REGISTRY_OPENID_CONFORMANCE_AUTHORIZATION_SERVER` when the authorization server differs from the issuer. Set `REGISTRY_OPENID_CONFORMANCE_CREDENTIAL_CONFIGURATION_ID` when the topology @@ -131,7 +160,17 @@ proof JWTs, issued credentials, transaction codes, or seeded civil identifiers. Review and redact an export before turning it into release evidence. A failed or warned result must remain visible in the reviewed summary. +The current runner keeps raw exports private but does not yet generate the +candidate-bound allowlisted summary or review promotion required for release +evidence. Until that source-side gate and a real frozen-candidate exercise +exist, its output remains candidate-exercise material rather than a completed +conformance claim. + The first metadata-only run and its known failures are recorded in [`initial-report.md`](initial-report.md). It is historical context only. It is not evidence for the current candidate, any wallet, any verifier, or the full issuer profile. + +The Rust SD-JWT verifier is a caller-invoked library, not an OID4VP endpoint. +Its library and fixture tests therefore do not support an OID4VP verifier +conformance claim. diff --git a/release/conformance/openid/nginx.Dockerfile b/release/conformance/openid/nginx.Dockerfile index 92491a19..fe87e108 100644 --- a/release/conformance/openid/nginx.Dockerfile +++ b/release/conformance/openid/nginx.Dockerfile @@ -3,5 +3,9 @@ FROM nginx:1.27.3@sha256:bc2f6a7c8ddbccf55bdb19659ce3b0a92ca6559e86d42677a5a02ef RUN openssl req -x509 -nodes -days 3650 -newkey rsa:2048 \ -keyout /etc/ssl/private/nginx-selfsigned.key \ -out /etc/ssl/certs/nginx-selfsigned.crt \ - -subj "/CN=localhost" + -subj "/CN=localhost.emobix.co.uk" \ + -addext "subjectAltName=DNS:localhost.emobix.co.uk,DNS:localhost,IP:127.0.0.1,IP:::1" \ + -addext "basicConstraints=critical,CA:TRUE" \ + -addext "keyUsage=critical,digitalSignature,keyEncipherment,keyCertSign" \ + -addext "extendedKeyUsage=serverAuth" COPY nginx.conf /etc/nginx/nginx.conf diff --git a/release/conformance/openid/plan-map.json b/release/conformance/openid/plan-map.json index 57b9f84a..fe00f3de 100644 --- a/release/conformance/openid/plan-map.json +++ b/release/conformance/openid/plan-map.json @@ -51,7 +51,7 @@ { "id": "notary-oid4vci-issuer-full", "surface": "Registry Notary registry-backed OID4VCI issuer", - "status": "blocked-by-suite-profile-and-offer-adapter", + "status": "blocked-by-suite-profile", "suite_plan": "oid4vci-1_0-issuer-test-plan", "suite_modules": [], "variants": { @@ -66,14 +66,21 @@ "vci_authorization_code_flow_variant": "issuer_initiated" }, "config_template": "registry-notary-oid4vci-issuer.template.json", + "offer_adapter": { + "command": "release/scripts/openid-conformance-runner.py submit-offer", + "input": "The owner-only openid-credential-offer URI rendered only after Registry Notary completes its authenticated /oid4vci/offer/callback and registry-backed evaluation.", + "output": "One no-redirect GET to the pinned suite module's exposed /credential_offer endpoint with the unchanged inline credential_offer value." + }, "requires": [ "A frozen Registry Notary release-candidate topology with a suite-reachable issuer URL.", - "A bridge that sends the issuer-initiated Notary pre-authorized offer to the suite's exposed /credential_offer endpoint.", + "The candidate-neutral submit-offer adapter must receive the real owner-only Notary offer URI produced after the authenticated callback; a fabricated offer does not qualify.", "A reviewed suite path that does not require DPoP, wallet attestation, PAR, an authorization-code wallet grant, ES256 holder proof, EUDI, or HAIP behavior outside Registry Notary 1.0." ], "notes": [ "Registry Notary supports issuer-initiated pre-authorized code only. The identity provider's authorization code is internal to Notary and is not a wallet grant.", + "The pinned suite accepts issuer-initiated pre-authorized offers at its exposed /credential_offer endpoint. The adapter now closes that transport gap without adding a Registry Notary product endpoint.", "The checked-in sender_constrain=dpop variant is required by this upstream suite plan shape and does not represent Registry Notary DPoP support.", + "The full plan remains blocked because its current module and variant roster exercises unsupported DPoP, attestation, batch, notification, and other behavior outside the Registry Notary 1.0 profile.", "Do not treat this blocked mapping, a development topology, or historical wallet smoke output as certification evidence." ] } @@ -84,6 +91,11 @@ "evidence": "The release-owned candidate-neutral Relay and Zitadel smoke is directly runnable against a published Relay image digest. Its live output requires candidate binding and maintainer review before it becomes release evidence.", "reason": "The OIDF suite does not publish a generic resource-server conformance plan for a Relay-style protected API. The separate release topology verifies discovery, signature, audience, token type, and native Zitadel role-object scope mapping." }, + { + "surface": "Registry Notary Rust SD-JWT verifier", + "evidence": "Rust library and fixture interoperability tests only; no OIDF OID4VP verifier-plan result applies to this surface.", + "reason": "The Rust SD-JWT verifier is a caller-invoked verification library, not an OID4VP endpoint. Registry Stack 1.0 therefore makes no OID4VP verifier conformance claim for it." + }, { "surface": "Third-party OpenID Providers used by demonstration topologies", "evidence": "Out of Registry Stack conformance scope.", diff --git a/release/conformance/relay-oidc/README.md b/release/conformance/relay-oidc/README.md index 01ab0696..45668fd1 100644 --- a/release/conformance/relay-oidc/README.md +++ b/release/conformance/relay-oidc/README.md @@ -6,20 +6,25 @@ resource server with `auth.mode: oidc`. It does not build Relay from the source checkout and does not depend on a hosted environment or Solmara Lab. The topology uses digest-pinned Zitadel, PostgreSQL, and Python images. The -runner accepts Relay only as the exact image reference -`ghcr.io/registrystack/registry-relay@sha256:`. The operator-supplied -candidate source commit and release identifier are mandatory and recorded for -later comparison with the release manifest. The runner does not derive or -cryptographically verify either identifier from the image. A maintainer must -perform that manifest binding before treating a report as release evidence. +runner resolves Relay from the exact release manifest and matching +`registryctl--image-lock.json` release asset. It rejects a source-ref, +release-tag, product-version, or image-digest mismatch before starting Docker. +The image lock must remain in its downloaded release asset directory alongside +`SHA256SUMS`, the release capsule, the shared release provenance, and the +Cosign signature and certificate files for both the image lock and capsule. +The runner verifies those bindings with installed `cosign` and `slsa-verifier` +before using either product image digest. +For a closed release manifest, it accepts only the byte-exact tag manifest with +the single `stack.status: release-candidate` to `released` closeout transition. +Any other post-tag manifest drift is rejected. ## Evidence boundary The checked-in assets and their offline tests prove that the harness is reviewable and candidate-neutral. They are not live release evidence. A live run writes a report classified as `unreviewed-live-candidate-output` with -`review_required: true`. A maintainer must bind that report to the published -candidate manifest and review it before it can become release evidence. +`review_required: true`. A maintainer must review the embedded candidate +binding and results before the report can become release evidence. The report contains identifiers, configuration and topology digests, bounded diagnostics, and assertion results. It never contains the bootstrap PAT, client @@ -69,21 +74,18 @@ HTTP issuer and discovery URL on loopback, which is the only insecure fetch form Relay permits for local development. The Relay API is published separately on a randomly selected loopback port. -## Offline review +## Candidate review Python 3.11 or later is required. Validate the checked-in topology and render a -candidate-bound plan without Docker or network access: +candidate-bound plan without Docker. Candidate binding invokes `cosign` and +`slsa-verifier`, which may use their normal verification network paths: ```bash -RELAY_IMAGE='ghcr.io/registrystack/registry-relay@sha256:'\ -'<64-lowercase-hex>' - release/scripts/relay-oidc-smoke.py validate release/scripts/relay-oidc-smoke.py plan \ - --relay-image "$RELAY_IMAGE" \ - --candidate-source-ref '<40-lowercase-hex-commit>' \ - --release-id '1.0.0-rc.1' + --release-manifest 'release/manifests/registry-stack-.yaml' \ + --image-lock '/private/path/registryctl-v-image-lock.json' ``` The plan deliberately records `live_evidence: false`. @@ -94,13 +96,9 @@ Docker with Docker Compose is required. The Relay image must already be published by digest: ```bash -RELAY_IMAGE='ghcr.io/registrystack/registry-relay@sha256:'\ -'<64-lowercase-hex>' - release/scripts/relay-oidc-smoke.py run \ - --relay-image "$RELAY_IMAGE" \ - --candidate-source-ref '<40-lowercase-hex-commit>' \ - --release-id '1.0.0-rc.1' + --release-manifest 'release/manifests/registry-stack-.yaml' \ + --image-lock '/private/path/registryctl-v-image-lock.json' ``` The command prints only the path to the unreviewed report. Use `--output-dir` @@ -108,6 +106,11 @@ to choose an empty report directory and `--host-port` only when a fixed free loopback port is required. The default output is under `target/relay-oidc-smoke/`, which Git ignores. +The release-owned topology is the default. An optional Solmara adopter +exercise must add +`--topology solmara --solmara-source-ref '<40-lowercase-hex-commit>'`; +an unpinned Solmara checkout is rejected. + If teardown fails, treat the run as an error. The diagnostic includes the exact random Compose project name only in the local command output, so an operator can inspect and remove that isolated project without risking unrelated Docker diff --git a/release/exercises/README.md b/release/exercises/README.md index 34fb56a2..a7e61351 100644 --- a/release/exercises/README.md +++ b/release/exercises/README.md @@ -28,16 +28,38 @@ release are frozen and independently verified: 1. Copy the template to a candidate-specific JSON file. 2. Change `record_kind` to `candidate_evidence`. 3. Replace every placeholder with an exact version, commit, digest, timestamp, - bounded authority identifier, or evidence label. + bounded authority identifier, or evidence label. `target_release.source_ref` + is the reviewed prepare commit P and `source_commit` is the finalized target + T. The manifest path and hash must identify the manifest stored at T. 4. Hash each committed configuration schema and every complete recovery-set artifact. Do not copy secret values into the record. -5. Exercise every required check against the pinned standalone Solmara +5. Fill the canonical artifact set with the two P and T binary inventories, + image-input inventories, retained image-layout-pair identities, target + images, manifest, image lock, and P/T release-input identities. Its + `sha256` is the SHA-256 of canonical compact JSON for the `artifacts` object + (`sort_keys=True`, separators `,` and `:`). + Under a private candidate-asset root, keep one directory named for each + target version. Each version directory contains the downloaded + `registryctl--image-lock.json` beside its `SHA256SUMS`, + signed release capsule, Cosign signatures and certificates, and shared SLSA + provenance. The validator authenticates each exact release asset and + requires its byte digest and image pins to match the corresponding record. +6. Exercise every required check against the pinned standalone Solmara topology. Record `passed` only when the retained evidence proves the check. -6. Set `candidate_frozen` and `candidate_independently_verified` to `true`. -7. Validate the candidate record without `--template`: + Honest `failed` and `not_run` records remain structurally valid; a `not_run` + result uses null evidence fields. +7. Set both candidate attestations to `true` only after independent review. +8. Validate the record structure, then require every promotion check to pass: ```sh + python3 release/scripts/prepare-upgrade-exercise-assets.py \ + --discover release/exercises \ + --asset-root /private/path/candidate-release-assets python3 release/scripts/validate-upgrade-exercise.py \ + --candidate-asset-root /private/path/candidate-release-assets \ + release/exercises/ + python3 release/scripts/validate-upgrade-exercise.py --require-pass \ + --candidate-asset-root /private/path/candidate-release-assets \ release/exercises/ ``` diff --git a/release/exercises/upgrade-exercise-v1.template.json b/release/exercises/upgrade-exercise-v1.template.json index 2236b93b..b6c03f42 100644 --- a/release/exercises/upgrade-exercise-v1.template.json +++ b/release/exercises/upgrade-exercise-v1.template.json @@ -11,11 +11,16 @@ }, "target_release": { "version": "", + "release_id": "", + "source_ref": "", "source_commit": "", "relay_image_digest": "", "notary_image_digest": "" }, - "target_release_manifest_sha256": "", + "target_release_manifest": { + "path": "", + "sha256": "" + }, "candidate_frozen": false, "candidate_independently_verified": false, "config_schemas": { @@ -28,6 +33,29 @@ "sha256": "" } }, + "candidate_artifact_set": { + "sha256": "", + "artifacts": { + "p1_binaries": "", + "p1_image_inputs": "", + "p2_binaries": "", + "p2_image_inputs": "", + "t1_binaries": "", + "t1_image_inputs": "", + "t2_binaries": "", + "t2_image_inputs": "", + "p_notary_layouts": "", + "p_relay_layouts": "", + "t_notary_layouts": "", + "t_relay_layouts": "", + "image_lock": "", + "manifest": "", + "notary_image": "", + "relay_image": "", + "p_release_inputs": "", + "t_release_inputs": "" + } + }, "topology": { "repository": "registrystack/solmara-lab", "release_tag": "", diff --git a/release/scripts/check-gates-inventory.py b/release/scripts/check-gates-inventory.py index 3d4837d6..0d7f389b 100644 --- a/release/scripts/check-gates-inventory.py +++ b/release/scripts/check-gates-inventory.py @@ -182,8 +182,20 @@ "run: python3 -m unittest release/scripts/test_validate_upgrade_exercise.py", ), ( - "Upgrade exercise template validation", - "python3 release/scripts/validate-upgrade-exercise.py --template", + "Upgrade exercise asset preparation tests", + "run: python3 -m unittest release/scripts/test_prepare_upgrade_exercise_assets.py", + ), + ( + "Upgrade exercise candidate asset preparation", + "python3 release/scripts/prepare-upgrade-exercise-assets.py\n" + " --discover release/exercises\n" + " --asset-root target/upgrade-exercise-assets", + ), + ( + "Upgrade exercise record discovery", + "python3 release/scripts/validate-upgrade-exercise.py --discover " + "release/exercises --candidate-asset-root " + "target/upgrade-exercise-assets", ), ( "Base-reference compatibility input", diff --git a/release/scripts/conformance_candidate.py b/release/scripts/conformance_candidate.py new file mode 100644 index 00000000..df9655f2 --- /dev/null +++ b/release/scripts/conformance_candidate.py @@ -0,0 +1,683 @@ +#!/usr/bin/env python3 +# SPDX-License-Identifier: Apache-2.0 +"""Resolve one immutable Registry Stack candidate for conformance evidence.""" + +from __future__ import annotations + +import hashlib +import json +import os +import re +import shutil +import stat +import subprocess +import tempfile +from contextlib import contextmanager +from pathlib import Path +from typing import Any, Callable, Iterator + +import yaml + + +REPO_ROOT = Path(__file__).resolve().parents[2] +MANIFEST_DIR = REPO_ROOT / "release" / "manifests" +COMMIT = re.compile(r"^[0-9a-f]{40}$") +RELEASE_ID = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$") +IMAGE_LOCK_FILE = re.compile( + r"^registryctl-(v[A-Za-z0-9][A-Za-z0-9._+-]*)-image-lock\.json$" +) +IMAGE_REPOSITORIES = { + "registry-notary": "ghcr.io/registrystack/registry-notary", + "registry-relay": "ghcr.io/registrystack/registry-relay", +} +CAPSULE_REPOSITORY = "registrystack/registry-stack" +SLSA_SOURCE_URI = "github.com/registrystack/registry-stack" +RELEASE_WORKFLOW = ( + "https://github.com/registrystack/registry-stack/.github/workflows/" + "release.yml@refs/tags/{tag}" +) + + +class CandidateError(RuntimeError): + """Candidate inputs are mutable, malformed, or disagree.""" + + +def normalized_absolute_path(path: Path) -> Path: + return Path(os.path.abspath(path.expanduser())) + + +def require_real_directory(path: Path) -> None: + try: + info = path.lstat() + except OSError as exc: + raise CandidateError( + f"candidate input directory is unavailable: {path}: {exc}" + ) from exc + if stat.S_ISLNK(info.st_mode) or not stat.S_ISDIR(info.st_mode): + raise CandidateError( + "candidate input directory must be a real, non-symlink directory" + ) + + +def open_directory_no_follow(path: Path) -> int: + require_real_directory(path) + no_follow = getattr(os, "O_NOFOLLOW", None) + directory_flag = getattr(os, "O_DIRECTORY", None) + if no_follow is None or directory_flag is None: + raise CandidateError( + "candidate snapshotting requires O_NOFOLLOW and O_DIRECTORY" + ) + try: + return os.open( + path, + os.O_RDONLY | os.O_CLOEXEC | no_follow | directory_flag, + ) + except OSError as exc: + raise CandidateError( + f"candidate input directory could not be opened safely: {exc}" + ) from exc + + +def read_regular_file_at( + directory_fd: int, + name: str, + *, + max_bytes: int, +) -> bytes: + no_follow = getattr(os, "O_NOFOLLOW", None) + if no_follow is None: + raise CandidateError("candidate snapshotting requires O_NOFOLLOW") + source_fd = None + try: + source_fd = os.open( + name, + os.O_RDONLY | os.O_CLOEXEC | os.O_NONBLOCK | no_follow, + dir_fd=directory_fd, + ) + before = os.fstat(source_fd) + if not stat.S_ISREG(before.st_mode): + raise CandidateError( + f"candidate input must be a regular, non-symlink file: {name}" + ) + if before.st_size <= 0 or before.st_size > max_bytes: + raise CandidateError( + f"candidate input size is invalid: {name}" + ) + with os.fdopen(source_fd, "rb") as source: + source_fd = None + content = source.read(max_bytes + 1) + after = os.fstat(source.fileno()) + identity_before = ( + before.st_dev, + before.st_ino, + before.st_size, + before.st_mtime_ns, + before.st_ctime_ns, + ) + identity_after = ( + after.st_dev, + after.st_ino, + after.st_size, + after.st_mtime_ns, + after.st_ctime_ns, + ) + if ( + len(content) != before.st_size + or len(content) > max_bytes + or identity_before != identity_after + ): + raise CandidateError( + f"candidate input changed while snapshotting: {name}" + ) + return content + except OSError as exc: + raise CandidateError( + f"candidate input could not be read safely: {name}: {exc}" + ) from exc + finally: + if source_fd is not None: + os.close(source_fd) + + +def read_regular_file_no_follow(path: Path, *, max_bytes: int) -> bytes: + path = normalized_absolute_path(path) + directory_fd = open_directory_no_follow(path.parent) + try: + return read_regular_file_at( + directory_fd, + path.name, + max_bytes=max_bytes, + ) + finally: + os.close(directory_fd) + + +def candidate_asset_limits(tag: str, lock_name: str) -> dict[str, int]: + capsule_name = f"registry-stack-{tag}-release-capsule.json" + return { + lock_name: 1024 * 1024, + f"{lock_name}.sig": 1024 * 1024, + f"{lock_name}.pem": 1024 * 1024, + capsule_name: 8 * 1024 * 1024, + f"{capsule_name}.sig": 1024 * 1024, + f"{capsule_name}.pem": 1024 * 1024, + f"registry-stack-{tag}-release-provenance.intoto.jsonl": ( + 128 * 1024 * 1024 + ), + "SHA256SUMS": 1024 * 1024, + } + + +@contextmanager +def candidate_asset_snapshot( + image_lock_path: Path, +) -> Iterator[tuple[Path, bytes]]: + """Capture one candidate so parsing and verification cannot see different bytes. + + The source directory is operator-supplied and remains writable. The + security invariant is that passive parsing and external authenticity tools + consume the same private, read-only files even if those source paths are + replaced concurrently. + """ + image_lock_path = normalized_absolute_path(image_lock_path) + match = IMAGE_LOCK_FILE.fullmatch(image_lock_path.name) + if match is None: + raise CandidateError("release image-lock filename is invalid") + tag = match.group(1) + directory_fd = open_directory_no_follow(image_lock_path.parent) + try: + with tempfile.TemporaryDirectory( + prefix="registry-conformance-candidate-" + ) as temporary: + snapshot = Path(temporary) + snapshot_fd = open_directory_no_follow(snapshot) + captured: dict[str, bytes] = {} + try: + for name, max_bytes in candidate_asset_limits( + tag, image_lock_path.name + ).items(): + content = read_regular_file_at( + directory_fd, + name, + max_bytes=max_bytes, + ) + destination_fd = None + try: + destination_fd = os.open( + name, + os.O_WRONLY + | os.O_CREAT + | os.O_EXCL + | os.O_CLOEXEC + | os.O_NOFOLLOW, + 0o600, + dir_fd=snapshot_fd, + ) + with os.fdopen(destination_fd, "wb") as output: + destination_fd = None + output.write(content) + os.fchmod(output.fileno(), 0o400) + finally: + if destination_fd is not None: + os.close(destination_fd) + captured[name] = content + os.fchmod(snapshot_fd, 0o500) + try: + yield snapshot, captured[image_lock_path.name] + finally: + os.fchmod(snapshot_fd, 0o700) + finally: + os.close(snapshot_fd) + except OSError as exc: + raise CandidateError( + f"could not create private candidate snapshot: {exc}" + ) from exc + finally: + os.close(directory_fd) + + +def git_output(arguments: list[str], max_bytes: int) -> bytes: + try: + result = subprocess.run( + ["git", *arguments], + cwd=REPO_ROOT, + stdout=subprocess.PIPE, + stderr=subprocess.DEVNULL, + check=False, + timeout=10, + ) + except (OSError, subprocess.SubprocessError): + raise CandidateError("candidate Git binding could not be verified") from None + if result.returncode != 0 or len(result.stdout) > max_bytes: + raise CandidateError("candidate Git binding could not be verified") + return result.stdout + + +def verify_git_binding( + stack: dict[str, Any], + lock: dict[str, Any], + manifest_path: Path, + manifest_bytes: bytes, +) -> None: + tag_target = lock["tag_target"] + tag_ref = f"refs/tags/{stack['source_tag']}^{{commit}}" + resolved = git_output(["rev-parse", "--verify", tag_ref], 41) + if resolved.strip() != tag_target.encode("ascii"): + raise CandidateError("candidate Git binding could not be verified") + git_output( + ["merge-base", "--is-ancestor", stack["source_ref"], tag_target], + 0, + ) + relative_path = manifest_path.relative_to(REPO_ROOT).as_posix() + object_name = f"{tag_target}:{relative_path}" + try: + object_size = int(git_output(["cat-file", "-s", object_name], 16)) + except ValueError: + raise CandidateError("candidate Git binding could not be verified") from None + if not 0 < object_size <= 1024 * 1024: + raise CandidateError("candidate Git binding could not be verified") + tagged_manifest_bytes = git_output(["show", object_name], object_size) + if len(tagged_manifest_bytes) != object_size: + raise CandidateError("candidate Git binding could not be verified") + verify_closeout_manifest_transition( + stack, manifest_bytes, tagged_manifest_bytes + ) + + +def verify_closeout_manifest_transition( + stack: dict[str, Any], + manifest_bytes: bytes, + tagged_manifest_bytes: bytes, +) -> None: + try: + tagged_manifest = yaml.safe_load(tagged_manifest_bytes.decode("utf-8")) + except (UnicodeDecodeError, yaml.YAMLError): + raise CandidateError("candidate Git binding could not be verified") from None + tagged_stack = ( + tagged_manifest.get("stack") + if isinstance(tagged_manifest, dict) + else None + ) + if ( + not isinstance(tagged_stack, dict) + or tagged_stack.get("status") != "release-candidate" + ): + raise CandidateError("candidate Git binding could not be verified") + if manifest_bytes == tagged_manifest_bytes: + if stack.get("status") != "release-candidate": + raise CandidateError("candidate Git binding could not be verified") + return + released_line = b" status: released\n" + candidate_line = b" status: release-candidate\n" + if ( + stack.get("status") != "released" + or manifest_bytes.count(released_line) != 1 + or tagged_manifest_bytes.count(candidate_line) != 1 + or manifest_bytes.replace(released_line, candidate_line, 1) + != tagged_manifest_bytes + ): + raise CandidateError("candidate Git binding could not be verified") + + +def require_regular_file(path: Path, *, max_bytes: int) -> Path: + path = path.expanduser() + try: + info = path.lstat() + except OSError as exc: + raise CandidateError(f"required file is unavailable: {path}: {exc}") from exc + if stat.S_ISLNK(info.st_mode) or not stat.S_ISREG(info.st_mode): + raise CandidateError(f"required path must be a regular file: {path}") + if not 0 < info.st_size <= max_bytes: + raise CandidateError(f"required file has an invalid size: {path}") + return path.resolve() + + +def sha256(path: Path) -> str: + return hashlib.sha256(path.read_bytes()).hexdigest() + + +def parse_checksums(path: Path) -> dict[str, str]: + path = require_regular_file(path, max_bytes=1024 * 1024) + checksums: dict[str, str] = {} + for line_number, line in enumerate( + path.read_text(encoding="utf-8").splitlines(), 1 + ): + match = re.fullmatch(r"([0-9a-f]{64}) \*?([^/\x00]+)", line) + if match is None: + raise CandidateError( + f"SHA256SUMS line {line_number} has an unsafe format" + ) + digest, name = match.groups() + if name in checksums: + raise CandidateError(f"SHA256SUMS contains duplicate entry {name}") + checksums[name] = digest + return checksums + + +def find_named(items: Any, name: str, label: str) -> dict[str, Any]: + if not isinstance(items, list) or any( + not isinstance(item, dict) for item in items + ): + raise CandidateError(f"release capsule {label} must be an object array") + matches = [item for item in items if item.get("name") == name] + if len(matches) != 1: + raise CandidateError( + f"release capsule {label} must contain exactly one {name}" + ) + return matches[0] + + +def run_authenticity_command(command: list[str]) -> None: + try: + result = subprocess.run( + command, + text=True, + capture_output=True, + check=False, + timeout=120, + ) + except (OSError, subprocess.SubprocessError): + raise CandidateError( + f"candidate authenticity command could not complete ({command[0]})" + ) from None + if result.returncode != 0: + detail = (result.stderr or result.stdout).strip().splitlines() + suffix = f": {detail[-1]}" if detail else "" + raise CandidateError( + f"candidate authenticity command failed ({command[0]}){suffix}" + ) + + +def verify_release_authenticity( + asset_dir: Path, + tag: str, + subjects: tuple[str, ...], + *, + command_runner: Callable[[list[str]], None] = run_authenticity_command, +) -> None: + cosign = shutil.which("cosign") + slsa = shutil.which("slsa-verifier") + if not cosign or not slsa: + missing = [ + name + for name, path in (("cosign", cosign), ("slsa-verifier", slsa)) + if not path + ] + raise CandidateError( + "candidate authenticity verification requires installed " + + " and ".join(missing) + ) + provenance = require_regular_file( + asset_dir / f"registry-stack-{tag}-release-provenance.intoto.jsonl", + max_bytes=128 * 1024 * 1024, + ) + identity = RELEASE_WORKFLOW.format(tag=tag) + for name in subjects: + subject = require_regular_file( + asset_dir / name, max_bytes=128 * 1024 * 1024 + ) + signature = require_regular_file( + asset_dir / f"{name}.sig", max_bytes=1024 * 1024 + ) + certificate = require_regular_file( + asset_dir / f"{name}.pem", max_bytes=1024 * 1024 + ) + command_runner( + [ + cosign, + "verify-blob", + str(subject), + "--signature", + str(signature), + "--certificate", + str(certificate), + "--certificate-oidc-issuer", + "https://token.actions.githubusercontent.com", + "--certificate-identity", + identity, + ] + ) + command_runner( + [ + slsa, + "verify-artifact", + str(subject), + "--provenance-path", + str(provenance), + "--source-uri", + SLSA_SOURCE_URI, + "--source-tag", + tag, + ] + ) + + +def verify_release_asset_binding( + stack: dict[str, Any], + lock: dict[str, Any], + image_lock_path: Path, + image_lock_sha256: str, +) -> str: + tag = lock["release_tag"] + asset_dir = image_lock_path.parent + lock_name = image_lock_path.name + capsule_name = f"registry-stack-{tag}-release-capsule.json" + capsule_path = require_regular_file( + asset_dir / capsule_name, max_bytes=8 * 1024 * 1024 + ) + checksums = parse_checksums(asset_dir / "SHA256SUMS") + if checksums.get(lock_name) != image_lock_sha256: + raise CandidateError( + "release image lock does not match its SHA256SUMS entry" + ) + try: + capsule = json.loads(capsule_path.read_bytes()) + except (UnicodeDecodeError, json.JSONDecodeError) as exc: + raise CandidateError(f"release capsule is not valid JSON: {exc}") from exc + if ( + not isinstance(capsule, dict) + or capsule.get("release_tag") != tag + or capsule.get("version") != stack["version"] + or capsule.get("repository") != CAPSULE_REPOSITORY + ): + raise CandidateError("release capsule identity does not match the candidate") + source = capsule.get("source") + if ( + not isinstance(source, dict) + or source.get("source_tag") != tag + or source.get("source_ref") != lock["manifest_source_ref"] + or source.get("source_commit") != lock["tag_target"] + ): + raise CandidateError( + "release capsule source lineage does not match the image lock" + ) + lineage = source.get("lineage") + lineage_keys = { + "tag_matches_source_tag", + "head_matches_tag_target", + "source_ref_ancestor_or_equal", + "default_branch_reachable", + } + if ( + not isinstance(lineage, dict) + or set(lineage) != lineage_keys + or any(value is not True for value in lineage.values()) + ): + raise CandidateError("release capsule does not prove source lineage") + lock_entry = find_named(capsule.get("release_files"), lock_name, "release_files") + if ( + lock_entry.get("kind") != "registryctl-release-image-lock" + or lock_entry.get("sha256") != image_lock_sha256 + ): + raise CandidateError( + "release capsule image-lock classification or hash is invalid" + ) + capsule_images = capsule.get("images") + if ( + not isinstance(capsule_images, list) + or len(capsule_images) != len(IMAGE_REPOSITORIES) + or { + item.get("name") + for item in capsule_images + if isinstance(item, dict) + } + != set(IMAGE_REPOSITORIES) + ): + raise CandidateError( + "release capsule must contain exactly the two product images" + ) + for component in IMAGE_REPOSITORIES: + if ( + find_named(capsule_images, component, "images").get("digest_ref") + != lock["images"][component] + ): + raise CandidateError( + "release capsule images do not match the release image lock" + ) + + capsule_sha256 = sha256(capsule_path) + verify_release_authenticity(asset_dir, tag, (lock_name, capsule_name)) + if sha256(image_lock_path) != image_lock_sha256 or sha256( + capsule_path + ) != capsule_sha256: + raise CandidateError("a signed candidate subject changed during verification") + return capsule_sha256 + + +def _load_candidate_snapshot( + manifest_path: Path, + image_lock_path: Path, + manifest_bytes: bytes, + image_lock_bytes: bytes, + *, + topology: str = "release-owned", + solmara_source_ref: str | None = None, +) -> dict[str, Any]: + try: + manifest = yaml.safe_load(manifest_bytes.decode("utf-8")) + lock = json.loads(image_lock_bytes) + except (UnicodeDecodeError, json.JSONDecodeError, yaml.YAMLError) as exc: + raise CandidateError( + f"candidate input is not valid YAML or JSON: {exc}" + ) from exc + stack = manifest.get("stack") if isinstance(manifest, dict) else None + if not isinstance(stack, dict): + raise CandidateError("release manifest is missing stack metadata") + release_id = stack.get("release") + version = stack.get("version") + source_ref = stack.get("source_ref") + if ( + not isinstance(release_id, str) + or RELEASE_ID.fullmatch(release_id) is None + or manifest_path.name != f"registry-stack-{release_id}.yaml" + ): + raise CandidateError("release manifest ID and filename disagree") + if ( + not isinstance(version, str) + or stack.get("source_repo") != "registrystack/registry-stack" + or not isinstance(source_ref, str) + or COMMIT.fullmatch(source_ref) is None + or stack.get("source_tag") != f"v{version}" + or stack.get("status") not in {"release-candidate", "released"} + ): + raise CandidateError( + "release manifest does not identify one immutable candidate" + ) + artifacts = manifest.get("artifacts") + if not isinstance(artifacts, dict) or any( + artifacts.get(component) != version for component in IMAGE_REPOSITORIES + ): + raise CandidateError( + "release manifest product artifacts do not match its version" + ) + + if ( + not isinstance(lock, dict) + or image_lock_path.name != f"registryctl-v{version}-image-lock.json" + or set(lock) + != { + "schema_version", + "release_tag", + "manifest_source_ref", + "tag_target", + "platform", + "images", + } + or lock.get("schema_version") != "registryctl.release_image_lock.v1" + or lock.get("release_tag") != stack["source_tag"] + or lock.get("manifest_source_ref") != source_ref + or not isinstance(lock.get("tag_target"), str) + or COMMIT.fullmatch(lock["tag_target"]) is None + or lock.get("platform") != "linux/amd64" + ): + raise CandidateError("release image lock does not match the manifest") + images = lock.get("images") + if not isinstance(images, dict) or set(images) != set(IMAGE_REPOSITORIES): + raise CandidateError("release image lock must contain only Notary and Relay") + for component, repository in IMAGE_REPOSITORIES.items(): + value = images[component] + if ( + not isinstance(value, str) + or re.fullmatch(rf"{re.escape(repository)}@sha256:[0-9a-f]{{64}}", value) + is None + ): + raise CandidateError(f"{component} is not pinned to its exact digest") + verify_git_binding(stack, lock, manifest_path, manifest_bytes) + image_lock_sha256 = hashlib.sha256(image_lock_bytes).hexdigest() + capsule_sha256 = verify_release_asset_binding( + stack, lock, image_lock_path, image_lock_sha256 + ) + + if topology == "solmara": + if ( + not isinstance(solmara_source_ref, str) + or COMMIT.fullmatch(solmara_source_ref) is None + ): + raise CandidateError("Solmara topology requires one exact source commit") + elif topology != "release-owned" or solmara_source_ref is not None: + raise CandidateError("Solmara must be explicitly selected and commit-pinned") + return { + "release_id": release_id, + "version": version, + "source_repo": stack["source_repo"], + "source_ref": source_ref, + "source_tag": stack["source_tag"], + "tag_target": lock["tag_target"], + "manifest_path": manifest_path.relative_to(REPO_ROOT).as_posix(), + "manifest_sha256": f"sha256:{hashlib.sha256(manifest_bytes).hexdigest()}", + "image_lock_sha256": f"sha256:{image_lock_sha256}", + "release_capsule_sha256": f"sha256:{capsule_sha256}", + "notary_image": images["registry-notary"], + "relay_image": images["registry-relay"], + "topology": topology, + "solmara_source_ref": solmara_source_ref, + } + + +def load_candidate( + manifest_path: Path, + image_lock_path: Path, + *, + topology: str = "release-owned", + solmara_source_ref: str | None = None, +) -> dict[str, Any]: + manifest_path = normalized_absolute_path(manifest_path) + image_lock_path = normalized_absolute_path(image_lock_path) + if manifest_path.parent != MANIFEST_DIR: + raise CandidateError(f"release manifest must be under {MANIFEST_DIR}") + manifest_bytes = read_regular_file_no_follow( + manifest_path, + max_bytes=1024 * 1024, + ) + with candidate_asset_snapshot(image_lock_path) as ( + snapshot, + image_lock_bytes, + ): + return _load_candidate_snapshot( + manifest_path, + snapshot / image_lock_path.name, + manifest_bytes, + image_lock_bytes, + topology=topology, + solmara_source_ref=solmara_source_ref, + ) diff --git a/release/scripts/openid-conformance-runner.py b/release/scripts/openid-conformance-runner.py index 8f412887..dee5463c 100755 --- a/release/scripts/openid-conformance-runner.py +++ b/release/scripts/openid-conformance-runner.py @@ -11,10 +11,13 @@ import os import shutil import ssl +import stat import subprocess import sys +import tempfile import time import urllib.error +import urllib.parse import urllib.request from pathlib import Path from string import Template @@ -35,12 +38,19 @@ SUITE_JAR = "target/fapi-test-suite.jar" SUITE_JAR_STAMP = "target/fapi-test-suite.jar.registry-stack-source-ref" COMPOSE_CONFIG_DIR_ENV = "REGISTRY_OPENID_CONFORMANCE_CONFIG_DIR" +SUITE_CA_CONTAINER_PATH = "/etc/ssl/certs/nginx-selfsigned.crt" +DEFAULT_SUITE_CA_PATH = DEFAULT_WORK_ROOT / "conformance-suite-ca.pem" class RunnerError(RuntimeError): """A user-actionable conformance runner failure.""" +class NoRedirect(urllib.request.HTTPRedirectHandler): + def redirect_request(self, request, file_pointer, code, message, headers, url): + return None + + def load_plan_map(path: Path = PLAN_MAP_PATH) -> dict[str, Any]: with path.open(encoding="utf-8") as handle: plan_map = json.load(handle) @@ -340,6 +350,212 @@ def wait_for_suite(base_url: str, timeout_seconds: int) -> None: raise RunnerError(f"conformance suite did not become ready at {url}: {last_error}") +def read_offer(path: Path, issuer_url: str) -> str: + nofollow = getattr(os, "O_NOFOLLOW", None) + cloexec = getattr(os, "O_CLOEXEC", None) + if nofollow is None or cloexec is None or not hasattr(os, "geteuid"): + raise RunnerError("secure offer input handling is unavailable") + descriptor: int | None = None + try: + descriptor = os.open(path, os.O_RDONLY | cloexec | nofollow) + info = os.fstat(descriptor) + if ( + not stat.S_ISREG(info.st_mode) + or info.st_uid != os.geteuid() + or info.st_mode & 0o077 + ): + raise RunnerError("offer input must be an owner-only regular file") + if not 0 < info.st_size <= 65_536: + raise RunnerError("offer input has an invalid size") + with os.fdopen(descriptor, "rb", closefd=True) as handle: + descriptor = None + raw = handle.read(65_537) + except OSError: + raise RunnerError("offer input could not be opened securely") from None + finally: + if descriptor is not None: + os.close(descriptor) + if len(raw) != info.st_size: + raise RunnerError("offer input changed while it was read") + try: + offer_uri = raw.decode("utf-8").strip() + except UnicodeDecodeError: + raise RunnerError("offer input is not valid UTF-8") from None + parsed = urllib.parse.urlsplit(offer_uri) + try: + query = urllib.parse.parse_qs(parsed.query, strict_parsing=True) + except ValueError: + raise RunnerError("offer input has a malformed query") from None + if ( + parsed.scheme != "openid-credential-offer" + or parsed.netloc + or parsed.path + or parsed.fragment + or set(query) != {"credential_offer"} + or len(query["credential_offer"]) != 1 + ): + raise RunnerError("offer input is not one inline credential offer URI") + inline = query["credential_offer"][0] + offer = json.loads(inline) + grant = "urn:ietf:params:oauth:grant-type:pre-authorized_code" + if ( + not isinstance(offer, dict) + or offer.get("credential_issuer") != issuer_url.rstrip("/") + or not isinstance(offer.get("grants"), dict) + or set(offer["grants"]) != {grant} + or not isinstance(offer["grants"][grant], dict) + or not isinstance(offer["grants"][grant].get("pre-authorized_code"), str) + ): + raise RunnerError("offer is not the expected Notary pre-authorized offer") + return inline + + +def read_suite_ca_certificate(path: Path) -> bytes: + path = path.expanduser() + nofollow = getattr(os, "O_NOFOLLOW", 0) + cloexec = getattr(os, "O_CLOEXEC", 0) + descriptor: int | None = None + before: os.stat_result | None = None + try: + if not nofollow: + before = path.lstat() + if stat.S_ISLNK(before.st_mode): + raise RunnerError( + "suite CA certificate could not be opened securely" + ) + descriptor = os.open(path, os.O_RDONLY | nofollow | cloexec) + info = os.fstat(descriptor) + if before is not None and ( + before.st_dev != info.st_dev or before.st_ino != info.st_ino + ): + raise RunnerError("suite CA certificate changed while it was opened") + if ( + not stat.S_ISREG(info.st_mode) + or not 0 < info.st_size <= 1024 * 1024 + ): + raise RunnerError( + "suite CA certificate must be a bounded regular file" + ) + with os.fdopen(descriptor, "rb", closefd=True) as handle: + descriptor = None + certificate = handle.read(1024 * 1024 + 1) + except OSError: + raise RunnerError( + "suite CA certificate could not be opened securely" + ) from None + finally: + if descriptor is not None: + os.close(descriptor) + if len(certificate) != info.st_size: + raise RunnerError("suite CA certificate changed while it was read") + return certificate + + +def add_suite_ca(context: ssl.SSLContext, certificate: bytes) -> None: + try: + text = certificate.decode("ascii") + except UnicodeDecodeError: + cadata: str | bytes = certificate + else: + cadata = text if "-----BEGIN CERTIFICATE-----" in text else certificate + try: + context.load_verify_locations(cadata=cadata) + except (OSError, ValueError): + raise RunnerError("suite CA certificate could not be loaded") from None + + +def suite_tls_context(ca_certificate: Path | None) -> ssl.SSLContext: + if ca_certificate is None: + return ssl.create_default_context() + context = ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT) + add_suite_ca(context, read_suite_ca_certificate(ca_certificate)) + return context + + +def cmd_submit_offer(args: argparse.Namespace) -> int: + inline = read_offer(args.offer_file, args.issuer_url) + base = urllib.parse.urlsplit(args.conformance_server) + endpoint = urllib.parse.urlsplit(args.suite_offer_endpoint) + if ( + (endpoint.scheme, endpoint.netloc) != (base.scheme, base.netloc) + or endpoint.scheme != "https" + or not endpoint.path.endswith("/credential_offer") + or endpoint.query + or endpoint.fragment + ): + raise RunnerError( + "suite offer endpoint must use HTTPS on the pinned suite origin" + ) + url = urllib.parse.urlunsplit( + endpoint._replace(query=urllib.parse.urlencode({"credential_offer": inline})) + ) + context = suite_tls_context(args.suite_ca_certificate) + opener = urllib.request.build_opener( + urllib.request.ProxyHandler({}), + urllib.request.HTTPSHandler(context=context), + NoRedirect(), + ) + try: + with opener.open(url, timeout=args.timeout_seconds) as response: + if not 200 <= response.status < 300: + raise RunnerError(f"suite offer endpoint returned HTTP {response.status}") + except urllib.error.HTTPError as exc: + raise RunnerError(f"suite offer endpoint returned HTTP {exc.code}") from None + except (OSError, urllib.error.URLError): + raise RunnerError("suite offer submission failed") from None + print("credential offer submitted") + return 0 + + +def write_new_file(path: Path, content: bytes) -> Path: + path = path.expanduser() + path.parent.mkdir(parents=True, exist_ok=True) + flags = os.O_WRONLY | os.O_CREAT | os.O_EXCL + flags |= getattr(os, "O_NOFOLLOW", 0) | getattr(os, "O_CLOEXEC", 0) + descriptor: int | None = None + try: + descriptor = os.open(path, flags, 0o600) + with os.fdopen(descriptor, "wb", closefd=True) as handle: + descriptor = None + handle.write(content) + except OSError: + raise RunnerError("suite CA output could not be created") from None + finally: + if descriptor is not None: + os.close(descriptor) + return path + + +def cmd_export_suite_ca(args: argparse.Namespace) -> int: + checkout = suite_dir(args) + if not checkout.is_dir(): + raise RunnerError("suite checkout is unavailable; run prepare first") + output = Path(args.output).expanduser() + output.parent.mkdir(parents=True, exist_ok=True) + env = os.environ.copy() + env[COMPOSE_CONFIG_DIR_ENV] = str(CONFIG_DIR) + with tempfile.TemporaryDirectory( + prefix=".openid-suite-ca-", dir=output.parent + ) as tmp: + copied = Path(tmp) / "nginx-selfsigned.crt" + run_checked( + compose_command( + checkout, + args, + "cp", + f"nginx:{SUITE_CA_CONTAINER_PATH}", + str(copied), + ), + env=env, + ) + certificate = read_suite_ca_certificate(copied) + validation_context = ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT) + add_suite_ca(validation_context, certificate) + write_new_file(output, certificate) + print(output) + return 0 + + def output_dir_for(args: argparse.Namespace, scenario_id: str) -> Path: if args.output_dir: return Path(args.output_dir).expanduser().resolve() @@ -498,6 +714,17 @@ def parse_args(argv: list[str]) -> argparse.Namespace: add_common(down_parser) down_parser.set_defaults(func=cmd_down) + export_ca_parser = subparsers.add_parser("export-suite-ca") + export_ca_parser.add_argument("--cache-dir", default=str(DEFAULT_CACHE_DIR)) + export_ca_parser.add_argument("--suite-dir") + export_ca_parser.add_argument( + "--output", + type=Path, + default=DEFAULT_SUITE_CA_PATH, + help="new file that receives the running suite's generated certificate", + ) + export_ca_parser.set_defaults(func=cmd_export_suite_ca) + render_parser = subparsers.add_parser("render-config") add_common(render_parser) add_config_args(render_parser) @@ -512,6 +739,22 @@ def parse_args(argv: list[str]) -> argparse.Namespace: run_parser.add_argument("--wait-seconds", type=int, default=180) run_parser.set_defaults(func=cmd_run) + offer_parser = subparsers.add_parser("submit-offer") + offer_parser.add_argument("--offer-file", type=Path, required=True) + offer_parser.add_argument("--issuer-url", required=True) + offer_parser.add_argument("--suite-offer-endpoint", required=True) + offer_parser.add_argument( + "--conformance-server", + default=load_plan_map()["suite"]["base_url"], + ) + offer_parser.add_argument( + "--suite-ca-certificate", + type=Path, + help="PEM or DER trust anchor captured from the local suite", + ) + offer_parser.add_argument("--timeout-seconds", type=int, default=10) + offer_parser.set_defaults(func=cmd_submit_offer) + return parser.parse_args(argv) diff --git a/release/scripts/prepare-upgrade-exercise-assets.py b/release/scripts/prepare-upgrade-exercise-assets.py new file mode 100644 index 00000000..c6b58809 --- /dev/null +++ b/release/scripts/prepare-upgrade-exercise-assets.py @@ -0,0 +1,152 @@ +#!/usr/bin/env python3 +"""Download version-keyed release assets for committed upgrade evidence.""" + +from __future__ import annotations + +import argparse +import json +import re +import subprocess +import sys +from pathlib import Path +from typing import Callable + + +STACK_REPOSITORY = "registrystack/registry-stack" +VERSION = re.compile(r"^v[0-9]+\.[0-9]+\.[0-9]+(?:-[0-9A-Za-z.-]+)?$") + + +class PreparationError(RuntimeError): + """Committed upgrade evidence cannot be prepared safely.""" + + +def candidate_versions(records: Path) -> tuple[str, ...]: + versions: set[str] = set() + for path in sorted(records.glob("*.json")): + try: + value = json.loads(path.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError): + raise PreparationError( + "upgrade exercise record could not be read" + ) from None + if not isinstance(value, dict) or value.get("record_kind") == "template": + continue + if value.get("record_kind") != "candidate_evidence": + raise PreparationError("upgrade exercise record kind is invalid") + target = value.get("target_release") + version = target.get("version") if isinstance(target, dict) else None + if not isinstance(version, str) or VERSION.fullmatch(version) is None: + raise PreparationError( + "candidate upgrade target version is invalid" + ) + versions.add(version) + return tuple(sorted(versions)) + + +def required_asset_names(version: str) -> tuple[str, ...]: + image_lock = f"registryctl-{version}-image-lock.json" + capsule = f"registry-stack-{version}-release-capsule.json" + return ( + image_lock, + f"{image_lock}.sig", + f"{image_lock}.pem", + capsule, + f"{capsule}.sig", + f"{capsule}.pem", + f"registry-stack-{version}-release-provenance.intoto.jsonl", + "SHA256SUMS", + ) + + +def run_download(command: list[str]) -> None: + try: + result = subprocess.run( + command, + text=True, + capture_output=True, + check=False, + timeout=120, + ) + except (OSError, subprocess.SubprocessError): + raise PreparationError( + "candidate release assets could not be downloaded" + ) from None + if result.returncode != 0: + raise PreparationError( + "candidate release assets could not be downloaded" + ) + + +def prepare_assets( + records: Path, + asset_root: Path, + *, + downloader: Callable[[list[str]], None] = run_download, +) -> tuple[str, ...]: + versions = candidate_versions(records) + if not versions: + return versions + asset_root.mkdir(parents=True, exist_ok=True) + for version in versions: + destination = asset_root / version + try: + destination.mkdir(mode=0o700) + except OSError: + raise PreparationError( + "candidate version asset directory must be new" + ) from None + names = required_asset_names(version) + command = [ + "gh", + "release", + "download", + version, + "--repo", + STACK_REPOSITORY, + "--dir", + str(destination), + ] + for name in names: + command.extend(("--pattern", name)) + downloader(command) + try: + actual = {path.name for path in destination.iterdir()} + except OSError: + raise PreparationError( + "candidate release asset set could not be inspected" + ) from None + if actual != set(names) or any( + not path.is_file() or path.is_symlink() + for path in destination.iterdir() + ): + raise PreparationError( + "candidate release asset set is incomplete or unsafe" + ) + return versions + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--discover", type=Path, required=True) + parser.add_argument("--asset-root", type=Path, required=True) + parser.add_argument("--github-output", type=Path) + args = parser.parse_args() + try: + versions = prepare_assets(args.discover, args.asset_root) + if args.github_output is not None: + with args.github_output.open("a", encoding="utf-8") as output: + output.write( + f"has_candidates={'true' if versions else 'false'}\n" + ) + output.write(f"versions={','.join(versions)}\n") + except (PreparationError, OSError) as error: + print(f"upgrade asset preparation failed: {error}", file=sys.stderr) + return 1 + print( + f"prepared upgrade release inputs for {len(versions)} version(s)" + ) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/release/scripts/relay-oidc-smoke.py b/release/scripts/relay-oidc-smoke.py index 27c1f9b6..d7b898c4 100755 --- a/release/scripts/relay-oidc-smoke.py +++ b/release/scripts/relay-oidc-smoke.py @@ -25,6 +25,8 @@ from string import Template from typing import Any +from conformance_candidate import CandidateError, load_candidate + REPO_ROOT = Path(__file__).resolve().parents[2] CONFIG_DIR = REPO_ROOT / "release" / "conformance" / "relay-oidc" @@ -34,12 +36,7 @@ HELPER_PATH = CONFIG_DIR / "zitadel-helper.py" DEFAULT_WORK_ROOT = REPO_ROOT / "target" / "relay-oidc-smoke" SCHEMA_VERSION = "registry.release.relay_oidc_smoke.v1" -RELAY_IMAGE_RE = re.compile( - r"^ghcr\.io/registrystack/registry-relay@sha256:[0-9a-f]{64}$" -) DIGEST_IMAGE_RE = re.compile(r"^[^\s@]+:[^\s@]+@sha256:[0-9a-f]{64}$") -SOURCE_REF_RE = re.compile(r"^[0-9a-f]{40}$") -RELEASE_ID_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._+-]{0,63}$") RUN_ID_RE = re.compile(r"^[0-9a-f]{12}$") MAX_HTTP_BODY = 65_536 REQUIRED_CHECKS = ( @@ -91,29 +88,6 @@ def file_sha256(path: Path) -> str: return sha256_bytes(path.read_bytes()) -def validate_relay_image(value: str) -> str: - if not RELAY_IMAGE_RE.fullmatch(value): - raise SmokeError( - "Relay image must be exactly " - "ghcr.io/registrystack/registry-relay@sha256:<64 lowercase hex>" - ) - return value - - -def validate_source_ref(value: str) -> str: - if not SOURCE_REF_RE.fullmatch(value): - raise SmokeError( - "candidate source ref must be 40 lowercase hexadecimal characters" - ) - return value - - -def validate_release_id(value: str) -> str: - if not RELEASE_ID_RE.fullmatch(value): - raise SmokeError("release id contains unsupported characters or is too long") - return value - - def compose_image_entries(text: str) -> list[str]: return [ match.group(1).strip() @@ -188,23 +162,24 @@ def validate_assets() -> dict[str, Any]: } -def plan_document(relay_image: str, source_ref: str, release_id: str) -> dict[str, Any]: +def plan_document(candidate: dict[str, Any]) -> dict[str, Any]: assets = validate_assets() return { "schema_version": SCHEMA_VERSION, "operation": "relay-oidc-smoke", "classification": "candidate-neutral-harness-plan", - "release_id": validate_release_id(release_id), - "candidate_source_ref": validate_source_ref(source_ref), - "relay_image": validate_relay_image(relay_image), + "candidate": candidate, "topology": assets, "checks": list(REQUIRED_CHECKS), - "plan_network_required": False, + "plan_network_required": True, "live_run_requires_docker": True, "live_run_network_required": True, "live_evidence": False, "notes": [ - "This plan validates checked-in inputs only and is not conformance evidence.", + ( + "This plan validates the checked-in harness and authenticated " + "candidate inputs, but is not conformance evidence." + ), ( "A live run remains unreviewed until its digest-bound report is " + "reviewed without raw secrets." @@ -602,9 +577,7 @@ def safe_report( "classification", "review_required", "contains_sensitive_material", - "release_id", - "candidate_source_ref", - "relay_image", + "candidate", "started_at", "completed_at", "result", @@ -614,6 +587,7 @@ def safe_report( "topology", "configuration_digests", "checks", + "canary_scan", } unexpected = set(report) - allowed_keys if unexpected: @@ -647,11 +621,19 @@ def default_output_dir() -> Path: return DEFAULT_WORK_ROOT / f"run-{stamp}-{secrets.token_hex(3)}" +def candidate_from_args(args: argparse.Namespace) -> dict[str, Any]: + return load_candidate( + args.release_manifest, + args.image_lock, + topology=args.topology, + solmara_source_ref=args.solmara_source_ref, + ) + + def execute_live(args: argparse.Namespace) -> Path: assets = validate_assets() - relay_image = validate_relay_image(args.relay_image) - source_ref = validate_source_ref(args.candidate_source_ref) - release_id = validate_release_id(args.release_id) + candidate = candidate_from_args(args) + relay_image = candidate["relay_image"] if not shutil.which("docker"): raise SmokeError("Docker with Compose is required for a live smoke") @@ -703,9 +685,7 @@ def execute_live(args: argparse.Namespace) -> Path: "classification": "unreviewed-live-candidate-output", "review_required": True, "contains_sensitive_material": False, - "release_id": release_id, - "candidate_source_ref": source_ref, - "relay_image": relay_image, + "candidate": candidate, "started_at": utc_now(), "completed_at": None, "result": "error", @@ -715,6 +695,10 @@ def execute_live(args: argparse.Namespace) -> Path: "topology": assets, "configuration_digests": {}, "checks": [], + "canary_scan": { + "passed": True, + "seeded_canaries": 1, + }, } stage = "topology-start" primary_error: SmokeError | None = None @@ -947,7 +931,7 @@ def cmd_validate(args: argparse.Namespace) -> int: def cmd_plan(args: argparse.Namespace) -> int: print( json.dumps( - plan_document(args.relay_image, args.candidate_source_ref, args.release_id), + plan_document(candidate_from_args(args)), indent=2, sort_keys=True, ) @@ -961,9 +945,14 @@ def cmd_run(args: argparse.Namespace) -> int: def add_candidate_args(parser: argparse.ArgumentParser) -> None: - parser.add_argument("--relay-image", required=True) - parser.add_argument("--candidate-source-ref", required=True) - parser.add_argument("--release-id", required=True) + parser.add_argument("--release-manifest", type=Path, required=True) + parser.add_argument("--image-lock", type=Path, required=True) + parser.add_argument( + "--topology", + choices=("release-owned", "solmara"), + default="release-owned", + ) + parser.add_argument("--solmara-source-ref") def parse_args(argv: list[str]) -> argparse.Namespace: @@ -986,6 +975,7 @@ def parse_args(argv: list[str]) -> argparse.Namespace: run_parser.add_argument("--host-port", type=int) run_parser.add_argument("--output-dir") run_parser.set_defaults(func=cmd_run) + return parser.parse_args(argv) @@ -993,7 +983,7 @@ def main(argv: list[str] | None = None) -> int: args = parse_args(argv or sys.argv[1:]) try: return int(args.func(args)) - except (OSError, SmokeError) as exc: + except (CandidateError, OSError, SmokeError) as exc: print(f"relay-oidc-smoke: {exc}", file=sys.stderr) return 2 diff --git a/release/scripts/test_check_gates_inventory.py b/release/scripts/test_check_gates_inventory.py index a050580f..89bf5700 100644 --- a/release/scripts/test_check_gates_inventory.py +++ b/release/scripts/test_check_gates_inventory.py @@ -367,13 +367,32 @@ def test_missing_openapi_base_reference_is_reported(self) -> None: ) self.assertIn("OpenAPI base-reference input", self.module.missing_gates(text)) - def test_missing_upgrade_exercise_template_validation_is_reported(self) -> None: + def test_missing_upgrade_exercise_record_discovery_is_reported(self) -> None: text = self.workflow.replace( - "python3 release/scripts/validate-upgrade-exercise.py --template", - "python3 release/scripts/validate-upgrade-exercise.py --skip-template", + "python3 release/scripts/validate-upgrade-exercise.py --discover release/exercises", + "python3 release/scripts/validate-upgrade-exercise.py --skip-discovery", ) self.assertIn( - "Upgrade exercise template validation", self.module.missing_gates(text) + "Upgrade exercise record discovery", self.module.missing_gates(text) + ) + + def test_missing_upgrade_exercise_asset_preparation_is_reported(self) -> None: + text = self.workflow.replace( + "python3 release/scripts/prepare-upgrade-exercise-assets.py", + "python3 release/scripts/skip-upgrade-exercise-assets.py", + ) + self.assertIn( + "Upgrade exercise candidate asset preparation", + self.module.missing_gates(text), + ) + + def test_missing_upgrade_exercise_asset_root_is_reported(self) -> None: + text = self.workflow.replace( + "--candidate-asset-root target/upgrade-exercise-assets", + "--candidate-asset-root target/unauthenticated-assets", + ) + self.assertIn( + "Upgrade exercise record discovery", self.module.missing_gates(text) ) def test_missing_stable_error_registry_path_filter_is_reported(self) -> None: diff --git a/release/scripts/test_openid_conformance_runner.py b/release/scripts/test_openid_conformance_runner.py index 245b0ccf..a34c2b49 100755 --- a/release/scripts/test_openid_conformance_runner.py +++ b/release/scripts/test_openid_conformance_runner.py @@ -5,16 +5,24 @@ import importlib.util import json import re +import shlex import shutil +import socket +import ssl +import subprocess import sys import tempfile +import threading +import urllib.parse +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer from pathlib import Path from unittest import TestCase, main -from unittest.mock import patch +from unittest.mock import MagicMock, patch SCRIPT_DIR = Path(__file__).resolve().parent RUNNER_PATH = SCRIPT_DIR / "openid-conformance-runner.py" +NGINX_DOCKERFILE = SCRIPT_DIR.parent / "conformance" / "openid" / "nginx.Dockerfile" def load_runner(): @@ -29,12 +37,45 @@ def load_runner(): return module +def nginx_certificate_command(certificate: Path, private_key: Path) -> list[str]: + dockerfile = NGINX_DOCKERFILE.read_text(encoding="utf-8") + recipe = dockerfile.split("RUN ", 1)[1].split("\nCOPY ", 1)[0] + command = shlex.split(recipe.replace("\\\n", " ")) + command[command.index("-out") + 1] = str(certificate) + command[command.index("-keyout") + 1] = str(private_key) + return command + + +class EmptyHttpsHandler(BaseHTTPRequestHandler): + def do_GET(self) -> None: + self.send_response(204) + self.end_headers() + + def log_message(self, _format: str, *_args) -> None: + return + + class OpenIdConformanceRunnerTest(TestCase): @classmethod def setUpClass(cls) -> None: cls.runner = load_runner() cls.plan_map = cls.runner.load_plan_map() + def offer_uri( + self, issuer: str = "https://issuer.example.test" + ) -> tuple[str, str]: + grant = "urn:ietf:params:oauth:grant-type:pre-authorized_code" + inline = json.dumps( + { + "credential_issuer": issuer, + "credential_configuration_ids": ["person_is_alive_sd_jwt"], + "grants": {grant: {"pre-authorized_code": "owner-only-code"}}, + } + ) + return inline, "openid-credential-offer://?" + urllib.parse.urlencode( + {"credential_offer": inline} + ) + def test_plan_map_has_unique_scenarios_and_pinned_suite_ref(self) -> None: scenarios = self.plan_map["scenarios"] self.assertEqual(len(scenarios), len({scenario["id"] for scenario in scenarios})) @@ -79,9 +120,7 @@ def test_notary_mapping_is_candidate_only_and_matches_the_1_0_profile(self) -> N self.assertIn("does not support or claim DPoP", metadata_notes) self.assertIn("frozen candidate artifact", metadata_notes) - self.assertEqual( - "blocked-by-suite-profile-and-offer-adapter", full["status"] - ) + self.assertEqual("blocked-by-suite-profile", full["status"]) self.assertEqual( "pre_authorization_code", full["variants"]["vci_grant_type"] ) @@ -92,6 +131,412 @@ def test_notary_mapping_is_candidate_only_and_matches_the_1_0_profile(self) -> N "policy decision on whether the first full run targets", full_contract, ) + verifier = next( + item + for item in self.plan_map["non_oidf_surfaces"] + if item["surface"] == "Registry Notary Rust SD-JWT verifier" + ) + self.assertIn("not an OID4VP endpoint", verifier["reason"]) + + def test_submit_offer_forwards_only_the_real_notary_preauthorized_offer( + self, + ) -> None: + issuer = "https://issuer.example.test" + inline, offer_uri = self.offer_uri(issuer) + with tempfile.TemporaryDirectory() as tmp: + offer_file = Path(tmp) / "offer.txt" + offer_file.write_text(offer_uri, encoding="utf-8") + offer_file.chmod(0o600) + args = self.runner.parse_args( + [ + "submit-offer", + "--offer-file", + str(offer_file), + "--issuer-url", + issuer, + "--suite-offer-endpoint", + "https://suite.example.test/run/credential_offer", + "--conformance-server", + "https://suite.example.test", + ] + ) + response = MagicMock() + response.__enter__.return_value.status = 204 + opener = MagicMock() + opener.open.return_value = response + tls_context = MagicMock() + with patch.object( + self.runner.ssl, + "create_default_context", + return_value=tls_context, + ) as create_context: + with patch.object( + self.runner.ssl, + "_create_unverified_context", + side_effect=AssertionError("unverified TLS must not be used"), + ): + with patch.object( + self.runner.urllib.request, + "build_opener", + return_value=opener, + ) as build_opener: + with patch("builtins.print") as printed: + self.assertEqual(0, self.runner.cmd_submit_offer(args)) + + submitted = urllib.parse.urlsplit(opener.open.call_args.args[0]) + self.assertEqual( + [inline], + urllib.parse.parse_qs(submitted.query)["credential_offer"], + ) + create_context.assert_called_once_with() + https_handler = next( + handler + for handler in build_opener.call_args.args + if isinstance(handler, self.runner.urllib.request.HTTPSHandler) + ) + self.assertIs(tls_context, https_handler._context) + printed.assert_called_once_with("credential offer submitted") + + opener.open.side_effect = self.runner.urllib.error.URLError(inline) + with patch.object( + self.runner.urllib.request, "build_opener", return_value=opener + ): + with self.assertRaisesRegex( + self.runner.RunnerError, "submission failed" + ) as caught: + self.runner.cmd_submit_offer(args) + self.assertNotIn("owner-only-code", str(caught.exception)) + + def test_submit_offer_rejects_untrusted_remote_tls(self) -> None: + issuer = "https://issuer.example.test" + inline, offer_uri = self.offer_uri(issuer) + with tempfile.TemporaryDirectory() as tmp: + offer_file = Path(tmp) / "offer.txt" + offer_file.write_text(offer_uri, encoding="utf-8") + offer_file.chmod(0o600) + args = self.runner.parse_args( + [ + "submit-offer", + "--offer-file", + str(offer_file), + "--issuer-url", + issuer, + "--suite-offer-endpoint", + "https://suite.example.test/run/credential_offer", + "--conformance-server", + "https://suite.example.test", + ] + ) + opener = MagicMock() + opener.open.side_effect = self.runner.urllib.error.URLError( + self.runner.ssl.SSLCertVerificationError( + 1, "self-signed certificate" + ) + ) + with patch.object( + self.runner.urllib.request, "build_opener", return_value=opener + ): + with self.assertRaisesRegex( + self.runner.RunnerError, "submission failed" + ) as caught: + self.runner.cmd_submit_offer(args) + + self.assertNotIn(inline, str(caught.exception)) + + def test_submit_offer_accepts_an_explicit_local_suite_ca(self) -> None: + issuer = "https://issuer.example.test" + _, offer_uri = self.offer_uri(issuer) + with tempfile.TemporaryDirectory() as tmp: + offer_file = Path(tmp) / "offer.txt" + offer_file.write_text(offer_uri, encoding="utf-8") + offer_file.chmod(0o600) + suite_ca = Path(tmp) / "suite-ca.pem" + suite_ca.write_text("local test CA", encoding="utf-8") + args = self.runner.parse_args( + [ + "submit-offer", + "--offer-file", + str(offer_file), + "--issuer-url", + issuer, + "--suite-offer-endpoint", + "https://localhost.emobix.co.uk:8443/run/credential_offer", + "--conformance-server", + "https://localhost.emobix.co.uk:8443", + "--suite-ca-certificate", + str(suite_ca), + ] + ) + response = MagicMock() + response.__enter__.return_value.status = 204 + opener = MagicMock() + opener.open.return_value = response + tls_context = MagicMock() + with patch.object( + self.runner.ssl, + "SSLContext", + return_value=tls_context, + ) as create_context: + with patch.object( + self.runner.urllib.request, + "build_opener", + return_value=opener, + ): + with patch("builtins.print"): + self.assertEqual(0, self.runner.cmd_submit_offer(args)) + + create_context.assert_called_once_with( + self.runner.ssl.PROTOCOL_TLS_CLIENT + ) + tls_context.load_verify_locations.assert_called_once_with( + cadata=b"local test CA" + ) + + def test_suite_ca_read_holds_one_descriptor_across_path_replacement( + self, + ) -> None: + original = ( + b"-----BEGIN CERTIFICATE-----\n" + b"captured-original\n" + b"-----END CERTIFICATE-----\n" + ) + replacement = ( + b"-----BEGIN CERTIFICATE-----\n" + b"replacement\n" + b"-----END CERTIFICATE-----\n" + ) + with tempfile.TemporaryDirectory() as tmp: + ca_path = Path(tmp) / "suite-ca.pem" + replacement_path = Path(tmp) / "replacement.pem" + ca_path.write_bytes(original) + replacement_path.write_bytes(replacement) + real_open = self.runner.os.open + + def open_then_replace(path, flags): + descriptor = real_open(path, flags) + self.runner.os.replace(replacement_path, ca_path) + return descriptor + + tls_context = MagicMock() + with patch.object( + self.runner.os, "open", side_effect=open_then_replace + ) as secure_open: + with patch.object( + self.runner.ssl, + "SSLContext", + return_value=tls_context, + ): + self.runner.suite_tls_context(ca_path) + + self.assertEqual(replacement, ca_path.read_bytes()) + secure_open.assert_called_once() + flags = secure_open.call_args.args[1] + for required_flag in ("O_NOFOLLOW", "O_CLOEXEC"): + value = getattr(self.runner.os, required_flag, 0) + if value: + self.assertEqual(value, flags & value) + tls_context.load_verify_locations.assert_called_once_with( + cadata=original.decode("ascii") + ) + + def test_suite_ca_loader_preserves_der_bytes(self) -> None: + tls_context = MagicMock() + certificate = b"\x30\x82\x01\x00\xff" + + self.runner.add_suite_ca(tls_context, certificate) + + tls_context.load_verify_locations.assert_called_once_with( + cadata=certificate + ) + + def test_suite_ca_read_rejects_symlink(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + target = Path(tmp) / "suite-ca.pem" + target.write_text("certificate", encoding="utf-8") + link = Path(tmp) / "suite-ca-link.pem" + link.symlink_to(target) + + with self.assertRaisesRegex( + self.runner.RunnerError, "opened securely" + ): + self.runner.read_suite_ca_certificate(link) + + def test_exported_certificate_recipe_authenticates_documented_suite_host( + self, + ) -> None: + openssl = shutil.which("openssl") + if not openssl: + self.skipTest("openssl is required for the checked-in certificate recipe") + issuer = "https://issuer.example.test" + _, offer_uri = self.offer_uri(issuer) + with tempfile.TemporaryDirectory() as tmp: + work = Path(tmp) + certificate = work / "recipe.crt" + private_key = work / "recipe.key" + command = nginx_certificate_command(certificate, private_key) + self.assertEqual(openssl, shutil.which(command[0])) + self.assertIn( + ( + "subjectAltName=DNS:localhost.emobix.co.uk,DNS:localhost," + "IP:127.0.0.1,IP:::1" + ), + command, + ) + subprocess.run( + command, + check=True, + capture_output=True, + text=True, + ) + + suite_dir = work / "suite" + suite_dir.mkdir() + exported = work / "conformance-suite-ca.pem" + export_args = self.runner.parse_args( + [ + "export-suite-ca", + "--suite-dir", + str(suite_dir), + "--output", + str(exported), + ] + ) + compose_commands: list[list[str]] = [] + + def copy_container_certificate( + compose_command: list[str], **_kwargs + ) -> None: + compose_commands.append(compose_command) + Path(compose_command[-1]).write_bytes(certificate.read_bytes()) + + with patch.object( + self.runner, + "run_checked", + side_effect=copy_container_certificate, + ): + with patch("builtins.print"): + self.assertEqual(0, self.runner.cmd_export_suite_ca(export_args)) + + self.assertEqual(certificate.read_bytes(), exported.read_bytes()) + self.assertEqual(0o600, exported.stat().st_mode & 0o777) + self.assertEqual( + f"nginx:{self.runner.SUITE_CA_CONTAINER_PATH}", + compose_commands[0][-2], + ) + + offer_file = work / "offer.txt" + offer_file.write_text(offer_uri, encoding="utf-8") + offer_file.chmod(0o600) + server = ThreadingHTTPServer(("127.0.0.1", 0), EmptyHttpsHandler) + server_context = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER) + server_context.minimum_version = ssl.TLSVersion.TLSv1_2 + server_context.load_cert_chain(certificate, private_key) + server.socket = server_context.wrap_socket( + server.socket, server_side=True + ) + thread = threading.Thread(target=server.serve_forever, daemon=True) + thread.start() + host = "localhost.emobix.co.uk" + port = server.server_port + submit_args = self.runner.parse_args( + [ + "submit-offer", + "--offer-file", + str(offer_file), + "--issuer-url", + issuer, + "--suite-offer-endpoint", + f"https://{host}:{port}/run/credential_offer", + "--conformance-server", + f"https://{host}:{port}", + "--suite-ca-certificate", + str(exported), + ] + ) + real_getaddrinfo = socket.getaddrinfo + + def loopback_suite(hostname, *args, **kwargs): + if hostname == host: + hostname = "127.0.0.1" + return real_getaddrinfo(hostname, *args, **kwargs) + + try: + with patch.object( + socket, "getaddrinfo", side_effect=loopback_suite + ): + with patch("builtins.print"): + self.assertEqual( + 0, self.runner.cmd_submit_offer(submit_args) + ) + finally: + server.shutdown() + server.server_close() + thread.join(timeout=2) + + def test_submit_offer_rejects_cleartext_suite_endpoint(self) -> None: + issuer = "https://issuer.example.test" + _, offer_uri = self.offer_uri(issuer) + with tempfile.TemporaryDirectory() as tmp: + offer_file = Path(tmp) / "offer.txt" + offer_file.write_text(offer_uri, encoding="utf-8") + offer_file.chmod(0o600) + args = self.runner.parse_args( + [ + "submit-offer", + "--offer-file", + str(offer_file), + "--issuer-url", + issuer, + "--suite-offer-endpoint", + "http://suite.example.test/run/credential_offer", + "--conformance-server", + "http://suite.example.test", + ] + ) + with patch.object( + self.runner.urllib.request, "build_opener" + ) as build_opener: + with self.assertRaisesRegex(self.runner.RunnerError, "HTTPS"): + self.runner.cmd_submit_offer(args) + + build_opener.assert_not_called() + + def test_read_offer_uses_one_no_follow_descriptor(self) -> None: + issuer = "https://issuer.example.test" + inline, offer_uri = self.offer_uri(issuer) + with tempfile.TemporaryDirectory() as tmp: + offer_file = Path(tmp) / "offer.txt" + offer_file.write_text(offer_uri, encoding="utf-8") + offer_file.chmod(0o600) + real_open = self.runner.os.open + with patch.object(Path, "read_text", side_effect=AssertionError): + with patch.object( + self.runner.os, "open", wraps=real_open + ) as secure_open: + self.assertEqual( + inline, self.runner.read_offer(offer_file, issuer) + ) + + secure_open.assert_called_once_with( + offer_file, + self.runner.os.O_RDONLY + | self.runner.os.O_CLOEXEC + | self.runner.os.O_NOFOLLOW, + ) + + def test_read_offer_rejects_symlink(self) -> None: + issuer = "https://issuer.example.test" + _, offer_uri = self.offer_uri(issuer) + with tempfile.TemporaryDirectory() as tmp: + target = Path(tmp) / "offer.txt" + target.write_text(offer_uri, encoding="utf-8") + target.chmod(0o600) + link = Path(tmp) / "offer-link.txt" + link.symlink_to(target) + with self.assertRaisesRegex( + self.runner.RunnerError, "could not be opened securely" + ): + self.runner.read_offer(link, issuer) def test_builder_override_pins_maven_image_by_digest(self) -> None: override = self.runner.BUILDER_COMPOSE_OVERRIDE_PATH.read_text( @@ -448,7 +893,7 @@ def test_blocked_full_scenario_requires_explicit_override(self) -> None: ] ) with self.assertRaisesRegex( - self.runner.RunnerError, "blocked-by-suite-profile-and-offer-adapter" + self.runner.RunnerError, "blocked-by-suite-profile" ): self.runner.cmd_run(args) diff --git a/release/scripts/test_prepare_upgrade_exercise_assets.py b/release/scripts/test_prepare_upgrade_exercise_assets.py new file mode 100644 index 00000000..278fe7b6 --- /dev/null +++ b/release/scripts/test_prepare_upgrade_exercise_assets.py @@ -0,0 +1,147 @@ +from __future__ import annotations + +import importlib.util +import json +import sys +import tempfile +import unittest +from pathlib import Path +from unittest import mock + + +ROOT = Path(__file__).resolve().parents[2] +SCRIPT = ROOT / "release" / "scripts" / "prepare-upgrade-exercise-assets.py" + + +def load_module(): + spec = importlib.util.spec_from_file_location( + "prepare_upgrade_exercise_assets", SCRIPT + ) + if spec is None or spec.loader is None: + raise ImportError(f"could not load module spec from {SCRIPT}") + module = importlib.util.module_from_spec(spec) + sys.modules[spec.name] = module + spec.loader.exec_module(module) + return module + + +class PrepareUpgradeExerciseAssetsTest(unittest.TestCase): + def setUp(self) -> None: + self.module = load_module() + + def write_record(self, directory: Path, name: str, version: str) -> None: + (directory / name).write_text( + json.dumps( + { + "record_kind": "candidate_evidence", + "target_release": {"version": version}, + } + ), + encoding="utf-8", + ) + + def download_fixture( + self, command: list[str], *, omit: str | None = None + ) -> None: + destination = Path(command[command.index("--dir") + 1]) + patterns = [ + command[index + 1] + for index, value in enumerate(command) + if value == "--pattern" + ] + for name in patterns: + if name != omit: + (destination / name).write_text("release asset", encoding="utf-8") + + def test_current_templates_require_no_download_or_asset_root(self) -> None: + with tempfile.TemporaryDirectory() as temporary: + asset_root = Path(temporary) / "assets" + downloader = mock.Mock(side_effect=AssertionError) + versions = self.module.prepare_assets( + ROOT / "release" / "exercises", + asset_root, + downloader=downloader, + ) + + self.assertEqual((), versions) + self.assertFalse(asset_root.exists()) + downloader.assert_not_called() + + def test_one_candidate_downloads_exact_version_asset_set(self) -> None: + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + records = root / "records" + records.mkdir() + self.write_record(records, "candidate.json", "v0.12.2") + commands: list[list[str]] = [] + + def download(command: list[str]) -> None: + commands.append(command) + self.download_fixture(command) + + versions = self.module.prepare_assets( + records, root / "assets", downloader=download + ) + + self.assertEqual(("v0.12.2",), versions) + self.assertEqual( + set(self.module.required_asset_names("v0.12.2")), + { + path.name + for path in (root / "assets" / "v0.12.2").iterdir() + }, + ) + self.assertEqual("v0.12.2", commands[0][3]) + + def test_multiple_versions_use_separate_authenticated_directories(self) -> None: + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + records = root / "records" + records.mkdir() + self.write_record(records, "candidate-a.json", "v0.11.0") + self.write_record(records, "candidate-b.json", "v0.12.2") + self.write_record(records, "candidate-c.json", "v0.12.2") + + versions = self.module.prepare_assets( + records, + root / "assets", + downloader=self.download_fixture, + ) + + self.assertEqual(("v0.11.0", "v0.12.2"), versions) + self.assertTrue((root / "assets" / "v0.11.0").is_dir()) + self.assertTrue((root / "assets" / "v0.12.2").is_dir()) + + def test_missing_release_asset_is_rejected(self) -> None: + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + records = root / "records" + records.mkdir() + self.write_record(records, "candidate.json", "v0.12.2") + with self.assertRaisesRegex( + self.module.PreparationError, "incomplete or unsafe" + ): + self.module.prepare_assets( + records, + root / "assets", + downloader=lambda command: self.download_fixture( + command, omit="SHA256SUMS" + ), + ) + + def test_missing_github_cli_is_reported_without_command_output(self) -> None: + with mock.patch.object( + self.module.subprocess, + "run", + side_effect=FileNotFoundError("gh missing"), + ): + with self.assertRaisesRegex( + self.module.PreparationError, "could not be downloaded" + ) as caught: + self.module.run_download(["gh", "release", "download", "v0.12.2"]) + + self.assertNotIn("gh missing", str(caught.exception)) + + +if __name__ == "__main__": + unittest.main() diff --git a/release/scripts/test_relay_oidc_smoke.py b/release/scripts/test_relay_oidc_smoke.py index 34dd3427..bb22e159 100644 --- a/release/scripts/test_relay_oidc_smoke.py +++ b/release/scripts/test_relay_oidc_smoke.py @@ -4,11 +4,16 @@ import base64 import contextlib +import hashlib import importlib.util import json +import os +import stat +import subprocess import sys import tempfile import threading +from argparse import Namespace from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer from pathlib import Path from unittest import TestCase, main @@ -18,6 +23,7 @@ SCRIPT_DIR = Path(__file__).resolve().parent RUNNER_PATH = SCRIPT_DIR / "relay-oidc-smoke.py" HELPER_PATH = SCRIPT_DIR.parent / "conformance" / "relay-oidc" / "zitadel-helper.py" +sys.path.insert(0, str(SCRIPT_DIR)) def load_runner(): @@ -147,6 +153,80 @@ def topology(self) -> dict[str, str]: "service_account_org_id": "org-1", } + def write_candidate_evidence( + self, image_lock: Path, lock: dict[str, object] + ) -> None: + lock_bytes = json.dumps(lock).encode("utf-8") + image_lock.write_bytes(lock_bytes) + lock_sha256 = hashlib.sha256(lock_bytes).hexdigest() + tag = str(lock["release_tag"]) + capsule_name = f"registry-stack-{tag}-release-capsule.json" + images = lock["images"] + if not isinstance(images, dict): + raise AssertionError("test image lock images must be an object") + capsule = { + "release_tag": tag, + "version": tag.removeprefix("v"), + "repository": "registrystack/registry-stack", + "source": { + "source_tag": tag, + "source_ref": lock["manifest_source_ref"], + "source_commit": lock["tag_target"], + "lineage": { + "tag_matches_source_tag": True, + "head_matches_tag_target": True, + "source_ref_ancestor_or_equal": True, + "default_branch_reachable": True, + }, + }, + "release_files": [ + { + "name": image_lock.name, + "kind": "registryctl-release-image-lock", + "sha256": lock_sha256, + } + ], + "images": [ + {"name": name, "digest_ref": digest_ref} + for name, digest_ref in images.items() + ], + } + capsule_path = image_lock.parent / capsule_name + capsule_path.write_text(json.dumps(capsule), encoding="utf-8") + (image_lock.parent / "SHA256SUMS").write_text( + f"{lock_sha256} {image_lock.name}\n", encoding="utf-8" + ) + provenance = ( + image_lock.parent + / f"registry-stack-{tag}-release-provenance.intoto.jsonl" + ) + provenance.write_text("{}\n", encoding="utf-8") + for subject in (image_lock, capsule_path): + subject.with_name(f"{subject.name}.sig").write_text( + "signature", encoding="utf-8" + ) + subject.with_name(f"{subject.name}.pem").write_text( + "certificate", encoding="utf-8" + ) + + def beta_15_lock(self, relay_digest: str = "d") -> dict[str, object]: + return { + "schema_version": "registryctl.release_image_lock.v1", + "release_tag": "v0.12.1", + "manifest_source_ref": "a6f409259158f44c9fdbe99242ec0f9ac10d9373", + "tag_target": "567fb93704d25855238e11fd43c7cb9bf8a2f28e", + "platform": "linux/amd64", + "images": { + "registry-notary": ( + "ghcr.io/registrystack/registry-notary@sha256:" + "c" * 64 + ), + "registry-relay": ( + "ghcr.io/registrystack/registry-relay@sha256:" + + relay_digest * 64 + ), + }, + } + def test_assets_are_candidate_neutral_and_digest_pinned(self) -> None: assets = self.runner.validate_assets() compose = self.runner.COMPOSE_PATH.read_text(encoding="utf-8") @@ -160,44 +240,453 @@ def test_assets_are_candidate_neutral_and_digest_pinned(self) -> None: self.assertRegex(image, self.runner.DIGEST_IMAGE_RE) self.assertNotIn("ghcr.io/registrystack/registry-relay@sha256:", compose) - def test_relay_image_requires_exact_repository_and_lowercase_digest(self) -> None: - valid = "ghcr.io/registrystack/registry-relay@sha256:" + "a" * 64 - self.assertEqual(valid, self.runner.validate_relay_image(valid)) + def test_plan_declares_authenticity_network_and_no_live_evidence(self) -> None: + candidate = {"release_id": "beta-17", "source_ref": "a" * 40} + plan = self.runner.plan_document(candidate) - invalid = ( - "ghcr.io/registrystack/registry-relay:1.0.0", - "ghcr.io/registrystack/registry-relay:1.0.0@sha256:" + "a" * 64, - "example.test/registry-relay@sha256:" + "a" * 64, - "ghcr.io/registrystack/registry-relay@sha256:" + "A" * 64, - ) - for image in invalid: - with self.subTest(image=image): - with self.assertRaises(self.runner.SmokeError): - self.runner.validate_relay_image(image) - - def test_plan_is_offline_and_does_not_claim_live_evidence(self) -> None: - plan = self.runner.plan_document( - "ghcr.io/registrystack/registry-relay@sha256:" + "a" * 64, - "b" * 40, - "1.0.0-rc.1", - ) - - self.assertFalse(plan["plan_network_required"]) + self.assertTrue(plan["plan_network_required"]) self.assertTrue(plan["live_run_requires_docker"]) self.assertTrue(plan["live_run_network_required"]) self.assertFalse(plan["live_evidence"]) self.assertEqual(list(self.runner.REQUIRED_CHECKS), plan["checks"]) self.assertEqual("candidate-neutral-harness-plan", plan["classification"]) + self.assertEqual(candidate, plan["candidate"]) + + def test_candidate_binding_rejects_manifest_lock_and_image_mismatches(self) -> None: + candidate_module = sys.modules["conformance_candidate"] + with tempfile.TemporaryDirectory() as tmp: + manifest = ( + self.runner.REPO_ROOT / "release/manifests/registry-stack-beta-15.yaml" + ) + image_lock = Path(tmp) / "registryctl-v0.12.1-image-lock.json" + lock = { + "schema_version": "registryctl.release_image_lock.v1", + "release_tag": "v0.12.1", + "manifest_source_ref": "a6f409259158f44c9fdbe99242ec0f9ac10d9373", + "tag_target": "567fb93704d25855238e11fd43c7cb9bf8a2f28e", + "platform": "linux/amd64", + "images": { + "registry-notary": ( + "ghcr.io/registrystack/registry-notary@sha256:" + "c" * 64 + ), + "registry-relay": ( + "ghcr.io/registrystack/registry-relay@sha256:" + "d" * 64 + ), + }, + } + self.write_candidate_evidence(image_lock, lock) + args = Namespace( + release_manifest=manifest, + image_lock=image_lock, + topology="release-owned", + solmara_source_ref=None, + ) + with patch.object( + candidate_module, "verify_release_authenticity" + ) as authenticate: + candidate = self.runner.candidate_from_args(args) + self.assertEqual("beta-15", candidate["release_id"]) + self.assertEqual(lock["images"]["registry-relay"], candidate["relay_image"]) + authenticate.assert_called_once() + + lock["manifest_source_ref"] = "e" * 40 + image_lock.write_text(json.dumps(lock), encoding="utf-8") + with self.assertRaisesRegex( + candidate_module.CandidateError, "does not match" + ): + self.runner.candidate_from_args(args) + + lock["manifest_source_ref"] = "a6f409259158f44c9fdbe99242ec0f9ac10d9373" + lock["images"]["registry-relay"] = "registry-relay:mutable" + image_lock.write_text(json.dumps(lock), encoding="utf-8") + with self.assertRaisesRegex(candidate_module.CandidateError, "not pinned"): + self.runner.candidate_from_args(args) + + lock["images"]["registry-relay"] = ( + "ghcr.io/registrystack/registry-relay@sha256:" + "d" * 64 + ) + lock["tag_target"] = "b" * 40 + image_lock.write_text(json.dumps(lock), encoding="utf-8") + with self.assertRaisesRegex(candidate_module.CandidateError, "Git binding"): + self.runner.candidate_from_args(args) + + def test_candidate_binding_rejects_locally_replaced_release_assets(self) -> None: + candidate_module = sys.modules["conformance_candidate"] + with tempfile.TemporaryDirectory() as tmp: + manifest = ( + self.runner.REPO_ROOT / "release/manifests/registry-stack-beta-15.yaml" + ) + image_lock = Path(tmp) / "registryctl-v0.12.1-image-lock.json" + lock = { + "schema_version": "registryctl.release_image_lock.v1", + "release_tag": "v0.12.1", + "manifest_source_ref": "a6f409259158f44c9fdbe99242ec0f9ac10d9373", + "tag_target": "567fb93704d25855238e11fd43c7cb9bf8a2f28e", + "platform": "linux/amd64", + "images": { + "registry-notary": ( + "ghcr.io/registrystack/registry-notary@sha256:" + "c" * 64 + ), + "registry-relay": ( + "ghcr.io/registrystack/registry-relay@sha256:" + "d" * 64 + ), + }, + } + self.write_candidate_evidence(image_lock, lock) + args = Namespace( + release_manifest=manifest, + image_lock=image_lock, + topology="release-owned", + solmara_source_ref=None, + ) + + lock["images"]["registry-relay"] = ( + "ghcr.io/registrystack/registry-relay@sha256:" + "e" * 64 + ) + image_lock.write_text(json.dumps(lock), encoding="utf-8") + with patch.object( + candidate_module, "verify_release_authenticity" + ) as authenticate: + with self.assertRaisesRegex( + candidate_module.CandidateError, "SHA256SUMS" + ): + self.runner.candidate_from_args(args) + authenticate.assert_not_called() + + tampered_sha256 = hashlib.sha256(image_lock.read_bytes()).hexdigest() + (image_lock.parent / "SHA256SUMS").write_text( + f"{tampered_sha256} {image_lock.name}\n", encoding="utf-8" + ) + with patch.object( + candidate_module, "verify_release_authenticity" + ) as authenticate: + with self.assertRaisesRegex( + candidate_module.CandidateError, "classification or hash" + ): + self.runner.candidate_from_args(args) + authenticate.assert_not_called() + + self.write_candidate_evidence(image_lock, lock) + with patch.object( + candidate_module, + "verify_release_authenticity", + side_effect=candidate_module.CandidateError( + "candidate authenticity rejected" + ), + ): + with self.assertRaisesRegex( + candidate_module.CandidateError, "authenticity rejected" + ): + self.runner.candidate_from_args(args) + + def test_candidate_authentication_cannot_swap_parsed_source_assets( + self, + ) -> None: + candidate_module = sys.modules["conformance_candidate"] + with tempfile.TemporaryDirectory() as tmp: + asset_dir = Path(tmp) + image_lock = asset_dir / "registryctl-v0.12.1-image-lock.json" + signed_lock = self.beta_15_lock() + self.write_candidate_evidence(image_lock, signed_lock) + capsule = asset_dir / "registry-stack-v0.12.1-release-capsule.json" + signed_lock_bytes = image_lock.read_bytes() + signed_capsule_bytes = capsule.read_bytes() + + forged_lock = self.beta_15_lock(relay_digest="e") + self.write_candidate_evidence(image_lock, forged_lock) + forged_lock_bytes = image_lock.read_bytes() + forged_capsule_bytes = capsule.read_bytes() + snapshot_paths: list[Path] = [] + + def authenticate( + snapshot: Path, + _tag: str, + _subjects: tuple[str, ...], + ) -> None: + snapshot_paths.append(snapshot) + image_lock.write_bytes(signed_lock_bytes) + capsule.write_bytes(signed_capsule_bytes) + try: + if ( + snapshot.joinpath(image_lock.name).read_bytes() + != signed_lock_bytes + ): + raise candidate_module.CandidateError( + "authenticated bytes do not match parsed bytes" + ) + finally: + image_lock.write_bytes(forged_lock_bytes) + capsule.write_bytes(forged_capsule_bytes) + + manifest = ( + self.runner.REPO_ROOT + / "release/manifests/registry-stack-beta-15.yaml" + ) + with patch.object( + candidate_module, + "verify_release_authenticity", + side_effect=authenticate, + ): + with self.assertRaisesRegex( + candidate_module.CandidateError, + "authenticated bytes do not match parsed bytes", + ): + candidate_module.load_candidate(manifest, image_lock) + + self.assertEqual(forged_lock_bytes, image_lock.read_bytes()) + self.assertEqual(forged_capsule_bytes, capsule.read_bytes()) + self.assertEqual(1, len(snapshot_paths)) + self.assertFalse(snapshot_paths[0].exists()) + + def test_candidate_verifiers_receive_private_read_only_snapshot(self) -> None: + candidate_module = sys.modules["conformance_candidate"] + with tempfile.TemporaryDirectory() as tmp: + asset_dir = Path(tmp) + image_lock = asset_dir / "registryctl-v0.12.1-image-lock.json" + self.write_candidate_evidence(image_lock, self.beta_15_lock()) + snapshot_paths: list[Path] = [] + + def authenticate( + snapshot: Path, + _tag: str, + _subjects: tuple[str, ...], + ) -> None: + snapshot_paths.append(snapshot) + self.assertNotEqual(asset_dir, snapshot) + self.assertEqual( + 0o500, stat.S_IMODE(snapshot.stat().st_mode) + ) + self.assertEqual(os.geteuid(), snapshot.stat().st_uid) + for path in snapshot.iterdir(): + self.assertEqual(0o400, stat.S_IMODE(path.stat().st_mode)) + self.assertFalse(path.is_symlink()) + + manifest = ( + self.runner.REPO_ROOT + / "release/manifests/registry-stack-beta-15.yaml" + ) + with patch.object( + candidate_module, + "verify_release_authenticity", + side_effect=authenticate, + ): + candidate = candidate_module.load_candidate(manifest, image_lock) + + self.assertEqual("beta-15", candidate["release_id"]) + self.assertEqual(1, len(snapshot_paths)) + self.assertFalse(snapshot_paths[0].exists()) + + def test_candidate_snapshot_rejects_symlink_asset(self) -> None: + candidate_module = sys.modules["conformance_candidate"] + with tempfile.TemporaryDirectory() as tmp: + asset_dir = Path(tmp) + image_lock = asset_dir / "registryctl-v0.12.1-image-lock.json" + self.write_candidate_evidence(image_lock, self.beta_15_lock()) + signature = image_lock.with_name(f"{image_lock.name}.sig") + signature_target = asset_dir / "replacement.sig" + signature_target.write_text("signature", encoding="utf-8") + signature.unlink() + signature.symlink_to(signature_target) + manifest = ( + self.runner.REPO_ROOT + / "release/manifests/registry-stack-beta-15.yaml" + ) - def test_candidate_identifiers_are_bounded(self) -> None: - self.assertEqual("a" * 40, self.runner.validate_source_ref("a" * 40)) - self.assertEqual("1.0.0-rc.1", self.runner.validate_release_id("1.0.0-rc.1")) - for source_ref in ("a" * 39, "A" * 40, "main"): - with self.assertRaises(self.runner.SmokeError): - self.runner.validate_source_ref(source_ref) - for release_id in ("", "contains space", "a" * 65): - with self.assertRaises(self.runner.SmokeError): - self.runner.validate_release_id(release_id) + with patch.object( + candidate_module, "verify_release_authenticity" + ) as authenticate: + with self.assertRaisesRegex( + candidate_module.CandidateError, + "read safely", + ): + candidate_module.load_candidate(manifest, image_lock) + authenticate.assert_not_called() + + def test_candidate_authenticity_command_timeout_is_bounded(self) -> None: + candidate_module = sys.modules["conformance_candidate"] + with patch.object( + candidate_module.subprocess, + "run", + side_effect=subprocess.TimeoutExpired("cosign", 120), + ): + with self.assertRaisesRegex( + candidate_module.CandidateError, + "could not complete", + ): + candidate_module.run_authenticity_command( + ["/tools/cosign", "verify-blob", "subject"] + ) + + def test_candidate_authenticity_is_pinned_to_the_tagged_release_workflow( + self, + ) -> None: + candidate_module = sys.modules["conformance_candidate"] + with tempfile.TemporaryDirectory() as tmp: + asset_dir = Path(tmp) + tag = "v0.12.1" + subjects = ( + f"registryctl-{tag}-image-lock.json", + f"registry-stack-{tag}-release-capsule.json", + ) + for name in subjects: + (asset_dir / name).write_text("subject", encoding="utf-8") + (asset_dir / f"{name}.sig").write_text( + "signature", encoding="utf-8" + ) + (asset_dir / f"{name}.pem").write_text( + "certificate", encoding="utf-8" + ) + ( + asset_dir + / f"registry-stack-{tag}-release-provenance.intoto.jsonl" + ).write_text("{}\n", encoding="utf-8") + commands: list[list[str]] = [] + tool_paths = { + "cosign": "/tools/cosign", + "slsa-verifier": "/tools/slsa-verifier", + } + with patch.object( + candidate_module.shutil, + "which", + side_effect=lambda name: tool_paths.get(name), + ): + candidate_module.verify_release_authenticity( + asset_dir, tag, subjects, command_runner=commands.append + ) + + self.assertEqual(4, len(commands)) + identity = candidate_module.RELEASE_WORKFLOW.format(tag=tag) + for command in commands[::2]: + self.assertEqual("/tools/cosign", command[0]) + self.assertIn(identity, command) + for command in commands[1::2]: + self.assertEqual("/tools/slsa-verifier", command[0]) + self.assertEqual(tag, command[command.index("--source-tag") + 1]) + self.assertEqual( + candidate_module.SLSA_SOURCE_URI, + command[command.index("--source-uri") + 1], + ) + + def test_candidate_binding_accepts_only_the_manifest_closeout_transition( + self, + ) -> None: + candidate_module = sys.modules["conformance_candidate"] + with tempfile.TemporaryDirectory() as tmp: + image_lock = Path(tmp) / "registryctl-v0.12.2-image-lock.json" + lock = { + "schema_version": "registryctl.release_image_lock.v1", + "release_tag": "v0.12.2", + "manifest_source_ref": ( + "0e76f5ea61f78bbc15d91fcb6e9dfcaa956c3df8" + ), + "tag_target": "e25f081ce800ade13e892503cc19b96588e081ef", + "platform": "linux/amd64", + "images": { + "registry-notary": ( + "ghcr.io/registrystack/registry-notary@sha256:" + + "c" * 64 + ), + "registry-relay": ( + "ghcr.io/registrystack/registry-relay@sha256:" + + "d" * 64 + ), + }, + } + self.write_candidate_evidence(image_lock, lock) + manifest = ( + self.runner.REPO_ROOT + / "release/manifests/registry-stack-beta-16.yaml" + ) + args = Namespace( + release_manifest=manifest, + image_lock=image_lock, + topology="release-owned", + solmara_source_ref=None, + ) + with patch.object( + candidate_module, "verify_release_authenticity" + ) as authenticate: + candidate = self.runner.candidate_from_args(args) + + self.assertEqual("beta-16", candidate["release_id"]) + authenticate.assert_called_once() + + tagged = candidate_module.git_output( + [ + "show", + ( + "e25f081ce800ade13e892503cc19b96588e081ef:" + "release/manifests/registry-stack-beta-16.yaml" + ), + ], + 1024 * 1024, + ) + released = manifest.read_bytes() + candidate_module.verify_closeout_manifest_transition( + {"status": "released"}, released, tagged + ) + with self.assertRaisesRegex( + candidate_module.CandidateError, "Git binding" + ): + candidate_module.verify_closeout_manifest_transition( + {"status": "released"}, released, released + ) + + real_git_output = candidate_module.git_output + + def released_tag(arguments, max_bytes): + if arguments[0] == "cat-file": + return str(len(released)).encode("ascii") + if arguments[0] == "show": + return released + return real_git_output(arguments, max_bytes) + + with patch.object( + candidate_module, + "git_output", + side_effect=released_tag, + ), patch.object( + candidate_module, "verify_release_authenticity" + ) as authenticate: + with self.assertRaisesRegex( + candidate_module.CandidateError, "Git binding" + ): + self.runner.candidate_from_args(args) + authenticate.assert_not_called() + + real_read = candidate_module.read_regular_file_no_follow + drifted = released + b"# Invalid post-tag manifest drift.\n" + + def drifted_manifest(path, *, max_bytes): + if path == manifest: + return drifted + return real_read(path, max_bytes=max_bytes) + + with patch.object( + candidate_module, + "read_regular_file_no_follow", + side_effect=drifted_manifest, + ), patch.object( + candidate_module, "verify_release_authenticity" + ) as authenticate: + with self.assertRaisesRegex( + candidate_module.CandidateError, "Git binding" + ): + self.runner.candidate_from_args(args) + authenticate.assert_not_called() + + with self.assertRaisesRegex( + candidate_module.CandidateError, "Git binding" + ): + candidate_module.verify_closeout_manifest_transition( + {"status": "released"}, + released + + ( + b"# Any post-tag change beyond the exact status closeout " + b"is rejected.\n" + ), + tagged, + ) def test_native_zitadel_role_claim_drives_scope_profile(self) -> None: token = fake_token(role_claim={"registry-smoke-reader": {"org-1": "active"}}) @@ -323,7 +812,9 @@ def test_output_directory_must_be_empty(self) -> None: def test_run_requires_all_candidate_binding_arguments(self) -> None: with patch.object(sys, "stderr"): with self.assertRaises(SystemExit): - self.runner.parse_args(["run", "--release-id", "1.0.0-rc.1"]) + self.runner.parse_args( + ["run", "--release-manifest", "release/manifests/example.yaml"] + ) def test_helper_never_logs_secret_response_fields(self) -> None: helper = self.runner.HELPER_PATH.read_text(encoding="utf-8") @@ -337,7 +828,7 @@ def test_helper_never_logs_secret_response_fields(self) -> None: def test_readme_states_binding_and_ephemeral_secret_boundaries(self) -> None: readme = (self.runner.CONFIG_DIR / "README.md").read_text(encoding="utf-8") - self.assertIn("does not derive or\ncryptographically verify", readme) + self.assertIn("release-tag, product-version, or image-digest mismatch", readme) self.assertIn("container environment metadata", readme) self.assertIn("container command metadata", readme) self.assertIn("never follow redirects", readme) diff --git a/release/scripts/test_validate_upgrade_exercise.py b/release/scripts/test_validate_upgrade_exercise.py index 4adf52ed..03ceb1ee 100644 --- a/release/scripts/test_validate_upgrade_exercise.py +++ b/release/scripts/test_validate_upgrade_exercise.py @@ -1,17 +1,24 @@ from __future__ import annotations import copy +import contextlib import hashlib import importlib.util +import io import json +import os import sys +import tempfile import unittest from pathlib import Path +from unittest import mock ROOT = Path(__file__).resolve().parents[2] SCRIPT = ROOT / "release" / "scripts" / "validate-upgrade-exercise.py" TEMPLATE = ROOT / "release" / "exercises" / "upgrade-exercise-v1.template.json" +TARGET_COMMIT = "e25f081ce800ade13e892503cc19b96588e081ef" +TARGET_MANIFEST = Path("release/manifests/registry-stack-beta-16.yaml") def load_module(): @@ -28,6 +35,32 @@ class UpgradeExerciseValidatorTest(unittest.TestCase): def setUp(self) -> None: self.module = load_module() self.template = json.loads(TEMPLATE.read_text(encoding="utf-8")) + self.candidate_asset_root = Path("/authenticated-candidate-assets") + self.real_load_candidate = self.module.load_candidate + self.load_candidate = mock.patch.object( + self.module, + "load_candidate", + return_value={ + "release_id": "beta-16", + "version": "0.12.2", + "source_ref": "0e76f5ea61f78bbc15d91fcb6e9dfcaa956c3df8", + "source_tag": "v0.12.2", + "tag_target": TARGET_COMMIT, + "image_lock_sha256": "sha256:" + "b" * 64, + "relay_image": ( + "ghcr.io/registrystack/registry-relay@sha256:" + "b" * 64 + ), + "notary_image": ( + "ghcr.io/registrystack/registry-notary@sha256:" + "b" * 64 + ), + }, + ) + self.load_candidate.start() + self.addCleanup(self.load_candidate.stop) + + def validate_record(self, data, **kwargs): + kwargs.setdefault("candidate_asset_root", self.candidate_asset_root) + return self.module.validate_record(data, **kwargs) def candidate(self): def replace(value): @@ -49,8 +82,14 @@ def replace(value): return "2026-07-19T12:00:00Z" if value == "": return "v0.11.0" + if value == "": + return "beta-16" + if value == "": + return "0e76f5ea61f78bbc15d91fcb6e9dfcaa956c3df8" + if value == "": + return TARGET_MANIFEST.as_posix() if "VERSION>" in value or "RELEASE_TAG>" in value: - return "v1.0.0" + return "v0.12.2" if "COMMIT>" in value: return "a" * 40 if "SHA256>" in value or "DIGEST>" in value: @@ -63,25 +102,79 @@ def replace(value): record["candidate_independently_verified"] = True for result in record["results"]: result["outcome"] = "passed" + record["target_release"]["source_commit"] = TARGET_COMMIT + manifest = self.module.git_bytes(ROOT, TARGET_COMMIT, TARGET_MANIFEST) + record["target_release_manifest"]["sha256"] = self.module.sha256_bytes(manifest) for product, path in self.module.CONFIG_SCHEMAS.items(): - record["config_schemas"][product]["sha256"] = "sha256:" + hashlib.sha256( - (ROOT / path).read_bytes() - ).hexdigest() + record["config_schemas"][product]["sha256"] = self.module.sha256_bytes( + self.module.git_bytes(ROOT, TARGET_COMMIT, path) + ) + artifacts = record["candidate_artifact_set"]["artifacts"] + artifacts["manifest"] = record["target_release_manifest"]["sha256"] + artifacts["relay_image"] = record["target_release"]["relay_image_digest"] + artifacts["notary_image"] = record["target_release"]["notary_image_digest"] + artifacts["p_release_inputs"] = self.module.release_inputs_sha256( + ROOT, record["target_release"]["source_ref"] + ) + artifacts["t_release_inputs"] = self.module.release_inputs_sha256( + ROOT, TARGET_COMMIT + ) + record["candidate_artifact_set"]["sha256"] = self.module.canonical_sha256(artifacts) return record def test_template_is_valid_preparation_but_not_candidate_evidence(self) -> None: - self.module.validate_record(self.template, allow_template=True) + self.validate_record(self.template, allow_template=True) with self.assertRaisesRegex(self.module.ExerciseError, "not candidate evidence"): - self.module.validate_record(self.template, allow_template=False) + self.validate_record(self.template, allow_template=False) def test_complete_candidate_record_is_valid(self) -> None: - self.module.validate_record(self.candidate(), allow_template=False) + self.module.load_candidate.reset_mock() + self.validate_record( + self.candidate(), allow_template=False, require_all_passed=True + ) + self.module.load_candidate.assert_called_once_with( + ROOT / TARGET_MANIFEST, + ( + self.candidate_asset_root + / "v0.12.2" + / "registryctl-v0.12.2-image-lock.json" + ), + ) + + def test_candidate_tag_must_resolve_to_exact_target_commit(self) -> None: + record = self.candidate() + tag_ref = f"refs/tags/{record['target_release']['version']}^{{commit}}" + run = self.module.subprocess.run + + def mismatched_tag(arguments, **kwargs): + if arguments == ["git", "rev-parse", "--verify", tag_ref]: + return mock.Mock( + returncode=0, + stdout=record["target_release"]["source_ref"] + "\n", + ) + return run(arguments, **kwargs) + + with mock.patch.object( + self.module.subprocess, "run", side_effect=mismatched_tag + ) as git_run: + with self.assertRaisesRegex( + self.module.ExerciseError, + "release tag v0.12.2 does not resolve to target_release.source_commit", + ): + self.validate_record(record, allow_template=False) + git_run.assert_any_call( + ["git", "rev-parse", "--verify", "refs/tags/v0.12.2^{commit}"], + cwd=ROOT, + capture_output=True, + text=True, + check=False, + ) def test_candidate_must_be_a_forward_version_upgrade(self) -> None: record = self.candidate() record["source_release"]["version"] = record["target_release"]["version"] with self.assertRaisesRegex(self.module.ExerciseError, "must be newer"): - self.module.validate_record(record, allow_template=False) + self.validate_record(record, allow_template=False) def test_candidate_release_identifiers_are_strict(self) -> None: for field, value in ( @@ -93,41 +186,368 @@ def test_candidate_release_identifiers_are_strict(self) -> None: record = self.candidate() record["target_release"][field] = value with self.assertRaisesRegex(self.module.ExerciseError, "invalid or unsafe"): - self.module.validate_record(record, allow_template=False) + self.validate_record(record, allow_template=False) def test_unknown_field_is_rejected_to_prevent_raw_evidence(self) -> None: record = self.candidate() record["results"][0]["raw_output"] = "Authorization: Bearer secret" with self.assertRaisesRegex(self.module.ExerciseError, "unknown raw_output"): - self.module.validate_record(record, allow_template=False) + self.validate_record(record, allow_template=False) def test_authority_identifier_cannot_contain_a_url_or_subject_data(self) -> None: record = self.candidate() record["topology"]["relay_authorities"][0] = "https://registry.example.test/subject/1" with self.assertRaisesRegex(self.module.ExerciseError, "invalid or unsafe"): - self.module.validate_record(record, allow_template=False) + self.validate_record(record, allow_template=False) - def test_every_required_check_must_pass(self) -> None: - record = self.candidate() - record["results"].pop() - with self.assertRaisesRegex(self.module.ExerciseError, "every required check"): - self.module.validate_record(record, allow_template=False) + def test_failed_and_not_run_results_are_recordable_but_fail_promotion(self) -> None: record = self.candidate() record["results"][0]["outcome"] = "failed" - with self.assertRaisesRegex(self.module.ExerciseError, "must be 'passed'"): - self.module.validate_record(record, allow_template=False) + record["results"][1].update( + {"outcome": "not_run", "observed_at": None, "evidence_label": None, "evidence_sha256": None} + ) + self.validate_record(record, allow_template=False) + with self.assertRaisesRegex(self.module.ExerciseError, "--require-pass"): + self.validate_record( + record, allow_template=False, require_all_passed=True + ) + + def test_discovery_requires_candidate_passes_and_preserves_templates(self) -> None: + with tempfile.TemporaryDirectory() as temporary: + records = Path(temporary) + (records / "template.json").write_text( + json.dumps(self.template), encoding="utf-8" + ) + with mock.patch.object( + sys, + "argv", + [str(SCRIPT), "--discover", str(records)], + ), mock.patch("sys.stdout", new=io.StringIO()): + self.assertEqual(self.module.main(), 0) + + candidate = self.candidate() + candidate["results"][0]["outcome"] = "failed" + (records / "candidate.json").write_text( + json.dumps(candidate), encoding="utf-8" + ) + stderr = io.StringIO() + with mock.patch.object( + sys, + "argv", + [ + str(SCRIPT), + "--discover", + str(records), + "--candidate-asset-root", + str(self.candidate_asset_root), + ], + ), mock.patch("sys.stderr", new=stderr): + self.assertEqual(self.module.main(), 1) + self.assertIn( + "--require-pass requires every check to pass", stderr.getvalue() + ) + + def test_promotion_rejects_product_oci_layout_drift(self) -> None: + for product in ("notary", "relay"): + with self.subTest(product=product): + record = self.candidate() + artifacts = record["candidate_artifact_set"]["artifacts"] + artifacts[f"t_{product}_layouts"] = "sha256:" + "0" * 64 + record["candidate_artifact_set"]["sha256"] = ( + self.module.canonical_sha256(artifacts) + ) + with self.assertRaisesRegex( + self.module.ExerciseError, + f"--require-pass rejects P/T {product} OCI layout drift", + ): + self.validate_record( + record, allow_template=False, require_all_passed=True + ) def test_complete_release_specific_recovery_set_is_required(self) -> None: record = self.candidate() record["recovery_set"].pop() with self.assertRaisesRegex(self.module.ExerciseError, "complete release-specific"): - self.module.validate_record(record, allow_template=False) + self.validate_record(record, allow_template=False) + + def test_candidate_uses_historical_schema_not_ambient_checkout(self) -> None: + record = self.candidate() + notary_schema = self.module.CONFIG_SCHEMAS["registry-notary"] + historical_schema = b'{"historical_target_schema": true}\n' + record["config_schemas"]["registry-notary"]["sha256"] = ( + self.module.sha256_bytes(historical_schema) + ) + ambient = "sha256:" + hashlib.sha256( + (ROOT / notary_schema).read_bytes() + ).hexdigest() + self.assertNotEqual(ambient, record["config_schemas"]["registry-notary"]["sha256"]) + read_git_bytes = self.module.git_bytes + + def historical_git_bytes(root: Path, commit: str, path: Path) -> bytes: + if commit == TARGET_COMMIT and path == notary_schema: + return historical_schema + return read_git_bytes(root, commit, path) + + with mock.patch.object( + self.module, "git_bytes", side_effect=historical_git_bytes + ) as git_bytes: + self.module.validate_config_schemas( + record["config_schemas"], + template=False, + root=ROOT, + target_commit=TARGET_COMMIT, + ) + git_bytes.assert_any_call(ROOT, TARGET_COMMIT, notary_schema) + + def test_manifest_hash_ref_and_artifact_set_drift_are_rejected(self) -> None: + record = self.candidate() + record["target_release_manifest"]["sha256"] = "sha256:" + "0" * 64 + record["candidate_artifact_set"]["artifacts"]["manifest"] = "sha256:" + "0" * 64 + record["candidate_artifact_set"]["sha256"] = self.module.canonical_sha256( + record["candidate_artifact_set"]["artifacts"] + ) + with self.assertRaisesRegex(self.module.ExerciseError, "does not match exact target"): + self.validate_record(record, allow_template=False) + record = self.candidate() + record["target_release"]["source_ref"] = TARGET_COMMIT + record["candidate_artifact_set"]["artifacts"]["p_release_inputs"] = ( + record["candidate_artifact_set"]["artifacts"]["t_release_inputs"] + ) + record["candidate_artifact_set"]["sha256"] = self.module.canonical_sha256( + record["candidate_artifact_set"]["artifacts"] + ) + with self.assertRaisesRegex(self.module.ExerciseError, "identity does not match"): + self.validate_record(record, allow_template=False) + record = self.candidate() + record["candidate_artifact_set"]["artifacts"]["t2_binaries"] = "sha256:" + "0" * 64 + with self.assertRaisesRegex(self.module.ExerciseError, "does not match its artifacts"): + self.validate_record(record, allow_template=False) + + def test_candidate_image_lock_digest_matches_authenticated_asset_bytes( + self, + ) -> None: + with tempfile.TemporaryDirectory() as temporary: + asset_root = Path(temporary) + asset_dir = asset_root / "v0.12.2" + asset_dir.mkdir() + image_lock = asset_dir / "registryctl-v0.12.2-image-lock.json" + image_lock.write_bytes(b'{"authenticated":"release image lock"}') + record = self.candidate() + artifacts = record["candidate_artifact_set"]["artifacts"] + artifacts["image_lock"] = self.module.sha256_bytes( + image_lock.read_bytes() + ) + record["candidate_artifact_set"]["sha256"] = ( + self.module.canonical_sha256(artifacts) + ) + candidate = self.module.load_candidate.return_value.copy() + + def authenticated_candidate(_manifest_path, lock_path): + metadata = candidate.copy() + metadata["image_lock_sha256"] = self.module.sha256_bytes( + lock_path.read_bytes() + ) + return metadata + + with mock.patch.object( + self.module, + "load_candidate", + side_effect=authenticated_candidate, + ): + self.validate_record( + record, + allow_template=False, + candidate_asset_root=asset_root, + ) + image_lock.write_bytes(image_lock.read_bytes() + b"\n") + with self.assertRaisesRegex( + self.module.ExerciseError, + "exact authenticated release image-lock", + ): + self.validate_record( + record, + allow_template=False, + candidate_asset_root=asset_root, + ) + + def test_candidate_evidence_requires_release_asset_directory(self) -> None: + with self.assertRaisesRegex( + self.module.ExerciseError, "--candidate-asset-root" + ): + self.module.validate_record( + self.candidate(), + allow_template=False, + candidate_asset_root=None, + ) + + def test_missing_authentication_tools_or_assets_fail_closed(self) -> None: + for detail in ( + "candidate authenticity verification requires installed cosign", + "required file is unavailable: image lock", + ): + with self.subTest(detail=detail): + with mock.patch.object( + self.module, + "load_candidate", + side_effect=self.module.CandidateError(detail), + ): + with self.assertRaisesRegex( + self.module.ExerciseError, + "could not be authenticated", + ) as caught: + self.validate_record( + self.candidate(), allow_template=False + ) + self.assertNotIn(detail, str(caught.exception)) + + def test_upgrade_consumer_enforces_candidate_to_released_closeout( + self, + ) -> None: + candidate_module = sys.modules["conformance_candidate"] + tagged_candidate = self.module.git_bytes( + ROOT, TARGET_COMMIT, TARGET_MANIFEST + ) + local_released = (ROOT / TARGET_MANIFEST).read_bytes() + cases = ( + ("valid closeout", tagged_candidate, local_released, True), + ("released at tag", local_released, local_released, False), + ( + "post-tag drift", + tagged_candidate, + local_released + b"# Invalid post-tag manifest drift.\n", + False, + ), + ) + for label, tagged, local, valid in cases: + with self.subTest(case=label), tempfile.TemporaryDirectory() as temporary: + asset_root = Path(temporary) + asset_dir = asset_root / "v0.12.2" + asset_dir.mkdir() + image_lock = asset_dir / "registryctl-v0.12.2-image-lock.json" + lock = { + "schema_version": "registryctl.release_image_lock.v1", + "release_tag": "v0.12.2", + "manifest_source_ref": ( + "0e76f5ea61f78bbc15d91fcb6e9dfcaa956c3df8" + ), + "tag_target": TARGET_COMMIT, + "platform": "linux/amd64", + "images": { + "registry-notary": ( + "ghcr.io/registrystack/registry-notary@sha256:" + + "b" * 64 + ), + "registry-relay": ( + "ghcr.io/registrystack/registry-relay@sha256:" + + "b" * 64 + ), + }, + } + lock_bytes = json.dumps(lock).encode("utf-8") + image_lock.write_bytes(lock_bytes) + record = self.candidate() + artifacts = record["candidate_artifact_set"]["artifacts"] + artifacts["image_lock"] = self.module.sha256_bytes(lock_bytes) + record["candidate_artifact_set"]["sha256"] = ( + self.module.canonical_sha256(artifacts) + ) + + @contextlib.contextmanager + def snapshot(_image_lock_path): + yield asset_dir, lock_bytes + + real_git_output = candidate_module.git_output + + def git_output(arguments, max_bytes): + if arguments[0] == "cat-file": + return str(len(tagged)).encode("ascii") + if arguments[0] == "show": + return tagged + return real_git_output(arguments, max_bytes) + + with mock.patch.object( + self.module, "load_candidate", self.real_load_candidate + ), mock.patch.object( + candidate_module, + "candidate_asset_snapshot", + snapshot, + ), mock.patch.object( + candidate_module, + "read_regular_file_no_follow", + return_value=local, + ), mock.patch.object( + candidate_module, + "git_output", + side_effect=git_output, + ), mock.patch.object( + candidate_module, + "verify_release_asset_binding", + return_value="c" * 64, + ): + if valid: + self.validate_record( + record, + allow_template=False, + candidate_asset_root=asset_root, + ) + else: + with self.assertRaisesRegex( + self.module.ExerciseError, + "could not be authenticated", + ): + self.validate_record( + record, + allow_template=False, + candidate_asset_root=asset_root, + ) + + def test_real_v0122_release_image_lock_authenticates(self) -> None: + asset_root_value = os.environ.get("REGISTRY_TEST_UPGRADE_ASSET_ROOT") + if not asset_root_value: + self.skipTest( + "REGISTRY_TEST_UPGRADE_ASSET_ROOT is required for real release assets" + ) + asset_root = Path(asset_root_value) + asset_dir = asset_root / "v0.12.2" + image_lock = asset_dir / "registryctl-v0.12.2-image-lock.json" + lock = json.loads(image_lock.read_text(encoding="utf-8")) + record = self.candidate() + artifacts = record["candidate_artifact_set"]["artifacts"] + record["target_release"]["relay_image_digest"] = lock["images"][ + "registry-relay" + ].split("@", 1)[1] + record["target_release"]["notary_image_digest"] = lock["images"][ + "registry-notary" + ].split("@", 1)[1] + artifacts["relay_image"] = record["target_release"]["relay_image_digest"] + artifacts["notary_image"] = record["target_release"][ + "notary_image_digest" + ] + artifacts["image_lock"] = self.module.sha256_bytes( + image_lock.read_bytes() + ) + record["candidate_artifact_set"]["sha256"] = ( + self.module.canonical_sha256(artifacts) + ) + + with mock.patch.object( + self.module, "load_candidate", self.real_load_candidate + ): + self.validate_record( + record, + allow_template=False, + require_all_passed=True, + candidate_asset_root=asset_root, + ) - def test_candidate_must_use_committed_config_schemas(self) -> None: + def test_topology_requires_one_dedicated_notary_per_relay(self) -> None: record = self.candidate() - record["config_schemas"]["registry-notary"]["sha256"] = "sha256:" + "0" * 64 - with self.assertRaisesRegex(self.module.ExerciseError, "committed schema"): - self.module.validate_record(record, allow_template=False) + record["topology"]["relay_authorities"].append("relay-authority-b") + record["topology"]["authority_pairs"].append( + {"relay": "relay-authority-b", "notary": "notary-authority-a"} + ) + with self.assertRaisesRegex(self.module.ExerciseError, "dedicated Notary"): + self.validate_record(record, allow_template=False) if __name__ == "__main__": diff --git a/release/scripts/validate-upgrade-exercise.py b/release/scripts/validate-upgrade-exercise.py index f7c2cee8..c2141bac 100644 --- a/release/scripts/validate-upgrade-exercise.py +++ b/release/scripts/validate-upgrade-exercise.py @@ -7,18 +7,55 @@ import hashlib import json import re +import subprocess import sys from pathlib import Path from typing import Any +import yaml + ROOT = Path(__file__).resolve().parents[2] +SCRIPT_DIR = Path(__file__).resolve().parent +if str(SCRIPT_DIR) not in sys.path: + sys.path.insert(0, str(SCRIPT_DIR)) + +from conformance_candidate import CandidateError, load_candidate # noqa: E402 + + SCHEMA = "registry-stack.upgrade-exercise/v1" SOLMARA_REPOSITORY = "registrystack/solmara-lab" +STACK_REPOSITORY = "registrystack/registry-stack" CONFIG_SCHEMAS = { "registry-relay": Path("schemas/registry-relay.config.schema.json"), "registry-notary": Path("schemas/registry-notary.config.schema.json"), } +RELEASE_INPUTS = ( + Path(".github/workflows/release.yml"), + Path("Cargo.lock"), + Path("Cargo.toml"), + Path("release/docker/Dockerfile.registry-notary"), + Path("release/docker/Dockerfile.registry-relay"), + Path("release/scripts/build-release-binaries.sh"), + Path("release/scripts/build-release-image.sh"), + Path("release/scripts/check-release-relay-features.py"), + Path("release/scripts/compare-release-image-layouts.py"), + Path("release/scripts/registry-release"), + Path("rust-toolchain.toml"), + *CONFIG_SCHEMAS.values(), +) +ARTIFACT_KEYS = { + *(f"{phase}{run}_{kind}" for phase in ("p", "t") for run in (1, 2) + for kind in ("binaries", "image_inputs")), + *(f"{phase}_{product}_layouts" for phase in ("p", "t") + for product in ("notary", "relay")), + "image_lock", + "manifest", + "notary_image", + "relay_image", + "p_release_inputs", + "t_release_inputs", +} REQUIRED_CHECKS = ( "candidate_artifacts_independently_verified", "source_release_ready", @@ -90,11 +127,43 @@ def bounded_string( return value +def sha256_bytes(value: bytes) -> str: + return "sha256:" + hashlib.sha256(value).hexdigest() + + +def canonical_sha256(value: Any) -> str: + return sha256_bytes(json.dumps(value, sort_keys=True, separators=(",", ":")).encode()) + + +def git_bytes(root: Path, commit: str, path: Path) -> bytes: + result = subprocess.run( + ["git", "show", f"{commit}:{path.as_posix()}"], + cwd=root, + capture_output=True, + check=False, + ) + if result.returncode != 0: + raise ExerciseError(f"{path} does not exist at exact Git object {commit}") + return result.stdout + + +def release_inputs_sha256(root: Path, commit: str) -> str: + return canonical_sha256( + [ + {"path": path.as_posix(), "sha256": sha256_bytes(git_bytes(root, commit, path))} + for path in RELEASE_INPUTS + ] + ) + + def validate_release(value: Any, label: str, *, template: bool) -> None: + keys = {"version", "source_commit", "relay_image_digest", "notary_image_digest"} + if label == "target_release": + keys.update({"release_id", "source_ref"}) release = require_object( value, label, - {"version", "source_commit", "relay_image_digest", "notary_image_digest"}, + keys, ) bounded_string(release["version"], f"{label}.version", VERSION, template=template) bounded_string(release["source_commit"], f"{label}.source_commit", COMMIT, template=template) @@ -104,6 +173,13 @@ def validate_release(value: Any, label: str, *, template: bool) -> None: SHA256, template=template, ) + if label == "target_release": + bounded_string( + release["release_id"], f"{label}.release_id", SLUG, template=template + ) + bounded_string( + release["source_ref"], f"{label}.source_ref", COMMIT, template=template + ) bounded_string( release["notary_image_digest"], f"{label}.notary_image_digest", @@ -117,7 +193,9 @@ def version_order(value: str) -> tuple[tuple[int, int, int], bool]: return tuple(int(part) for part in core.split(".")), not bool(separator) -def validate_config_schemas(value: Any, *, template: bool, root: Path) -> None: +def validate_config_schemas( + value: Any, *, template: bool, root: Path, target_commit: str | None +) -> None: schemas = require_object(value, "config_schemas", set(CONFIG_SCHEMAS)) for product, expected_path in CONFIG_SCHEMAS.items(): entry = require_object( @@ -131,13 +209,45 @@ def validate_config_schemas(value: Any, *, template: bool, root: Path) -> None: entry["sha256"], f"config_schemas.{product}.sha256", SHA256, template=template ) if not template: - actual = hashlib.sha256((root / expected_path).read_bytes()).hexdigest() - if digest.removeprefix("sha256:") != actual: + assert target_commit is not None + actual = sha256_bytes(git_bytes(root, target_commit, expected_path)) + if digest.removeprefix("sha256:") != actual.removeprefix("sha256:"): raise ExerciseError( - f"config_schemas.{product}.sha256 does not match the committed schema" + f"config_schemas.{product}.sha256 does not match the exact target Git object" ) +def validate_artifact_set(value: Any, record: dict[str, Any], *, template: bool) -> None: + artifact_set = require_object(value, "candidate_artifact_set", {"sha256", "artifacts"}) + bounded_string( + artifact_set["sha256"], + "candidate_artifact_set.sha256", + SHA256, + template=template, + ) + artifacts = require_object( + artifact_set["artifacts"], "candidate_artifact_set.artifacts", ARTIFACT_KEYS + ) + for name, digest in artifacts.items(): + bounded_string( + digest, + f"candidate_artifact_set.artifacts.{name}", + SHA256, + template=template, + ) + if template: + return + expected = { + "manifest": record["target_release_manifest"]["sha256"], + "relay_image": record["target_release"]["relay_image_digest"], + "notary_image": record["target_release"]["notary_image_digest"], + } + if any(artifacts[name] != digest for name, digest in expected.items()): + raise ExerciseError("candidate_artifact_set does not match target release coordinates") + if artifact_set["sha256"] != canonical_sha256(artifacts): + raise ExerciseError("candidate_artifact_set.sha256 does not match its artifacts") + + def validate_topology(value: Any, *, template: bool) -> None: topology = require_object( value, @@ -179,7 +289,7 @@ def validate_topology(value: Any, *, template: bool) -> None: if not template and (pair_relay not in relay or pair_notary not in notary): raise ExerciseError("topology.authority_pairs references an undeclared authority") if pair_relay in seen_relay or pair_notary in seen_notary: - raise ExerciseError("topology.authority_pairs must be one-to-one") + raise ExerciseError("each Relay must have exactly one dedicated Notary authority") seen_relay.add(pair_relay) seen_notary.add(pair_notary) if not template and (seen_relay != relay or seen_notary != notary): @@ -243,26 +353,30 @@ def validate_results(value: Any, *, template: bool) -> None: ) if check_id not in REQUIRED_CHECKS: raise ExerciseError(f"results[{index}].check_id is not a required check") - expected_outcome = "not_run" if template else "passed" - if result["outcome"] != expected_outcome: - raise ExerciseError( - f"results[{index}].outcome must be {expected_outcome!r} for this record kind" + outcome = result["outcome"] + if outcome not in {"passed", "failed", "not_run"} or template and outcome != "not_run": + raise ExerciseError(f"results[{index}].outcome is invalid for this record kind") + if not template and outcome == "not_run": + if any(result[field] is not None for field in ( + "observed_at", "evidence_label", "evidence_sha256" + )): + raise ExerciseError("not_run result evidence fields must be null") + else: + bounded_string( + result["observed_at"], f"results[{index}].observed_at", TIMESTAMP, template=template + ) + bounded_string( + result["evidence_label"], + f"results[{index}].evidence_label", + SLUG, + template=template, + ) + bounded_string( + result["evidence_sha256"], + f"results[{index}].evidence_sha256", + SHA256, + template=template, ) - bounded_string( - result["observed_at"], f"results[{index}].observed_at", TIMESTAMP, template=template - ) - bounded_string( - result["evidence_label"], - f"results[{index}].evidence_label", - SLUG, - template=template, - ) - bounded_string( - result["evidence_sha256"], - f"results[{index}].evidence_sha256", - SHA256, - template=template, - ) if check_id in seen: raise ExerciseError(f"duplicate result check_id: {check_id}") seen.add(check_id) @@ -270,7 +384,144 @@ def validate_results(value: Any, *, template: bool) -> None: raise ExerciseError("results must contain every required check exactly once") -def validate_record(data: Any, *, allow_template: bool, root: Path = ROOT) -> None: +def validate_target_binding(record: dict[str, Any], root: Path) -> None: + target = record["target_release"] + source_ref = target["source_ref"] + target_commit = target["source_commit"] + tag_ref = f"refs/tags/{target['version']}^{{commit}}" + tag_target = subprocess.run( + ["git", "rev-parse", "--verify", tag_ref], + cwd=root, capture_output=True, text=True, check=False, + ) + if tag_target.returncode != 0 or tag_target.stdout.strip() != target_commit: + raise ExerciseError( + f"release tag {target['version']} does not resolve to target_release.source_commit" + ) + for value, label in ((source_ref, "source_ref"), (target_commit, "source_commit")): + resolved = subprocess.run( + ["git", "rev-parse", "--verify", f"{value}^{{commit}}"], + cwd=root, capture_output=True, text=True, check=False, + ) + if resolved.returncode != 0 or resolved.stdout.strip() != value: + raise ExerciseError(f"target_release.{label} does not resolve exactly") + if subprocess.run( + ["git", "merge-base", "--is-ancestor", source_ref, target_commit], + cwd=root, capture_output=True, check=False, + ).returncode != 0: + raise ExerciseError("target_release.source_ref is not an ancestor of source_commit") + + coordinate = record["target_release_manifest"] + manifest_bytes = git_bytes(root, target_commit, Path(coordinate["path"])) + if coordinate["sha256"].removeprefix("sha256:") != sha256_bytes( + manifest_bytes + ).removeprefix("sha256:"): + raise ExerciseError("target_release_manifest.sha256 does not match exact target") + try: + manifest = yaml.safe_load(manifest_bytes) + except yaml.YAMLError as error: + raise ExerciseError(f"target release manifest is invalid YAML: {error}") from error + stack = manifest.get("stack") if isinstance(manifest, dict) else None + expected = { + "release": target["release_id"], + "version": target["version"].removeprefix("v"), + "source_repo": STACK_REPOSITORY, + "source_ref": source_ref, + "source_tag": target["version"], + } + if not isinstance(stack, dict) or any(str(stack.get(key)) != value for key, value in expected.items()): + raise ExerciseError("target release manifest identity does not match target_release") + artifacts = manifest.get("artifacts") + if not isinstance(artifacts, dict) or not artifacts or any( + str(version) != expected["version"] for version in artifacts.values() + ): + raise ExerciseError("target release manifest artifact versions do not match target_release") + + artifact_set = record["candidate_artifact_set"]["artifacts"] + for field, commit in (("p_release_inputs", source_ref), ("t_release_inputs", target_commit)): + if artifact_set[field] != release_inputs_sha256(root, commit): + raise ExerciseError(f"candidate_artifact_set.artifacts.{field} does not match exact Git object") + + +def validate_image_lock_binding( + record: dict[str, Any], + root: Path, + candidate_asset_root: Path | None, +) -> None: + if candidate_asset_root is None: + raise ExerciseError( + "candidate evidence requires --candidate-asset-root for image-lock authentication" + ) + target = record["target_release"] + candidate_asset_dir = candidate_asset_root.expanduser() / target["version"] + image_lock_path = ( + candidate_asset_dir / f"registryctl-{target['version']}-image-lock.json" + ) + manifest_path = root / record["target_release_manifest"]["path"] + try: + candidate = load_candidate(manifest_path, image_lock_path) + except (CandidateError, OSError): + raise ExerciseError( + "candidate release image lock could not be authenticated" + ) from None + artifacts = record["candidate_artifact_set"]["artifacts"] + expected = { + "release_id": target["release_id"], + "version": target["version"].removeprefix("v"), + "source_ref": target["source_ref"], + "source_tag": target["version"], + "tag_target": target["source_commit"], + "image_lock_sha256": artifacts["image_lock"], + "relay_image": "ghcr.io/registrystack/registry-relay@sha256:" + + target["relay_image_digest"].removeprefix("sha256:"), + "notary_image": "ghcr.io/registrystack/registry-notary@sha256:" + + target["notary_image_digest"].removeprefix("sha256:"), + } + if candidate["image_lock_sha256"].removeprefix( + "sha256:" + ) != expected["image_lock_sha256"].removeprefix("sha256:"): + raise ExerciseError( + "candidate_artifact_set.artifacts.image_lock does not match " + "the exact authenticated release image-lock asset" + ) + for field in ( + "release_id", + "version", + "source_ref", + "source_tag", + "tag_target", + "relay_image", + "notary_image", + ): + if candidate[field] != expected[field]: + raise ExerciseError( + "authenticated release image lock does not match target release coordinates" + ) + + +def require_pass(record: dict[str, Any]) -> None: + if not record["candidate_frozen"] or not record["candidate_independently_verified"]: + raise ExerciseError("--require-pass requires both candidate attestations") + if any(result["outcome"] != "passed" for result in record["results"]): + raise ExerciseError("--require-pass requires every check to pass") + artifacts = record["candidate_artifact_set"]["artifacts"] + for kind in ("binaries", "image_inputs"): + if len({artifacts[f"{phase}{run}_{kind}"] for phase in ("p", "t") for run in (1, 2)}) != 1: + raise ExerciseError(f"--require-pass rejects P/T {kind} drift") + for product in ("notary", "relay"): + if artifacts[f"p_{product}_layouts"] != artifacts[f"t_{product}_layouts"]: + raise ExerciseError(f"--require-pass rejects P/T {product} OCI layout drift") + if artifacts["p_release_inputs"] != artifacts["t_release_inputs"]: + raise ExerciseError("--require-pass rejects P/T release-input drift") + + +def validate_record( + data: Any, + *, + allow_template: bool, + require_all_passed: bool = False, + root: Path = ROOT, + candidate_asset_root: Path | None = None, +) -> None: record = require_object( data, "record", @@ -281,10 +532,11 @@ def validate_record(data: Any, *, allow_template: bool, root: Path = ROOT) -> No "recorded_at", "source_release", "target_release", - "target_release_manifest_sha256", + "target_release_manifest", "candidate_frozen", "candidate_independently_verified", "config_schemas", + "candidate_artifact_set", "topology", "recovery_set", "results", @@ -308,37 +560,78 @@ def validate_record(data: Any, *, allow_template: bool, root: Path = ROOT) -> No record["source_release"]["version"] ): raise ExerciseError("target_release.version must be newer than source_release.version") - bounded_string( - record["target_release_manifest_sha256"], - "target_release_manifest_sha256", - SHA256, + manifest = require_object( + record["target_release_manifest"], "target_release_manifest", {"path", "sha256"} + ) + if not (template and PLACEHOLDER.fullmatch(str(manifest["path"]))): + path = Path(str(manifest["path"])) + if path.is_absolute() or ".." in path.parts or not path.as_posix().startswith("release/manifests/"): + raise ExerciseError("target_release_manifest.path must be a safe release manifest path") + bounded_string(manifest["sha256"], "target_release_manifest.sha256", SHA256, template=template) + for field in ("candidate_frozen", "candidate_independently_verified"): + if not isinstance(record[field], bool) or template and record[field]: + raise ExerciseError(f"{field} must be false in a template and boolean in evidence") + validate_config_schemas( + record["config_schemas"], template=template, + root=root, + target_commit=None if template else record["target_release"]["source_commit"], ) - expected_bool = not template - if record["candidate_frozen"] is not expected_bool: - raise ExerciseError(f"candidate_frozen must be {str(expected_bool).lower()}") - if record["candidate_independently_verified"] is not expected_bool: - raise ExerciseError( - f"candidate_independently_verified must be {str(expected_bool).lower()}" - ) - validate_config_schemas(record["config_schemas"], template=template, root=root) + validate_artifact_set(record["candidate_artifact_set"], record, template=template) validate_topology(record["topology"], template=template) validate_recovery_set(record["recovery_set"], template=template) validate_results(record["results"], template=template) + if not template: + validate_target_binding(record, root) + validate_image_lock_binding(record, root, candidate_asset_root) + if require_all_passed: + require_pass(record) def main() -> int: parser = argparse.ArgumentParser(description=__doc__) - parser.add_argument("record", type=Path) + parser.add_argument("record", nargs="?", type=Path) parser.add_argument( "--template", action="store_true", help="validate a preparation template; templates never count as candidate evidence", ) + parser.add_argument("--require-pass", action="store_true") + parser.add_argument("--discover", type=Path) + parser.add_argument( + "--candidate-asset-root", + type=Path, + help=( + "root containing one downloaded and authenticated asset directory " + "per target version" + ), + ) args = parser.parse_args() try: + if args.discover: + records = sorted(args.discover.glob("*.json")) + if not records: + raise ExerciseError("--discover found no JSON records") + for path in records: + data = json.loads(path.read_text(encoding="utf-8")) + template = data.get("record_kind") == "template" + validate_record( + data, + allow_template=template, + require_all_passed=not template, + candidate_asset_root=args.candidate_asset_root, + ) + print(f"upgrade exercise discovery passed: {len(records)} record(s)") + return 0 + if args.record is None: + raise ExerciseError("a record path is required") data = json.loads(args.record.read_text(encoding="utf-8")) - validate_record(data, allow_template=args.template) + validate_record( + data, + allow_template=args.template, + require_all_passed=args.require_pass, + candidate_asset_root=args.candidate_asset_root, + ) except (ExerciseError, OSError, json.JSONDecodeError) as error: print(f"upgrade exercise validation failed: {error}", file=sys.stderr) return 1