From 5bd9d7771f35f4a518387bbf5aeb4bb8b0e801ee Mon Sep 17 00:00:00 2001 From: Jon Langevin Date: Mon, 13 Jul 2026 07:45:12 -0400 Subject: [PATCH 1/3] fix(release): share verified guest methods --- .../prepare-encrypted-spaces-guest-bundle.py | 241 +++++++++++++++ .github/workflows/release-bridge.yml | 162 ++++++++-- .github/workflows/release-publish.yml | 64 +++- PATCHES.md | 12 +- bridge/tests/runtime.rs | 279 +++++++++++++++++- ffproof/client_methods/build.rs | 6 + ffproof/methods/build.rs | 6 + ffproof/prebuilt_guest.rs | 27 ++ ffproof/tracer/methods/build.rs | 6 + 9 files changed, 766 insertions(+), 37 deletions(-) create mode 100644 .github/scripts/prepare-encrypted-spaces-guest-bundle.py create mode 100644 ffproof/prebuilt_guest.rs diff --git a/.github/scripts/prepare-encrypted-spaces-guest-bundle.py b/.github/scripts/prepare-encrypted-spaces-guest-bundle.py new file mode 100644 index 0000000..79cfc75 --- /dev/null +++ b/.github/scripts/prepare-encrypted-spaces-guest-bundle.py @@ -0,0 +1,241 @@ +#!/usr/bin/env python3 +"""Create and verify the architecture-independent Encrypted Spaces guest bundle.""" + +from __future__ import annotations + +import argparse +import hashlib +import json +import re +import shutil +from pathlib import Path + + +VERSION = 1 +GUEST_DIR_ENV = "ENCRYPTED_SPACES_PREBUILT_GUEST_DIR" +EXPECTED_METHODS = { + "encrypted-spaces-ffproof-methods": ("EXTEND_FF", "HASH_TEST"), + "encrypted-spaces-client-methods": ("CHECK_ENTRY", "CHECK_NONCE"), + "ffproof-tracer-methods": ("BENCH_TRACER",), +} +METHOD_RE = re.compile( + r"pub const (?P[A-Z0-9_]+)_ELF: &\[u8\] = " + r"include_bytes!\((?P\"(?:\\.|[^\"\\])*\")\);\s*" + r"pub const (?P=name)_PATH: &str = .*?;\s*" + r"pub const (?P=name)_ID: \[u32; 8\] = \[(?P[^]]+)\];", + re.DOTALL, +) + + +def sha256(path: Path) -> str: + digest = hashlib.sha256() + with path.open("rb") as stream: + for chunk in iter(lambda: stream.read(1024 * 1024), b""): + digest.update(chunk) + return digest.hexdigest() + + +def generated_methods(target_dir: Path, package: str) -> Path: + matches = sorted(target_dir.glob(f"release/build/{package}-*/out/methods.rs")) + if len(matches) != 1: + raise SystemExit( + f"expected one generated methods.rs for {package}, found {len(matches)}" + ) + return matches[0] + + +def parse_methods(source: Path, package: str) -> list[dict[str, object]]: + parsed: dict[str, dict[str, object]] = {} + for match in METHOD_RE.finditer(source.read_text()): + name = match.group("name") + elf = Path(json.loads(match.group("path"))) + words = [int(word.strip()) for word in match.group("id").split(",") if word.strip()] + if len(words) != 8 or any(word < 0 or word > 0xFFFFFFFF for word in words): + raise SystemExit(f"invalid guest image ID for {package}/{name}") + if not any(words): + raise SystemExit(f"zero guest image ID for {package}/{name}") + if elf.suffix != ".bin" or not elf.is_file() or elf.stat().st_size == 0: + raise SystemExit(f"missing guest ELF for {package}/{name}: {elf}") + if name in parsed: + raise SystemExit(f"duplicate guest method for {package}/{name}") + parsed[name] = {"name": name, "source": elf, "image_id": words} + if set(parsed) != set(EXPECTED_METHODS[package]): + raise SystemExit(f"unexpected methods for {package}: {tuple(parsed)}") + return [parsed[name] for name in EXPECTED_METHODS[package]] + + +def normalized_method(name: str, package: str, image_id: list[int]) -> str: + filename = f"{name.lower()}.bin" + relative = f"/{package}/{filename}" + words = ", ".join(str(word) for word in image_id) + return ( + f'pub const {name}_ELF: &[u8] = include_bytes!(concat!(' + f'env!("{GUEST_DIR_ENV}"), "{relative}"));\n' + f'pub const {name}_PATH: &str = concat!(' + f'env!("{GUEST_DIR_ENV}"), "{relative}");\n' + f"pub const {name}_ID: [u32; 8] = [{words}];\n" + ) + + +def write_checksums(bundle_dir: Path) -> None: + files = sorted(path for path in bundle_dir.rglob("*") if path.is_file()) + lines = [f"{sha256(path)} {path.relative_to(bundle_dir).as_posix()}" for path in files] + (bundle_dir / "SHA256SUMS").write_text("\n".join(lines) + "\n") + + +def create(target_dir: Path, output_dir: Path) -> None: + if output_dir.exists(): + shutil.rmtree(output_dir) + output_dir.mkdir(parents=True) + manifest: dict[str, object] = {"version": VERSION, "packages": []} + package_records: list[dict[str, object]] = [] + for package in EXPECTED_METHODS: + package_dir = output_dir / package + package_dir.mkdir() + method_records: list[dict[str, object]] = [] + normalized = [] + for method in parse_methods(generated_methods(target_dir, package), package): + name = str(method["name"]) + source = Path(method["source"]) + image_id = list(method["image_id"]) + filename = f"{name.lower()}.bin" + destination = package_dir / filename + shutil.copyfile(source, destination) + normalized.append(normalized_method(name, package, image_id)) + method_records.append( + { + "name": name, + "file": f"{package}/{filename}", + "image_id": image_id, + "sha256": sha256(destination), + "size": destination.stat().st_size, + } + ) + (package_dir / "methods.rs").write_text("".join(normalized)) + package_records.append({"name": package, "methods": method_records}) + manifest["packages"] = package_records + (output_dir / "manifest.json").write_text( + json.dumps(manifest, indent=2, sort_keys=True) + "\n" + ) + write_checksums(output_dir) + + +def safe_relative(bundle_dir: Path, relative: str) -> Path: + candidate = Path(relative) + if candidate.is_absolute() or ".." in candidate.parts: + raise SystemExit(f"unsafe checksum path: {relative}") + path = bundle_dir / candidate + current = bundle_dir + for part in candidate.parts: + current /= part + if current.is_symlink(): + raise SystemExit(f"symlink is not allowed in guest bundle: {relative}") + if not path.is_file(): + raise SystemExit(f"checksum path is missing: {relative}") + try: + path.resolve(strict=True).relative_to(bundle_dir.resolve(strict=True)) + except ValueError as error: + raise SystemExit(f"checksum path escapes guest bundle: {relative}") from error + return path + + +def verify(bundle_dir: Path) -> None: + checksum_file = bundle_dir / "SHA256SUMS" + if checksum_file.is_symlink() or not checksum_file.is_file(): + raise SystemExit("SHA256SUMS is missing") + checked: set[str] = set() + for line in checksum_file.read_text().splitlines(): + match = re.fullmatch(r"([0-9a-f]{64}) (.+)", line) + if not match: + raise SystemExit(f"invalid SHA256SUMS line: {line}") + expected, relative = match.groups() + if relative in checked: + raise SystemExit(f"duplicate checksum path: {relative}") + if sha256(safe_relative(bundle_dir, relative)) != expected: + raise SystemExit(f"checksum mismatch: {relative}") + checked.add(relative) + + manifest_path = bundle_dir / "manifest.json" + manifest = json.loads(manifest_path.read_text()) + if manifest.get("version") != VERSION: + raise SystemExit("unsupported guest bundle version") + packages = manifest.get("packages") + if not isinstance(packages, list) or not all(isinstance(package, dict) for package in packages): + raise SystemExit("guest bundle packages are missing") + expected_files = {"manifest.json"} + if [package.get("name") for package in packages] != list(EXPECTED_METHODS): + raise SystemExit("guest bundle package set is invalid") + for package in packages: + package_name = package["name"] + methods = package.get("methods") + if not isinstance(methods, list): + raise SystemExit(f"methods are missing for {package_name}") + if tuple(method.get("name") for method in methods) != EXPECTED_METHODS[package_name]: + raise SystemExit(f"guest method set is invalid for {package_name}") + methods_relative = f"{package_name}/methods.rs" + methods_source = safe_relative(bundle_dir, methods_relative) + expected_files.add(methods_relative) + source_text = methods_source.read_text() + expected_source = "".join( + normalized_method(method["name"], package_name, method["image_id"]) + for method in methods + ) + if source_text != expected_source: + raise SystemExit(f"normalized methods are invalid for {package_name}") + for method in methods: + image_id = method.get("image_id") + if ( + not isinstance(image_id, list) + or len(image_id) != 8 + or any(type(word) is not int or word < 0 or word > 0xFFFFFFFF for word in image_id) + or not any(image_id) + ): + raise SystemExit(f"zero guest image ID for {package_name}/{method.get('name')}") + relative = method.get("file") + expected_relative = f"{package_name}/{method['name'].lower()}.bin" + if not isinstance(relative, str) or relative != expected_relative: + raise SystemExit("guest method file is invalid") + path = safe_relative(bundle_dir, relative) + if sha256(path) != method.get("sha256") or path.stat().st_size != method.get("size"): + raise SystemExit(f"guest method manifest mismatch: {relative}") + expected_files.add(relative) + if checked != expected_files: + raise SystemExit( + f"guest checksum file set mismatch: expected {sorted(expected_files)}, got {sorted(checked)}" + ) + actual_files = { + path.relative_to(bundle_dir).as_posix() + for path in bundle_dir.rglob("*") + if path.is_file() and path != checksum_file + } + symlinks = [ + path.relative_to(bundle_dir).as_posix() + for path in bundle_dir.rglob("*") + if path.is_symlink() + ] + if symlinks: + raise SystemExit(f"symlinks are not allowed in guest bundle: {sorted(symlinks)}") + if actual_files != expected_files: + raise SystemExit( + f"guest bundle file set mismatch: expected {sorted(expected_files)}, got {sorted(actual_files)}" + ) + + +def main() -> None: + parser = argparse.ArgumentParser() + subparsers = parser.add_subparsers(dest="command", required=True) + create_parser = subparsers.add_parser("create") + create_parser.add_argument("--target-dir", type=Path, required=True) + create_parser.add_argument("--output-dir", type=Path, required=True) + verify_parser = subparsers.add_parser("verify") + verify_parser.add_argument("--bundle-dir", type=Path, required=True) + args = parser.parse_args() + if args.command == "create": + create(args.target_dir, args.output_dir) + verify(args.output_dir) + else: + verify(args.bundle_dir) + + +if __name__ == "__main__": + main() diff --git a/.github/workflows/release-bridge.yml b/.github/workflows/release-bridge.yml index ebeb2d9..aed6590 100644 --- a/.github/workflows/release-bridge.yml +++ b/.github/workflows/release-bridge.yml @@ -48,9 +48,58 @@ jobs: grep -Fq 'channel = "1.94.1"' rust-toolchain.toml grep -Fq 'real-proofs = ["encrypted-spaces-ffproof/real-proofs"]' bridge/Cargo.toml + guest-methods: + name: Build and verify real guest methods + needs: legal + runs-on: ubuntu-24.04 + steps: + - name: Checkout pinned source + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 + + - name: Install protobuf compiler + shell: bash + run: | + set -euo pipefail + sudo apt-get update + sudo apt-get install --yes protobuf-compiler + protoc --version + + - name: Install pinned Rust and RISC Zero + shell: bash + env: + GITHUB_TOKEN: ${{ github.token }} + run: | + set -euo pipefail + rustup toolchain install "$RUST_VERSION" --profile minimal + cargo install rzup --version "$RZUP_VERSION" --locked + rzup install cargo-risczero "$RISC0_VERSION" + rzup install r0vm "$RISC0_VERSION" + rzup install rust "$RISC0_RUST_VERSION" + + - name: Build and normalize real guest methods + shell: bash + run: | + set -euo pipefail + cargo risczero --version + RISC0_BUILD_LOCKED=1 rustup run "$RUST_VERSION" cargo build --locked --release \ + -p encrypted-spaces-bridge --features real-proofs + python3 .github/scripts/prepare-encrypted-spaces-guest-bundle.py create \ + --target-dir target \ + --output-dir "$RUNNER_TEMP/encrypted-spaces-guest-methods" + python3 .github/scripts/prepare-encrypted-spaces-guest-bundle.py verify \ + --bundle-dir "$RUNNER_TEMP/encrypted-spaces-guest-methods" + + - name: Upload verified guest methods + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 + with: + name: encrypted-spaces-guest-methods + path: ${{ runner.temp }}/encrypted-spaces-guest-methods + if-no-files-found: error + retention-days: 7 + assets: name: ${{ matrix.archive }} - needs: legal + needs: [legal, guest-methods] strategy: fail-fast: false matrix: @@ -104,41 +153,68 @@ jobs: target: macos-arm64 archive: encrypted-spaces-bridge-macos-arm64.tar.gz runs-on: ${{ matrix.os }} + env: + ENCRYPTED_SPACES_PREBUILT_GUEST_DIR: ${{ github.workspace }}/.release-guests steps: - name: Checkout pinned source uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 - - name: Install pinned Rust and RISC Zero + - name: Download verified guest methods + uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 + with: + name: encrypted-spaces-guest-methods + path: ${{ github.workspace }}/.release-guests + + - name: Verify guest methods + shell: bash + run: | + set -euo pipefail + python3 .github/scripts/prepare-encrypted-spaces-guest-bundle.py verify \ + --bundle-dir "$ENCRYPTED_SPACES_PREBUILT_GUEST_DIR" + + - name: Install pinned Rust shell: bash run: | set -euo pipefail rustup toolchain install "$RUST_VERSION" --profile minimal - cargo install rzup --version "$RZUP_VERSION" --locked - rzup install cargo-risczero "$RISC0_VERSION" - rzup install r0vm "$RISC0_VERSION" - rzup install rust "$RISC0_RUST_VERSION" - - name: Build real guest ELFs and native binary + - name: Install protobuf compiler + shell: bash + run: | + set -euo pipefail + if [[ "$RUNNER_OS" == Linux ]]; then + sudo apt-get update + sudo apt-get install --yes protobuf-compiler + else + brew install protobuf + fi + protoc --version + + - name: Embed verified guest ELFs and build native binary shell: bash env: PACKAGE: ${{ matrix.package }} BINARY: ${{ matrix.binary }} run: | set -euo pipefail - cargo risczero --version rustup run "$RUST_VERSION" cargo test --locked -p "$PACKAGE" --features real-proofs --no-run rustup run "$RUST_VERSION" cargo build --locked --release -p "$PACKAGE" --features real-proofs built="$GITHUB_WORKSPACE/target/release/$BINARY" test -x "$built" - methods="$(find target/release/build -path '*/out/methods.rs' -type f -print)" - test -n "$methods" - while IFS= read -r method; do - grep -Eq 'ELF:.*&\[[^]]' "$method" + for methods_package in \ + encrypted-spaces-ffproof-methods \ + encrypted-spaces-client-methods \ + ffproof-tracer-methods; do + method="$(find target/release/build \ + -path "*/$methods_package-*/out/methods.rs" -type f -print)" + test -n "$method" + test "$(printf '%s\n' "$method" | wc -l | tr -d ' ')" = 1 + cmp "$method" "$ENCRYPTED_SPACES_PREBUILT_GUEST_DIR/$methods_package/methods.rs" if grep -Eq 'ID: \[u32; 8\] = \[0, 0, 0, 0, 0, 0, 0, 0\]' "$method"; then echo "zero guest image ID in $method" >&2 exit 1 fi - done <<< "$methods" + done echo "BUILT_BINARY=$built" >> "$GITHUB_ENV" - name: Launch release backend and bridge API suite @@ -204,19 +280,38 @@ jobs: name: Packaged Linux real-proof gate needs: assets runs-on: ubuntu-24.04 + env: + ENCRYPTED_SPACES_PREBUILT_GUEST_DIR: ${{ github.workspace }}/.release-guests steps: - name: Checkout pinned source uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 - - name: Install pinned Rust and RISC Zero + - name: Download verified guest methods + uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 + with: + name: encrypted-spaces-guest-methods + path: ${{ github.workspace }}/.release-guests + + - name: Verify guest methods + shell: bash + run: | + set -euo pipefail + python3 .github/scripts/prepare-encrypted-spaces-guest-bundle.py verify \ + --bundle-dir "$ENCRYPTED_SPACES_PREBUILT_GUEST_DIR" + + - name: Install pinned Rust shell: bash run: | set -euo pipefail rustup toolchain install "$RUST_VERSION" --profile minimal - cargo install rzup --version "$RZUP_VERSION" --locked - rzup install cargo-risczero "$RISC0_VERSION" - rzup install r0vm "$RISC0_VERSION" - rzup install rust "$RISC0_RUST_VERSION" + + - name: Install protobuf compiler + shell: bash + run: | + set -euo pipefail + sudo apt-get update + sudo apt-get install --yes protobuf-compiler + protoc --version - name: Download packaged Linux backend uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 @@ -253,10 +348,6 @@ jobs: export ENCRYPTED_SPACES_BRIDGE_TEST_BINARY="$RUNNER_TEMP/release-runtime/encrypted-spaces-bridge-linux-amd64/encrypted-spaces-bridge" test -x "$ENCRYPTED_SPACES_BACKEND_TEST_BINARY" test -x "$ENCRYPTED_SPACES_BRIDGE_TEST_BINARY" - r0vm_path="$(command -v r0vm)" - hidden_r0vm="$RUNNER_TEMP/r0vm-build-only" - mv "$r0vm_path" "$hidden_r0vm" - trap 'mv "$hidden_r0vm" "$r0vm_path"' EXIT test -z "$(command -v r0vm || true)" env -u RISC0_DEV_MODE -u RISC0_SKIP_BUILD \ rustup run "$RUST_VERSION" cargo test --locked \ @@ -289,6 +380,19 @@ jobs: path: dist merge-multiple: true + - name: Download verified guest manifest + uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 + with: + name: encrypted-spaces-guest-methods + path: guest-methods + + - name: Verify guest methods + shell: bash + run: | + set -euo pipefail + python3 .github/scripts/prepare-encrypted-spaces-guest-bundle.py verify \ + --bundle-dir guest-methods + - name: Generate checksums and manifest shell: bash run: | @@ -306,6 +410,8 @@ jobs: toolchain = os.environ["RUST_VERSION"] fork_commit = os.environ["GITHUB_SHA"] repository = os.environ["GITHUB_REPOSITORY"] + guest_manifest = dist.parent / "guest-methods" / "manifest.json" + guest_methods = json.loads(guest_manifest.read_text()) artifacts = [] for archive in sorted(dist.glob("*.tar.gz")): parts = archive.name.removesuffix(".tar.gz").split("-") @@ -335,6 +441,7 @@ jobs: "rust_toolchain": toolchain, "risc0_version": os.environ["RISC0_VERSION"], "risc0_rust_version": os.environ["RISC0_RUST_VERSION"], + "guest_methods": guest_methods, "artifacts": artifacts, } (dist / "release-manifest.json").write_text( @@ -360,6 +467,17 @@ jobs: .rust_toolchain == env.RUST_VERSION and .risc0_version == env.RISC0_VERSION and .risc0_rust_version == env.RISC0_RUST_VERSION and + .guest_methods.version == 1 and + ([.guest_methods.packages[].name] == [ + "encrypted-spaces-ffproof-methods", + "encrypted-spaces-client-methods", + "ffproof-tracer-methods" + ]) and + (all(.guest_methods.packages[].methods[]; + (.sha256 | test("^[0-9a-f]{64}$")) and + (.image_id | length == 8) and + (any(.image_id[]; . != 0)) + )) and (.artifacts | length == 8) and ([.artifacts[].archive] | unique | length == 8) and all(.artifacts[]; diff --git a/.github/workflows/release-publish.yml b/.github/workflows/release-publish.yml index e1d7c39..7cc9010 100644 --- a/.github/workflows/release-publish.yml +++ b/.github/workflows/release-publish.yml @@ -82,6 +82,17 @@ jobs: .rust_toolchain == env.RUST_VERSION and .risc0_version == env.RISC0_VERSION and .risc0_rust_version == env.RISC0_RUST_VERSION and + .guest_methods.version == 1 and + ([.guest_methods.packages[].name] == [ + "encrypted-spaces-ffproof-methods", + "encrypted-spaces-client-methods", + "ffproof-tracer-methods" + ]) and + (all(.guest_methods.packages[].methods[]; + (.sha256 | test("^[0-9a-f]{64}$")) and + (.image_id | length == 8) and + (any(.image_id[]; . != 0)) + )) and (.artifacts | length == 8) and ([.artifacts[].archive] | unique | length == 8) ' dist/release-manifest.json @@ -102,21 +113,60 @@ jobs: echo "is_release=false" >> "$GITHUB_OUTPUT" fi - - name: Attest release archives with GitHub OIDC - id: attest + - name: Write verified release source predicate + shell: bash + env: + HEAD_SHA: ${{ steps.source.outputs.head_sha }} + RELEASE_WORKFLOW_RUN_ID: ${{ github.event.workflow_run.id }} + run: | + set -euo pipefail + jq -n \ + --arg release_head_sha "$HEAD_SHA" \ + --arg release_repository "$GITHUB_REPOSITORY" \ + --arg release_source_ref "$(jq -r .source_ref dist/release-manifest.json)" \ + --arg release_source_ref_type "$(jq -r .source_ref_type dist/release-manifest.json)" \ + --arg release_workflow_run_id "$RELEASE_WORKFLOW_RUN_ID" \ + --arg upstream_commit "$UPSTREAM_COMMIT" \ + '{ + release_head_sha: $release_head_sha, + release_repository: $release_repository, + release_source_ref: $release_source_ref, + release_source_ref_type: $release_source_ref_type, + release_workflow_run_id: ($release_workflow_run_id | tonumber), + upstream_commit: $upstream_commit + }' > "$RUNNER_TEMP/release-source-predicate.json" + + - name: Attest publisher provenance with GitHub OIDC + id: provenance-attest uses: actions/attest@a1948c3f048ba23858d222213b7c278aabede763 with: - subject-path: dist/*.tar.gz + subject-path: | + dist/*.tar.gz + dist/release-manifest.json + + - name: Attest verified release source binding + id: source-attest + uses: actions/attest@a1948c3f048ba23858d222213b7c278aabede763 + with: + subject-path: | + dist/*.tar.gz + dist/release-manifest.json + predicate-type: https://gocodealone.com/attestations/encrypted-spaces-source/v1 + predicate-path: ${{ runner.temp }}/release-source-predicate.json - - name: Preserve signed attestation bundle + - name: Preserve signed attestation bundles shell: bash env: - ATTESTATION_BUNDLE: ${{ steps.attest.outputs.bundle-path }} + PROVENANCE_ATTESTATION_BUNDLE: ${{ steps.provenance-attest.outputs.bundle-path }} + SOURCE_ATTESTATION_BUNDLE: ${{ steps.source-attest.outputs.bundle-path }} run: | set -euo pipefail - test -s "$ATTESTATION_BUNDLE" + test -s "$PROVENANCE_ATTESTATION_BUNDLE" + test -s "$SOURCE_ATTESTATION_BUNDLE" mkdir -p dist/provenance - cp "$ATTESTATION_BUNDLE" dist/provenance/release.intoto.jsonl + cat "$PROVENANCE_ATTESTATION_BUNDLE" "$SOURCE_ATTESTATION_BUNDLE" \ + > dist/provenance/release.intoto.jsonl + test -s dist/provenance/release.intoto.jsonl - name: Upload trusted complete release uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 diff --git a/PATCHES.md b/PATCHES.md index 8c5921d..7517357 100644 --- a/PATCHES.md +++ b/PATCHES.md @@ -20,11 +20,13 @@ This ledger records the intentional delta from upstream commit multi-process membership, verified synchronization, revocation, and every bridge data primitive. - Adds native Linux/macOS amd64/arm64 release automation with real RISC Zero - guest builds, in-process real-proof-enabled binaries, a packaged-runtime real - receipt gate, checksums, GitHub OIDC-signed artifact attestations, Apache - attribution, and tag-only GitHub release publication. Build/tag code is - unprivileged; a default-branch `workflow_run` validates exact `main` ancestry - before attestation or publication. + guest builds, a checksum-verified architecture-independent guest bundle for + native embedding, in-process real-proof-enabled binaries, a packaged-runtime + real receipt gate without build-only RISC Zero tools, checksums, GitHub + OIDC-signed artifact and manifest attestations with an explicit originating + release-run source binding, Apache attribution, and tag-only GitHub release + publication. Build/tag code is unprivileged; a default-branch `workflow_run` + validates exact `main` ancestry before attestation or publication. - Adds weekly upstream merge PRs whose exact head and fork/upstream ancestry are checked by a correlated trusted-main, read-only, no-secret compatibility workflow and revalidated immediately before ancestry-preserving merge. diff --git a/bridge/tests/runtime.rs b/bridge/tests/runtime.rs index bf9612e..1d3078a 100644 --- a/bridge/tests/runtime.rs +++ b/bridge/tests/runtime.rs @@ -2073,14 +2073,21 @@ fn release_contract_builds_and_publishes_native_assets() { assert_pinned_actions(&publisher); let legal = workflow.find(" legal:").expect("legal job"); + let guest_methods = workflow + .find(" guest-methods:") + .expect("guest methods job"); let assets = workflow.find(" assets:").expect("asset matrix"); let real_proof = workflow.find(" real-proof:").expect("real proof job"); let aggregate = workflow.find(" aggregate:").expect("aggregate job"); assert!( - legal < assets && assets < real_proof && real_proof < aggregate, + legal < guest_methods + && guest_methods < assets + && assets < real_proof + && real_proof < aggregate, "release jobs have unexpected layout" ); - assert!(workflow[assets..real_proof].contains("needs: legal")); + assert!(workflow[guest_methods..assets].contains("needs: legal")); + assert!(workflow[assets..real_proof].contains("needs: [legal, guest-methods]")); for marker in [ "workflow_run:", @@ -2096,6 +2103,10 @@ fn release_contract_builds_and_publishes_native_assets() { "git merge-base --is-ancestor \"$HEAD_SHA\" origin/main", "refs/tags/v$RELEASE_VERSION", "actions/attest@a1948c3f048ba23858d222213b7c278aabede763", + "predicate-type: https://gocodealone.com/attestations/encrypted-spaces-source/v1", + "release_head_sha", + "release_workflow_run_id", + "release_source_ref", "attestations: write", "id-token: write", "bundle-path", @@ -2137,7 +2148,7 @@ fn release_contract_builds_and_publishes_native_assets() { "runtime_packaged_backend_generates_real_fast_forward_receipt", "ENCRYPTED_SPACES_REQUIRE_REAL_PROOF: 1", "ENCRYPTED_SPACES_REQUEST_TIMEOUT_MS: 3600000", - "command -v r0vm", + "test -z \"$(command -v r0vm || true)\"", "env -u RISC0_DEV_MODE -u RISC0_SKIP_BUILD", "test -x", "cmp", @@ -2185,6 +2196,268 @@ fn release_contract_builds_and_publishes_native_assets() { assert!(!patches.contains("NOT_IMPLEMENTED")); } +#[test] +fn release_contract_reuses_verified_guest_bundle_on_native_runners() { + let root = Path::new(env!("CARGO_MANIFEST_DIR")).join(".."); + let workflow = fs::read_to_string(root.join(".github/workflows/release-bridge.yml")) + .expect("release workflow"); + let ffproof_build = + fs::read_to_string(root.join("ffproof/methods/build.rs")).expect("ffproof build script"); + let client_build = fs::read_to_string(root.join("ffproof/client_methods/build.rs")) + .expect("client methods build script"); + let tracer_build = fs::read_to_string(root.join("ffproof/tracer/methods/build.rs")) + .expect("tracer methods build script"); + let prebuilt_support = fs::read_to_string(root.join("ffproof/prebuilt_guest.rs")) + .expect("prebuilt guest build support"); + let prepare = + fs::read_to_string(root.join(".github/scripts/prepare-encrypted-spaces-guest-bundle.py")) + .expect("guest bundle preparation script"); + + for marker in [ + "name: Build and verify real guest methods", + "GITHUB_TOKEN: ${{ github.token }}", + "python3 .github/scripts/prepare-encrypted-spaces-guest-bundle.py create", + "python3 .github/scripts/prepare-encrypted-spaces-guest-bundle.py verify", + "name: encrypted-spaces-guest-methods", + "ENCRYPTED_SPACES_PREBUILT_GUEST_DIR:", + "needs: [legal, guest-methods]", + "Install protobuf compiler", + "protobuf-compiler", + "brew install protobuf", + "protoc --version", + ".guest_methods.version == 1", + ] { + assert!(workflow.contains(marker), "release workflow omits {marker}"); + } + assert_eq!( + workflow.matches("rzup install cargo-risczero").count(), + 1, + "only the supported guest builder may install cargo-risczero" + ); + assert_eq!( + workflow.matches("rzup install r0vm").count(), + 1, + "only the supported guest builder may install r0vm" + ); + assert!(!workflow.contains("mv \"$r0vm_path\"")); + + for build_script in [&ffproof_build, &client_build, &tracer_build] { + assert!(build_script.contains("prebuilt_guest::embed")); + } + for marker in [ + "ENCRYPTED_SPACES_PREBUILT_GUEST_DIR", + "methods.rs", + "rerun-if-env-changed", + "rustc-env", + ] { + assert!( + prebuilt_support.contains(marker), + "prebuilt guest support omits {marker}" + ); + } + for marker in [ + "SHA256SUMS", + "manifest.json", + "include_bytes!(concat!(", + "GUEST_DIR_ENV", + "encrypted-spaces-ffproof-methods", + "encrypted-spaces-client-methods", + "ffproof-tracer-methods", + "zero guest image ID", + ] { + assert!( + prepare.contains(marker), + "guest bundle script omits {marker}" + ); + } +} + +#[test] +fn guest_bundle_tool_normalizes_and_verifies_generated_methods() { + let root = Path::new(env!("CARGO_MANIFEST_DIR")).join(".."); + let fixture = std::env::temp_dir().join(format!( + "encrypted-spaces-guest-bundle-{}", + std::process::id() + )); + let target = fixture.join("target"); + let output = fixture.join("bundle"); + fs::create_dir_all(&target).expect("create synthetic target"); + + let packages: [(&str, &[(&str, &[u8])]); 3] = [ + ( + "encrypted-spaces-ffproof-methods", + &[ + ("EXTEND_FF", b"extend-elf".as_slice()), + ("HASH_TEST", b"hash-elf".as_slice()), + ], + ), + ( + "encrypted-spaces-client-methods", + &[ + ("CHECK_ENTRY", b"entry-elf".as_slice()), + ("CHECK_NONCE", b"nonce-elf".as_slice()), + ], + ), + ( + "ffproof-tracer-methods", + &[("BENCH_TRACER", b"tracer-elf".as_slice())], + ), + ]; + for (package, methods) in packages { + let generated = target + .join("release/build") + .join(format!("{package}-fixture/out")); + fs::create_dir_all(&generated).expect("create generated methods directory"); + let mut source = String::new(); + for (index, (name, bytes)) in methods.iter().copied().enumerate() { + let elf = fixture.join(format!("{}.bin", name.to_ascii_lowercase())); + fs::write(&elf, bytes).expect("write synthetic guest ELF"); + source.push_str(&format!( + "pub const {name}_ELF: &[u8] = include_bytes!({elf:?});\n\ + pub const {name}_PATH: &str = {elf:?};\n\ + pub const {name}_ID: [u32; 8] = [{}, 2, 3, 4, 5, 6, 7, 8];\n", + index + 1 + )); + } + fs::write(generated.join("methods.rs"), source).expect("write generated methods"); + } + + let script = root.join(".github/scripts/prepare-encrypted-spaces-guest-bundle.py"); + let create = Command::new("python3") + .arg(&script) + .args(["create", "--target-dir"]) + .arg(&target) + .arg("--output-dir") + .arg(&output) + .status() + .expect("run guest bundle create"); + assert!(create.success(), "guest bundle creation failed"); + + let normalized = fs::read_to_string(output.join("encrypted-spaces-ffproof-methods/methods.rs")) + .expect("normalized methods"); + assert!(normalized.contains( + "include_bytes!(concat!(env!(\"ENCRYPTED_SPACES_PREBUILT_GUEST_DIR\"), \ + \"/encrypted-spaces-ffproof-methods/extend_ff.bin\"))" + )); + assert!(normalized.contains("EXTEND_FF_ID: [u32; 8] = [1, 2, 3, 4, 5, 6, 7, 8]")); + + let verify = Command::new("python3") + .arg(&script) + .arg("verify") + .arg("--bundle-dir") + .arg(&output) + .status() + .expect("run guest bundle verify"); + assert!(verify.success(), "guest bundle verification failed"); + + let probe_source = fixture.join("guest_bundle_probe.rs"); + let probe_binary = fixture.join("guest_bundle_probe"); + fs::write( + &probe_source, + format!( + "include!({:?});\n\ + fn main() {{\n\ + assert_eq!(EXTEND_FF_ELF, b\"extend-elf\");\n\ + assert_eq!(EXTEND_FF_ID, [1, 2, 3, 4, 5, 6, 7, 8]);\n\ + }}\n", + output.join("encrypted-spaces-ffproof-methods/methods.rs") + ), + ) + .expect("write guest bundle probe"); + let compile_probe = Command::new("rustc") + .env("ENCRYPTED_SPACES_PREBUILT_GUEST_DIR", &output) + .arg(&probe_source) + .arg("-o") + .arg(&probe_binary) + .status() + .expect("compile guest bundle probe"); + assert!( + compile_probe.success(), + "guest bundle probe did not compile" + ); + let probe = Command::new(&probe_binary) + .status() + .expect("run guest bundle probe"); + assert!(probe.success(), "guest bundle probe failed"); + + let methods_relative = "encrypted-spaces-ffproof-methods/methods.rs"; + let methods_path = output.join(methods_relative); + fs::write(&methods_path, format!("{normalized}// substituted\n")) + .expect("substitute normalized methods"); + rewrite_bundle_checksum(&output, methods_relative); + let substituted = Command::new("python3") + .arg(&script) + .arg("verify") + .arg("--bundle-dir") + .arg(&output) + .status() + .expect("run substituted guest bundle verify"); + assert!( + !substituted.success(), + "substituted normalized methods were accepted" + ); + fs::write(&methods_path, &normalized).expect("restore normalized methods"); + rewrite_bundle_checksum(&output, methods_relative); + + #[cfg(unix)] + { + use std::os::unix::fs::symlink; + + let elf_path = output.join("encrypted-spaces-ffproof-methods/extend_ff.bin"); + let outside = fixture.join("outside-extend-ff.bin"); + fs::write(&outside, b"extend-elf").expect("write outside guest ELF"); + fs::remove_file(&elf_path).expect("remove bundled guest ELF"); + symlink(&outside, &elf_path).expect("symlink guest ELF outside bundle"); + let symlinked = Command::new("python3") + .arg(&script) + .arg("verify") + .arg("--bundle-dir") + .arg(&output) + .status() + .expect("run symlinked guest bundle verify"); + assert!(!symlinked.success(), "symlinked guest ELF was accepted"); + fs::remove_file(&elf_path).expect("remove guest ELF symlink"); + fs::write(&elf_path, b"extend-elf").expect("restore guest ELF"); + } + + fs::write( + output.join("encrypted-spaces-ffproof-methods/extend_ff.bin"), + b"tampered", + ) + .expect("tamper guest ELF"); + let tampered = Command::new("python3") + .arg(&script) + .arg("verify") + .arg("--bundle-dir") + .arg(&output) + .status() + .expect("run tampered guest bundle verify"); + assert!(!tampered.success(), "tampered guest bundle was accepted"); + + fs::remove_dir_all(fixture).expect("remove guest bundle fixture"); +} + +fn rewrite_bundle_checksum(bundle: &Path, relative: &str) { + let checksum_path = bundle.join("SHA256SUMS"); + let digest = format!( + "{:x}", + Sha256::digest(fs::read(bundle.join(relative)).expect("read bundle file")) + ); + let checksum = fs::read_to_string(&checksum_path).expect("read bundle checksums"); + let updated = checksum + .lines() + .map(|line| { + if line.ends_with(&format!(" {relative}")) { + format!("{digest} {relative}") + } else { + line.to_owned() + } + }) + .collect::>() + .join("\n"); + fs::write(checksum_path, format!("{updated}\n")).expect("rewrite bundle checksum"); +} + #[test] fn ci_prebuilds_bridge_runtime_fixtures_for_nextest() { let root = Path::new(env!("CARGO_MANIFEST_DIR")).join(".."); diff --git a/ffproof/client_methods/build.rs b/ffproof/client_methods/build.rs index a4aa256..fd7b343 100644 --- a/ffproof/client_methods/build.rs +++ b/ffproof/client_methods/build.rs @@ -12,6 +12,12 @@ // See the License for the specific language governing permissions and // limitations under the License. +#[path = "../prebuilt_guest.rs"] +mod prebuilt_guest; + fn main() { + if prebuilt_guest::embed(env!("CARGO_PKG_NAME")) { + return; + } risc0_build::embed_methods(); } diff --git a/ffproof/methods/build.rs b/ffproof/methods/build.rs index a4aa256..fd7b343 100644 --- a/ffproof/methods/build.rs +++ b/ffproof/methods/build.rs @@ -12,6 +12,12 @@ // See the License for the specific language governing permissions and // limitations under the License. +#[path = "../prebuilt_guest.rs"] +mod prebuilt_guest; + fn main() { + if prebuilt_guest::embed(env!("CARGO_PKG_NAME")) { + return; + } risc0_build::embed_methods(); } diff --git a/ffproof/prebuilt_guest.rs b/ffproof/prebuilt_guest.rs new file mode 100644 index 0000000..d18b55b --- /dev/null +++ b/ffproof/prebuilt_guest.rs @@ -0,0 +1,27 @@ +use std::env; +use std::fs; +use std::path::Path; + +const PREBUILT_GUEST_DIR: &str = "ENCRYPTED_SPACES_PREBUILT_GUEST_DIR"; + +pub fn embed(package: &str) -> bool { + println!("cargo:rerun-if-env-changed={PREBUILT_GUEST_DIR}"); + let Some(root) = env::var_os(PREBUILT_GUEST_DIR) else { + return false; + }; + let root = fs::canonicalize(Path::new(&root)).expect("canonicalize prebuilt guest directory"); + let source = root.join(package).join("methods.rs"); + let output = Path::new(&env::var_os("OUT_DIR").expect("OUT_DIR is set")).join("methods.rs"); + assert!( + source.is_file(), + "prebuilt guest methods are missing for {package}: {}", + source.display() + ); + fs::copy(&source, &output).expect("copy prebuilt guest methods"); + println!("cargo:rerun-if-changed={}", source.display()); + println!( + "cargo:rustc-env={PREBUILT_GUEST_DIR}={}", + root.to_str().expect("prebuilt guest directory is UTF-8") + ); + true +} diff --git a/ffproof/tracer/methods/build.rs b/ffproof/tracer/methods/build.rs index 08a8a4e..bbcf999 100644 --- a/ffproof/tracer/methods/build.rs +++ b/ffproof/tracer/methods/build.rs @@ -1,3 +1,9 @@ +#[path = "../../prebuilt_guest.rs"] +mod prebuilt_guest; + fn main() { + if prebuilt_guest::embed(env!("CARGO_PKG_NAME")) { + return; + } risc0_build::embed_methods(); } From 86122500984361259d4912468906e6ee8ed4b5a0 Mon Sep 17 00:00:00 2001 From: Jon Langevin Date: Mon, 13 Jul 2026 07:57:17 -0400 Subject: [PATCH 2/3] test(bridge): simplify guest fixture types --- bridge/tests/runtime.rs | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/bridge/tests/runtime.rs b/bridge/tests/runtime.rs index 1d3078a..8254a44 100644 --- a/bridge/tests/runtime.rs +++ b/bridge/tests/runtime.rs @@ -19,6 +19,9 @@ const RESPONSE_TIMEOUT: Duration = Duration::from_secs(60); const EXIT_TIMEOUT: Duration = Duration::from_secs(2); const BACKEND_START_TIMEOUT: Duration = Duration::from_secs(10); +type GuestFixtureMethod<'a> = (&'a str, &'a [u8]); +type GuestFixturePackage<'a> = (&'a str, &'a [GuestFixtureMethod<'a>]); + struct BackendProcess { child: Child, schema_path: PathBuf, @@ -2283,7 +2286,7 @@ fn guest_bundle_tool_normalizes_and_verifies_generated_methods() { let output = fixture.join("bundle"); fs::create_dir_all(&target).expect("create synthetic target"); - let packages: [(&str, &[(&str, &[u8])]); 3] = [ + let packages: [GuestFixturePackage<'_>; 3] = [ ( "encrypted-spaces-ffproof-methods", &[ From d6c8c9850dea0a5d6bc722c4c715dd4c50b85bb6 Mon Sep 17 00:00:00 2001 From: Jon Langevin Date: Mon, 13 Jul 2026 08:03:34 -0400 Subject: [PATCH 3/3] fix(bundle): validate manifest before parsing Reject manifest symlinks before reading their targets, including when the checksum manifest omits manifest.json. --- .../prepare-encrypted-spaces-guest-bundle.py | 2 +- bridge/tests/runtime.rs | 35 +++++++++++++++++++ 2 files changed, 36 insertions(+), 1 deletion(-) diff --git a/.github/scripts/prepare-encrypted-spaces-guest-bundle.py b/.github/scripts/prepare-encrypted-spaces-guest-bundle.py index 79cfc75..9253251 100644 --- a/.github/scripts/prepare-encrypted-spaces-guest-bundle.py +++ b/.github/scripts/prepare-encrypted-spaces-guest-bundle.py @@ -155,7 +155,7 @@ def verify(bundle_dir: Path) -> None: raise SystemExit(f"checksum mismatch: {relative}") checked.add(relative) - manifest_path = bundle_dir / "manifest.json" + manifest_path = safe_relative(bundle_dir, "manifest.json") manifest = json.loads(manifest_path.read_text()) if manifest.get("version") != VERSION: raise SystemExit("unsupported guest bundle version") diff --git a/bridge/tests/runtime.rs b/bridge/tests/runtime.rs index 8254a44..efec107 100644 --- a/bridge/tests/runtime.rs +++ b/bridge/tests/runtime.rs @@ -2406,6 +2406,41 @@ fn guest_bundle_tool_normalizes_and_verifies_generated_methods() { { use std::os::unix::fs::symlink; + let manifest_path = output.join("manifest.json"); + let manifest = fs::read(&manifest_path).expect("read guest bundle manifest"); + let checksum_path = output.join("SHA256SUMS"); + let checksums = fs::read_to_string(&checksum_path).expect("read guest bundle checksums"); + let checksums_without_manifest = checksums + .lines() + .filter(|line| !line.ends_with(" manifest.json")) + .collect::>() + .join("\n") + + "\n"; + let outside_manifest = fixture.join("outside-manifest.json"); + fs::write(&outside_manifest, b"not JSON").expect("write outside manifest"); + fs::write(&checksum_path, checksums_without_manifest).expect("remove manifest checksum"); + fs::remove_file(&manifest_path).expect("remove bundled manifest"); + symlink(&outside_manifest, &manifest_path).expect("symlink manifest outside bundle"); + let symlinked_manifest = Command::new("python3") + .arg(&script) + .arg("verify") + .arg("--bundle-dir") + .arg(&output) + .output() + .expect("run symlinked manifest verify"); + assert!( + !symlinked_manifest.status.success(), + "symlinked manifest was accepted" + ); + let stderr = String::from_utf8_lossy(&symlinked_manifest.stderr); + assert!( + stderr.contains("symlink is not allowed in guest bundle: manifest.json"), + "manifest was read before symlink validation: {stderr}" + ); + fs::remove_file(&manifest_path).expect("remove manifest symlink"); + fs::write(&manifest_path, manifest).expect("restore guest bundle manifest"); + fs::write(&checksum_path, checksums).expect("restore guest bundle checksums"); + let elf_path = output.join("encrypted-spaces-ffproof-methods/extend_ff.bin"); let outside = fixture.join("outside-extend-ff.bin"); fs::write(&outside, b"extend-elf").expect("write outside guest ELF");