From 0d46147f09cbddd6dfeb7b63b3c720e184534d80 Mon Sep 17 00:00:00 2001 From: Xynnn007 Date: Wed, 13 May 2026 10:18:28 +0800 Subject: [PATCH 1/2] feat: add local CoCo reference value build from Kata artifacts Introduce versions.yaml-driven release.py (oras pull, payload extract, calculator run, JSON output), upstream attestation verification via verify-attestations.py and verify-provenance.sh, update-oras-digests.py for pinning OCI digests, README for purpose and local usage, and gitignore for __pycache__ and results/. Now this commit only contains TDX firmware and kernel reference values, and the overall framework is ready for other architectures and payloads Assisted-by: Cursor Signed-off-by: Xynnn007 --- .gitignore | 3 + DEVELOPMENT.md | 123 ++++++++++++++++++++++ README.md | 49 +++++++++ arch/tdx.yaml | 18 ++++ internal/__init__.py | 5 + internal/commands.py | 142 +++++++++++++++++++++++++ internal/config.py | 195 +++++++++++++++++++++++++++++++++++ internal/workspace.py | 160 ++++++++++++++++++++++++++++ measurements/tdx-kernel.sh | 13 +++ measurements/tdx-mrtd.sh | 13 +++ reference_values.py | 69 +++++++++++++ requirements.txt | 1 + scripts/verify-provenance.sh | 188 +++++++++++++++++++++++++++++++++ versions.yaml | 17 +++ 14 files changed, 996 insertions(+) create mode 100644 .gitignore create mode 100644 DEVELOPMENT.md create mode 100644 arch/tdx.yaml create mode 100644 internal/__init__.py create mode 100644 internal/commands.py create mode 100644 internal/config.py create mode 100644 internal/workspace.py create mode 100755 measurements/tdx-kernel.sh create mode 100755 measurements/tdx-mrtd.sh create mode 100755 reference_values.py create mode 100644 requirements.txt create mode 100755 scripts/verify-provenance.sh create mode 100644 versions.yaml diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..e882ed2 --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +__pycache__/ +results/ +.work diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md new file mode 100644 index 0000000..1d2170d --- /dev/null +++ b/DEVELOPMENT.md @@ -0,0 +1,123 @@ +# Development Guide + +This document covers repository layout, configuration, and how to add or change measurement plugins. + +## Repository layout + +```text +reference_values.py # CLI: verify | build | update-digests +internal/ # Python helpers (not a published package) + config.py # Load / validate / write YAML + workspace.py # Git clone, oras pull, run measurements/*.sh + commands.py # verify, build, update-digests implementations +measurements/ # One shell plugin per reference value +scripts/ # verify-provenance.sh +arch/ # Per-platform OCI digests + RV URIs +versions.yaml # CoCo version, Kata pins, git tool pins +``` + +YAML pins *what* to use (versions, digests, URIs). Shell plugins in `measurements/` define *how* to measure. + +## Configuration + +### `versions.yaml` + +- `version` — CoCo release version (used in output JSON keys) +- `kata` — OCI registry and attestation metadata for upstream verification (`revision` is the authoritative pin; release tag can be kept as a comment) +- `git` — external tool repos (`url` + `digest`); cloned to `.work/git//` before plugins run +- `reference_values_files` — list of arch-specific YAML files to merge + +### `arch/*.yaml` + +Each `reference_values` entry includes: + +- `name` — stable identifier for logs and extract paths (need not match the script filename) +- `measurement_script` — filename under `measurements/` (e.g. `tdx-kernel.sh`) +- `reference_value_uri` — RVPS URI prefix for the output key +- `artifacts` — list of the materials to derive this reference value `{ name, oci_sha256 }` +- optional `description`, `arch` + +## Adding a reference value + +1. Add an entry in `arch/.yaml` with `name`, `measurement_script`, `reference_value_uri`, and pinned `artifacts`. +2. Add an executable `measurements/` whose **stdout** is the reference value as hex only: no trailing newline, no `0x` prefix, and no other text. Send logs and diagnostics to **stderr** (they are not stored). +3. If the measurement needs a new external repo, add it under `git:` in `versions.yaml` (`url` + commit `digest`). + +The build step reads plugin stdout and applies `.strip()` before writing JSON, so a trailing newline is tolerated but must not be relied on—plugins should emit exactly the hex string. + +Example plugin (see also `measurements/tdx-kernel.sh`): + +```bash +#!/usr/bin/env bash +set -euo pipefail + +CALC="${GIT_TD_SHIM_ROOT}/td-shim-tools/src/bin/td-payload-reference-calculator/td_payload_qemu_hash.py" +python3 "${CALC}" -k "./opt/kata/share/kata-containers/vmlinuz.container" +``` + +Use paths **inside the cloned git repo** in the plugin; do not list tool file paths in `versions.yaml`. + +Example arch entry: + +```yaml +- name: tdx-kernel + measurement_script: tdx-kernel.sh + reference_value_uri: "rvps:///github.com/confidential-containers/tdx/kernel" + artifacts: + - name: kernel + oci_sha256: "sha256:..." +``` + +`name` and `measurement_script` may differ when reusing a script or renaming an RV without renaming the plugin file. + +## Plugin environment + +Set by `reference_values.py build` before each plugin runs: + +| Variable | Meaning | +|----------|---------| +| `GIT__ROOT` | Clone root for `git.` (e.g. `GIT_TD_SHIM_ROOT`) | +| `GIT_REPOS_ROOT` | `.work/git` | +| `RV_EXTRACT_DIR` | Extracted Kata payload tree (same as plugin cwd) | +| `COCO_VERSION` | `versions.yaml` → `version` | +| `REPO_ROOT` | Root of this repository | + +Plugins must be executable (`chmod +x measurements/*.sh`). + +## Extract layout + +| Artifacts per RV | Extract target | Plugin cwd | +|------------------|----------------|------------| +| **One** | `.work/extracts///` | That directory | +| **Two or more** | All into `.work/extracts//` (shared root) | Shared root | + +Kata archives unpack with paths such as `opt/kata/share/...` at the archive root. Plugin paths like `./opt/kata/...` are resolved relative to cwd. + +For multi-artifact entries, archives are extracted in list order; colliding paths are overwritten (last wins). + +## Updating OCI digests + +When Kata is bumped, every artifact digest under `reference_values` usually changes. Manually finding and updating each `oci_sha256` across arch files is error-prone and time-consuming, especially when multiple artifacts are listed per entry. + +The `update-digests` subcommand exists to automate that repetitive work: given one Kata release tag, it resolves the latest digest for each configured artifact and updates all related YAML files in one pass. + +After a Kata release bump: + +```bash +python3 reference_values.py update-digests versions.yaml +``` + +This updates `oci_sha256` in each file listed in `reference_values_files` (not a full merge into `versions.yaml`). Re-run `verify` and `build` afterward. + +> [NOTE!] +> Files are rewritten with PyYAML, so quoting/indentation may change; review the diff before committing. It does not update `kata.revision` or other attestation fields—only the digests. + +To bump an external tool, update `git..digest` in `versions.yaml` manually when changing the pinned commit. + +## Local workflow + +```bash +pip install -r requirements.txt +python3 reference_values.py verify versions.yaml +python3 reference_values.py build versions.yaml results/reference-values.json +``` diff --git a/README.md b/README.md index 4a697bd..a82f6e4 100644 --- a/README.md +++ b/README.md @@ -1 +1,50 @@ # CoCo Official Release Reference Values + +This repository is to calculate and publish the official reference values that correspond to **CoCo (Confidential Containers)** community releases. + +## Repository Purpose + +- Maintain a declarative mapping of official CoCo release targets in `versions.yaml` + per-arch files under `arch/` +- Verify Kata upstream artifact attestations before building release reference values +- Reproducibly calculate reference values from Kata payloads for each official CoCo release +- Publish generated reference values as workflow/release artifacts for RVPS consumers +- Generate SLSA build provenance attestation metadata for the published output JSON + +## How It Works + +1. Read `versions.yaml` and load referenced files from `reference_values_files` (for example `arch/x86_64-tdx.yaml`). +2. Verify each configured artifact attestation with `reference_values.py verify`. + - Checks each artifact digest against the expected Kata source repository, source revision, workflow digest, workflow trigger, and main branch workflow ref. + - Tooling: `gh attestation verify`. +3. Run `reference_values.py build`, which for each `reference_values` entry: + - Pulls Kata OCI artifacts (`/@`) and extracts `kata-static-.tar.zst` + - Clones pinned external tool repos from `versions.yaml` → `git` + - Runs the shell plugin named by `measurement_script` under `measurements/` and collects stdout as the reference value + - Tooling: `oras`, `tar`, `git`. +4. Write final output JSON to `results/reference-values.json`. + +Measurement command lines live in `measurements/` (not in YAML). See [DEVELOPMENT.md](DEVELOPMENT.md) for how to add or change plugins. + +## Local Run + +Prerequisites: `python3`, `git`, `oras`, `gh`, `jq`, `tar`, and Python deps from `requirements.txt`. + +```bash +pip install -r requirements.txt + +# Verify upstream kata provenance attestation +python3 reference_values.py verify versions.yaml + +# Generate reference value manifest +python3 reference_values.py build versions.yaml results/reference-values.json + +# (Optional) Update all OCI artifact digests in *.yamls using given kata-version +python3 reference_values.py update-digests versions.yaml 3.30.0 +``` + +> [!NOTE] +> If local `gh attestation ...` commands fail with `unknown command "attestation" for "gh"`, upgrade GitHub CLI to a version that includes the attestation subcommand. + +## Development + +To add a reference value, update OCI digests, or write a measurement plugin, see [DEVELOPMENT.md](DEVELOPMENT.md). diff --git a/arch/tdx.yaml b/arch/tdx.yaml new file mode 100644 index 0000000..3f957e4 --- /dev/null +++ b/arch/tdx.yaml @@ -0,0 +1,18 @@ +reference_values: + - name: "tdx-kernel" + measurement_script: "tdx-kernel.sh" + arch: "x86_64" + reference_value_uri: "rvps:///github.com/confidential-containers/tdx/kernel" + description: "Measurement of guest kernel image is covered by a CCEL eventlog entry" + artifacts: + - name: "kernel" + oci_sha256: "sha256:0754b7494883f55d003309c0fcda9448e83262007ceb4da6b1e7c30a0fb320bb" + + - name: "tdx-mrtd" + measurement_script: "tdx-mrtd.sh" + arch: "x86_64" + reference_value_uri: "rvps:///github.com/confidential-containers/tdx/mr_td" + description: "Measurement of TDVF (TDX Virtual Firmware/OVMF) is covered by MR_TD register." + artifacts: + - name: "ovmf-tdx" + oci_sha256: "sha256:0802af108fbaaf535a348160d68dc2acc7ee06025a1d135b4cdac459cb8a6c4e" diff --git a/internal/__init__.py b/internal/__init__.py new file mode 100644 index 0000000..a16b5e0 --- /dev/null +++ b/internal/__init__.py @@ -0,0 +1,5 @@ +# Copyright (c) 2026 Alibaba Cloud +# +# SPDX-License-Identifier: Apache-2.0 + +"""Internal library for reference_values.py (not a published package).""" diff --git a/internal/commands.py b/internal/commands.py new file mode 100644 index 0000000..580113b --- /dev/null +++ b/internal/commands.py @@ -0,0 +1,142 @@ +#!/usr/bin/env python3 + +# Copyright (c) 2026 Alibaba Cloud +# +# SPDX-License-Identifier: Apache-2.0 + +import json +import logging +import os +import pathlib +import subprocess + +from internal.config import _read_yaml, load_config, load_config_tree, write_config_tree +from internal.workspace import ( + plugin_script, + prepare_git_repos, + prepare_rv_extract, + repo_root_from_config, + reset_dir, + run_measurement, + work_paths, +) + +LOG = logging.getLogger("reference-values") + + +def verify(config_path: str | pathlib.Path) -> int: + config_file = pathlib.Path(config_path).resolve() + repo_root = repo_root_from_config(config_file) + config = load_config(config_file) + + script = repo_root / "scripts" / "verify-provenance.sh" + if not script.is_file() or not os.access(script, os.R_OK | os.X_OK): + LOG.error("Missing or non-executable: %s", script) + return 1 + + kata = config["kata"] + oci_base = kata["oci"].rstrip("/") + failures = 0 + seen: set[str] = set() + + for rv in config["reference_values"]: + for artifact in rv["artifacts"]: + key = f"{artifact['name']}@{artifact['oci_sha256']}" + if key in seen: + continue + seen.add(key) + oci = f"{oci_base}/{artifact['name']}@{artifact['oci_sha256']}" + LOG.info("Verifying %s", oci) + proc = subprocess.run( + [ + str(script), + "-a", oci, + "-s", kata["revision"], + "-w", kata["workflow_digest"], + "-t", kata["workflow_trigger"], + "-r", kata["source_repository"], + ], + cwd=repo_root, + ) + if proc.returncode != 0: + failures += 1 + + if failures: + LOG.error("Attestation verification failed for %d artifact(s).", failures) + return 1 + LOG.info("All attestations OK (%d unique artifacts).", len(seen)) + return 0 + + +def build(config_path: str | pathlib.Path, output_path: str | pathlib.Path) -> None: + config_file = pathlib.Path(config_path).resolve() + repo_root = repo_root_from_config(config_file) + config = load_config(config_file) + version = str(config["version"]) + oci_base = config["kata"]["oci"].rstrip("/") + + paths = work_paths(repo_root) + reset_dir(paths["pulls"]) + reset_dir(paths["extracts"]) + if not (repo_root / "measurements").is_dir(): + raise RuntimeError(f"Missing measurements/ under {repo_root}") + + env = prepare_git_repos(config["git"], paths["git"]) + env["COCO_VERSION"] = version + env["REPO_ROOT"] = str(repo_root) + + result = {} + for rv in config["reference_values"]: + LOG.info("Processing %s", rv["name"]) + extract_dir = prepare_rv_extract(rv, oci_base, paths["pulls"], paths["extracts"]) + value = run_measurement(plugin_script(rv, repo_root), extract_dir, env) + key = f"{rv['reference_value_uri']}:{version}" + result[key] = value + LOG.info("Collected %s", key) + + out = pathlib.Path(output_path) + out.parent.mkdir(parents=True, exist_ok=True) + with open(out, "w", encoding="utf-8") as f: + json.dump(result, f, ensure_ascii=False, indent=2) + f.write("\n") + LOG.info("Wrote %s (%d entries)", out, len(result)) + print(json.dumps(result, ensure_ascii=False, indent=2)) + + +def _fetch_artifact_digest(oci_base: str, artifact_name: str, kata_tag: str, arch: str) -> str: + ref = f"{oci_base}/{artifact_name}:{kata_tag}-{arch}" + proc = subprocess.run( + ["oras", "manifest", "fetch", "--descriptor", ref], + capture_output=True, + text=True, + check=True, + ) + digest = json.loads(proc.stdout).get("digest", "") + if not digest.startswith("sha256:"): + raise RuntimeError(f"Unexpected digest for {ref}: {digest}") + LOG.info("%s -> %s", ref, digest) + return digest.lower() + + +def update_digests(config_path: str | pathlib.Path, kata_tag: str) -> int: + config_file = pathlib.Path(config_path).resolve() + # Root YAML as stored on disk (no merged reference_values from includes). + root_doc = _read_yaml(config_file) + config, includes = load_config_tree(config_file) + oci_base = config["kata"]["oci"].rstrip("/") + + docs_to_update: list[tuple[pathlib.Path, dict]] = list(includes) + if root_doc.get("reference_values"): + docs_to_update.append((config_file, root_doc)) + + for path, doc in docs_to_update: + for rv in doc.get("reference_values", []): + for artifact in rv["artifacts"]: + arch = artifact.get("arch", rv.get("arch", "x86_64")) + artifact["oci_sha256"] = _fetch_artifact_digest( + oci_base, artifact["name"], kata_tag, arch + ) + + write_config_tree(config_file, root_doc, includes) + LOG.info("Updated OCI digests under %s", config_file.parent) + return 0 diff --git a/internal/config.py b/internal/config.py new file mode 100644 index 0000000..cb82bbb --- /dev/null +++ b/internal/config.py @@ -0,0 +1,195 @@ +#!/usr/bin/env python3 + +# Copyright (c) 2026 Alibaba Cloud +# +# SPDX-License-Identifier: Apache-2.0 + +import pathlib +import re + +try: + import yaml +except ImportError as exc: + raise SystemExit( + "Missing dependency: PyYAML. Install with `pip install -r requirements.txt`." + ) from exc + +_SHA256_RE = re.compile(r"^sha256:[0-9a-fA-F]{64}$") +_COMMIT_RE = re.compile(r"^[0-9a-fA-F]{40}$") +_KATA_KEYS = ( + "oci", + "revision", + "source_repository", + "workflow_digest", + "workflow_trigger", +) + + +class ConfigError(Exception): + """Raised when versions.yaml or an included arch file is invalid.""" + + +def _read_yaml(path: pathlib.Path) -> dict: + with open(path, "r", encoding="utf-8") as f: + data = yaml.safe_load(f) + if not isinstance(data, dict): + raise ConfigError(f"{path}: expected a YAML mapping at the top level.") + return data + + +def _resolve_include(config_file: pathlib.Path, include_path: str) -> pathlib.Path: + if not include_path or not isinstance(include_path, str): + raise ConfigError(f"{config_file}: reference_values_files entries must be strings.") + pure = pathlib.PurePosixPath(include_path) + if pure.is_absolute() or ".." in pure.parts: + raise ConfigError(f"{config_file}: unsafe include path '{include_path}'.") + include_file = (config_file.parent / include_path).resolve() + try: + include_file.relative_to(config_file.parent.resolve()) + except ValueError as exc: + raise ConfigError( + f"{config_file}: include path '{include_path}' resolves outside the config directory." + ) from exc + if not include_file.is_file(): + raise ConfigError(f"{config_file}: include file not found: {include_file}") + return include_file + + +def _require_str(obj: dict, key: str, context: str) -> str: + if key not in obj: + raise ConfigError(f"{context}: missing required field '{key}'.") + value = obj[key] + if not isinstance(value, str) or not value.strip(): + raise ConfigError(f"{context}: '{key}' must be a non-empty string.") + return value.strip() + + +def _normalize_sha256(digest: object, context: str) -> str: + raw = str(digest).strip() + if not raw: + raise ConfigError(f"{context}: missing or empty digest.") + if not raw.startswith("sha256:"): + raw = f"sha256:{raw}" + if not _SHA256_RE.match(raw): + raise ConfigError(f"{context}: digest must be sha256:<64 hex chars>, got '{raw}'.") + return raw.lower() + + +def _validate_kata(kata: object, context: str) -> None: + if not isinstance(kata, dict): + raise ConfigError(f"{context}: 'kata' must be a mapping.") + for key in _KATA_KEYS: + _require_str(kata, key, f"{context}.kata") + + +def _validate_git(git: object, config_file: pathlib.Path) -> None: + if not isinstance(git, dict) or not git: + raise ConfigError(f"{config_file}: 'git' must be a non-empty mapping.") + for repo_key, entry in git.items(): + ctx = f"{config_file} git[{repo_key!r}]" + if not isinstance(entry, dict): + raise ConfigError(f"{ctx}: must be a mapping.") + _require_str(entry, "url", ctx) + if not _COMMIT_RE.match(_require_str(entry, "digest", ctx)): + raise ConfigError(f"{ctx}: digest must be a 40-character git commit SHA.") + + +def _validate_rv(rv: object, source: pathlib.Path) -> None: + if not isinstance(rv, dict): + raise ConfigError(f"{source}: each reference_values entry must be a mapping.") + name = _require_str(rv, "name", f"{source} reference_values[]") + ctx = f"{source} reference_values[{name!r}]" + if not _require_str(rv, "reference_value_uri", ctx).startswith("rvps:///"): + raise ConfigError(f"{ctx}: reference_value_uri must start with 'rvps:///'.") + artifacts = rv.get("artifacts") + if not isinstance(artifacts, list) or not artifacts: + raise ConfigError(f"{ctx}: 'artifacts' must be a non-empty list.") + if "arch" in rv: + _require_str(rv, "arch", ctx) + _require_str(rv, "measurement_script", ctx) + for artifact in artifacts: + actx = f"{ctx}.artifacts[]" + if not isinstance(artifact, dict): + raise ConfigError(f"{actx}: each artifact must be a mapping.") + aname = _require_str(artifact, "name", actx) + artifact["oci_sha256"] = _normalize_sha256( + artifact["oci_sha256"], f"{actx} '{aname}'" + ) + + +def _validate_rv_list(items: object, source: pathlib.Path) -> list[dict]: + if items is None: + return [] + if not isinstance(items, list): + raise ConfigError(f"{source}: 'reference_values' must be a list.") + seen: set[str] = set() + for rv in items: + _validate_rv(rv, source) + if rv["name"] in seen: + raise ConfigError(f"{source}: duplicate reference value name '{rv['name']}'.") + seen.add(rv["name"]) + return items + + +def load_config_tree( + config_path: str | pathlib.Path, +) -> tuple[dict, list[tuple[pathlib.Path, dict]]]: + config_file = pathlib.Path(config_path).resolve() + config = _read_yaml(config_file) + _require_str(config, "version", str(config_file)) + _validate_kata(config.get("kata"), str(config_file)) + _validate_git(config.get("git"), config_file) + + include_items: list[tuple[pathlib.Path, dict]] = [] + reference_values: list[dict] = [] + seen_global: dict[str, pathlib.Path] = {} + + def merge_rvs(items: list[dict], source: pathlib.Path) -> None: + for rv in items: + if rv["name"] in seen_global: + raise ConfigError( + f"Duplicate reference value name '{rv['name']}' in " + f"{seen_global[rv['name']]} and {source}." + ) + seen_global[rv["name"]] = source + reference_values.extend(items) + + merge_rvs(_validate_rv_list(config.get("reference_values", []), config_file), config_file) + + includes = config.get("reference_values_files") or [] + if not isinstance(includes, list): + raise ConfigError(f"{config_file}: 'reference_values_files' must be a list.") + + for include_path in includes: + include_file = _resolve_include(config_file, include_path) + include_config = _read_yaml(include_file) + include_items.append((include_file, include_config)) + merge_rvs( + _validate_rv_list(include_config.get("reference_values", []), include_file), + include_file, + ) + + if not reference_values: + raise ConfigError(f"{config_file}: no reference_values configured.") + + config["reference_values"] = reference_values + return config, include_items + + +def load_config(config_path: str | pathlib.Path) -> dict: + config, _ = load_config_tree(config_path) + return config + + +def write_config_tree( + config_file: pathlib.Path, + config: dict, + include_items: list[tuple[pathlib.Path, dict]], +) -> None: + with open(config_file, "w", encoding="utf-8") as f: + yaml.dump(config, f, default_flow_style=False, sort_keys=False, allow_unicode=True, indent=2) + f.write("\n") + for path, doc in include_items: + with open(path, "w", encoding="utf-8") as f: + yaml.dump(doc, f, default_flow_style=False, sort_keys=False, allow_unicode=True, indent=2) + f.write("\n") diff --git a/internal/workspace.py b/internal/workspace.py new file mode 100644 index 0000000..cf9a3dc --- /dev/null +++ b/internal/workspace.py @@ -0,0 +1,160 @@ +#!/usr/bin/env python3 + +# Copyright (c) 2026 Alibaba Cloud +# +# SPDX-License-Identifier: Apache-2.0 + +"""Artifact pull/extract, git tool clones, and measurement shell plugins.""" + +import logging +import os +import pathlib +import shutil +import subprocess + +LOG = logging.getLogger("reference-values") + + +def repo_root_from_config(config_path: pathlib.Path) -> pathlib.Path: + return config_path.resolve().parent + + +def work_paths(repo_root: pathlib.Path) -> dict[str, pathlib.Path]: + work = repo_root / ".work" + return { + "work": work, + "pulls": work / "pulls", + "extracts": work / "extracts", + "git": work / "git", + } + + +def reset_dir(path: pathlib.Path) -> None: + shutil.rmtree(path, ignore_errors=True) + path.mkdir(parents=True, exist_ok=True) + + +def prepare_git_repos(git_config: dict, clone_dir: pathlib.Path) -> dict[str, str]: + clone_dir.mkdir(parents=True, exist_ok=True) + env: dict[str, str] = {"GIT_REPOS_ROOT": str(clone_dir.resolve())} + for repo_key, entry in git_config.items(): + dest = clone_dir / repo_key + url, digest = entry["url"], entry["digest"] + if not (dest / ".git").is_dir(): + LOG.info("Cloning %s -> %s", url, dest) + subprocess.run( + ["git", "clone", url, str(dest)], + check=True, + capture_output=True, + text=True, + ) + head = subprocess.run( + ["git", "-C", str(dest), "rev-parse", "HEAD"], + check=True, + capture_output=True, + text=True, + ).stdout.strip() + if head != digest: + LOG.info("Checking out %s at %s", repo_key, digest) + subprocess.run( + ["git", "-C", str(dest), "fetch", "origin"], + check=False, + capture_output=True, + text=True, + ) + subprocess.run( + ["git", "-C", str(dest), "checkout", "--force", digest], + check=True, + capture_output=True, + text=True, + ) + if ( + subprocess.run( + ["git", "-C", str(dest), "rev-parse", "HEAD"], + check=True, + capture_output=True, + text=True, + ).stdout.strip() + != digest + ): + raise RuntimeError(f"Git repo '{repo_key}' at {dest} is not at {digest}.") + env[f"GIT_{repo_key.upper().replace('-', '_')}_ROOT"] = str(dest.resolve()) + LOG.info("Prepared %d git repo(s) under %s", len(git_config), clone_dir) + return env + + +def prepare_rv_extract( + rv: dict, + oci_base: str, + pulls_dir: pathlib.Path, + extracts_dir: pathlib.Path, +) -> pathlib.Path: + rv_name = rv["name"] + rv_pulls = pulls_dir / rv_name + rv_extracts = extracts_dir / rv_name + reset_dir(rv_pulls) + reset_dir(rv_extracts) + + merge = len(rv["artifacts"]) > 1 + cwd: pathlib.Path | None = None + + for artifact in rv["artifacts"]: + name = artifact["name"] + LOG.info(" Pulling %s for %s (arch=%s)", name, rv_name, artifact.get("arch", rv.get("arch", "x86_64"))) + ref = f"{oci_base}/{name}@{artifact['oci_sha256']}" + pulled = rv_pulls / name + reset_dir(pulled) + proc = subprocess.run( + ["oras", "pull", "--output", str(pulled), ref], + capture_output=True, + text=True, + ) + if proc.returncode != 0: + raise RuntimeError(f"oras pull failed for '{ref}': {proc.stderr.strip()}") + + archive = pulled / f"kata-static-{name}.tar.zst" + if not archive.exists(): + raise FileNotFoundError(f"Expected archive '{archive.name}' under '{pulled}'.") + + extract_to = rv_extracts if merge else rv_extracts / name + if not merge: + reset_dir(extract_to) + + LOG.info("Extracting %s -> %s", archive, extract_to) + listed = subprocess.run(["tar", "-tf", str(archive)], capture_output=True, text=True, check=True) + for member in listed.stdout.splitlines(): + p = pathlib.PurePosixPath(member) + if p.is_absolute() or ".." in p.parts: + raise RuntimeError(f"Unsafe path in '{archive}': {member}") + subprocess.run(["tar", "-xf", str(archive), "-C", str(extract_to)], check=True, capture_output=True, text=True) + cwd = extract_to + + if cwd is None: + raise RuntimeError(f"Reference value '{rv_name}' has no artifacts.") + return cwd + + +def plugin_script(rv: dict, repo_root: pathlib.Path) -> pathlib.Path: + path = pathlib.Path(rv["measurement_script"]) + return path if path.is_absolute() else repo_root / "measurements" / path + + +def run_measurement(script: pathlib.Path, extract_dir: pathlib.Path, env: dict[str, str]) -> str: + if not script.is_file(): + raise FileNotFoundError(f"Measurement plugin not found: {script}") + if not os.access(script, os.X_OK): + raise RuntimeError(f"Measurement plugin is not executable: {script}") + + run_env = os.environ.copy() + run_env.update(env) + run_env["RV_EXTRACT_DIR"] = str(extract_dir.resolve()) + LOG.info("Running plugin (cwd=%s): %s", extract_dir, script.name) + proc = subprocess.run([str(script)], capture_output=True, text=True, cwd=extract_dir, env=run_env) + out = proc.stdout.strip() + if proc.returncode != 0: + raise RuntimeError( + f"Plugin '{script.name}' failed (exit {proc.returncode}): {proc.stderr.strip() or out}" + ) + if not out: + raise RuntimeError(f"Plugin '{script.name}' produced empty stdout.") + return out diff --git a/measurements/tdx-kernel.sh b/measurements/tdx-kernel.sh new file mode 100755 index 0000000..4c735b8 --- /dev/null +++ b/measurements/tdx-kernel.sh @@ -0,0 +1,13 @@ +#!/usr/bin/env bash +# Copyright (c) 2026 Alibaba Cloud +# +# SPDX-License-Identifier: Apache-2.0 +# +# Preconditions (set by reference_values.py build): +# GIT_TD_SHIM_ROOT — cloned td-shim at versions.yaml git.td-shim.digest +# cwd / RV_EXTRACT_DIR — extracted Kata payload tree + +set -euo pipefail + +CALCULATOR="${GIT_TD_SHIM_ROOT}/td-shim-tools/src/bin/td-payload-reference-calculator/td_payload_qemu_hash.py" +python3 "${CALCULATOR}" -k "./opt/kata/share/kata-containers/vmlinuz.container" diff --git a/measurements/tdx-mrtd.sh b/measurements/tdx-mrtd.sh new file mode 100755 index 0000000..2ce84a6 --- /dev/null +++ b/measurements/tdx-mrtd.sh @@ -0,0 +1,13 @@ +#!/usr/bin/env bash +# Copyright (c) 2026 Alibaba Cloud +# +# SPDX-License-Identifier: Apache-2.0 +# +# Preconditions (set by reference_values.py build): +# GIT_TD_SHIM_ROOT — cloned td-shim at versions.yaml git.td-shim.digest +# cwd / RV_EXTRACT_DIR — extracted Kata payload tree + +set -euo pipefail + +CALCULATOR="${GIT_TD_SHIM_ROOT}/td-shim-tools/src/bin/td-shim-tee-info-hash/td_shim_tee_info_hash.py" +python3 "${CALCULATOR}" -i "opt/kata/share/ovmf/OVMF.inteltdx.fd" diff --git a/reference_values.py b/reference_values.py new file mode 100755 index 0000000..b9d58a6 --- /dev/null +++ b/reference_values.py @@ -0,0 +1,69 @@ +#!/usr/bin/env python3 + +# Copyright (c) 2026 Alibaba Cloud +# +# SPDX-License-Identifier: Apache-2.0 + +""" +CoCo reference values — single CLI entry point. + + verify Upstream Kata artifact attestation checks + build Generate results/reference-values.json + update-digests Refresh oci_sha256 for a Kata release tag + +Library code lives in internal/; per-RV logic in measurements/*.sh +""" + +import argparse +import logging + +from internal.commands import build, update_digests, verify +from internal.config import ConfigError + + +def _setup_logging() -> logging.Logger: + handler = logging.StreamHandler() + handler.setFormatter(logging.Formatter("%(levelname)s - %(message)s")) + root = logging.getLogger() + root.setLevel(logging.INFO) + root.handlers = [handler] + return logging.getLogger("reference-values") + + +def main(argv: list[str] | None = None) -> int: + log = _setup_logging() + parser = argparse.ArgumentParser(description="CoCo release reference values.") + sub = parser.add_subparsers(dest="command", required=True) + + p = sub.add_parser("verify", help="Verify upstream Kata attestations") + p.add_argument("config", nargs="?", default="versions.yaml") + p.set_defaults(func="verify") + + p = sub.add_parser("build", help="Build reference-values.json") + p.add_argument("config", nargs="?", default="versions.yaml") + p.add_argument("output", nargs="?", default="results/reference-values.json") + p.set_defaults(func="build") + + p = sub.add_parser( + "update-digests", + help="Update pinned OCI digests using a Kata release tag", + ) + p.add_argument("config") + p.add_argument("kata_tag") + p.set_defaults(func="update-digests") + + args = parser.parse_args(argv) + try: + if args.func == "verify": + return verify(args.config) + if args.func == "build": + build(args.config, args.output) + return 0 + return update_digests(args.config, args.kata_tag) + except ConfigError as exc: + log.error("%s", exc) + return 1 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..ffe9c1b --- /dev/null +++ b/requirements.txt @@ -0,0 +1 @@ +PyYAML>=6.0.1,<7 diff --git a/scripts/verify-provenance.sh b/scripts/verify-provenance.sh new file mode 100755 index 0000000..eeabba5 --- /dev/null +++ b/scripts/verify-provenance.sh @@ -0,0 +1,188 @@ +#!/bin/bash + +# This is almost a copy-version of +# https://github.com/confidential-containers/cloud-api-adaptor/blob/main/src/cloud-api-adaptor/hack/verify-provenance.sh + +# Verify GitHub's attestation reports. Meant to verify binaries built +# by upstream projects (kata-containers and guest-components). +# +# GH cli is used to verify. +# +# Asserts on the claims are: +# - GitHub workflow trigger matches the value given with -t (expected_workflow_trigger), +# e.g. workflow_dispatch or push — not assumed to be push +# - GitHub workflow ref is refs/heads/main (hard-coded; not configurable) +# - Built on the given repository +# - The gh action workflow is matching the given digest +# - The code is matching the given digest +# +# -g will fetch attestation via gh cli, this requires GH_TOKEN to be +# set. By default the attestation will be retrieved by walking the OCI +# manifest + +set -euo pipefail + +usage() { + echo "Usage: $0 " + echo " -a " + echo " -s " + echo " -w " + echo " -t " + echo " -r " + echo " [-g] (optional. fetch attestation using github api)" + exit 1 +} + +oci_artifact="" +expected_source_revision="" +expected_workflow_digest="" +expected_workflow_trigger="" +repository="" +github="0" + +# Parse options using getopts +while getopts ":a:s:w:t:r:g" opt; do + case "${opt}" in + a) + oci_artifact="${OPTARG}" + ;; + s) + expected_source_revision="${OPTARG}" + ;; + w) + expected_workflow_digest="${OPTARG}" + ;; + t) + expected_workflow_trigger="${OPTARG}" + ;; + r) + repository="${OPTARG}" + ;; + g) + github="1" + ;; + *) + usage + ;; + esac +done + +# Check if all required arguments are provided +if [ -z "${oci_artifact}" ] || [ -z "${expected_source_revision}" ] || [ -z "${expected_workflow_digest}" ] || [ -z "${expected_workflow_trigger}" ] || [ -z "${repository}" ]; then + usage +fi + +if ! [[ "$oci_artifact" =~ @sha256:[a-fA-F0-9]{64}$ ]]; then + echo "The OCI artifact should be specified using its digest: my-repo.io/my-image@sha256:abc..." + exit 1 +fi + +# Convention by gh cli; set before trap so cleanup never sees an unbound variable under set -u. +attestation_bundle="${oci_artifact#*@}.jsonl" + +cleanup() { + rm -f "$attestation_bundle" +} +trap cleanup EXIT SIGINT SIGTERM + +if [ "$github" != "1" ]; then + attestation_manifest_digest=$(oras discover "$oci_artifact" --format json | jq -r ' + [(.referrers // [])[] + | select(.artifactType | test("sigstore.bundle.*json")) + | .digest][0] // empty + ') + + # If referrers discovery does not return a bundle digest (layout/permissions/compat + # differences), switch to GitHub API mode when GH_TOKEN is available; the download runs + # once in the shared if/else block below (same as -g). + if [ -z "$attestation_manifest_digest" ]; then + if [ -n "${GH_TOKEN:-}" ] || [ -n "${GITHUB_TOKEN:-}" ]; then + echo "No attestation referrer found via oras discover; falling back to gh attestation download" + github="1" + else + echo "Failed to discover attestation manifest digest for ${oci_artifact}" + echo "Hint: provide GH_TOKEN/GITHUB_TOKEN and use -g to fetch bundle via GitHub API" + exit 1 + fi + fi + + # Fallback above may switch github=1; skip ORAS-manifest logic in that case. + if [ "$github" != "1" ]; then + oci_base="${oci_artifact%@*}" + attestation_manifest="${oci_base}@${attestation_manifest_digest}" + + # One manifest may list multiple layers whose mediaType matches sigstore.bundle.*json. + # Collect digests as JSON array so we never pass newline-separated values into an OCI ref. + # If multiple match, use the first deterministically and log once. + attestation_manifest_json=$(oras manifest fetch "$attestation_manifest" --format json) + bundle_layer_digests_json=$(echo "$attestation_manifest_json" | jq -c ' + [(.layers // .content.layers // [])[] + | select(.mediaType | test("sigstore.bundle.*json")) + | .digest] + ') + bundle_layer_count=$(echo "$bundle_layer_digests_json" | jq 'length') + + if [ "$bundle_layer_count" -eq 0 ]; then + echo "No sigstore bundle JSON layer in attestation manifest ${attestation_manifest}: expected .layers[] or .content.layers[] with mediaType matching sigstore.bundle.*json" + exit 1 + fi + + attestation_bundle_digest=$(echo "$bundle_layer_digests_json" | jq -r '.[0]') + + if [ "$bundle_layer_count" -gt 1 ]; then + echo "Note: ${bundle_layer_count} layers match sigstore.bundle.*json in ${attestation_manifest}; using first digest: ${attestation_bundle_digest}" + fi + + attestation_image="${oci_base}@${attestation_bundle_digest}" + + oras blob fetch --no-tty "$attestation_image" --output "$attestation_bundle" + fi +fi + +if [ "$github" = "1" ]; then + gh attestation download "oci://${oci_artifact}" -R "$repository" +fi + +claims=$( + gh attestation verify "oci://${oci_artifact}" \ + -b "$attestation_bundle" \ + -R "$repository" \ + --format json \ + -q '[.[].verificationResult.signature.certificate + | { + digest: .sourceRepositoryDigest, + workflowDigest: .githubWorkflowSHA, + workflowTrigger: .githubWorkflowTrigger, + workflowRef: .githubWorkflowRef, + }]' +) + +# gh attestation verify may return multiple verification results. Treat verification +# as successful when any one attestation matches all expected claims. +matching_claims_count=$(echo "$claims" | jq -r \ + --arg expected_source_revision "$expected_source_revision" \ + --arg expected_workflow_digest "$expected_workflow_digest" \ + --arg expected_workflow_trigger "$expected_workflow_trigger" \ + '[.[] | select( + .digest == $expected_source_revision and + .workflowDigest == $expected_workflow_digest and + .workflowTrigger == $expected_workflow_trigger and + .workflowRef == "refs/heads/main" + )] | length') + +if [ "$matching_claims_count" -eq 0 ]; then + echo "Verification failed: no attestation matched all expected claims" + echo "Expected source digest: $expected_source_revision" + echo "Expected workflow digest: $expected_workflow_digest" + echo "Expected workflow trigger: $expected_workflow_trigger" + echo "Expected workflow ref: refs/heads/main" + + # Print observed values to make triage easier when multiple attestations exist. + echo "Observed source digests: $(echo "$claims" | jq -r '[.[].digest] | unique | join(", ")')" + echo "Observed workflow digests: $(echo "$claims" | jq -r '[.[].workflowDigest] | unique | join(", ")')" + echo "Observed workflow triggers: $(echo "$claims" | jq -r '[.[].workflowTrigger] | unique | join(", ")')" + echo "Observed workflow refs: $(echo "$claims" | jq -r '[.[].workflowRef] | unique | join(", ")')" + exit 1 +fi + +echo "Verification passed" \ No newline at end of file diff --git a/versions.yaml b/versions.yaml new file mode 100644 index 0000000..ddedbdd --- /dev/null +++ b/versions.yaml @@ -0,0 +1,17 @@ +version: "0.21.0" + +kata: + oci: "ghcr.io/kata-containers/cached-artefacts" + revision: "86e5975ad6a20f091ed686e492672c70496d0400" # tag: 3.30.0 + source_repository: "kata-containers/kata-containers" + workflow_digest: "86e5975ad6a20f091ed686e492672c70496d0400" + workflow_trigger: "workflow_dispatch" + +# External tool repositories (cloned under .work/git/ before measurements run). +git: + td-shim: + url: "https://github.com/confidential-containers/td-shim" + digest: "28bfa349f59a2d8da580e032c6fdcd79abd30824" + +reference_values_files: + - "arch/tdx.yaml" From fea4041340e88f18940410167d3ec1ebd6a1b1a5 Mon Sep 17 00:00:00 2001 From: Xynnn007 Date: Wed, 13 May 2026 10:19:32 +0800 Subject: [PATCH 2/2] ci: add reference-values workflow and attestation Introduce GitHub Actions to verify attestations, build JSON on PR, publish reference-values.json on release, and optionally attest on reusable workflow_call. Document triggers, inputs, default behavior, and gh CLI verification of release attestations in README. Assisted-by: Cursor Signed-off-by: Xynnn007 --- .../actions/build-reference-values/action.yml | 32 +++++++++ .github/workflows/reference-values.yml | 68 +++++++++++++++++++ README.md | 27 ++++++++ 3 files changed, 127 insertions(+) create mode 100644 .github/actions/build-reference-values/action.yml create mode 100644 .github/workflows/reference-values.yml diff --git a/.github/actions/build-reference-values/action.yml b/.github/actions/build-reference-values/action.yml new file mode 100644 index 0000000..233dd28 --- /dev/null +++ b/.github/actions/build-reference-values/action.yml @@ -0,0 +1,32 @@ +name: Build reference values +description: Checkout, install tooling, verify upstream attestations, and build results/reference-values.json +runs: + using: composite + steps: + - name: Setup Python + uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0 + with: + python-version: "3.12" + + - name: Install dependencies + shell: bash + run: | + python -m pip install --upgrade pip + pip install -r requirements.txt + + - uses: oras-project/setup-oras@38de303aac69abb66f3e6255b7198bff35f323e3 # v2.0.0 + with: + version: "1.3.1" + + - name: Verify upstream artifact attestations + shell: bash + env: + GH_TOKEN: ${{ github.token }} + run: | + python3 reference_values.py verify versions.yaml + + - name: Build reference values JSON + shell: bash + run: | + mkdir -p results + python3 reference_values.py build versions.yaml results/reference-values.json diff --git a/.github/workflows/reference-values.yml b/.github/workflows/reference-values.yml new file mode 100644 index 0000000..28e1838 --- /dev/null +++ b/.github/workflows/reference-values.yml @@ -0,0 +1,68 @@ +name: Build Reference Values For CoCo Release + +on: + pull_request: + release: + types: [published] + +jobs: + build-reference-values-pr: + if: github.event_name == 'pull_request' + runs-on: ubuntu-latest + permissions: + contents: read + + steps: + - name: Checkout repository + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + + - uses: ./.github/actions/build-reference-values + + build-reference-values-release: + if: github.event_name == 'release' + runs-on: ubuntu-latest + permissions: + contents: read + actions: write + + steps: + - name: Checkout repository + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + + - uses: ./.github/actions/build-reference-values + + - name: Upload reference values for release job + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: reference-values-json + path: results/reference-values.json + + publish-release: + needs: build-reference-values-release + if: github.event_name == 'release' + runs-on: ubuntu-latest + permissions: + contents: write + actions: read + id-token: write + attestations: write + artifact-metadata: write + + steps: + - name: Download reference values JSON + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: reference-values-json + path: results + + - name: Release Reference Values JSON + uses: softprops/action-gh-release@b4309332981a82ec1c5618f44dd2e27cc8bfbfda # v3.0.0 + with: + files: | + results/reference-values.json + + - name: Generate SLSA provenance attestation + id: attest_provenance + uses: actions/attest@59d89421af93a897026c735860bf21b6eb4f7b26 # v4.1.0 + with: + subject-path: results/reference-values.json diff --git a/README.md b/README.md index a82f6e4..e516253 100644 --- a/README.md +++ b/README.md @@ -45,6 +45,33 @@ python3 reference_values.py update-digests versions.yaml 3.30.0 > [!NOTE] > If local `gh attestation ...` commands fail with `unknown command "attestation" for "gh"`, upgrade GitHub CLI to a version that includes the attestation subcommand. +## GitHub Actions + +Workflow: `.github/workflows/reference-values.yml` + +- **pull_request**: verify upstream attestations and build JSON (no release upload) +- **release** (`published`): build, upload `reference-values.json`, attach to the GitHub Release, and run `actions/attest` + +## Verify Release Attestation (gh CLI) + +When `reference-values.json` is published as a GitHub Release asset, you can verify its artifact attestation with `gh attestation verify`. + +```bash +TAG="v0.21.0" + +gh release download "$TAG" \ + --repo confidential-containers/reference-values \ + --pattern "reference-values.json" + +gh attestation verify reference-values.json \ + --repo confidential-containers/reference-values + +# Stricter: pin workflow to the release tag +gh attestation verify reference-values.json \ + --repo confidential-containers/reference-values \ + --signer-workflow "confidential-containers/reference-values/.github/workflows/reference-values.yml@refs/tags/${TAG}" +``` + ## Development To add a reference value, update OCI digests, or write a measurement plugin, see [DEVELOPMENT.md](DEVELOPMENT.md).