From 42a4ade12e9bc0774e92a09a50e420c072ed2afa Mon Sep 17 00:00:00 2001 From: Koen van der Veen Date: Tue, 15 Sep 2026 17:40:32 +0200 Subject: [PATCH 01/21] feat: add Tinfoil as a second enclave deployment target Everything about enclave attestation was hardcoded to GCP Confidential Space. This adds a provider seam on both sides so a second target fits, and implements that target. Wire format: VersionInfo gains a generic 'extra' bag, so syft carries attestation without knowing anything about it. syft-enclave owns the 'attestation' key and the envelope, which both targets now share. The old attestation_token stays declared but unused - protocol-0 froze VersionInfoV1's schema, so it cannot be removed. Enclave side: evidence.py selects a provider by probing its marker path, with providers/confidential_space.py and providers/tinfoil.py behind it. Tinfoil reads /tinfoil/attestation.json and refuses a caller nonce rather than silently ignoring one. Adds a tinfoil token bootstrap provider and de-duplicates the unix-socket client. Verification: tinfoil evidence is appraised from the live enclave over a connection pinned to the TLS key its report commits to - no CA is involved, since the certificate is self-signed and the report decides whether to trust it. The enclave serves its syft key bundle over that channel and signs a client nonce with it, which proves possession and freshness that the report itself cannot carry. attest_peer then sets those keys for the peer. The Drive-published copy remains as provenance but is never appraised in its place. The tinfoil SDK is an optional extra with an install-instruction error, and stays off the always-imported path. Also adds just recipes (including a debug/logs path, since there is no control-plane log API), the measured config and release workflows, and docs/tinfoil.md. Retitles the terraform docs to name Confidential Spaces. Co-Authored-By: Claude Opus 5 (1M context) --- .../docker/Dockerfile | 3 + .../src/enclave_model_api/__main__.py | 2 + packages/syft-enclave/Justfile | 278 +++++++- packages/syft-enclave/README.md | 18 +- packages/syft-enclave/docker/Dockerfile | 3 + .../syft-enclave/docker/attestation_server.py | 232 +++---- packages/syft-enclave/docker/entrypoint.sh | 17 +- packages/syft-enclave/docs/api.md | 54 +- packages/syft-enclave/docs/dev.md | 2 +- packages/syft-enclave/docs/security.md | 14 +- packages/syft-enclave/docs/terraform.md | 2 +- packages/syft-enclave/docs/tinfoil.md | 264 ++++++++ packages/syft-enclave/pyproject.toml | 10 + .../syft-enclave/scripts/tinfoil_e2e_check.py | 143 +++++ .../syft-enclave/scripts/verify_tinfoil.py | 75 +++ .../src/syft_enclaves/__main__.py | 7 +- .../src/syft_enclaves/_unix_socket.py | 24 + .../src/syft_enclaves/attestation.py | 5 + .../src/syft_enclaves/attestation_dispatch.py | 77 +++ .../src/syft_enclaves/attestation_envelope.py | 156 +++++ .../src/syft_enclaves/attestation_https.py | 186 ++++++ .../src/syft_enclaves/attestation_tinfoil.py | 597 ++++++++++++++++++ .../src/syft_enclaves/bootstrap.py | 51 +- .../syft-enclave/src/syft_enclaves/client.py | 94 ++- .../src/syft_enclaves/evidence.py | 111 ++++ .../src/syft_enclaves/key_bundle.py | 89 +++ .../src/syft_enclaves/nonce_challenge.py | 97 +++ .../src/syft_enclaves/optional_deps.py | 58 ++ .../src/syft_enclaves/providers/__init__.py | 5 + .../providers/confidential_space.py | 128 ++++ .../src/syft_enclaves/providers/tinfoil.py | 121 ++++ .../syft-enclave/src/syft_enclaves/runner.py | 88 ++- .../src/syft_enclaves/settings.py | 63 +- .../src/syft_enclaves/tee_token.py | 18 +- .../tests/test_attestation_dispatch.py | 200 ++++++ .../tests/test_attestation_envelope.py | 142 +++++ .../tests/test_attestation_https.py | 137 ++++ .../tests/test_attestation_tinfoil.py | 531 ++++++++++++++++ .../tests/test_evidence_providers.py | 248 ++++++++ packages/syft-enclave/tests/test_runner.py | 162 ++++- .../workflows/tinfoil-release-publish.yml | 35 + .../.github/workflows/tinfoil-release.yml | 57 ++ .../syft-enclave/tinfoil/tinfoil-config.yml | 98 +++ syft/sync/version/version_info.py | 18 +- .../unit/test_version_info_fields.py | 33 +- .../unit/test_version_info_serialization.py | 1 + 46 files changed, 4464 insertions(+), 290 deletions(-) create mode 100644 packages/syft-enclave/docs/tinfoil.md create mode 100644 packages/syft-enclave/scripts/tinfoil_e2e_check.py create mode 100644 packages/syft-enclave/scripts/verify_tinfoil.py create mode 100644 packages/syft-enclave/src/syft_enclaves/_unix_socket.py create mode 100644 packages/syft-enclave/src/syft_enclaves/attestation_dispatch.py create mode 100644 packages/syft-enclave/src/syft_enclaves/attestation_envelope.py create mode 100644 packages/syft-enclave/src/syft_enclaves/attestation_https.py create mode 100644 packages/syft-enclave/src/syft_enclaves/attestation_tinfoil.py create mode 100644 packages/syft-enclave/src/syft_enclaves/evidence.py create mode 100644 packages/syft-enclave/src/syft_enclaves/key_bundle.py create mode 100644 packages/syft-enclave/src/syft_enclaves/nonce_challenge.py create mode 100644 packages/syft-enclave/src/syft_enclaves/optional_deps.py create mode 100644 packages/syft-enclave/src/syft_enclaves/providers/__init__.py create mode 100644 packages/syft-enclave/src/syft_enclaves/providers/confidential_space.py create mode 100644 packages/syft-enclave/src/syft_enclaves/providers/tinfoil.py create mode 100644 packages/syft-enclave/tests/test_attestation_dispatch.py create mode 100644 packages/syft-enclave/tests/test_attestation_envelope.py create mode 100644 packages/syft-enclave/tests/test_attestation_https.py create mode 100644 packages/syft-enclave/tests/test_attestation_tinfoil.py create mode 100644 packages/syft-enclave/tests/test_evidence_providers.py create mode 100644 packages/syft-enclave/tinfoil/.github/workflows/tinfoil-release-publish.yml create mode 100644 packages/syft-enclave/tinfoil/.github/workflows/tinfoil-release.yml create mode 100644 packages/syft-enclave/tinfoil/tinfoil-config.yml diff --git a/packages/enclave-model-api-example/docker/Dockerfile b/packages/enclave-model-api-example/docker/Dockerfile index 92884788bf7..88287414a73 100644 --- a/packages/enclave-model-api-example/docker/Dockerfile +++ b/packages/enclave-model-api-example/docker/Dockerfile @@ -28,6 +28,9 @@ SYFT_ENCLAVE_DATA_OWNERS,\ SYFT_ENCLAVE_REQUIRE_TEE,\ SYFT_ENCLAVE_FRESH_STATE,\ SYFT_ENCLAVE_USE_ENCRYPTION,\ +SYFT_ENCLAVE_ATTESTATION_PROVIDER,\ +SYFT_ENCLAVE_TINFOIL_REPO,\ +SYFT_ENCLAVE_TINFOIL_RELEASE_TAG,\ SYFT_DEFAULT_JOB_TIMEOUT_SECONDS,\ SYFT_BOOTSTRAP,\ SYFT_BOOTSTRAP_WIF_AUDIENCE,\ diff --git a/packages/enclave-model-api-example/src/enclave_model_api/__main__.py b/packages/enclave-model-api-example/src/enclave_model_api/__main__.py index 68ef7fd8795..8754350ee8f 100644 --- a/packages/enclave-model-api-example/src/enclave_model_api/__main__.py +++ b/packages/enclave-model-api-example/src/enclave_model_api/__main__.py @@ -71,6 +71,8 @@ def main() -> None: poll_interval=settings.poll_interval, require_tee=settings.require_tee, fresh_state=settings.fresh_state, + attestation_provider=settings.attestation_provider, + settings=settings, post_init=lambda: ensure_logs_dataset(client, inference.logs_dataset), ) logger.info("EnclaveRunner ready — calling runner.run()") diff --git a/packages/syft-enclave/Justfile b/packages/syft-enclave/Justfile index c8db39bc1c4..dc8502eb32b 100644 --- a/packages/syft-enclave/Justfile +++ b/packages/syft-enclave/Justfile @@ -11,6 +11,10 @@ vm_default := "syft-enclave-vm" secret_name_default := "syft-enclave-token" sa_name_default := "syft-enclave-service-account" tf_dir := "terraform" +tinfoil_repo := "OpenMined/syft-enclave-tinfoil" +tinfoil_container := "syft-enclave" +tinfoil_config := "tinfoil/tinfoil-config.yml" +tinfoil_org_domain := "openmined.containers.tinfoil.dev" job_timeout_seconds := "2592000" # 30 days # Shared shell snippet: loads settings from ~/.syft-enclaves/settings.json. @@ -44,7 +48,13 @@ ensure_dockerhub_login := ''' # Preflight for terraform recipes: terraform installed + tfvars present. tf_preflight := ''' command -v terraform >/dev/null 2>&1 || { echo "Error: terraform not found. Install: https://developer.hashicorp.com/terraform/install" >&2; exit 1; } - [ -f terraform/terraform.tfvars ] || { echo "Error: terraform/terraform.tfvars not found. Copy terraform/terraform.tfvars.example and fill it in (see docs/terraform.md)." >&2; exit 1; } + [ -f terraform/terraform.tfvars ] || { echo "Error: terraform/terraform.tfvars not found. Copy terraform/terraform.tfvars.example and fill it in (see docs/terraform.md — Confidential Spaces deployment)." >&2; exit 1; } +''' + +# Preflight for tinfoil recipes: CLI installed + authenticated. +tinfoil_preflight := ''' + command -v tinfoil >/dev/null 2>&1 || { echo "Error: tinfoil CLI not found. Install: curl -fsSL https://github.com/tinfoilsh/tinfoil-cli/raw/main/install.sh | sh" >&2; exit 1; } + [ -f "$HOME/.tinfoil/config.json" ] || [ -n "${TINFOIL_API_KEY:-}" ] || { echo "Error: not authenticated with Tinfoil. Run: tinfoil login (or set TINFOIL_API_KEY). See docs/tinfoil.md." >&2; exit 1; } ''' # List all available commands @@ -656,3 +666,269 @@ tf-ssh: "$(terraform -chdir={{tf_dir}} output -raw vm_name)" \ --project="$(terraform -chdir={{tf_dir}} output -raw project_id)" \ --zone="$(terraform -chdir={{tf_dir}} output -raw zone)" + + +# --------------------------------------------------------------------------------------------------------------------- +# Tinfoil +# +# The alternative to Confidential Spaces: the enclave runs in an AMD SEV-SNP / +# Intel TDX CVM managed by Tinfoil, with no GCP involved. Two repos are in play +# — the image is built and pushed from here, while `{{tinfoil_repo}}` holds the +# measured config whose signed GitHub releases publish the expected +# measurement. `tinfoil-release` is what keeps the two in step. +# +# Full walkthrough: docs/tinfoil.md +# --------------------------------------------------------------------------------------------------------------------- + +# Who am I logged in to Tinfoil as +[group('tinfoil')] +tinfoil-whoami: + #!/bin/bash + set -e + {{tinfoil_preflight}} + tinfoil whoami + +# The config the release repo currently holds +[group('tinfoil')] +tinfoil-config-get *args: + #!/bin/bash + set -e + {{tinfoil_preflight}} + tinfoil repo config get {{tinfoil_repo}} {{args}} + +# Open a PR syncing the local config to the release repo +[group('tinfoil')] +tinfoil-config-pr file=tinfoil_config body="sync tinfoil-config.yml from PySyft": + #!/bin/bash + set -e + {{tinfoil_preflight}} + tinfoil repo config pr {{tinfoil_repo}} --file {{file}} --body "{{body}}" + +# Status of a config PR +[group('tinfoil')] +tinfoil-pr-status number: + #!/bin/bash + set -e + {{tinfoil_preflight}} + tinfoil repo pr status {{tinfoil_repo}} {{number}} + +# Latest published release and the suggested next version +[group('tinfoil')] +tinfoil-build-info: + #!/bin/bash + set -e + {{tinfoil_preflight}} + tinfoil repo build info {{tinfoil_repo}} + +# Publish a measured, Sigstore-signed config release +[group('tinfoil')] +tinfoil-publish version: + #!/bin/bash + set -e + {{tinfoil_preflight}} + echo "Publishing {{tinfoil_repo}} {{version}} — a release is permanent and cannot be unpublished." + tinfoil repo build run {{tinfoil_repo}} --version {{version}} + +# Without this it is easy to publish a release pinned to a stale image. +# Build + push the image, pin its digest in the config, and open the PR +[group('tinfoil')] +tinfoil-release version: + #!/bin/bash + set -e + {{tinfoil_preflight}} + just build-push-amd {{version}} + digest=$(docker buildx imagetools inspect {{image_base}}:{{version}} --format '{{{{.Manifest.Digest}}}}') + [ -n "$digest" ] || { echo "Error: could not read the pushed image digest" >&2; exit 1; } + echo "Pushed {{image_base}}:{{version}} @ $digest" + tmp=$(mktemp) + # Replace whichever digest the config currently pins (placeholder or real). + sed -E "s|({{image_base}})@sha256:[0-9a-f]{64}|\1@$digest|" {{tinfoil_config}} > "$tmp" + grep -q "@$digest" "$tmp" || { echo "Error: no image line to pin in {{tinfoil_config}}" >&2; rm -f "$tmp"; exit 1; } + cp "$tmp" {{tinfoil_config}} && rm -f "$tmp" + just tinfoil-config-pr {{tinfoil_config}} "pin syft-enclave {{version}} ($digest)" + echo "" + echo "Next: merge the PR, then 'just tinfoil-publish {{version}}', then 'just tinfoil-deploy {{version}}'." + echo "Keep $digest — a data owner needs it for attest_peer(expected_image_digest=...)." + +# EMAIL and DATA_OWNERS are deploy-time variables, so they are NOT part of the +# measurement and a data owner cannot verify them — same as tee-env-* metadata +# on Confidential Spaces. See docs/tinfoil.md. +# Deploy a container from a published release +[group('tinfoil')] +tinfoil-deploy tag email data_owners="" extra_args="": + #!/bin/bash + set -e + {{tinfoil_preflight}} + owners="{{data_owners}}" + if [ -z "$owners" ]; then + settings="$HOME/.syft-enclaves/settings.json" + [ -f "$settings" ] && owners=$(jq -r '.data_owners // empty' "$settings") + fi + [ -n "$owners" ] || { echo "Error: pass DATA_OWNERS (comma-separated) or run 'just init' first." >&2; exit 1; } + echo "Deploying {{tinfoil_container}} from {{tinfoil_repo}} {{tag}} for $owners" + tinfoil container create {{tinfoil_container}} \ + --repo {{tinfoil_repo}} \ + --tag {{tag}} \ + --variable SYFT_ENCLAVE_EMAIL={{email}} \ + --variable SYFT_ENCLAVE_DATA_OWNERS="$owners" \ + --variable SYFT_ENCLAVE_REQUIRE_TEE=true \ + --variable SYFT_ENCLAVE_USE_ENCRYPTION=true \ + --variable SYFT_ENCLAVE_TINFOIL_RELEASE_TAG={{tag}} \ + --variable SYFT_ENCLAVE_TINFOIL_HOST={{tinfoil_container}}.{{tinfoil_org_domain}} \ + --secret SYFT_ENCLAVE_TOKEN_CONTENT \ + {{extra_args}} + +# Move the deployment to another published release +[group('tinfoil')] +tinfoil-update tag *args: + #!/bin/bash + set -e + {{tinfoil_preflight}} + tinfoil deployment update {{tinfoil_repo}} --tag {{tag}} {{args}} + +# Relaunch the container on a given release (use --promote-release=false to roll back) +[group('tinfoil')] +tinfoil-relaunch tag *args: + #!/bin/bash + set -e + {{tinfoil_preflight}} + tinfoil container relaunch {{tinfoil_container}} --tag {{tag}} {{args}} + +# Where an in-flight update has got to +[group('tinfoil')] +tinfoil-status: + #!/bin/bash + set -e + {{tinfoil_preflight}} + tinfoil container update status {{tinfoil_container}} + +# Accept a staged update +[group('tinfoil')] +tinfoil-accept: + #!/bin/bash + set -e + {{tinfoil_preflight}} + tinfoil container update accept {{tinfoil_container}} + +# Cancel a staged update +[group('tinfoil')] +tinfoil-cancel: + #!/bin/bash + set -e + {{tinfoil_preflight}} + tinfoil container update cancel {{tinfoil_container}} + +# Unlike the Confidential Spaces equivalent this needs no SSH — the shim serves +# it whether or not the path is in the config's allowlist. +# +# -k is deliberate: the enclave presents a self-signed certificate, because its +# TLS key is generated inside the enclave and the attestation report commits to +# that key's fingerprint. Trust comes from the report, not from a CA — so +# fetching the report over an unvalidated transport is exactly right. Verifying +# it is `just tinfoil-verify`. +# The enclave's raw attestation document, straight from the shim +[group('tinfoil')] +tinfoil-attest host: + #!/bin/bash + set -e + curl -sSk "https://{{host}}/.well-known/tinfoil-attestation" | python3 -m json.tool + +# Needs the optional tinfoil extra: uv pip install "syft-enclave[tinfoil]" +# Verify a live enclave the way a data owner would +[group('tinfoil')] +tinfoil-verify host *args: + #!/bin/bash + set -e + uv run --project ../.. python scripts/verify_tinfoil.py {{host}} --repo {{tinfoil_repo}} {{args}} + +# --------------------------------------------------------------------------------------------------------------------- +# Tinfoil — debug +# +# A debug instance is a SEPARATE deployment at +# `.debug..containers.tinfoil.dev`, and it deliberately does NOT +# pass attestation: `SecureClient` and `attest_peer` will refuse it, because a +# debug enclave is not confidential. Use it to find out why a container will +# not start, then redeploy without --debug for anything real. +# --------------------------------------------------------------------------------------------------------------------- + +# Register a public SSH key with the org (once per machine) +[group('tinfoil-debug')] +tinfoil-ssh-key name keyfile="~/.ssh/id_ed25519.pub": + #!/bin/bash + set -e + {{tinfoil_preflight}} + tinfoil ssh-key create {{name}} --public-key-file {{keyfile}} + +# EMAIL/DATA_OWNERS must be repeated: relaunch overrides variables wholesale. +# Redeploy the container in debug mode with SSH enabled +[group('tinfoil-debug')] +tinfoil-debug tag email data_owners="" ssh_key="koen-debug": + #!/bin/bash + set -e + {{tinfoil_preflight}} + owners="{{data_owners}}" + if [ -z "$owners" ]; then + settings="$HOME/.syft-enclaves/settings.json" + [ -f "$settings" ] && owners=$(jq -r '.data_owners // empty' "$settings") + fi + [ -n "$owners" ] || { echo "Error: pass DATA_OWNERS (comma-separated) or run 'just init' first." >&2; exit 1; } + echo "⚠️ Debug instances do not pass attestation — do not use for real data." + tinfoil container relaunch {{tinfoil_container}} \ + --tag {{tag}} \ + --debug true \ + --ssh-key {{ssh_key}} \ + --variable SYFT_ENCLAVE_EMAIL={{email}} \ + --variable SYFT_ENCLAVE_DATA_OWNERS="$owners" \ + --variable SYFT_ENCLAVE_REQUIRE_TEE=true \ + --variable SYFT_ENCLAVE_USE_ENCRYPTION=true \ + --variable SYFT_ENCLAVE_TINFOIL_RELEASE_TAG={{tag}} \ + --variable SYFT_ENCLAVE_TINFOIL_HOST={{tinfoil_container}}.debug.{{tinfoil_org_domain}} \ + --secret SYFT_ENCLAVE_TOKEN_CONTENT + +# The SSH command for the running debug instance (host and port come from the API) +[group('tinfoil-debug')] +tinfoil-ssh-command: + #!/bin/bash + set -e + {{tinfoil_preflight}} + j=$(tinfoil container get {{tinfoil_container}} -o json) + port=$(echo "$j" | jq -r '.ssh_port') + host=$(echo "$j" | jq -r '.host_name') + [ "$port" != "0" ] || { echo "Error: no SSH port. Deploy with 'just tinfoil-debug ' first." >&2; exit 1; } + echo "ssh -p $port root@$host" + +# Enclave container logs (debug instances only — there is no control-plane log API) +[group('tinfoil-debug')] +tinfoil-logs n="80": + #!/bin/bash + set -e + {{tinfoil_preflight}} + j=$(tinfoil container get {{tinfoil_container}} -o json) + port=$(echo "$j" | jq -r '.ssh_port') + host=$(echo "$j" | jq -r '.host_name') + [ "$port" != "0" ] || { echo "Error: logs need a debug instance. Run 'just tinfoil-debug ' first." >&2; exit 1; } + ssh -o StrictHostKeyChecking=accept-new -p "$port" "root@$host" \ + "docker logs \$(docker ps -aq --filter name={{tinfoil_container}} | head -1) 2>&1 | tail -n {{n}}" + +# Shell inside the running debug enclave +[group('tinfoil-debug')] +tinfoil-shell: + #!/bin/bash + set -e + {{tinfoil_preflight}} + j=$(tinfoil container get {{tinfoil_container}} -o json) + port=$(echo "$j" | jq -r '.ssh_port') + host=$(echo "$j" | jq -r '.host_name') + [ "$port" != "0" ] || { echo "Error: no SSH port. Run 'just tinfoil-debug ' first." >&2; exit 1; } + ssh -o StrictHostKeyChecking=accept-new -p "$port" "root@$host" + +# Boot stages and errors, straight from the control plane +[group('tinfoil-debug')] +tinfoil-why: + #!/bin/bash + set -e + {{tinfoil_preflight}} + tinfoil container get {{tinfoil_container}} -o json | jq '{status, current_tag, debug, ssh_port, error_message}' + host=$(tinfoil container get {{tinfoil_container}} -o json | jq -r '.domain') + echo "--- shim boot status (https://$host/health) ---" + curl -sk --max-time 20 "https://$host/health" | jq '.' 2>/dev/null || echo "(no JSON — the workload may be serving instead)" diff --git a/packages/syft-enclave/README.md b/packages/syft-enclave/README.md index 42293330242..2a35479d2e0 100644 --- a/packages/syft-enclave/README.md +++ b/packages/syft-enclave/README.md @@ -8,18 +8,28 @@ Enclave support for syft, enabling secure computation in Trusted Execution Envir - [Security Overview](./docs/security.md) - [Enclave Architecture](./docs/enclave_architecture.md) - [API](./docs/api.md) -- [Terraform Deployment](./docs/terraform.md) +- [Confidential Spaces Deployment (Terraform)](./docs/terraform.md) +- [Tinfoil Deployment](./docs/tinfoil.md) ## Prerequisites +Shared by both deployment targets: + - Docker with buildx support (Docker Desktop includes this) +- [`just`](https://github.com/casey/just) and `jq` + +For Confidential Spaces (the rest of this README): + - `gcloud` [CLI installed](https://docs.cloud.google.com/sdk/docs/install-sdk) - A GCP project with billing enabled -- [`just`](https://github.com/casey/just) and `jq` + +For Tinfoil, see [docs/tinfoil.md](./docs/tinfoil.md) — no GCP needed. All commands are defined in the [`Justfile`](./Justfile). Run them from this directory. -Prefer declarative deploys? The same stack can be managed with Terraform — see [Terraform Deployment](./docs/terraform.md) (`just tf-apply` / `just tf-apply-dev`). +Prefer declarative deploys? The same stack can be managed with Terraform — see [Confidential Spaces Deployment](./docs/terraform.md) (`just tf-apply` / `just tf-apply-dev`). + +Deploying without GCP? See [Tinfoil Deployment](./docs/tinfoil.md) (`just tinfoil-release` / `just tinfoil-deploy`), which needs the `tinfoil` CLI instead of `gcloud`. ## One-time setup @@ -69,7 +79,7 @@ just hardware=gpu start EMAIL # production just hardware=gpu start-debug EMAIL # debug ``` -GPU enclaves use flex-start provisioning: the create call may wait for H100 capacity (up to 2h), then the VM runs `gpu_run_duration_seconds` (default 2 days). Details: [docs/terraform.md — GPU deployments](docs/terraform.md#gpu-deployments). +GPU enclaves use flex-start provisioning: the create call may wait for H100 capacity (up to 2h), then the VM runs `gpu_run_duration_seconds` (default 2 days). Details: [Confidential Spaces — GPU deployments](docs/terraform.md#gpu-deployments). ## Inspect a running VM diff --git a/packages/syft-enclave/docker/Dockerfile b/packages/syft-enclave/docker/Dockerfile index 8b1e5cb6027..6c98af7e6a5 100644 --- a/packages/syft-enclave/docker/Dockerfile +++ b/packages/syft-enclave/docker/Dockerfile @@ -54,6 +54,9 @@ SYFT_ENCLAVE_DATA_OWNERS,\ SYFT_ENCLAVE_REQUIRE_TEE,\ SYFT_ENCLAVE_FRESH_STATE,\ SYFT_ENCLAVE_USE_ENCRYPTION,\ +SYFT_ENCLAVE_ATTESTATION_PROVIDER,\ +SYFT_ENCLAVE_TINFOIL_REPO,\ +SYFT_ENCLAVE_TINFOIL_RELEASE_TAG,\ SYFT_DEFAULT_JOB_TIMEOUT_SECONDS,\ SYFT_BOOTSTRAP,\ SYFT_BOOTSTRAP_WIF_AUDIENCE,\ diff --git a/packages/syft-enclave/docker/attestation_server.py b/packages/syft-enclave/docker/attestation_server.py index 6f970cc632d..6b72ebabc12 100644 --- a/packages/syft-enclave/docker/attestation_server.py +++ b/packages/syft-enclave/docker/attestation_server.py @@ -1,38 +1,28 @@ """ Syft Client Attestation Server -When running inside Google Confidential Spaces, this server fetches -the TEE attestation token from the Confidential Space launcher and -displays it as structured JSON. - -The attestation token is a signed JWT issued by Google's Confidential -Computing attestation service. It contains cryptographic proof of: - - The hardware TEE type (AMD SEV-SNP, Intel TDX) - - Secure boot status - - The exact container image digest running - - Debug status of the VM - - GPU confidential computing mode (if applicable) - -Architecture: - Container -> Unix socket (/run/container_launcher/teeserver.sock) - -> Confidential Space Launcher - -> Google Attestation Service - -> Signed JWT returned to container +Operator-facing view of the enclave's own attestation evidence, whichever +deployment target it is running on. The evidence itself comes from +``syft_enclaves.evidence``: + + - Confidential Space: a signed JWT from the launcher socket, proving the + hardware TEE type, secure boot, debug status and container image digest. + - Tinfoil: the hardware attestation document from the ``/tinfoil`` mount. + +This endpoint is for humans and for ``just attest`` / ``just tinfoil-attest``. +Production publishes evidence to peers through ``SYFT_version.json``; nothing +here is verified, and a relying party appraises the evidence itself (see +``syft_enclaves.attestation``). """ -import base64 -import json import os -from datetime import datetime, timezone from fastapi import FastAPI from fastapi.responses import JSONResponse -from syft_enclaves.tee_token import ( - TEE_SOCKET_PATH, - build_eat_nonce, - fetch_attestation_token, - validate_nonce, -) +from syft_enclaves.evidence import probed_locations, select_provider +from syft_enclaves.key_bundle import read_public_bundle, sign_nonce +from syft_enclaves.settings import AttestationSettings +from syft_enclaves.tee_token import validate_nonce app = FastAPI(title="Syft Client Enclave", version="0.1.0") @@ -46,101 +36,27 @@ def _get_syft_version() -> str: return "unknown" -def _is_confidential_space() -> bool: - """Check if we're running inside Google Confidential Spaces.""" - return os.path.exists(str(TEE_SOCKET_PATH)) +def _detect_provider(): + """The configured provider for this deployment target, or None outside a TEE. - -def _decode_jwt_payload(token: str) -> dict: - """Base64-decode the JWT payload (middle segment) without signature verification. - - We decode without verification because the purpose of this endpoint is to - DISPLAY the attestation claims. The token itself (returned in raw_token) - is what a relying party would verify against Google's JWKS endpoint. + Reads settings per request rather than at import so the endpoint reflects + the environment the container was actually started with. """ - parts = token.split(".") - if len(parts) != 3: - raise ValueError(f"Invalid JWT: expected 3 parts, got {len(parts)}") - - # JWT base64url encoding — add padding if needed - payload_b64 = parts[1] - padding = 4 - len(payload_b64) % 4 - if padding != 4: - payload_b64 += "=" * padding - - payload_bytes = base64.urlsafe_b64decode(payload_b64) - return json.loads(payload_bytes) - - -def _format_timestamp(epoch: int | float | None) -> str | None: - if epoch is None: - return None - return datetime.fromtimestamp(epoch, tz=timezone.utc).isoformat() - - -def _structure_claims(claims: dict) -> dict: - """Organize raw JWT claims into logical sections for display.""" - submods = claims.get("submods", {}) - container = submods.get("container", {}) - gce = submods.get("gce", {}) - cs = {} - for key, val in submods.items(): - if key.startswith("confidential_space"): - cs[key] = val - - result = { - "hardware": { - "hwmodel": claims.get("hwmodel"), - "secboot": claims.get("secboot"), - "dbgstat": claims.get("dbgstat"), - }, - "software": { - "swname": claims.get("swname"), - "swversion": claims.get("swversion"), - }, - "container": { - "image_digest": container.get("image_digest"), - "image_reference": container.get("image_reference"), - "restart_policy": container.get("restart_policy"), - "env": container.get("env"), - }, - "gce": { - "project_id": gce.get("project_id"), - "zone": gce.get("zone"), - "instance_id": gce.get("instance_id"), - }, - "issuer": claims.get("iss"), - "subject": claims.get("sub"), - "issued_at": _format_timestamp(claims.get("iat")), - "expires_at": _format_timestamp(claims.get("exp")), - } - - # GPU confidential computing claims (if present) - nvidia_cc = claims.get("nvidia_gpu", submods.get("nvidia_gpu", {})) - if nvidia_cc: - result["gpu"] = nvidia_cc - - # Confidential Space-specific claims - if cs: - result["confidential_space"] = cs - - eat_nonce_raw = claims.get("eat_nonce") - if eat_nonce_raw: - result["eat_nonce"] = eat_nonce_raw - - return result + settings = AttestationSettings() + return select_provider(settings.attestation_provider, settings) @app.get("/") def index(): """Landing page with syft info and available endpoints.""" + provider = _detect_provider() return { "service": "syft-enclave", "syft_version": _get_syft_version(), - "confidential_space_detected": _is_confidential_space(), + "attestation_provider": provider.kind.value if provider else None, "endpoints": { "/": "This page", - "/attestation": "TEE attestation report (requires Confidential Spaces)", + "/attestation": "TEE attestation evidence (requires a TEE deployment)", "/health": "Health check", }, } @@ -153,22 +69,15 @@ def health(): @app.get("/attestation") def attestation(nonce: str | None = None): - """Fetch and display the TEE attestation report. + """Show this enclave's own attestation evidence. Query params: - nonce — optional caller-supplied freshness nonce. When provided it is - embedded in the signed JWT via ``eat_nonce`` so the caller can - verify the report was generated on demand (not replayed). - - When running in Confidential Spaces: - 1. Validates the nonce (if supplied) - 2. Builds an eat_nonce array (version hash + optional caller nonce) - 3. Requests an OIDC attestation token with eat_nonce - 4. Decodes the JWT claims - 5. Returns structured attestation data + the raw token - - When NOT in Confidential Spaces: - Returns instructions for how to deploy correctly. + nonce — optional freshness nonce. Only Confidential Space can bind one + into the evidence (via ``eat_nonce``); Tinfoil rejects it, + because its report's user data is fully used by the shim's keys + and the attestation document is a static file. + + Outside a TEE, returns deployment instructions instead. """ if nonce is not None: error = validate_nonce(nonce) @@ -176,53 +85,68 @@ def attestation(nonce: str | None = None): return JSONResponse(status_code=400, content={"error": error}) version = _get_syft_version() - - if not _is_confidential_space(): - return { - "status": "not_in_confidential_space", - "syft_version": version, - "message": ( - "Attestation unavailable. This container must run on " - "Google Confidential Spaces. The TEE socket at " - f"{TEE_SOCKET_PATH} was not found." - ), - "instructions": { - "build": "docker build -t syft-enclave -f docker/Dockerfile .", - "deploy": ( - "Deploy on a Confidential VM with the Confidential Spaces " - "image to enable attestation." - ), - }, - } + provider = _detect_provider() + if provider is None: + return _not_in_a_tee(version) try: - eat_nonce = build_eat_nonce(caller_nonce=nonce) - raw_token = fetch_attestation_token(eat_nonce=eat_nonce) - claims = _decode_jwt_payload(raw_token) - structured = _structure_claims(claims) - + # Only some TEEs can bind a caller nonce into the report itself. + # Where they cannot, the nonce is still answered — signed with the + # enclave's own key below — so the caller gets freshness either way. + evidence = ( + provider.collect(caller_nonce=nonce) + if nonce and provider.accepts_caller_nonce + else provider.collect() + ) return { - "status": "running_in_confidential_space", + "status": "running_in_tee", + "provider": provider.kind.value, "syft_version": version, - "attestation": structured, - "nonce_info": { - "version": eat_nonce[0], - "caller_nonce": nonce, - }, - "raw_token": raw_token, + "attestation": provider.describe(evidence), + "evidence": evidence.to_version_field(), + # The enclave's syft public keys. A caller that pinned this + # connection to the TLS key the report commits to can trust these; + # over an unpinned connection they are worth nothing. None when + # encryption is disabled or the runner has not started yet. + "key_bundle": read_public_bundle(), + # Proof the enclave holds the private half of that bundle, and + # that this response was produced for this exchange: a signature + # over the caller's nonce by the bundle's identity key. + "nonce": nonce, + "nonce_signature": sign_nonce(nonce) if nonce else None, } - except Exception as e: return JSONResponse( status_code=500, content={ "status": "attestation_error", + "provider": provider.kind.value, "syft_version": version, "error": str(e), }, ) +def _not_in_a_tee(version: str) -> dict: + return { + "status": "not_in_a_tee", + "syft_version": version, + "message": ( + f"Attestation unavailable: no TEE detected. Probed: {probed_locations()}." + ), + "instructions": { + "build": "docker build -t syft-enclave -f docker/Dockerfile .", + "confidential_space": ( + "Deploy on a Confidential VM with the Confidential Space image " + "— see docs/terraform.md." + ), + "tinfoil": ( + "Deploy a Tinfoil container from the config repo — see docs/tinfoil.md." + ), + }, + } + + if __name__ == "__main__": import uvicorn diff --git a/packages/syft-enclave/docker/entrypoint.sh b/packages/syft-enclave/docker/entrypoint.sh index 325ba231e26..82dd13f483b 100644 --- a/packages/syft-enclave/docker/entrypoint.sh +++ b/packages/syft-enclave/docker/entrypoint.sh @@ -1,7 +1,10 @@ #!/bin/bash set -euo pipefail -TEE_SOCKET="/run/container_launcher/teeserver.sock" +# Marker paths for the two supported deployment targets; kept in step with +# the providers in src/syft_enclaves/providers/. +CS_TEE_SOCKET="/run/container_launcher/teeserver.sock" +TINFOIL_ATTESTATION="/tinfoil/attestation.json" echo "=== Syft Enclave Server ===" echo "syft version: $(python -c 'from syft import __version__; print(__version__)' 2>/dev/null || echo 'unknown')" @@ -12,12 +15,14 @@ echo "syft version: $(python -c 'from syft import __version__; print(__version__ export SYFT_ENCLAVE_TOKEN_PATH python -m syft_enclaves.bootstrap -if [ -S "$TEE_SOCKET" ]; then - echo "Confidential Spaces detected: TEE socket found at $TEE_SOCKET" +if [ -S "$CS_TEE_SOCKET" ]; then + echo "Confidential Space detected: launcher socket at $CS_TEE_SOCKET" +elif [ -f "$TINFOIL_ATTESTATION" ]; then + echo "Tinfoil detected: attestation document at $TINFOIL_ATTESTATION" else - echo "WARNING: TEE socket not found at $TEE_SOCKET" - echo "Attestation endpoint will return instructions instead of real attestation data." - echo "To enable attestation, deploy this container on a GCP Confidential VM with Confidential Spaces." + echo "WARNING: no TEE detected (looked for $CS_TEE_SOCKET and $TINFOIL_ATTESTATION)" + echo "The attestation endpoint will return deployment instructions instead of real evidence." + echo "See docs/terraform.md (Confidential Spaces) or docs/tinfoil.md (Tinfoil)." fi # Attestation server (background) — configured via PORT. diff --git a/packages/syft-enclave/docs/api.md b/packages/syft-enclave/docs/api.md index a3bd75eb7be..2d44d2a0b1c 100644 --- a/packages/syft-enclave/docs/api.md +++ b/packages/syft-enclave/docs/api.md @@ -20,18 +20,24 @@ or from a `.env` file during local development). Start it with: python -m syft_enclaves ``` -| Variable | Required | Default | Description | -| ----------------------------- | -------- | ----------------- | --------------------------------------- | -| `SYFT_ENCLAVE_EMAIL` | yes | — | Enclave datasite email | -| `SYFT_ENCLAVE_SYFTBOX_FOLDER` | no | `~/SyftBox_email` | Root SyftBox folder | -| `SYFT_ENCLAVE_TOKEN_PATH` | yes | — | Pre-authorized Google Drive OAuth token | -| `SYFT_ENCLAVE_POLL_INTERVAL` | no | `10` | Seconds between poll cycles | -| `SYFT_ENCLAVE_REQUIRE_TEE` | no | `false` | Refuse to start outside a TEE | -| `SYFT_ENCLAVE_LOG_LEVEL` | no | `INFO` | Logging level | +| Variable | Required | Default | Description | +| ----------------------------------- | -------- | ----------------- | --------------------------------------------------- | +| `SYFT_ENCLAVE_EMAIL` | yes | — | Enclave datasite email | +| `SYFT_ENCLAVE_SYFTBOX_FOLDER` | no | `~/SyftBox_email` | Root SyftBox folder | +| `SYFT_ENCLAVE_TOKEN_PATH` | yes | — | Pre-authorized Google Drive OAuth token | +| `SYFT_ENCLAVE_POLL_INTERVAL` | no | `10` | Seconds between poll cycles | +| `SYFT_ENCLAVE_REQUIRE_TEE` | no | `false` | Refuse to start outside a TEE | +| `SYFT_ENCLAVE_LOG_LEVEL` | no | `INFO` | Logging level | +| `SYFT_ENCLAVE_ATTESTATION_PROVIDER` | no | `auto` | `auto` / `confidential_space` / `tinfoil` / `none` | +| `SYFT_ENCLAVE_TINFOIL_REPO` | no | — | Tinfoil config repo, recorded in published evidence | +| `SYFT_ENCLAVE_TINFOIL_RELEASE_TAG` | no | — | Tinfoil config release tag, as above | For local development, place these in a `.env` file in the working directory. The same `python -m syft_enclaves` entry point runs unchanged locally, inside -Docker, and in Confidential Spaces — only the environment differs. +Docker, in Confidential Spaces, and in a Tinfoil CVM — only the environment +differs. Which attestation provider is used is detected from the environment +unless `SYFT_ENCLAVE_ATTESTATION_PROVIDER` says otherwise; see +[Tinfoil Deployment](./tinfoil.md). ## Example: Fetching the attestation report @@ -39,12 +45,26 @@ Docker, and in Confidential Spaces — only the environment differs. curl http://EXTERNAL_IP:8080/attestation | python3 -m json.tool ``` -The response includes: +The response always includes: -- `attestation.hardware.hwmodel` - TEE hardware type (`GCP_AMD_SEV`; `GCP_INTEL_TDX` on gpu deployments) -- `attestation.hardware.secboot` - Secure boot status -- `attestation.hardware.dbgstat` - Debug status (`enabled` for debug image, `disabled-since-boot` for production) -- `attestation.container.image_digest` - SHA256 of the running container image -- `attestation.nvidia_gpu` - gpu deployments only: `cc_mode` (`"ON"` = confidential computing active), `gpus[].hwmodel` (`GCP_NVIDIA_H100`), driver version -- `attestation.gce.*` - GCP project, zone, instance info -- `raw_token` - Full JWT for independent verification against Google's JWKS +- `provider` - which deployment target produced the evidence (`confidential_space` or `tinfoil`) +- `evidence` - the provider-agnostic envelope, exactly as published to peers in + `SYFT_version.json`. `evidence.body` is the raw evidence for independent + verification: the full JWT on Confidential Spaces, the base64 hardware report + on Tinfoil. +- `attestation` - an unverified, display-only summary, whose shape depends on + the provider. + +On Confidential Spaces, `attestation` holds: + +- `hardware.hwmodel` - TEE hardware type (`GCP_AMD_SEV`; `GCP_INTEL_TDX` on gpu deployments) +- `hardware.secboot` - Secure boot status +- `hardware.dbgstat` - Debug status (`enabled` for debug image, `disabled-since-boot` for production) +- `container.image_digest` - SHA256 of the running container image +- `gpu` - gpu deployments only: `cc_mode` (`"ON"` = confidential computing active), `gpus[].hwmodel` (`GCP_NVIDIA_H100`), driver version +- `gce.*` - GCP project, zone, instance info + +On Tinfoil it holds the `document` (`format` + `body`), the verified `config` +the enclave booted with, and `container_status`. The hardware measurements are +not decoded here — a relying party gets them by verifying the report, which is +what `attest_peer` does. diff --git a/packages/syft-enclave/docs/dev.md b/packages/syft-enclave/docs/dev.md index 9376d0c1b84..88db6785c74 100644 --- a/packages/syft-enclave/docs/dev.md +++ b/packages/syft-enclave/docs/dev.md @@ -22,7 +22,7 @@ The debug image allows SSH access and container log redirection to serial output ```bash just start-debug # cpu (default) -just hardware=gpu start-debug # gpu (a3-highgpu-1g, 1x H100) — see docs/terraform.md "GPU deployments" +just hardware=gpu start-debug # gpu (a3-highgpu-1g, 1x H100) — see docs/terraform.md (Confidential Spaces) "GPU deployments" ``` Debug enclaves run with encryption off — data owner clients must match: `login_do(encryption=False)`. diff --git a/packages/syft-enclave/docs/security.md b/packages/syft-enclave/docs/security.md index a896ab1ef59..3d54551af57 100644 --- a/packages/syft-enclave/docs/security.md +++ b/packages/syft-enclave/docs/security.md @@ -86,9 +86,17 @@ keys into that attestation report**, and shares the report on Google Drive with Because any peer can **verify the attestation report**, they know those public keys were genuinely produced by an enclave running the expected open-source container — not by some person who happens to -have access to the account. The data owners and the DS then download the enclave's verified keys (and -share their own), and from that point on there is a **trusted, end-to-end secure channel** between the -enclave and every participant. +have access to the account. + +> **Status on each deployment target.** On **Tinfoil** this binding is implemented, by a different +> route than nonces: the report commits to the TLS key of the enclave's own endpoint, so a peer that +> pins its connection to that key can trust the key bundle served over it. `attest_peer` does this +> and sets the peer's keys from the result. On **Confidential Spaces** it is still unimplemented — +> the channel exists (a workload can inject nonces into the token) but is unused, so there the key +> bundle remains an unsigned Drive file. See +> [Tinfoil Deployment](./tinfoil.md#what-this-proves-and-what-it-does-not). The data owners and the DS then download the enclave's verified keys (and +> share their own), and from that point on there is a **trusted, end-to-end secure channel** between the +> enclave and every participant. Crucially, Google Drive is treated purely as an **untrusted transport** — a message-passing channel and nothing more. The threat model assumes a fully adversarial transport: an attacker (or Google diff --git a/packages/syft-enclave/docs/terraform.md b/packages/syft-enclave/docs/terraform.md index f5da8d5ffb5..ef533783fc7 100644 --- a/packages/syft-enclave/docs/terraform.md +++ b/packages/syft-enclave/docs/terraform.md @@ -1,4 +1,4 @@ -# Terraform Deployment +# Confidential Spaces Deployment (Terraform) A declarative alternative to the gcloud-based Justfile recipes (`init` / `provision-secret-sa` / `start` / `start-debug`). Terraform manages the full stack in one `apply`: diff --git a/packages/syft-enclave/docs/tinfoil.md b/packages/syft-enclave/docs/tinfoil.md new file mode 100644 index 00000000000..c8d8d991344 --- /dev/null +++ b/packages/syft-enclave/docs/tinfoil.md @@ -0,0 +1,264 @@ +# Tinfoil Deployment + +An alternative to [Confidential Spaces](./terraform.md) with no GCP involved. The enclave runs in an AMD SEV-SNP or Intel TDX confidential VM managed by [Tinfoil](https://docs.tinfoil.sh), and a data owner verifies it against a measurement published in a Sigstore-signed GitHub release. + +Two repositories are in play: + +- **this one** builds and pushes the enclave image to Docker Hub; +- **[`OpenMined/syft-enclave-tinfoil`](https://github.com/OpenMined/syft-enclave-tinfoil)** holds the measured `tinfoil-config.yml`, and its GitHub releases publish the expected launch measurement. + +The canonical copy of that config lives here at [`tinfoil/tinfoil-config.yml`](../tinfoil/tinfoil-config.yml), so the image and the config that pins it are reviewed together. `just tinfoil-release` syncs it over. + +Run all commands from `packages/syft-enclave/`. + +## What this proves, and what it does not + +**Proves.** The enclave is genuine SEV-SNP/TDX hardware with debug disabled and its firmware TCB at or above minimum; it booted the exact CVM image and config published in a named release of the config repo; that release was signed by GitHub OIDC from that repo's tag via Sigstore; and the container image digest matches the one you pinned. + +**Does not prove.** Which email or data owners the enclave was started with. Those are deploy-time `--variable`s, so they are outside the measurement and the attested code merely relays whatever its deployer handed it. Confidential Spaces is in the same position today (`tee-env-*` metadata is not checked either). + +**Key binding, and how it is achieved.** A workload cannot inject a nonce into the report: its 64 bytes of user data are the sha256 of the shim's TLS public key followed by its HPKE public key. But that is exactly what makes binding possible — the report *commits to the key terminating a TLS connection to the enclave*. So the client: + +1. verifies the report, +2. opens HTTPS to the enclave and checks the certificate it is served carries that same key, +3. checks the enclave signed the client's nonce with the key bundle it served, +4. and then trusts that bundle, which came down the same connection. + +No certificate authority is involved anywhere: the enclave's certificate is self-signed, and the *report* is what decides whether to trust it. `attest_peer` then sets those keys for the peer, so the enclave's public keys are no longer an unsigned Drive file. This is the binding `docs/security.md` §5 describes. + +It also gets freshness for free: a replayed report commits to a TLS key whose private half lives in an enclave the attacker does not control, so the pin fails. + +**Freshness comes from a nonce, not from the report.** A workload cannot influence the report's user data, so the client sends a random nonce and the enclave signs it with the identity key from the bundle it just served. That proves two things the report cannot: the enclave *holds the private half* of the key we are about to encrypt to, and the answer was produced for *this* exchange rather than replayed. The bundle is adopted only when both `key_binding` and `nonce_freshness` pass. + +**Drive is not a fallback.** Evidence is still published to `SYFT_version.json` — as provenance, and so the path exists if it is ever needed again — but the client always appraises a Tinfoil enclave from the live API. An unreachable enclave is an error, not a downgrade: accepting the Drive copy would silently mean unbound keys and a replayable report. + +## Prerequisites + +- The `tinfoil` CLI: + ```bash + curl -fsSL https://github.com/tinfoilsh/tinfoil-cli/raw/main/install.sh | sh + ``` + The installer writes to `/usr/local/bin` and so wants `sudo`. To avoid that, + grab the release tarball and `install -m 0755 tinfoil ~/.local/bin/tinfoil`. +- A Tinfoil organisation account, with the Tinfoil GitHub App installed on the config repo (that App, not your PAT, is what dispatches the release workflow) +- Docker with buildx, and push access to `docker.io/openminedreleasebot` +- [`just`](https://github.com/casey/just) and `jq` +- For verifying: the optional extra, `uv pip install "syft-enclave[tinfoil]"` + +No `gcloud`, no terraform, no GCP project. + +## Authentication (read this first) + +`tinfoil login` needs an **admin API key**, created in the Tinfoil dashboard under +**Settings → API Keys → Admin keys**. Keys are scoped to a single organisation, so you need an +account in the org that will own the container — there is no way to get one from the CLI. + +```bash +tinfoil login --api-key admin_... # or omit --api-key to be prompted +just tinfoil-whoami +tinfoil logout +``` + +The key is stored in `~/.tinfoil/config.json` (mode 0600). `TINFOIL_API_KEY` and +`TINFOIL_CONTROLPLANE_URL` override it per command, which is what to use in CI. Every +`just tinfoil-*` recipe refuses to run without one or the other. + +Publishing a release does **not** need the key — it is a GitHub Actions run, so +`gh workflow run tinfoil-release.yml -f version=vX.Y.Z` works with nothing but your GitHub auth. +The key is needed for the control-plane operations: `container create`, `deployment update`, +`relaunch`, and the secret store. + +## Configure + +[`tinfoil/tinfoil-config.yml`](../tinfoil/tinfoil-config.yml) declares everything inside the enclave. Two things about it matter more than the rest. + +**Everything in the file is measured.** Its sha256 goes into the CVM's kernel command line, so any edit changes the measurement and needs a new config release. That is why the enclave email and data owners are *not* in it — see the table below. + +**Egress defaults to `closed`.** Without the `allowlist` the enclave cannot reach Google Drive at all and will sit there doing nothing. Only exact hostnames work — wildcards and IP literals are both rejected by the schema. The two the code actually needs are `www.googleapis.com` (Drive v3) and `oauth2.googleapis.com` (refreshing the OAuth token). `accounts.google.com` is deliberately absent so an accidental interactive OAuth flow fails loudly. + +Note that `networks` is a **map keyed by network name**, not a list of objects — the canonical Go +schema is `map[string]*NetworkSpec`. A container may attach to several networks but at most one of +them may have egress other than `closed`. + +| Setting | Where it lives | Verifiable by a data owner? | +|---|---|---| +| Container image digest | measured config | yes | +| `SYFT_ENCLAVE_ATTESTATION_PROVIDER`, `SYFT_BOOTSTRAP` | measured config | yes | +| CPU / memory / GPU shape | measured config | yes | +| Egress allowlist, exposed paths | measured config | yes | +| `SYFT_ENCLAVE_EMAIL`, `SYFT_ENCLAVE_DATA_OWNERS` | `--variable` at deploy | **no** | +| `SYFT_ENCLAVE_REQUIRE_TEE`, `SYFT_ENCLAVE_USE_ENCRYPTION` | `--variable` at deploy | **no** | +| Drive OAuth token | `--secret` (name measured, value not) | n/a | + +To make any of the deploy-time values verifiable, move them into the config's `env` block — at the cost of one config release per combination. + +The Drive token reaches the enclave as `--secret SYFT_ENCLAVE_TOKEN_CONTENT`; register it with your Tinfoil org first (`tinfoil secret --help`). Tinfoil has no Secret Manager equivalent, so `SYFT_BOOTSTRAP=tinfoil` reads that injected env var and writes it to `SYFT_ENCLAVE_TOKEN_PATH` at 0600 before the runner starts. + +## GPU deployments + +Set `gpus:` in the config and publish a new release. Which shapes are available, and whether GPU changes the evidence format (NVIDIA confidential computing adds its own claims on Confidential Spaces), is not yet confirmed with Tinfoil — check before relying on it. + +`cvm-version` pins the CVM base image the measurement is computed against; we track the version +[`tinfoilsh/tinfoil-containers-template`](https://github.com/tinfoilsh/tinfoil-containers-template) +uses, currently `0.14.7`. Its deprecation policy is not documented. + +## Quickstart: production + +```bash +# 1. Build + push the image, pin its digest in the config, open the config PR. +just tinfoil-release vX.Y.Z # prints the digest — keep it + +# 2. Merge that PR, then publish the measured, signed release. +just tinfoil-build-info # latest tag + suggested next version +just tinfoil-publish vX.Y.Z # ~1 min for the measurement to compute + +# 3. Deploy. +just tinfoil-deploy vX.Y.Z enclave@openmined.org do1@openmined.org,do2@openmined.org + +# 4. Check it is attesting at all. +just tinfoil-attest syft-enclave.openmined.containers.tinfoil.dev + +# 5. Verify it the way a data owner would. +just tinfoil-verify syft-enclave.openmined.containers.tinfoil.dev \ + --expected-image-digest sha256:... --tag vX.Y.Z +``` + +Steps 1 and 2 need no Tinfoil API key. If the Tinfoil GitHub App is not installed on the config +repo, `tinfoil-publish` will not be able to dispatch — run the workflow directly instead, which +only needs your GitHub auth: + +```bash +cd ~/workspace/syft-enclave-tinfoil +gh workflow run tinfoil-release.yml -f version=vX.Y.Z +gh run list --limit 2 # both "Release" and "Publish release" must succeed +``` + +Step 3 onwards is where the admin API key becomes mandatory. + +Then from a data owner's client, against the evidence the enclave published to Drive: + +```python +do.attest_peer(ENCLAVE_EMAIL, expected_image_digest="sha256:...") +``` + +Pass the digest `tinfoil-release` printed. Without it the image-digest check is **skipped**, not failed — the attestation then proves a genuine enclave booted a signed config, but not that the config pinned the image you reviewed. + +The whole data-owner side (peer, attest over Drive, upload a dataset) is scripted: + +```bash +# Reset the data owners FIRST, then redeploy — the enclave caches peer Drive +# folders at boot, so wiping state afterwards breaks peering. +uv run python ../enclave-model-api-example/scripts/reset_state.py \ + model_owner@openmined.org=../../credentials/token_model_owner.json +just tinfoil-deploy vX.Y.Z enclave@openmined.org model_owner@openmined.org + +uv run --project ../.. python scripts/tinfoil_e2e_check.py \ + --enclave-email enclave@openmined.org \ + --do-email model_owner@openmined.org \ + --token ../../credentials/token_model_owner.json \ + --tag vX.Y.Z --expected-image-digest sha256:... +``` + +That is the check that matters: `just tinfoil-verify` fetches the report over HTTPS, while +`attest_peer` reads it from `SYFT_version.json` on Drive, which is the path the syft flow +actually uses. + +Prefer `--tag` over the default "latest release" where you can. Unpinned, the release digest is fetched over the network, and a hostile source could substitute the digest of another *legitimately signed* release of the same repo — a rollback. A pinned tag makes the signature policy require that exact tag. + +## Teardown + +```bash +just tinfoil-relaunch vX.Y.Z-1 --promote-release=false # roll back without changing "latest" +tinfoil container stop syft-enclave # stop, keep the record +tinfoil container delete syft-enclave # remove it +``` + +A **config release cannot be unpublished** — it is a signed GitHub release and a transparency-log +entry, permanent by design. Plan version numbers accordingly; there is no undo. Nothing is lost by +deleting a container: Tinfoil Containers have no persistent disk, so enclave state is gone on every +restart regardless. + +## Dev + +There is no control-plane log API, so **logs require a debug instance**: + +```bash +just tinfoil-ssh-key koen-debug # once per machine +just tinfoil-debug v0.1.8 enclave@openmined.org # redeploy with SSH +just tinfoil-logs 100 # container logs +just tinfoil-shell # shell in the enclave +just tinfoil-why # status, error_message, shim boot stages +``` + +A debug instance is a **separate deployment** at `.debug..containers.tinfoil.dev` and +deliberately **does not pass attestation** — `attest_peer` and Tinfoil's own `SecureClient` will +refuse it, because a debug enclave is not confidential. Never point real data at one. + +Going back to production is not `--debug false`: while an SSH key is attached the container stays +in debug mode. Delete and recreate: + +```bash +tinfoil container delete syft-enclave +just tinfoil-deploy v0.1.8 enclave@openmined.org do1@x.org,do2@x.org +``` + +`just tinfoil-why` is the first thing to run on any failure — `error_message` from the control +plane named the cause immediately in every failure we hit, and the shim's `/health` reports +per-stage boot status (config, network, identity, cpu-attestation, certificate, firewall, +containers) until the workload takes over the port. + +Other knobs: + +- `--staging` / `--promote-release=false` to deploy a release without marking it latest. +- `dummy-attestation: true` in the config for a non-confidential host. Never in production: it defeats the entire point, and the choice is visible in the published config. +- Locally, without Tinfoil at all: bind-mount a fake mount and let auto-detection find it. + ```bash + mkdir -p /tmp/fake-tinfoil + curl -s https://inference.tinfoil.sh/.well-known/tinfoil-attestation > /tmp/fake-tinfoil/attestation.json + docker run --rm -v /tmp/fake-tinfoil:/tinfoil:ro ... docker.io/openminedreleasebot/syft-enclave:dev + ``` + The enclave will publish that document as its own evidence; verification against our config repo will of course fail the measurement check. Useful for exercising the publish path, not the verify path. +- `SYFT_ENCLAVE_ATTESTATION_PROVIDER=none` disables attestation entirely. + +## Troubleshooting + +Every row below was hit for real while bringing the first enclave up, in this order. + +| Symptom | Cause and fix | +|---|---| +| `Firewall setup failed: creating docker network "default": operation is not permitted on predefined default network` | a network named `default` collides with Docker's predefined one. The canonical schema only reserves `shim-net`, so this **validates fine and fails on the host**. Name it anything else. | +| `FileNotFoundError: No usable temporary directory found in ['/tmp', ...]` and a crash-loop | containers run with a read-only rootfs, and `portalocker` (a syft dependency) calls `tempfile.gettempdir()` at import. Set `read_only: false`. | +| `2 validation errors for EnclaveSettings: email / data_owners Field required` — despite passing `--variable` | the shim only injects variables whose **key the measured config declares**. An undeclared `--variable` is silently dropped. Declare it as a bare name under `env:`. | +| The enclave hangs at "Building SyftEnclaveClient" | OAuth token refresh cannot reach `oauth2.googleapis.com`. See the egress note in `tinfoil-config.yml`: `allowlist` resolves hostnames to IPs once at boot and Google rotates them, so use `egress: open`. | +| `curl: (60) SSL certificate problem: self signed certificate` | expected. The enclave's TLS key is generated inside it and the report commits to that key, so there is no CA. Use `-k` (as `just tinfoil-attest` does) and get your trust from `just tinfoil-verify`. | +| `The server had an error while processing your request.` from the domain | the shim is up but your container is not serving on `upstream-port`. It has probably crashed — `just tinfoil-debug ` then `just tinfoil-logs`. | +| `ValueError: Serialization error: sender fingerprint mismatch` on a data owner's login | with `encryption=True` the keypair is persisted at `//private/crypto_keys.json`. A fresh `SYFTBOX_FOLDER` mints a new identity that cannot verify that account's own history on Drive. Point at the account's existing folder, or wipe its Drive state first. | +| `upstream port is not set` | the config's `shim.upstream-port` is missing; it is required | +| Config rejected: image must be a digest | `image:` uses a tag; it must be `repo@sha256:...` | +| A path 404s | it is not in `shim.paths`. `/.well-known/tinfoil-attestation` is exempt | +| `Tag vX.Y.Z already exists` | releases are permanent; pick the next version | +| `image_digest` check fails with a 400 from `github-proxy.tinfoil.sh` | expected — the proxy only serves `tinfoil.hash`; the verifier falls through to github.com | +| `MissingOptionalDependency: ... tinfoil` | `uv pip install "syft-enclave[tinfoil]"` | +| `measurement_match` fails | the running enclave is not the release you are verifying against — check the deployed tag with `just tinfoil-status` | +| Peer skipped with "published no attestation evidence" | the enclave booted outside a TEE, or with `attestation_provider=none` | + +Redeploying? Delete the accounts' syftboxes **first**, then redeploy — the enclave caches peer Drive folders at boot, so wiping state afterwards breaks the flow. + +## Formatting and validation + +```bash +just tinfoil-config-get --raw > /tmp/published.yml # what the repo currently holds +diff /tmp/published.yml tinfoil/tinfoil-config.yml +``` + +The CLI validates the config against the canonical schema when opening the PR and again in the +release workflow. To check it yourself before either (needs Go): + +```bash +go run github.com/tinfoilsh/tinfoil-config/cmd/tinfoil-config@latest tinfoil/tinfoil-config.yml +``` + +It reports schema and policy violations and exits non-zero — worth running after any edit, since a +release is permanent. + +To re-derive a measurement independently, take `tinfoil-deployment.json` from the release, confirm its sha256 equals the release's `tinfoil.hash` (and that the Sigstore DSSE signed that same digest), then run `sev-snp-measure` / `tdx-measure` over the firmware, kernel, initrd and `cmdline` it records. That file also embeds the full config, base64-encoded under `config` — which is how the verifier reads the image digest without trusting whoever served the file. diff --git a/packages/syft-enclave/pyproject.toml b/packages/syft-enclave/pyproject.toml index dea2d25aabf..2ef6f2c6d41 100644 --- a/packages/syft-enclave/pyproject.toml +++ b/packages/syft-enclave/pyproject.toml @@ -15,6 +15,16 @@ dependencies = [ "google-auth[pyjwt]>=2.22.0", ] +# Verifying Tinfoil attestation evidence. Optional because the SDK pulls in +# openai, sigstore and pyopenssl, which an enclave deployed on Confidential +# Space has no use for. The enclave container never needs it either — it reads +# its own evidence from a file and never verifies itself. +[project.optional-dependencies] +tinfoil = [ + "tinfoil>=0.14.0", + "pyyaml>=6.0", # reading the measured tinfoil-config.yml +] + [build-system] requires = ["hatchling"] build-backend = "hatchling.build" diff --git a/packages/syft-enclave/scripts/tinfoil_e2e_check.py b/packages/syft-enclave/scripts/tinfoil_e2e_check.py new file mode 100644 index 00000000000..4ca64345fbc --- /dev/null +++ b/packages/syft-enclave/scripts/tinfoil_e2e_check.py @@ -0,0 +1,143 @@ +"""Manual end-to-end check against a live Tinfoil enclave. + +Walks the data-owner side of the flow: peer with the enclave, attest it, and +upload a private dataset. Attestation goes to the enclave's own API over a connection pinned to the TLS +key its report commits to, and the enclave's syft keys are set for the peer +from that verified exchange. The Drive-published evidence supplies the host and +serves as provenance; it is never appraised in its place. + +Needs the optional extra (``uv pip install "syft-enclave[tinfoil]"``) and a +Drive token for the data owner. + +Before running: reset the data owners, THEN redeploy the enclave. The enclave +caches peer Drive folders at boot, so wiping state afterwards breaks peering:: + + uv run python ../enclave-model-api-example/scripts/reset_state.py \\ + model_owner@openmined.org=credentials/token_model_owner.json + just tinfoil-deploy vX.Y.Z enclave@openmined.org model_owner@openmined.org + +Usage, from packages/syft-enclave:: + + uv run --project ../.. python scripts/tinfoil_e2e_check.py \\ + --enclave-email enclave@openmined.org \\ + --do-email model_owner@openmined.org \\ + --token ../../credentials/token_model_owner.json \\ + --tag vX.Y.Z --expected-image-digest sha256:... +""" + +from __future__ import annotations + +import argparse +import os +import sys +import time +from pathlib import Path + +DEFAULT_REPO = "OpenMined/syft-enclave-tinfoil" + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--enclave-email", required=True) + parser.add_argument("--do-email", required=True) + parser.add_argument("--token", required=True, help="Drive token for the data owner") + parser.add_argument("--repo", default=DEFAULT_REPO) + parser.add_argument("--tag", default=None, help="pin the release tag being verified") + parser.add_argument("--expected-image-digest", default=None) + parser.add_argument("--dataset-name", default="tinfoil-e2e-dataset") + parser.add_argument("--peer-attempts", type=int, default=15) + parser.add_argument("--peer-interval", type=int, default=15) + return parser.parse_args() + + +def wait_for_peering(client, enclave_email: str, attempts: int, interval: int) -> bool: + """Poll until the enclave has accepted our peer request.""" + for attempt in range(attempts): + client.sync() + client.load_peers() + states = {p.email: str(getattr(p, "state", "?")) for p in client.peers} + print(f" [{attempt}] {states}", flush=True) + if "ACCEPTED" in states.get(enclave_email, "").upper(): + return True + time.sleep(interval) + return False + + +def write_dataset_files(directory: Path) -> tuple[Path, Path]: + directory.mkdir(parents=True, exist_ok=True) + private, mock = directory / "private.txt", directory / "mock.txt" + private.write_text("secret,42\nsecret,43\n") + mock.write_text("mock,1\nmock,2\n") + return private, mock + + +def main() -> int: + args = parse_args() + # Each account's keypair lives under its own syftbox folder; a fresh folder + # mints a new identity that cannot verify that account's Drive history. + os.environ.setdefault( + "SYFTBOX_FOLDER", os.path.expanduser(f"~/SyftBox_{args.do_email}") + ) + os.environ.setdefault("PRE_SYNC", "false") + + from syft_enclaves import login_do + from syft_enclaves.attestation_tinfoil import TinfoilAppraisalPolicy + + print("=== 1. login as the data owner ===", flush=True) + client = login_do(email=args.do_email, token_path=args.token, encryption=True) + + print("=== 2. peer with the enclave ===", flush=True) + client.add_peer(args.enclave_email) + if not wait_for_peering( + client, args.enclave_email, args.peer_attempts, args.peer_interval + ): + print("FAILED: the enclave never accepted the peer request", file=sys.stderr) + return 1 + + print("=== 3. attest the enclave over its pinned API ===", flush=True) + policy = TinfoilAppraisalPolicy( + repo=args.repo, + release_tag=args.tag, + expected_image_digest=args.expected_image_digest, + # The config pins no SYFT_VERSION, so leave this unset rather than + # failing a check the deployment cannot satisfy. + expected_syft_version=None, + ) + result = client.attest_peer(args.enclave_email, policy=policy) + if result is None: + print("FAILED: the enclave published no attestation", file=sys.stderr) + return 1 + store = client._rds.peer_manager.peer_store + bound = result.verified_key_bundle is not None + print(f" key bundle bound to the report: {bound}", flush=True) + print(f" peer keys now set: {store.has_peer_bundle(args.enclave_email)}", flush=True) + if not bound: + print("FAILED: no attestation-bound key bundle", file=sys.stderr) + return 1 + + print("=== 4. upload a private dataset and share it ===", flush=True) + private, mock = write_dataset_files(Path("/tmp/tinfoil-e2e/data")) + existing = [ + d for d in client.datasets.get_all() if getattr(d, "name", None) == args.dataset_name + ] + if existing: + print(" dataset already exists, reusing", flush=True) + else: + client.create_dataset( + name=args.dataset_name, + mock_path=str(mock), + private_path=str(private), + summary="Uploaded by scripts/tinfoil_e2e_check.py", + upload_private=True, + ) + print(" dataset created", flush=True) + + client.share_private_dataset(args.dataset_name, args.enclave_email) + client.sync() + print(" shared with the enclave", flush=True) + print("=== PASSED ===", flush=True) + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/packages/syft-enclave/scripts/verify_tinfoil.py b/packages/syft-enclave/scripts/verify_tinfoil.py new file mode 100644 index 00000000000..be21ec0e46a --- /dev/null +++ b/packages/syft-enclave/scripts/verify_tinfoil.py @@ -0,0 +1,75 @@ +"""Verify a live Tinfoil enclave the way a data owner would. + +Fetches the enclave's attestation document from the shim and appraises it +against the signed release of a config repo. Used by ``just tinfoil-verify``; +a data owner in a notebook calls ``client.attest_peer(...)`` instead, which +reads the same evidence from the peer's ``SYFT_version.json`` on Drive. + +Needs the optional extra: ``uv pip install "syft-enclave[tinfoil]"``. +""" + +from __future__ import annotations + +import argparse +import sys + +from syft_enclaves.attestation import AttestationError +from syft_enclaves.attestation_envelope import tinfoil_evidence +from syft_enclaves.attestation_tinfoil import ( + DEFAULT_TINFOIL_CONFIG_REPO, + TinfoilAppraisalPolicy, + verify_tinfoil_evidence, +) +from syft_enclaves.optional_deps import require + +ATTESTATION_PATH = "/.well-known/tinfoil-attestation" + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("host", help="enclave hostname, e.g. x.y.containers.tinfoil.dev") + parser.add_argument("--repo", default=DEFAULT_TINFOIL_CONFIG_REPO) + parser.add_argument( + "--tag", + default=None, + help="pin a release tag; omit to appraise against the latest release", + ) + parser.add_argument( + "--expected-image-digest", + default=None, + help="'sha256:...' digest to pin; omit to skip the image check", + ) + parser.add_argument("--container-name", default="syft-enclave") + return parser.parse_args() + + +def fetch_document(host: str) -> dict: + requests = require( + "requests", + extra="tinfoil", + feature="Verifying a Tinfoil enclave", + docs="packages/syft-enclave/docs/tinfoil.md", + ) + response = requests.get(f"https://{host}{ATTESTATION_PATH}", timeout=30) + response.raise_for_status() + return response.json() + + +def main() -> int: + args = parse_args() + policy = TinfoilAppraisalPolicy( + repo=args.repo, + release_tag=args.tag, + expected_image_digest=args.expected_image_digest, + container_name=args.container_name, + ) + try: + verify_tinfoil_evidence(tinfoil_evidence(fetch_document(args.host)), policy) + except AttestationError: + # verify_tinfoil_evidence already printed the full checklist. + return 1 + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/packages/syft-enclave/src/syft_enclaves/__main__.py b/packages/syft-enclave/src/syft_enclaves/__main__.py index e3848cab4a8..08d48be3b1d 100644 --- a/packages/syft-enclave/src/syft_enclaves/__main__.py +++ b/packages/syft-enclave/src/syft_enclaves/__main__.py @@ -43,7 +43,10 @@ def main() -> None: f"Enclave settings — email={settings.email} data_owners={settings.data_owners} " f"token_path={settings.token_path} poll_interval={settings.poll_interval}s " f"require_tee={settings.require_tee} fresh_state={settings.fresh_state} " - f"use_encryption={settings.use_encryption}" + f"use_encryption={settings.use_encryption} " + f"attestation_provider={settings.attestation_provider} " + f"tinfoil_repo={settings.tinfoil_repo} " + f"tinfoil_release_tag={settings.tinfoil_release_tag}" ) logger.info("Building SyftEnclaveClient...") @@ -61,6 +64,8 @@ def main() -> None: poll_interval=settings.poll_interval, require_tee=settings.require_tee, fresh_state=settings.fresh_state, + attestation_provider=settings.attestation_provider, + settings=settings, ) logger.info("EnclaveRunner ready — calling runner.run()") runner.run() diff --git a/packages/syft-enclave/src/syft_enclaves/_unix_socket.py b/packages/syft-enclave/src/syft_enclaves/_unix_socket.py new file mode 100644 index 00000000000..b0c94a0df73 --- /dev/null +++ b/packages/syft-enclave/src/syft_enclaves/_unix_socket.py @@ -0,0 +1,24 @@ +"""Unix-domain-socket HTTP client. + +Shared by the two places that talk to the Confidential Space launcher: the +attestation-evidence provider and the token bootstrap. Deliberately stdlib-only +— ``bootstrap`` runs as its own process before the runner starts, so anything +imported here is paid on every boot. +""" + +from __future__ import annotations + +import socket +from http.client import HTTPConnection + + +class UnixSocketConnection(HTTPConnection): + """``HTTPConnection`` that connects over a Unix domain socket.""" + + def __init__(self, socket_path: str): + super().__init__("localhost") + self._socket_path = socket_path + + def connect(self) -> None: + self.sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) + self.sock.connect(self._socket_path) diff --git a/packages/syft-enclave/src/syft_enclaves/attestation.py b/packages/syft-enclave/src/syft_enclaves/attestation.py index da1733d3af6..d0636070e4b 100644 --- a/packages/syft-enclave/src/syft_enclaves/attestation.py +++ b/packages/syft-enclave/src/syft_enclaves/attestation.py @@ -72,6 +72,11 @@ class CheckResult: @dataclass class AttestationResult: checks: list[CheckResult] = field(default_factory=list) + #: The peer's syft public key bundle, when it arrived over a channel bound + #: to the attestation report (see ``attestation_https``). None whenever + #: there was no such channel — a bundle read from Drive is not bound to + #: anything and must not be set here. + verified_key_bundle: Optional[dict] = None def add(self, name: str, label: str, passed: bool, detail: str) -> None: self.checks.append( diff --git a/packages/syft-enclave/src/syft_enclaves/attestation_dispatch.py b/packages/syft-enclave/src/syft_enclaves/attestation_dispatch.py new file mode 100644 index 00000000000..e470f67eeff --- /dev/null +++ b/packages/syft-enclave/src/syft_enclaves/attestation_dispatch.py @@ -0,0 +1,77 @@ +"""Routing attestation evidence to the verifier for its deployment target. + +The evidence itself says which kind it is, and the envelope refuses a ``kind`` +that disagrees with its ``format`` (see ``attestation_envelope``), so a peer +cannot pick a weaker verifier for its own evidence. +""" + +from __future__ import annotations + +from typing import Optional, Union + +from syft_enclaves.attestation import ( + AppraisalPolicy, + AttestationResult, + verify_attestation_token, +) +from syft_enclaves.attestation_envelope import AttestationEvidence, AttestationKind + +Policy = Union[AppraisalPolicy, "object"] + +#: Which policy class each kind expects. Kept here rather than imported from +#: the tinfoil module so this module never pulls in the optional SDK. +_POLICY_CLASS_NAMES = { + AttestationKind.CONFIDENTIAL_SPACE: "AppraisalPolicy", + AttestationKind.TINFOIL: "TinfoilAppraisalPolicy", +} + + +def verify_evidence( + evidence: AttestationEvidence, + policy: Optional[Policy] = None, + verbose: bool = True, +) -> AttestationResult: + """Appraise *evidence* with the verifier for its kind.""" + if evidence.kind is AttestationKind.CONFIDENTIAL_SPACE: + _require_policy_type(evidence.kind, policy, AppraisalPolicy) + return verify_attestation_token(evidence.body, policy=policy, verbose=verbose) + + if evidence.kind is AttestationKind.TINFOIL: + # Imported here so installs without the tinfoil extra can still verify + # Confidential Space evidence. + from syft_enclaves.attestation_tinfoil import ( + TinfoilAppraisalPolicy, + verify_tinfoil_evidence, + ) + + _require_policy_type(evidence.kind, policy, TinfoilAppraisalPolicy) + return verify_tinfoil_evidence(evidence, policy=policy, verbose=verbose) + + raise ValueError(f"No verifier for attestation kind {evidence.kind!r}") + + +def policy_for(kind: AttestationKind, **kwargs) -> Policy: + """Build the appraisal policy class that *kind*'s verifier expects.""" + if kind is AttestationKind.CONFIDENTIAL_SPACE: + return AppraisalPolicy(**kwargs) + if kind is AttestationKind.TINFOIL: + from syft_enclaves.attestation_tinfoil import TinfoilAppraisalPolicy + + return TinfoilAppraisalPolicy(**kwargs) + raise ValueError(f"No appraisal policy for attestation kind {kind!r}") + + +def _require_policy_type( + kind: AttestationKind, policy: Optional[Policy], expected: type +) -> None: + """Refuse a policy meant for a different target. + + Silently ignoring its fields would drop the caller's pinned image digest + and quietly weaken the appraisal. + """ + if policy is None or isinstance(policy, expected): + return + raise ValueError( + f"{type(policy).__name__} cannot appraise {kind.value} evidence — " + f"pass a {_POLICY_CLASS_NAMES[kind]} instead." + ) diff --git a/packages/syft-enclave/src/syft_enclaves/attestation_envelope.py b/packages/syft-enclave/src/syft_enclaves/attestation_envelope.py new file mode 100644 index 00000000000..482ace12a85 --- /dev/null +++ b/packages/syft-enclave/src/syft_enclaves/attestation_envelope.py @@ -0,0 +1,156 @@ +"""Provider-agnostic attestation evidence carried in ``SYFT_version.json``. + +``syft`` knows nothing about attestation: it carries an opaque ``extra`` bag on +``VersionInfo``, and this package owns the ``"attestation"`` key inside it +along with everything under it. Both TEE providers use the same envelope: + +- ``format`` names the evidence type, as a predicate URI. +- ``body`` is the opaque evidence: a Google-signed JWT for Confidential Space, a + base64 gzip hardware report for Tinfoil. +- ``metadata`` carries whatever the verifier of that kind needs to locate its + reference values (for Tinfoil, which config repo and release published the + expected measurement). +""" + +from __future__ import annotations + +from enum import Enum +from typing import Any, Optional + +from pydantic import BaseModel, Field, field_validator, model_validator + + +class AttestationKind(str, Enum): + """Which TEE produced the evidence, and therefore which verifier reads it.""" + + CONFIDENTIAL_SPACE = "confidential_space" + TINFOIL = "tinfoil" + + +# Confidential Space has no predicate URI of its own — the evidence is an OIDC +# token — so syft names the format it publishes. +CONFIDENTIAL_SPACE_FORMAT = ( + "https://syft.openmined.org/predicate/gcp-confidential-space/v1" +) +# Tinfoil's formats come from the enclave's own attestation document; see +# https://docs.tinfoil.sh/verification/predicate. +TINFOIL_FORMAT_PREFIX = "https://tinfoil.sh/predicate/" + +#: The key this package owns in ``VersionInfo.extra``. Nothing in ``syft`` +#: refers to it; it is defined and read here only. +EXTRA_KEY = "attestation" + +_FORMATS: dict[AttestationKind, tuple[str, ...]] = { + AttestationKind.CONFIDENTIAL_SPACE: (CONFIDENTIAL_SPACE_FORMAT,), + AttestationKind.TINFOIL: (TINFOIL_FORMAT_PREFIX,), +} + + +class AttestationEvidence(BaseModel): + """What an enclave publishes about itself, before anyone has verified it. + + Untrusted until appraised: every field here is written by the enclave (or by + whoever controls its transport), so a verifier must treat ``metadata`` as a + hint and never as a source of trust. In particular the Tinfoil config repo + must come from the verifier's own policy, since it decides *who was allowed + to produce the expected measurement*. + """ + + model_config = {"frozen": True} + + schema_version: int = 1 + kind: AttestationKind + format: str + body: str + metadata: dict[str, Any] = Field(default_factory=dict) + + @field_validator("body") + @classmethod + def _body_is_not_empty(cls, v: str) -> str: + if not v.strip(): + raise ValueError("attestation evidence has an empty body") + return v + + @model_validator(mode="after") + def _format_matches_kind(self) -> "AttestationEvidence": + """Reject a ``kind`` that disagrees with ``format``. + + Without this, a peer could label a Confidential Space token as Tinfoil + evidence (or vice versa) and pick which verifier appraises it. + """ + allowed = _FORMATS[self.kind] + if not any(self.format.startswith(prefix) for prefix in allowed): + raise ValueError( + f"format {self.format!r} is not valid for kind {self.kind.value!r} " + f"(expected one of {allowed})" + ) + return self + + def to_version_field(self) -> dict[str, Any]: + """The JSON object stored under ``VersionInfo.extra["attestation"]``.""" + return self.model_dump(mode="json") + + @classmethod + def from_version_field( + cls, value: Optional[dict[str, Any]] + ) -> Optional["AttestationEvidence"]: + """Parse one stored envelope; ``None`` when the peer published none. + + Raises ``ValueError`` on a malformed envelope rather than returning + ``None``: a peer that published *something* unparseable must not be + treated the same as a peer that published nothing, which callers skip. + """ + if value is None: + return None + return cls.model_validate(value) + + def publish_to(self, version_info: Any) -> None: + """Store this evidence in a ``VersionInfo``'s extra bag.""" + version_info.extra[EXTRA_KEY] = self.to_version_field() + + @classmethod + def read_from(cls, version_info: Any) -> Optional["AttestationEvidence"]: + """The evidence a ``VersionInfo`` carries, or ``None`` if it carries none.""" + return cls.from_version_field((version_info.extra or {}).get(EXTRA_KEY)) + + +def confidential_space_evidence(token: str, audience: str) -> AttestationEvidence: + """Wrap a Confidential Space attestation JWT.""" + return AttestationEvidence( + kind=AttestationKind.CONFIDENTIAL_SPACE, + format=CONFIDENTIAL_SPACE_FORMAT, + body=token, + metadata={"audience": audience}, + ) + + +def tinfoil_evidence( + document: dict[str, Any], + repo: Optional[str] = None, + release_tag: Optional[str] = None, + host: Optional[str] = None, +) -> AttestationEvidence: + """Wrap a Tinfoil ``{format, body}`` attestation document. + + ``repo``/``release_tag`` are recorded so a verifier can warn when its own + policy points somewhere else; they never widen trust. ``host`` tells a peer + where to fetch the report over a pinned connection — also untrusted, since + a wrong host either fails the pin or is the right enclave. + """ + try: + format_, body = document["format"], document["body"] + except (KeyError, TypeError) as e: + raise ValueError( + "tinfoil attestation document must have 'format' and 'body' keys" + ) from e + metadata = { + key: value + for key, value in (("repo", repo), ("release_tag", release_tag), ("host", host)) + if value + } + return AttestationEvidence( + kind=AttestationKind.TINFOIL, + format=format_, + body=body, + metadata=metadata, + ) diff --git a/packages/syft-enclave/src/syft_enclaves/attestation_https.py b/packages/syft-enclave/src/syft_enclaves/attestation_https.py new file mode 100644 index 00000000000..237d92fb5e7 --- /dev/null +++ b/packages/syft-enclave/src/syft_enclaves/attestation_https.py @@ -0,0 +1,186 @@ +"""Fetching a Tinfoil enclave's attestation over a connection pinned to it. + +Why not plain HTTPS: the enclave serves a self-signed certificate, because its +TLS key is generated inside the enclave and no CA is involved. Validating that +certificate the usual way is therefore impossible, and trusting it blindly +would let anyone in the path serve a replayed report along with their own keys. + +What makes this sound is the report itself. A SEV-SNP report's 64 bytes of user +data are the sha256 of the shim's TLS public key followed by its HPKE public +key, so the report *commits to the key terminating the connection*. Verify the +report, then check that the certificate we were served carries that key, and +the channel provably ends inside the attested enclave — no CA required. Whatever +else came down it, notably the enclave's syft key bundle, is then bound to the +hardware report too. + +Two useful consequences: the transport does not have to be trusted, and replay +stops working — a captured report commits to a TLS key whose private half lives +in an enclave the attacker does not control. +""" + +from __future__ import annotations + +import hashlib +import http.client +import json +import ssl +from dataclasses import dataclass +from typing import Any, Optional + +from syft_enclaves.nonce_challenge import new_nonce + +ATTESTATION_PATH = "/attestation" +WELL_KNOWN_PATH = "/.well-known/tinfoil-attestation" +DEFAULT_TIMEOUT_SECONDS = 30 + + +@dataclass(frozen=True) +class AttestedPayload: + """What the enclave served, and the key that terminated the connection. + + Untrusted until :mod:`syft_enclaves.attestation_tinfoil` has checked + ``tls_public_key_fp`` against the verified report and the signature over + ``nonce`` against ``key_bundle``. + """ + + document: dict[str, Any] + key_bundle: Optional[dict[str, Any]] + #: sha256 of the served certificate's DER SubjectPublicKeyInfo. + tls_public_key_fp: str + host: str + #: The nonce we sent, and the enclave's signature over it. + nonce: str = "" + nonce_signature: Optional[str] = None + + +class AttestationFetchError(RuntimeError): + """The enclave could not be reached, or served something unusable.""" + + +def fetch_attested_payload( + host: str, + nonce: Optional[str] = None, + timeout: float = DEFAULT_TIMEOUT_SECONDS, +) -> AttestedPayload: + """GET the enclave's attestation and key bundle, recording its TLS key. + + Certificate validation is deliberately disabled: the certificate is + self-signed, and it is the *report* that decides whether to trust the key. + Nothing here is trusted — the caller must verify both the fingerprint and + the signature over *nonce*. + + A nonce is generated when none is given, so a caller cannot accidentally + skip the freshness proof. + """ + nonce = nonce or new_nonce() + connection = http.client.HTTPSConnection( + host, timeout=timeout, context=_unverified_context() + ) + try: + payload = _attestation_or_empty(connection, nonce) + document, key_bundle = _split_payload(payload, connection, timeout) + fingerprint = _peer_public_key_fp(connection) + except (OSError, ssl.SSLError, ValueError) as e: + raise AttestationFetchError(f"Could not fetch attestation from {host}: {e}") from e + finally: + connection.close() + + echoed = payload.get("nonce") + if echoed is not None and echoed != nonce: + # Never verify against a nonce the responder chose. + raise AttestationFetchError( + f"{host} echoed a different nonce than the one sent" + ) + return AttestedPayload( + document=document, + key_bundle=key_bundle, + tls_public_key_fp=fingerprint, + host=host, + nonce=nonce, + nonce_signature=payload.get("nonce_signature"), + ) + + +def _attestation_or_empty( + connection: http.client.HTTPSConnection, nonce: str +) -> dict[str, Any]: + """The /attestation response, or {} if the enclave will not serve it. + + An enclave running an older image rejects the nonce outright. Rather than + surface its 500, fall through to the shim's own endpoint for the report — + the nonce check then fails with "no signature", which says plainly what is + wrong instead of hiding it behind a transport error. + """ + try: + return _get_json(connection, f"{ATTESTATION_PATH}?nonce={nonce}") + except ValueError: + return {} + + +def _split_payload( + payload: dict[str, Any], connection: http.client.HTTPSConnection, timeout: float +) -> tuple[dict[str, Any], Optional[dict[str, Any]]]: + """Pull the raw document and key bundle out of an /attestation response. + + Falls back to the shim's well-known path when the enclave does not run + syft's own endpoint — that still yields a verifiable report, just no key + bundle. + """ + evidence = payload.get("evidence") or {} + document = {k: evidence[k] for k in ("format", "body") if k in evidence} + if not document: + document = _get_json(connection, WELL_KNOWN_PATH) + if "format" not in document or "body" not in document: + raise ValueError("response carried no attestation document") + return document, payload.get("key_bundle") + + +def _get_json(connection: http.client.HTTPSConnection, path: str) -> dict[str, Any]: + connection.request("GET", path) + response = connection.getresponse() + body = response.read() + if response.status != 200: + raise ValueError(f"GET {path} returned {response.status}") + try: + parsed = json.loads(body) + except json.JSONDecodeError as e: + raise ValueError(f"GET {path} did not return JSON: {e}") from e + if not isinstance(parsed, dict): + raise ValueError(f"GET {path} did not return an object") + return parsed + + +def _unverified_context() -> ssl.SSLContext: + context = ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT) + context.check_hostname = False + context.verify_mode = ssl.CERT_NONE + return context + + +def _peer_public_key_fp(connection: http.client.HTTPSConnection) -> str: + """sha256 of the served certificate's public key, as the report encodes it.""" + socket = connection.sock + if socket is None: + raise ValueError("connection closed before the certificate could be read") + der = socket.getpeercert(binary_form=True) + if not der: + raise ValueError("peer presented no certificate") + return public_key_fp_from_cert(der) + + +def public_key_fp_from_cert(der_certificate: bytes) -> str: + """sha256 over the certificate's DER SubjectPublicKeyInfo. + + Computed here rather than imported from ``tinfoil.attestation.attestation``: + the equivalent helper there is not in that package's ``__all__``, so it is + not a stable API to depend on. + """ + from cryptography import x509 + from cryptography.hazmat.primitives import serialization + + certificate = x509.load_der_x509_certificate(der_certificate) + spki = certificate.public_key().public_bytes( + encoding=serialization.Encoding.DER, + format=serialization.PublicFormat.SubjectPublicKeyInfo, + ) + return hashlib.sha256(spki).hexdigest() diff --git a/packages/syft-enclave/src/syft_enclaves/attestation_tinfoil.py b/packages/syft-enclave/src/syft_enclaves/attestation_tinfoil.py new file mode 100644 index 00000000000..3e77fceeb11 --- /dev/null +++ b/packages/syft-enclave/src/syft_enclaves/attestation_tinfoil.py @@ -0,0 +1,597 @@ +"""Appraising Tinfoil attestation evidence. + +The hardware report is verified by the ``tinfoil`` SDK, which owns the +SEV-SNP/TDX parsing and the AMD KDS / Intel PCS trust chains — we never +reimplement that. What this module adds is the syft-specific appraisal: that +the measurement matches the one a *named config repo* published in a +Sigstore-signed release, and that the config it committed to pins the container +image (and optionally the syft version) we expect. + +How this differs from Confidential Space +---------------------------------------- +Stronger: the reference values come from a signed, publicly auditable release +rather than from claims the workload asked for. The image digest is committed +to by the measurement itself, not asserted by the token. + +A workload cannot inject a nonce: the report's 64 bytes of user data are the +sha256 of the shim's TLS public key followed by its HPKE public key. That is +also what makes key binding possible, because the report commits to the key +terminating a TLS connection to the enclave — see +``syft_enclaves.attestation_https``. Freshness for the syft key comes from a nonce the +enclave signs with that bundle (``nonce_challenge``), which the report cannot +carry. Evidence stays published to Drive as provenance, but it is never a +fallback here: appraising it instead would mean unbound keys and a replayable +report, so an unreachable enclave is an error. +""" + +from __future__ import annotations + +import base64 +import hashlib +import json +from typing import Any, NoReturn, Optional + +from pydantic import BaseModel + +from syft.version import SYFT_VERSION + +from syft_enclaves.attestation import AttestationError, AttestationResult +from syft_enclaves.attestation_envelope import AttestationEvidence +from syft_enclaves.attestation_https import ( + AttestationFetchError, + AttestedPayload, + fetch_attested_payload, +) +from syft_enclaves.nonce_challenge import NonceVerificationError, verify_challenge +from syft_enclaves.optional_deps import MissingOptionalDependency, require + +#: The config repo whose signed releases publish the expected measurement for +#: OpenMined's enclave image. Shipped as a constant because it is the trust +#: anchor: it decides *who was allowed* to produce a measurement we accept. +DEFAULT_TINFOIL_CONFIG_REPO = "OpenMined/syft-enclave-tinfoil" + +DEPLOYMENT_ASSET = "tinfoil-deployment.json" +HASH_ASSET = "tinfoil.hash" +#: Tinfoil's GitHub proxy only serves some asset paths (``tinfoil.hash`` yes, +#: ``tinfoil-deployment.json`` no), so fall through to GitHub itself. +GITHUB_RELEASES = "https://github.com" +DOCS = "packages/syft-enclave/docs/tinfoil.md" +REQUEST_TIMEOUT_SECONDS = 30 + + +class TinfoilAppraisalPolicy(BaseModel): + """Reference values a Tinfoil enclave's evidence is appraised against. + + ``repo`` deliberately has a shipped default and is never taken from the + peer: the enclave (and whoever controls its transport) writes its own + evidence, so letting it name the repo would let it choose which releases + are trusted. + """ + + model_config = {"frozen": True} + + repo: str = DEFAULT_TINFOIL_CONFIG_REPO + # None -> appraise against the repo's latest release. + release_tag: Optional[str] = None + # None -> the image-digest check is skipped and the image is not pinned. + expected_image_digest: Optional[str] = None + # None -> skipped. Only meaningful when the config pins SYFT_VERSION. + expected_syft_version: Optional[str] = SYFT_VERSION + # Which container in the config carries the enclave. + container_name: str = "syft-enclave" + # Where to fetch the report over a connection pinned to the key the report + # commits to. None -> fall back to the host the enclave advertised in its + # evidence; still None -> Drive-only, with no key binding. + host: Optional[str] = None + + + +def verify_tinfoil_evidence( + evidence: AttestationEvidence, + policy: Optional[TinfoilAppraisalPolicy] = None, + verbose: bool = True, +) -> AttestationResult: + """Verify Tinfoil evidence and return the check checklist. + + Prefers a pinned HTTPS fetch from the enclave: that yields a fresh report + and binds the enclave's syft key bundle to it (see ``attestation_https``). + Falls back to the evidence the enclave published to Drive, which still + proves what code is running but gives no key binding and no freshness. + + Runs every check before raising, so one failure does not hide later ones. + The hardware report is the exception: without a verified report there are + no measurements to compare, so it fails fast. + """ + policy = policy or TinfoilAppraisalPolicy() + return _TinfoilVerifier(evidence, policy, verbose, _fetch_pinned(evidence, policy)).run() + + +def _fetch_pinned( + evidence: AttestationEvidence, policy: TinfoilAppraisalPolicy +) -> AttestedPayload: + """Fetch the report over a pinned connection. Never optional. + + Tinfoil evidence is always appraised from the live enclave: the report on + its own says what code is running, but only a pinned connection binds the + enclave's syft keys to it and only a nonce proves the answer is fresh. The + Drive copy stays published as provenance, and as something to fall back on + if this ever needs to become optional again — but accepting it here would + mean silently downgrading to unbound keys and a replayable report. + + The host may come from the verifier's policy or from the enclave's own + evidence; either way it is untrusted, because a wrong host either fails + the fingerprint check or is the enclave we wanted. + """ + host = policy.host or evidence.metadata.get("host") + if not host: + raise AttestationError( + "Cannot verify a Tinfoil enclave: no host to reach it on. The " + "enclave publishes one in its evidence metadata (set " + "SYFT_ENCLAVE_TINFOIL_HOST when deploying), or pass " + "TinfoilAppraisalPolicy(host=...)." + ) + try: + return fetch_attested_payload(host) + except AttestationFetchError as e: + raise AttestationError( + f"Cannot verify a Tinfoil enclave: {host} is unreachable ({e}). " + "Attestation is not downgraded to the Drive-published copy, which " + "would bind no keys and be replayable." + ) from e + + +class _TinfoilVerifier: + """One verification run. Holds the state the checks hand to each other.""" + + def __init__( + self, + evidence: AttestationEvidence, + policy: TinfoilAppraisalPolicy, + verbose: bool, + payload: AttestedPayload, + ) -> None: + self.evidence = evidence + self.policy = policy + self.verbose = verbose + # Always set: we reached the enclave directly. Its document is used + # rather than the Drive copy, because it answers our nonce. + self.payload = payload + self.result = AttestationResult() + self.sdk = _TinfoilSDK() + self.actual_measurement: Any = None + self.verification: Any = None + self.expected_measurement: Any = None + self.release_tag: Optional[str] = policy.release_tag + self.release_digest: Optional[str] = None + self.config: Optional[dict[str, Any]] = None + self.config_error: Optional[str] = None + + def run(self) -> AttestationResult: + if self.verbose: + print("🔒 Verifying enclave attestation (tinfoil)...") + self._warn_on_repo_mismatch() + self._check_hardware_report() + self._check_key_binding() + self._check_nonce_freshness() + self._check_release_lookup() + self._check_sigstore_bundle() + self._check_measurement_match() + self._check_image_digest() + self._check_version_match() + return self._finish() + + # -- checks ----------------------------------------------------------- + + def _check_hardware_report(self) -> None: + """The quote is genuine SEV-SNP/TDX, chained to the CPU vendor. + + The SDK's defaults also enforce debug-disabled, minimum TCB and + firmware versions, so this subsumes the secure-boot and debug checks + the Confidential Space verifier makes separately. + """ + self._progress("Hardware report") + document = json.dumps(self.payload.document).encode() + try: + verification = self.sdk.attestation.verify_attestation_json(document) + except MissingOptionalDependency: + raise + except Exception as e: + self._fail_fast("hardware_report", "Hardware report", f"invalid: {e}") + self.actual_measurement = verification.measurement + self.verification = verification + self.result.add( + "hardware_report", + "Hardware report", + True, + "genuine TEE quote, debug disabled, TCB at minimum " + f"(fetched from {self.payload.host})", + ) + + def _check_key_binding(self) -> None: + """The channel ends inside the attested enclave, so its keys are its own. + + The report's user data is the sha256 of the shim's TLS public key, so + comparing it to the certificate we were actually served proves the + connection terminates in the enclave the report describes. Only then is + the key bundle that came down the same connection trustworthy. + """ + self._progress("Key binding") + expected = getattr(self.verification, "public_key_fp", None) + if not expected: + self.result.add( + "key_binding", "Key binding", False, "report commits to no TLS key" + ) + return + if expected != self.payload.tls_public_key_fp: + self.result.add( + "key_binding", + "Key binding", + False, + "the certificate served is not the key the report commits to " + f"(served {self.payload.tls_public_key_fp[:16]}…, report " + f"{expected[:16]}…) — the connection does not end in this enclave", + ) + return + self._record_verified_bundle() + + def _record_verified_bundle(self) -> None: + """Note that the channel is bound. The bundle is adopted in _finish. + + Adoption waits for the nonce proof: a bundle that arrived over a bound + channel is authentic, but until the enclave signs our challenge we have + no evidence it can actually use the key. + """ + bundle = self.payload.key_bundle + detail = ( + "channel ends in the attested enclave; its key bundle is bound to " + "the report" + if bundle + else "channel ends in the attested enclave, but it served no key " + "bundle (encryption off, or an older enclave image)" + ) + self.result.add("key_binding", "Key binding", True, detail) + + def _check_nonce_freshness(self) -> None: + """The enclave holds the key it served, and answered *this* exchange. + + The hardware report cannot carry a caller nonce, so freshness for the + syft key comes from a challenge: the enclave signs our nonce with the + identity key from the bundle it served. Failing this means the + responder produced a bundle it cannot use, or replayed an older answer. + """ + self._progress("Nonce freshness") + bundle = self.payload.key_bundle + if not bundle: + self.result.add( + "nonce_freshness", + "Nonce freshness", + None, + "the enclave served no key bundle, so there is no key to prove " + "possession of (skipped)", + ) + return + try: + verify_challenge(bundle, self.payload.nonce, self.payload.nonce_signature) + except NonceVerificationError as e: + self.result.add("nonce_freshness", "Nonce freshness", False, str(e)) + return + self.result.add( + "nonce_freshness", + "Nonce freshness", + True, + "the enclave signed our nonce with the key it served", + ) + + def _check_release_lookup(self) -> None: + """Resolve which published release we appraise against.""" + self._progress("Release lookup") + try: + self.release_tag, self.release_digest = self._resolve_release() + except MissingOptionalDependency: + raise + except Exception as e: + self.result.add( + "release_lookup", "Release lookup", False, f"could not resolve: {e}" + ) + return + self.result.add( + "release_lookup", + "Release lookup", + True, + f"{self.policy.repo} @ {self.release_tag}", + ) + + def _check_sigstore_bundle(self) -> None: + """The measurement was signed by that repo's own release workflow.""" + self._progress("Code transparency") + if self.release_digest is None: + self.result.add( + "sigstore_bundle", "Code transparency", None, "no release (skipped)" + ) + return + try: + self.expected_measurement = self._verified_expected_measurement() + except MissingOptionalDependency: + raise + except Exception as e: + self.result.add( + "sigstore_bundle", "Code transparency", False, f"unverified: {e}" + ) + return + self.result.add( + "sigstore_bundle", + "Code transparency", + True, + f"signed by {self.policy.repo} on refs/tags/{self.release_tag}", + ) + + def _check_measurement_match(self) -> None: + """What is running equals what was published.""" + self._progress("Measurement match") + if self.expected_measurement is None or self.actual_measurement is None: + self.result.add( + "measurement_match", + "Measurement match", + None, + "no published measurement to compare (skipped)", + ) + return + try: + self.expected_measurement.assert_equal(self.actual_measurement) + except Exception as e: + self.result.add( + "measurement_match", + "Measurement match", + False, + f"enclave does not match the published release: {e}", + ) + return + self.result.add( + "measurement_match", + "Measurement match", + True, + "enclave matches the published measurement", + ) + + def _check_image_digest(self) -> None: + """The measured config pins the container image we expect.""" + self._progress("Image digest") + if not self.policy.expected_image_digest: + self.result.add( + "image_digest", + "Image digest", + None, + "no expected image digest supplied — pass one via " + "TinfoilAppraisalPolicy to pin the image (skipped)", + ) + return + digest = self._config_image_digest() + if digest is None: + self.result.add( + "image_digest", + "Image digest", + False, + "could not read the image digest from the verified config" + + (f": {self.config_error}" if self.config_error else ""), + ) + return + passed = digest == self.policy.expected_image_digest + detail = ( + "container matches expected image" + if passed + else f"digest mismatch (got {digest}, expected " + f"{self.policy.expected_image_digest})" + ) + self.result.add("image_digest", "Image digest", passed, detail) + + def _check_version_match(self) -> None: + """The measured config pins the syft version we expect, if it pins one.""" + self._progress("Version match") + actual = self._config_env().get("SYFT_VERSION") + expected = self.policy.expected_syft_version + if not expected: + self.result.add( + "version_match", "Version match", None, "no expected version (skipped)" + ) + return + if not actual: + self.result.add( + "version_match", + "Version match", + None, + "config pins no SYFT_VERSION (skipped)", + ) + return + passed = actual == expected + detail = ( + f"enclave runs expected syft {expected}" + if passed + else f"version mismatch (enclave={actual!r}, expected={expected!r})" + ) + self.result.add("version_match", "Version match", passed, detail) + + # -- reference values ------------------------------------------------- + + def _resolve_release(self) -> tuple[str, str]: + """The (tag, digest) to appraise against. + + The digest is the sha256 of the release's ``tinfoil-deployment.json``, + and it is what the Sigstore DSSE commits to as its subject. + """ + if self.policy.release_tag is None: + release = self.sdk.github.fetch_latest_release(self.policy.repo) + return release.tag, release.digest + tag = self.policy.release_tag + # No SDK helper takes a tag, so read the release's own hash asset. + return tag, self._fetch_asset(tag, HASH_ASSET).decode().strip() + + def _verified_expected_measurement(self) -> Any: + bundle = self.sdk.github.fetch_attestation_bundle( + self.policy.repo, self.release_digest + ) + return self.sdk.sigstore.verify_attestation( + bundle, + self.release_digest, + self.policy.repo, + expected_release_tag=self.release_tag, + ) + + def _config_env(self) -> dict[str, Any]: + container = self._config_container() + if container is None: + return {} + return _env_mapping(container.get("env") or []) + + def _config_image_digest(self) -> Optional[str]: + container = self._config_container() + if container is None: + return None + image = container.get("image", "") + _, _, digest = image.partition("@") + return digest or None + + def _config_container(self) -> Optional[dict[str, Any]]: + try: + config = self._verified_config() + except MissingOptionalDependency: + raise + except Exception as e: + self.config_error = str(e) + return None + if config is None: + return None + for container in config.get("containers") or []: + if container.get("name") == self.policy.container_name: + return container + return None + + def _verified_config(self) -> Optional[dict[str, Any]]: + """The ``tinfoil-config.yml`` the enclave booted with. + + Trustworthy only because the deployment JSON that embeds it hashes to + the digest the Sigstore DSSE signed as its subject — so we check that + before reading anything out of it. + """ + if self.config is not None or self.release_digest is None: + return self.config + raw = self._fetch_asset(self.release_tag, DEPLOYMENT_ASSET) + if hashlib.sha256(raw).hexdigest() != self.release_digest: + raise AttestationError( + f"{DEPLOYMENT_ASSET} does not hash to the signed release digest " + f"{self.release_digest}" + ) + deployment = json.loads(raw) + yaml = require( + "yaml", extra="tinfoil", feature="Tinfoil attestation", docs=DOCS + ) + self.config = yaml.safe_load(base64.b64decode(deployment["config"])) + return self.config + + def _fetch_asset(self, tag: Optional[str], name: str) -> bytes: + """Download a release asset, trying each host in turn. + + The deployment JSON needs no trusted source: it is accepted only once + it hashes to the digest the Sigstore DSSE signed. The digest itself is + a trust input — a hostile source could substitute the digest of another + *legitimately signed* release of the same repo, i.e. roll us back. Pin + ``policy.release_tag`` to rule that out; the signature policy then + requires that exact tag. + """ + requests = require( + "requests", extra="tinfoil", feature="Tinfoil attestation", docs=DOCS + ) + errors = [] + for host in (self.sdk.github.GITHUB_PROXY, GITHUB_RELEASES): + url = f"{host}/{self.policy.repo}/releases/download/{tag}/{name}" + try: + response = requests.get(url, timeout=REQUEST_TIMEOUT_SECONDS) + response.raise_for_status() + return response.content + except Exception as e: + errors.append(f"{url}: {e}") + raise AttestationError(f"Could not fetch {name} — tried {'; '.join(errors)}") + + # -- plumbing --------------------------------------------------------- + + def _warn_on_repo_mismatch(self) -> None: + """The peer's own claim about its repo is a hint, never a trust input.""" + claimed = self.evidence.metadata.get("repo") + if claimed and claimed != self.policy.repo: + print( + f"⚠️ Enclave claims config repo {claimed!r} but verifying " + f"against {self.policy.repo!r} (the verifier's policy wins)." + ) + + def _progress(self, label: str) -> None: + if self.verbose: + print(f" ⏳ {label} ...") + + def _fail_fast(self, name: str, label: str, detail: str) -> NoReturn: + self.result.add(name, label, False, detail) + if self.verbose: + self.result.print_checklist() + print("❌ Attestation failed — no verified report, cannot inspect further") + raise AttestationError(f"Attestation failed: {name}", self.result) + + def _adopt_bundle_if_proven(self) -> None: + """Hand the bundle back only if both binding and possession hold.""" + proven = { + check.name: check.passed + for check in self.result.checks + if check.name in ("key_binding", "nonce_freshness") + } + if proven.get("key_binding") and proven.get("nonce_freshness"): + self.result.verified_key_bundle = self.payload.key_bundle + + def _finish(self) -> AttestationResult: + if self.verbose: + self.result.print_checklist() + self._adopt_bundle_if_proven() + failed = [c for c in self.result.checks if c.passed is False] + if failed: + names = ", ".join(c.name for c in failed) + if self.verbose: + print( + f"❌ Attestation failed — {len(failed)} check(s) did not pass: " + f"{names}" + ) + raise AttestationError(f"Attestation failed: {names}", self.result) + if self.verbose: + print("🔒 Attestation verified — enclave is trusted") + return self.result + + +class _TinfoilSDK: + """The tinfoil SDK submodules, imported on first use. + + Lazy so that importing this module — for ``TinfoilAppraisalPolicy``, say — + never requires the optional dependency. + """ + + def __init__(self) -> None: + self._modules: dict[str, Any] = {} + + def __getattr__(self, name: str) -> Any: + if name not in self._modules: + self._modules[name] = require( + f"tinfoil.{name}", + extra="tinfoil", + feature="Tinfoil attestation", + docs=DOCS, + ) + return self._modules[name] + + +def _env_mapping(entries: Any) -> dict[str, Any]: + """Flatten a tinfoil config ``env`` block into a mapping. + + Entries are either ``{"KEY": "value"}`` maps or bare ``"KEY"`` strings + (inherited from the deploy environment, so no value is pinned). + """ + env: dict[str, Any] = {} + if isinstance(entries, dict): + return dict(entries) + for entry in entries: + if isinstance(entry, dict): + env.update(entry) + return env diff --git a/packages/syft-enclave/src/syft_enclaves/bootstrap.py b/packages/syft-enclave/src/syft_enclaves/bootstrap.py index 20b594680d0..9afcc1dd6cb 100644 --- a/packages/syft-enclave/src/syft_enclaves/bootstrap.py +++ b/packages/syft-enclave/src/syft_enclaves/bootstrap.py @@ -9,6 +9,9 @@ Federation. The Confidential Spaces attestation JWT is exchanged at STS for a federated Google access token, which is used to call Secret Manager. +- ``tinfoil`` — read ``SYFT_ENCLAVE_TOKEN_CONTENT``, which Tinfoil + populates from a deploy-time ``--secret``. Tinfoil has no Secret + Manager equivalent. If ``SYFT_BOOTSTRAP`` is unset, a pre-existing token at the path is accepted (bind mount, init container, etc.). Adding a new @@ -22,13 +25,14 @@ import json import logging import os -import socket import sys -from http.client import HTTPConnection from pathlib import Path import requests +from syft_enclaves._unix_socket import UnixSocketConnection +from syft_enclaves.providers.tinfoil import TINFOIL_ATTESTATION_PATH + logger = logging.getLogger(__name__) TEE_SOCKET_PATH = "/run/container_launcher/teeserver.sock" @@ -55,16 +59,6 @@ def write_atomic(path: Path, data: bytes, *, mode: int = 0o600) -> None: # --------------------------------------------------------------------------- -class _UnixSocketConnection(HTTPConnection): - def __init__(self, socket_path: str): - super().__init__("localhost") - self._socket_path = socket_path - - def connect(self) -> None: - self.sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) - self.sock.connect(self._socket_path) - - def request_attestation_jwt(audience: str) -> str: """POST to the Confidential Spaces launcher socket; return the JWT.""" if not os.path.exists(TEE_SOCKET_PATH): @@ -72,7 +66,7 @@ def request_attestation_jwt(audience: str) -> str: f"TEE socket not found at {TEE_SOCKET_PATH}. " "wif requires a Confidential Spaces VM." ) - conn = _UnixSocketConnection(TEE_SOCKET_PATH) + conn = UnixSocketConnection(TEE_SOCKET_PATH) conn.request( "POST", "/v1/token", @@ -122,8 +116,9 @@ def secret_manager_access(resource: str, bearer: str) -> bytes: def envvar_provider() -> bytes: logger.warning( - "DEPRECATION: SYFT_BOOTSTRAP=envvar exposes the token in instance " - "metadata and attestation claims. Use only with a dev token." + "DEPRECATION: on Confidential Space, SYFT_BOOTSTRAP=envvar exposes " + "the token in instance metadata and attestation claims. Use only with " + "a dev token. On Tinfoil use SYFT_BOOTSTRAP=tinfoil instead." ) v = os.environ.get("SYFT_ENCLAVE_TOKEN_CONTENT") if not v: @@ -167,10 +162,36 @@ def sa_provider() -> bytes: return secret_manager_access(secret, resp.json()["access_token"]) +def tinfoil_provider() -> bytes: + """Read the token from the env var Tinfoil injects the secret into. + + Tinfoil supplies ``--secret NAME`` values as environment variables inside + the CVM, and the measured config records only the *name*. So unlike + ``envvar`` on Confidential Space, the value is not in instance metadata or + in the attestation — which is why this is its own provider rather than an + alias, and why it carries no deprecation warning. + """ + if not TINFOIL_ATTESTATION_PATH.exists(): + raise RuntimeError( + f"SYFT_BOOTSTRAP=tinfoil but {TINFOIL_ATTESTATION_PATH} is missing. " + "This is not a Tinfoil enclave; use SYFT_BOOTSTRAP=envvar for local " + "testing." + ) + value = os.environ.get("SYFT_ENCLAVE_TOKEN_CONTENT") + if not value: + raise RuntimeError( + "SYFT_BOOTSTRAP=tinfoil but SYFT_ENCLAVE_TOKEN_CONTENT is unset. " + "Deploy with: tinfoil container create ... --secret " + "SYFT_ENCLAVE_TOKEN_CONTENT" + ) + return value.encode() + + PROVIDERS = { "envvar": envvar_provider, "wif": wif_provider, "sa": sa_provider, + "tinfoil": tinfoil_provider, } diff --git a/packages/syft-enclave/src/syft_enclaves/client.py b/packages/syft-enclave/src/syft_enclaves/client.py index 8dce7d1e4ff..c6a0b037b84 100644 --- a/packages/syft-enclave/src/syft_enclaves/client.py +++ b/packages/syft-enclave/src/syft_enclaves/client.py @@ -1,6 +1,6 @@ from datetime import datetime, timezone from pathlib import Path -from typing import Optional +from typing import TYPE_CHECKING, Optional import os from syft_rds import SyftRDSClient, SyftRDSClientConfig @@ -17,10 +17,8 @@ PartyApprovalStatus, enclave_approval_file_name, ) -from syft_enclaves.attestation import ( - AppraisalPolicy, - verify_attestation_token, -) +from syft_enclaves.attestation_dispatch import policy_for, verify_evidence +from syft_enclaves.attestation_envelope import AttestationEvidence from syft_perms.syftperm_context import SyftPermContext from syft_enclaves.enclave_job_client import EnclaveJobClient @@ -36,6 +34,12 @@ make_private_dataset_immutability_filter, ) +if TYPE_CHECKING: + # Only for the attest_peer annotation: importing the tinfoil policy at + # runtime would drag the optional SDK onto the always-imported path. + from syft_enclaves.attestation import AppraisalPolicy + from syft_enclaves.attestation_tinfoil import TinfoilAppraisalPolicy + class SyftEnclaveClient: def __init__( @@ -86,27 +90,78 @@ def attest_peer( self, peer_email: str, expected_image_digest: str | None = None, - policy: "AppraisalPolicy | None" = None, + policy: "AppraisalPolicy | TinfoilAppraisalPolicy | None" = None, ): """Verify an enclave peer's attestation by re-reading SYFT_version.json - from Drive. Returns None (with an info print) when no token is available; - raises AttestationError only when verification of an existing token fails. + from Drive. Returns None (with an info print) when the peer published no + evidence; raises AttestationError when verification of existing evidence + fails. + + The peer's evidence says which TEE produced it, and it is routed to that + target's verifier (Confidential Space or Tinfoil). Verifying Tinfoil + evidence needs the optional ``tinfoil`` package; see + ``docs/tinfoil.md``. Args: peer_email: the enclave peer to attest. expected_image_digest: a "sha256:..." container image digest you - trust — . When set, the attestation - is appraised against it. - policy: a full ``AppraisalPolicy`` for finer control (image digest - *and* syft version). Mutually exclusive with + trust. When set, the attestation is appraised against it. + policy: a full appraisal policy for finer control — an + ``AppraisalPolicy`` for Confidential Space or a + ``TinfoilAppraisalPolicy`` for Tinfoil. Mutually exclusive with ``expected_image_digest``. """ if expected_image_digest is not None and policy is not None: raise ValueError("Pass either expected_image_digest or policy, not both.") + + evidence = self._peer_evidence(peer_email) + if evidence is None: + return None if expected_image_digest is not None: - policy = AppraisalPolicy(expected_image_digest=expected_image_digest) + policy = policy_for( + evidence.kind, expected_image_digest=expected_image_digest + ) + result = verify_evidence(evidence, policy=policy) + self._adopt_verified_key_bundle(peer_email, result) + return result + + def _adopt_verified_key_bundle(self, peer_email: str, result) -> None: + """Trust the peer's keys when attestation bound them to its report. + + Only a bundle delivered over a channel pinned to the attested TLS key + gets here — see ``attestation_https``. The copy the peer publishes to + Drive is unsigned, so a mismatch means the Drive copy was tampered + with; the bound one wins and we say so rather than failing, since the + bound one is exactly what we should be using. + """ + bundle = getattr(result, "verified_key_bundle", None) + if not bundle: + return + peer_store = self._rds.peer_manager.peer_store + previous = None + if peer_store.has_peer_bundle(peer_email): + previous = peer_store.get_cached_peer(peer_email).public_encryption_bundle + if previous and previous != bundle: + print( + f"⚠️ {peer_email!r} published a different key bundle to Drive than " + "the one its attestation binds; using the attested one." + ) + peer_store.set_peer_bundle(peer_email, bundle) + self._persist_peer_bundle(peer_email, bundle) + print(f"🔑 Set {peer_email!r} encryption keys from its attestation.") + + def _persist_peer_bundle(self, peer_email: str, bundle: dict) -> None: + """Write the bundle alongside the peer's state, as load_peers does.""" + peer = self._rds.peer_manager.peer_store.get_cached_peer(peer_email) + if peer is None: + return + self._rds.peer_manager.connection_router.update_peer_state( + peer_email, peer.state.value, public_encryption_bundle=bundle + ) + def _peer_evidence(self, peer_email: str) -> "AttestationEvidence | None": + """The peer's published attestation evidence, or None if it has none.""" version_info = self._rds.peer_manager.connection_router.read_peer_version_file( peer_email ) @@ -115,13 +170,16 @@ def attest_peer( f"ℹ️ No version file available for peer {peer_email!r}; skipping attestation." ) return None - if not version_info.attestation_token: + # A malformed envelope raises rather than skipping: a peer that + # published something unparseable is not the same as one that + # published nothing. + evidence = AttestationEvidence.read_from(version_info) + if evidence is None: print( - f"ℹ️ Peer {peer_email!r} has no attestation token " - "(not running in a Confidential Space); skipping attestation." + f"ℹ️ Peer {peer_email!r} published no attestation evidence " + "(not running in an attested enclave); skipping attestation." ) - return None - return verify_attestation_token(version_info.attestation_token, policy=policy) + return evidence def sync(self): self._rds.sync() diff --git a/packages/syft-enclave/src/syft_enclaves/evidence.py b/packages/syft-enclave/src/syft_enclaves/evidence.py new file mode 100644 index 00000000000..34ae1494d57 --- /dev/null +++ b/packages/syft-enclave/src/syft_enclaves/evidence.py @@ -0,0 +1,111 @@ +"""Where the enclave's attestation evidence comes from. + +One provider per deployment target. A provider knows how to tell whether it is +running on its own kind of TEE (``detect``), how to obtain the evidence +(``collect``), and how to summarise it for the operator-facing HTTP endpoint +(``describe``). Nothing here verifies anything — an enclave never appraises its +own evidence; that is the verifier's job (``attestation_dispatch``). + +Adding a provider is: write a class with ``kind``/``probe_path``/``detect``/ +``from_settings``/``collect``/``describe``, and add it to ``PROVIDERS``. +""" + +from __future__ import annotations + +import logging +from pathlib import Path +from typing import Any, Optional, Protocol, runtime_checkable + +from syft_enclaves.attestation_envelope import AttestationEvidence, AttestationKind +from syft_enclaves.providers.confidential_space import ConfidentialSpaceProvider +from syft_enclaves.providers.tinfoil import TinfoilProvider + +logger = logging.getLogger(__name__) + +AUTO = "auto" +NONE = "none" + + +@runtime_checkable +class EvidenceProvider(Protocol): + """How the enclave obtains evidence about itself on one deployment target.""" + + kind: AttestationKind + #: The file or socket whose presence means "we are on this TEE". + probe_path: Path + + @classmethod + def detect(cls) -> bool: + """Whether this provider's TEE is the one we are running on.""" + + @classmethod + def from_settings(cls, settings: Any) -> "EvidenceProvider": + """Build a provider from ``EnclaveSettings``. + + Each provider reads only the settings it needs, so a new target adds + its configuration without the generic seam knowing about it. + """ + + def collect(self, caller_nonce: Optional[str] = None) -> AttestationEvidence: + """Obtain fresh evidence, ready to publish to peers.""" + + def describe(self, evidence: AttestationEvidence) -> dict[str, Any]: + """Human-readable summary of *evidence*, for the /attestation endpoint. + + Display only, and explicitly unverified — a relying party appraises the + evidence itself, never this summary. + """ + + +PROVIDERS: dict[str, type[EvidenceProvider]] = { + AttestationKind.CONFIDENTIAL_SPACE.value: ConfidentialSpaceProvider, + AttestationKind.TINFOIL.value: TinfoilProvider, +} + + +def select_provider( + name: str = AUTO, settings: Any = None +) -> Optional[EvidenceProvider]: + """Resolve *name* to a configured provider, or ``None`` outside a TEE. + + ``"auto"`` probes each provider in turn; ``"none"`` disables attestation + (local development); any other value selects that provider explicitly and + still requires it to detect, so a misconfigured deployment publishes + nothing loudly rather than quietly. + """ + if name == NONE: + return None + provider_cls = _detect_provider_class() if name == AUTO else _named(name) + if provider_cls is None: + return None + if name != AUTO and not provider_cls.detect(): + logger.warning( + "Attestation provider %r was requested but %s is not present", + name, + provider_cls.probe_path, + ) + return None + return provider_cls.from_settings(settings) + + +def _named(name: str) -> type[EvidenceProvider]: + try: + return PROVIDERS[name] + except KeyError: + raise ValueError( + f"Unknown attestation provider {name!r}. " + f"Expected one of: {AUTO}, {NONE}, {', '.join(sorted(PROVIDERS))}." + ) from None + + +def _detect_provider_class() -> Optional[type[EvidenceProvider]]: + for name, provider_cls in PROVIDERS.items(): + if provider_cls.detect(): + logger.info("Detected attestation provider: %s", name) + return provider_cls + return None + + +def probed_locations() -> str: + """The paths ``auto`` detection looks at, for error messages.""" + return ", ".join(f"{name} ({cls.probe_path})" for name, cls in PROVIDERS.items()) diff --git a/packages/syft-enclave/src/syft_enclaves/key_bundle.py b/packages/syft-enclave/src/syft_enclaves/key_bundle.py new file mode 100644 index 00000000000..65950b13586 --- /dev/null +++ b/packages/syft-enclave/src/syft_enclaves/key_bundle.py @@ -0,0 +1,89 @@ +"""Publishing the enclave's own public key bundle to its HTTP endpoint. + +The runner owns the enclave's keypair; the attestation HTTP server runs as a +separate process in the same container. Rather than have the server load the +private key file too, the runner writes the *public* bundle here and the server +just serves it. + +Why the endpoint carries it at all: a peer that fetches the attestation report +over a connection pinned to the key the report commits to can trust whatever +else came down that connection. That turns the key bundle from an unsigned +Drive file into one bound to the hardware report — which is the binding +``docs/security.md`` describes and that the Drive-only path cannot provide. +""" + +from __future__ import annotations + +import json +import logging +import os +from pathlib import Path +from typing import Any, Optional + +logger = logging.getLogger(__name__) + +#: Written by the runner, read by docker/attestation_server.py. Lives beside +#: the Drive token, on a path that is writable in every deployment. +PUBLIC_BUNDLE_PATH = Path( + os.environ.get("SYFT_ENCLAVE_PUBLIC_BUNDLE_PATH", "/run/syft-enclave/public_bundle.json") +) + + +def write_public_bundle( + bundle: dict[str, Any], keys_path: Path, path: Path = PUBLIC_BUNDLE_PATH +) -> None: + """Record the public bundle and where its private half lives. + + ``keys_path`` lets the HTTP server sign a caller's nonce, proving the + enclave holds the key it serves. Both processes run as the same user in the + same container, so this crosses no trust boundary — but note the file names + a private key location and so must not be served anywhere. + """ + path.parent.mkdir(parents=True, exist_ok=True) + tmp = path.with_name(f".{path.name}.tmp") + tmp.write_text(json.dumps({"bundle": bundle, "keys_path": str(keys_path)})) + os.replace(tmp, path) + logger.info("Published public key bundle to %s", path) + + +def read_published(path: Path = PUBLIC_BUNDLE_PATH) -> Optional[dict[str, Any]]: + """What the runner published, or None if it has not published yet. + + Returns None rather than raising: encryption may be disabled, or the + endpoint may be queried before the runner has finished starting. + """ + try: + published = json.loads(path.read_text()) + except (OSError, json.JSONDecodeError): + return None + return published if isinstance(published, dict) else None + + +def read_public_bundle(path: Path = PUBLIC_BUNDLE_PATH) -> Optional[dict[str, Any]]: + """Just the public bundle — what the endpoint is allowed to hand out.""" + published = read_published(path) + return published.get("bundle") if published else None + + +def sign_nonce(nonce: str, path: Path = PUBLIC_BUNDLE_PATH) -> Optional[str]: + """Sign a caller's nonce with the enclave's identity key. + + None when there is nothing to sign with, so the endpoint degrades to + "served a bundle but proved nothing" rather than failing outright — the + client is what decides whether to accept that. + """ + published = read_published(path) + if not published or not published.get("keys_path"): + return None + try: + import syft_crypto_python as syc + + from syft_enclaves.nonce_challenge import sign_challenge + + keys = syc.SyftPrivateKeys.from_jwks( + json.loads(Path(published["keys_path"]).read_text())["keys_jwk"] + ) + return sign_challenge(keys.to_jwks(), nonce) + except Exception as e: + logger.warning("Could not sign the attestation nonce: %s", e) + return None diff --git a/packages/syft-enclave/src/syft_enclaves/nonce_challenge.py b/packages/syft-enclave/src/syft_enclaves/nonce_challenge.py new file mode 100644 index 00000000000..00dacb115fa --- /dev/null +++ b/packages/syft-enclave/src/syft_enclaves/nonce_challenge.py @@ -0,0 +1,97 @@ +"""Proving an enclave holds the syft key it just handed us, right now. + +The hardware report cannot carry a caller nonce: its 64 bytes of user data are +the shim's TLS key fingerprint and HPKE public key, and there is no parameter to +influence them. So freshness for the *syft* key comes from a challenge instead. + +The client sends a random nonce; the enclave signs it with the Ed25519 identity +key from its own bundle; the client verifies that signature against the bundle +the pinned channel delivered. Together with the TLS pin that gives the full +chain: + + hardware report → commits to the shim's TLS key + TLS pin → this channel really ends in that enclave + key bundle → therefore authentic, it came down that channel + nonce signature → and the enclave holds the private half, right now + +Without the nonce a bundle would be authentic but unproven: nothing would show +the enclave could actually use the key we are about to encrypt to, and nothing +would tie the response to this exchange rather than an earlier one. +""" + +from __future__ import annotations + +import base64 +import secrets +from typing import Any + +#: Domain separator, so a signature made here can never be replayed as a +#: signature for some other syft protocol that also signs with the identity key. +CHALLENGE_PREFIX = b"syft-enclave-attestation-nonce-v1:" +NONCE_BYTES = 32 + + +class NonceVerificationError(Exception): + """The enclave did not prove possession of the key bundle it served.""" + + +def new_nonce() -> str: + """A fresh client-chosen nonce, hex encoded.""" + return secrets.token_hex(NONCE_BYTES) + + +def challenge_message(nonce: str) -> bytes: + """Exactly the bytes both sides sign and verify.""" + return CHALLENGE_PREFIX + nonce.encode() + + +def sign_challenge(private_jwks: dict[str, Any], nonce: str) -> str: + """Sign *nonce* with the identity key from a JWKS, base64 encoded. + + Runs inside the enclave. Takes the JWKS rather than a ``PeerStore`` so the + attestation HTTP server can sign without depending on the sync engine. + """ + from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey + + identity = private_jwks["identity_key"] + if identity.get("crv") != "Ed25519": + raise ValueError(f"identity key is not Ed25519: {identity.get('crv')!r}") + private_key = Ed25519PrivateKey.from_private_bytes(_b64url_decode(identity["d"])) + return base64.b64encode(private_key.sign(challenge_message(nonce))).decode() + + +def verify_challenge(bundle: dict[str, Any], nonce: str, signature_b64: str) -> None: + """Check the enclave signed *our* nonce with the bundle's identity key. + + Raises :class:`NonceVerificationError` on any failure — a bad signature is + indistinguishable from a replayed or absent one, and all of them mean the + same thing: no proof of possession. + """ + from cryptography.exceptions import InvalidSignature + from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PublicKey + + if not signature_b64: + raise NonceVerificationError("the enclave returned no nonce signature") + try: + public_key = Ed25519PublicKey.from_public_bytes(identity_key_bytes(bundle)) + public_key.verify(base64.b64decode(signature_b64), challenge_message(nonce)) + except InvalidSignature as e: + raise NonceVerificationError( + "the nonce signature does not match the key bundle: the responder " + "does not hold the private half of the key it served" + ) from e + except (ValueError, KeyError, TypeError) as e: + raise NonceVerificationError(f"malformed nonce signature or bundle: {e}") from e + + +def identity_key_bytes(bundle: dict[str, Any]) -> bytes: + """The bundle's Ed25519 identity public key, via syft-crypto's own parser.""" + import syft_crypto_python as syc + + parsed = syc.SyftPublicKeyBundle.from_did_document(bundle) + key_bytes = parsed.identity_key_bytes + return key_bytes() if callable(key_bytes) else key_bytes + + +def _b64url_decode(value: str) -> bytes: + return base64.urlsafe_b64decode(value + "=" * (-len(value) % 4)) diff --git a/packages/syft-enclave/src/syft_enclaves/optional_deps.py b/packages/syft-enclave/src/syft_enclaves/optional_deps.py new file mode 100644 index 00000000000..c52e2d46936 --- /dev/null +++ b/packages/syft-enclave/src/syft_enclaves/optional_deps.py @@ -0,0 +1,58 @@ +"""Importing optional dependencies with an actionable error. + +Verifying Tinfoil evidence needs the ``tinfoil`` SDK, which most installs do +not want: it pulls in ``openai``, ``sigstore`` and ``pyopenssl``. So it lives +behind an extra, and every import of it goes through :func:`require` so a +missing install produces install instructions rather than a bare ImportError. +""" + +from __future__ import annotations + +import importlib +from types import ModuleType + +PACKAGE = "syft-enclave" + + +class MissingOptionalDependency(ImportError): + """An optional dependency is needed for the requested feature. + + Subclasses ``ImportError`` so existing ``except ImportError`` handlers keep + working. + """ + + +def require(module: str, *, extra: str, feature: str, docs: str = "") -> ModuleType: + """Import *module*, or raise with how to install it. + + Args: + module: the module to import, e.g. ``"tinfoil.attestation"``. + extra: the extra that provides it, e.g. ``"tinfoil"``. + feature: what the caller was trying to do, for the message. + docs: optional repo-relative doc path to point at. + """ + try: + return importlib.import_module(module) + except ImportError as e: + raise MissingOptionalDependency( + _message(module, extra=extra, feature=feature, docs=docs) + ) from e + + +def _message(module: str, *, extra: str, feature: str, docs: str) -> str: + root = module.split(".")[0] + lines = [ + f"{feature} requires the optional '{root}' package, which is not installed.", + "", + "Install it with one of:", + "", + f' pip install "{PACKAGE}[{extra}]"', + f' uv pip install "{PACKAGE}[{extra}]"', + "", + "or install the package on its own:", + "", + f" pip install {root}", + ] + if docs: + lines += ["", f"See {docs} for the full deploy and verify flow."] + return "\n".join(lines) diff --git a/packages/syft-enclave/src/syft_enclaves/providers/__init__.py b/packages/syft-enclave/src/syft_enclaves/providers/__init__.py new file mode 100644 index 00000000000..c19517bdad0 --- /dev/null +++ b/packages/syft-enclave/src/syft_enclaves/providers/__init__.py @@ -0,0 +1,5 @@ +"""Per-deployment-target attestation evidence providers. + +See ``syft_enclaves.evidence`` for the interface they implement and the +registry that selects between them. +""" diff --git a/packages/syft-enclave/src/syft_enclaves/providers/confidential_space.py b/packages/syft-enclave/src/syft_enclaves/providers/confidential_space.py new file mode 100644 index 00000000000..7af828adace --- /dev/null +++ b/packages/syft-enclave/src/syft_enclaves/providers/confidential_space.py @@ -0,0 +1,128 @@ +"""Attestation evidence on GCP Confidential Space. + +The launcher issues a Google-signed OIDC token over a Unix socket. A workload +can inject up to two nonces into it, which is the one channel a Confidential +Space workload has for binding its own data into hardware-signed evidence. +""" + +from __future__ import annotations + +import base64 +import json +from datetime import datetime, timezone +from typing import Any, Optional + +from syft_enclaves.attestation_envelope import ( + AttestationEvidence, + AttestationKind, + confidential_space_evidence, +) +from syft_enclaves.tee_token import ( + TEE_SOCKET_PATH, + TOKEN_AUDIENCE, + build_eat_nonce, + fetch_attestation_token, +) + + +class ConfidentialSpaceProvider: + """Evidence from the Confidential Space launcher.""" + + kind = AttestationKind.CONFIDENTIAL_SPACE + probe_path = TEE_SOCKET_PATH + #: Confidential Space lets a workload inject nonces into the token. + accepts_caller_nonce = True + + @classmethod + def detect(cls) -> bool: + return TEE_SOCKET_PATH.exists() + + @classmethod + def from_settings(cls, settings: Any = None) -> "ConfidentialSpaceProvider": + # Nothing to configure: the launcher socket and audience are fixed. + return cls() + + def collect(self, caller_nonce: Optional[str] = None) -> AttestationEvidence: + token = fetch_attestation_token(eat_nonce=build_eat_nonce(caller_nonce)) + return confidential_space_evidence(token, audience=TOKEN_AUDIENCE) + + def describe(self, evidence: AttestationEvidence) -> dict[str, Any]: + return structure_claims(decode_jwt_payload(evidence.body)) + + +def decode_jwt_payload(token: str) -> dict[str, Any]: + """Base64-decode a JWT's payload segment **without** verifying the signature. + + For display only. A relying party verifies the token itself against + Google's JWKS; see ``syft_enclaves.attestation``. + """ + parts = token.split(".") + if len(parts) != 3: + raise ValueError(f"Invalid JWT: expected 3 parts, got {len(parts)}") + + payload_b64 = parts[1] + padding = 4 - len(payload_b64) % 4 + if padding != 4: + payload_b64 += "=" * padding + return json.loads(base64.urlsafe_b64decode(payload_b64)) + + +def _format_timestamp(epoch: int | float | None) -> Optional[str]: + if epoch is None: + return None + return datetime.fromtimestamp(epoch, tz=timezone.utc).isoformat() + + +def structure_claims(claims: dict[str, Any]) -> dict[str, Any]: + """Organize raw JWT claims into logical sections for display.""" + submods = claims.get("submods", {}) + container = submods.get("container", {}) + gce = submods.get("gce", {}) + + result: dict[str, Any] = { + "hardware": { + "hwmodel": claims.get("hwmodel"), + "secboot": claims.get("secboot"), + "dbgstat": claims.get("dbgstat"), + }, + "software": { + "swname": claims.get("swname"), + "swversion": claims.get("swversion"), + }, + "container": { + "image_digest": container.get("image_digest"), + "image_reference": container.get("image_reference"), + "restart_policy": container.get("restart_policy"), + "env": container.get("env"), + }, + "gce": { + "project_id": gce.get("project_id"), + "zone": gce.get("zone"), + "instance_id": gce.get("instance_id"), + }, + "issuer": claims.get("iss"), + "subject": claims.get("sub"), + "issued_at": _format_timestamp(claims.get("iat")), + "expires_at": _format_timestamp(claims.get("exp")), + } + result.update(_optional_claim_sections(claims, submods)) + return result + + +def _optional_claim_sections( + claims: dict[str, Any], submods: dict[str, Any] +) -> dict[str, Any]: + """Sections that only appear on some deployments (GPU, CS internals, nonce).""" + sections: dict[str, Any] = {} + + nvidia_cc = claims.get("nvidia_gpu", submods.get("nvidia_gpu", {})) + if nvidia_cc: + sections["gpu"] = nvidia_cc + + cs = {k: v for k, v in submods.items() if k.startswith("confidential_space")} + if cs: + sections["confidential_space"] = cs + + if claims.get("eat_nonce"): + sections["eat_nonce"] = claims["eat_nonce"] + return sections diff --git a/packages/syft-enclave/src/syft_enclaves/providers/tinfoil.py b/packages/syft-enclave/src/syft_enclaves/providers/tinfoil.py new file mode 100644 index 00000000000..6621f25ac95 --- /dev/null +++ b/packages/syft-enclave/src/syft_enclaves/providers/tinfoil.py @@ -0,0 +1,121 @@ +"""Attestation evidence on Tinfoil. + +Tinfoil mounts a read-only ``/tinfoil`` directory into every container holding +the enclave's own attestation document, the verified config it booted with, and +the status of the launched containers. Collecting evidence is therefore a file +read — the enclave never verifies itself, and needs no Tinfoil SDK. + +See https://docs.tinfoil.sh/containers/config-runtime. +""" + +from __future__ import annotations + +import json +from pathlib import Path +from typing import Any, Optional + +from syft_enclaves.attestation_envelope import ( + AttestationEvidence, + AttestationKind, + tinfoil_evidence, +) + +TINFOIL_DIR = Path("/tinfoil") +TINFOIL_ATTESTATION_PATH = TINFOIL_DIR / "attestation.json" +TINFOIL_CONFIG_PATH = TINFOIL_DIR / "config.yml" +TINFOIL_STATUS_PATH = TINFOIL_DIR / "container-status.json" + + +class TinfoilProvider: + """Evidence from Tinfoil's ``/tinfoil`` mount.""" + + kind = AttestationKind.TINFOIL + probe_path = TINFOIL_ATTESTATION_PATH + #: Tinfoil cannot: the report's user data is the shim's own keys. + accepts_caller_nonce = False + + def __init__( + self, + repo: Optional[str] = None, + release_tag: Optional[str] = None, + host: Optional[str] = None, + ) -> None: + # Recorded in the published evidence so a verifier can warn when its + # own policy points at a different config repo. Never a source of trust. + self.repo = repo + self.release_tag = release_tag + # Where peers can reach us for a pinned fetch. Also not trusted. + self.host = host + + @classmethod + def detect(cls) -> bool: + return TINFOIL_ATTESTATION_PATH.exists() + + @classmethod + def from_settings(cls, settings: Any = None) -> "TinfoilProvider": + return cls( + repo=getattr(settings, "tinfoil_repo", None), + release_tag=getattr(settings, "tinfoil_release_tag", None), + host=getattr(settings, "tinfoil_host", None), + ) + + def collect(self, caller_nonce: Optional[str] = None) -> AttestationEvidence: + if caller_nonce is not None: + raise ValueError( + "Tinfoil evidence cannot carry a caller nonce: the report's 64 " + "bytes of user data are fully used by the shim's TLS key " + "fingerprint and HPKE public key, and the attestation document " + "is a static file. Requesting a nonce here would silently give " + "no freshness guarantee at all." + ) + return tinfoil_evidence( + self._read_attestation_document(), + repo=self.repo, + release_tag=self.release_tag, + host=self.host, + ) + + def describe(self, evidence: AttestationEvidence) -> dict[str, Any]: + return { + "document": {"format": evidence.format, "body": evidence.body}, + "config": _read_text(TINFOIL_CONFIG_PATH), + "container_status": _read_json(TINFOIL_STATUS_PATH), + **evidence.metadata, + } + + def _read_attestation_document(self) -> dict[str, Any]: + try: + document = json.loads(TINFOIL_ATTESTATION_PATH.read_text()) + except FileNotFoundError as e: + raise RuntimeError( + f"No Tinfoil attestation document at {TINFOIL_ATTESTATION_PATH}. " + "This container is not running inside a Tinfoil enclave." + ) from e + except json.JSONDecodeError as e: + raise RuntimeError( + f"Malformed Tinfoil attestation document at " + f"{TINFOIL_ATTESTATION_PATH}: {e}" + ) from e + if not isinstance(document, dict): + raise RuntimeError( + f"Malformed Tinfoil attestation document at " + f"{TINFOIL_ATTESTATION_PATH}: expected an object" + ) + return document + + +def _read_text(path: Path) -> Optional[str]: + try: + return path.read_text() + except OSError: + return None + + +def _read_json(path: Path) -> Optional[Any]: + raw = _read_text(path) + if raw is None: + return None + try: + return json.loads(raw) + except json.JSONDecodeError: + return None diff --git a/packages/syft-enclave/src/syft_enclaves/runner.py b/packages/syft-enclave/src/syft_enclaves/runner.py index 96a071c7f9a..b78761379d2 100644 --- a/packages/syft-enclave/src/syft_enclaves/runner.py +++ b/packages/syft-enclave/src/syft_enclaves/runner.py @@ -16,11 +16,15 @@ import time from typing import Callable, Optional +from syft.sync.peers.peer_store import datasite_crypto_keys_path + from syft_enclaves.client import SyftEnclaveClient -from syft_enclaves.tee_token import ( - TEE_SOCKET_PATH, - build_eat_nonce, - fetch_attestation_token, +from syft_enclaves.key_bundle import write_public_bundle +from syft_enclaves.evidence import ( + AUTO, + EvidenceProvider, + probed_locations, + select_provider, ) logger = logging.getLogger(__name__) @@ -37,12 +41,19 @@ def __init__( require_tee: bool = False, fresh_state: bool = True, post_init: Optional[Callable[[], None]] = None, + attestation_provider: str = AUTO, + settings: Optional[object] = None, ) -> None: self.client = client self.poll_interval = poll_interval self.require_tee = require_tee self.fresh_state = fresh_state self.post_init = post_init + # Which deployment target to collect evidence from; see + # syft_enclaves.evidence. ``settings`` is passed through so each + # provider can read its own configuration. + self.attestation_provider = attestation_provider + self.settings = settings self._shutdown_requested = False # -- public API ------------------------------------------------------- @@ -116,27 +127,62 @@ def _on_initializing(self) -> None: logger.info("State wipe complete — enclave starts with a clean slate") def _on_attesting(self) -> None: - """Verify TEE environment and publish attestation token to version file.""" - in_tee = TEE_SOCKET_PATH.exists() - if self.require_tee and not in_tee: - raise RuntimeError( - f"TEE socket not found at {TEE_SOCKET_PATH}. " - "Set require_tee=False for local testing." - ) - if in_tee: - logger.info("Confidential Spaces TEE detected — fetching attestation token") - self._publish_attestation() - else: + """Collect attestation evidence and publish it to the version file.""" + provider = select_provider(self.attestation_provider, self.settings) + if provider is None: + if self.require_tee: + raise RuntimeError( + "No TEE detected. Probed: " + f"{probed_locations()}. " + "Set require_tee=False for local testing." + ) logger.warning("Running outside TEE — attestation unavailable") + return + logger.info( + "TEE detected (%s) — collecting attestation evidence", provider.kind.value + ) + self._publish_attestation(provider) + self._publish_key_bundle() - def _publish_attestation(self) -> None: - """Fetch attestation JWT from the TEE and write it into the version file.""" - eat_nonce = build_eat_nonce() - token = fetch_attestation_token(eat_nonce=eat_nonce) + def _publish_attestation(self, provider: EvidenceProvider) -> None: + """Write the provider's evidence into the peer-visible version file.""" + evidence = provider.collect() peer_manager = self.client._rds.peer_manager - peer_manager.get_own_version().attestation_token = token + evidence.publish_to(peer_manager.get_own_version()) peer_manager.write_own_version() - logger.info("Attestation token published to SYFT_version.json") + logger.info( + "Attestation evidence (%s) published to SYFT_version.json", + evidence.kind.value, + ) + + def _publish_key_bundle(self) -> None: + """Expose our public key bundle on the attestation endpoint. + + A peer fetching the report over a pinned connection gets the bundle + from the same channel, which binds it to the hardware report. Skipped + when encryption is off, since then there is no bundle. + """ + peer_store = self.client._rds.peer_manager.peer_store + if not peer_store.use_encryption or not peer_store.has_my_keys(): + logger.info("Encryption disabled — no key bundle to publish") + return + peer_manager = self.client._rds.peer_manager + # The per-datasite default location; the enclave never overrides + # crypto_keys_path, and PeerManager does not carry the resolved value. + keys_path = datasite_crypto_keys_path( + peer_manager.syftbox_folder, peer_store.email + ) + try: + # fresh_state wiped the syftbox folder a moment ago, taking the key + # file with it — the keys live on in memory, so persist them again + # before advertising where they are. Without this the endpoint + # serves a bundle it cannot sign a nonce with. + peer_store.save_keys(keys_path) + write_public_bundle(peer_store.get_public_bundle(), keys_path=keys_path) + except OSError as e: + # Not fatal: peers can still fall back to the Drive-published + # bundle, they just lose the attestation binding. + logger.warning("Could not publish the public key bundle: %s", e) def _on_peering(self) -> None: """Load peers and accept pending peer requests.""" diff --git a/packages/syft-enclave/src/syft_enclaves/settings.py b/packages/syft-enclave/src/syft_enclaves/settings.py index d3d490c572f..3309128b48a 100644 --- a/packages/syft-enclave/src/syft_enclaves/settings.py +++ b/packages/syft-enclave/src/syft_enclaves/settings.py @@ -1,13 +1,68 @@ from __future__ import annotations from pathlib import Path -from typing import Annotated, Literal +from typing import Annotated, Literal, Optional from pydantic import Field, field_validator from pydantic_settings import BaseSettings, NoDecode, SettingsConfigDict -class EnclaveSettings(BaseSettings): +class AttestationSettings(BaseSettings): + """Which attestation provider to use, and its configuration. + + Separate from :class:`EnclaveSettings` because the attestation HTTP server + needs only these, and requiring ``email``/``data_owners`` there would mean + the endpoint silently degraded whenever they were absent. ``EnclaveSettings`` + inherits it, so both paths read the same environment variables. + """ + + model_config = SettingsConfigDict( + env_prefix="SYFT_ENCLAVE_", + env_file=".env", + env_file_encoding="utf-8", + extra="ignore", + frozen=True, + ) + + attestation_provider: Literal["auto", "confidential_space", "tinfoil", "none"] = ( + Field( + default="auto", + description=( + "Which deployment target to collect attestation evidence from. " + "'auto' probes each provider's marker path; 'none' disables " + "attestation entirely (local development)." + ), + ) + ) + tinfoil_repo: Optional[str] = Field( + default=None, + description=( + "Tinfoil config repo ('owner/name') whose signed release published " + "this enclave's expected measurement. Recorded in the published " + "evidence so a verifier can warn on a mismatch; verifiers must " + "still supply their own, since this value is enclave-controlled." + ), + ) + tinfoil_release_tag: Optional[str] = Field( + default=None, + description=( + "Tinfoil config release tag this enclave was deployed from, e.g. " + "'v0.1.3'. Recorded in the published evidence, as above." + ), + ) + tinfoil_host: Optional[str] = Field( + default=None, + description=( + "Public hostname peers can reach this enclave on, e.g. " + "'syft-enclave.openmined.containers.tinfoil.dev'. Published in the " + "evidence so a peer knows where to fetch the attestation over a " + "pinned connection. Untrusted: a wrong host either fails the pin " + "or is the right enclave." + ), + ) + + +class EnclaveSettings(AttestationSettings): """Runtime configuration for ``python -m syft_enclaves``. Every field maps to an environment variable with a ``SYFT_ENCLAVE_`` @@ -70,8 +125,8 @@ def _split_data_owners(cls, v: object) -> object: require_tee: bool = Field( default=False, description=( - "Refuse to start unless a Confidential Spaces TEE socket is " - "present. Set true in production, false for local testing." + "Refuse to start unless an attestation provider detects its TEE. " + "Set true in production, false for local testing." ), ) log_level: Literal["DEBUG", "INFO", "WARNING", "ERROR"] = Field( diff --git a/packages/syft-enclave/src/syft_enclaves/tee_token.py b/packages/syft-enclave/src/syft_enclaves/tee_token.py index d6c6acfaec2..5de75b3b0f4 100644 --- a/packages/syft-enclave/src/syft_enclaves/tee_token.py +++ b/packages/syft-enclave/src/syft_enclaves/tee_token.py @@ -9,12 +9,12 @@ import json import re -import socket -from http.client import HTTPConnection from pathlib import Path import syft +from syft_enclaves._unix_socket import UnixSocketConnection + TEE_SOCKET_PATH = Path("/run/container_launcher/teeserver.sock") TOKEN_AUDIENCE = "syft-attestation" @@ -53,25 +53,13 @@ def validate_nonce(nonce: str) -> str | None: # -- token fetching ----------------------------------------------------------- -class _UnixSocketConnection(HTTPConnection): - """HTTPConnection subclass that connects over a Unix domain socket.""" - - def __init__(self, socket_path: str): - super().__init__("localhost") - self._socket_path = socket_path - - def connect(self): - self.sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) - self.sock.connect(self._socket_path) - - def fetch_attestation_token(eat_nonce: list[str] | None = None) -> str: """Fetch an OIDC attestation token from the Confidential Spaces launcher. Sends a POST to ``/v1/token`` over the Unix domain socket exposed by the Confidential Space launcher. Returns the raw signed JWT string. """ - conn = _UnixSocketConnection(str(TEE_SOCKET_PATH)) + conn = UnixSocketConnection(str(TEE_SOCKET_PATH)) payload: dict = { "audience": TOKEN_AUDIENCE, "token_type": "OIDC", diff --git a/packages/syft-enclave/tests/test_attestation_dispatch.py b/packages/syft-enclave/tests/test_attestation_dispatch.py new file mode 100644 index 00000000000..606c082832c --- /dev/null +++ b/packages/syft-enclave/tests/test_attestation_dispatch.py @@ -0,0 +1,200 @@ +"""Tests for routing evidence to the right verifier, and for attest_peer.""" + +import subprocess +import sys +from unittest.mock import MagicMock, patch + +import pytest + +from syft_enclaves.attestation import AppraisalPolicy +from syft_enclaves.attestation_dispatch import policy_for, verify_evidence +from syft_enclaves.attestation_envelope import ( + AttestationKind, + confidential_space_evidence, + tinfoil_evidence, +) +from syft_enclaves.attestation_tinfoil import TinfoilAppraisalPolicy +from syft_enclaves.client import SyftEnclaveClient + +TINFOIL_DOC = { + "format": "https://tinfoil.sh/predicate/sev-snp-guest/v2", + "body": "H4sIAAAAAAAA/2JmgAEEixBg", +} +CS_EVIDENCE = confidential_space_evidence("header.payload.sig", "syft-attestation") +TINFOIL_EVIDENCE = tinfoil_evidence(TINFOIL_DOC) + + +class TestRouting: + def test_confidential_space_goes_to_the_jwt_verifier(self): + with patch( + "syft_enclaves.attestation_dispatch.verify_attestation_token" + ) as verify: + verify_evidence(CS_EVIDENCE, verbose=False) + assert verify.call_args.args[0] == "header.payload.sig" + + def test_tinfoil_goes_to_the_tinfoil_verifier(self): + with patch( + "syft_enclaves.attestation_tinfoil.verify_tinfoil_evidence" + ) as verify: + verify_evidence(TINFOIL_EVIDENCE, verbose=False) + assert verify.call_args.args[0] is TINFOIL_EVIDENCE + + def test_policy_for_builds_the_matching_class(self): + assert isinstance( + policy_for(AttestationKind.CONFIDENTIAL_SPACE), AppraisalPolicy + ) + assert isinstance(policy_for(AttestationKind.TINFOIL), TinfoilAppraisalPolicy) + + +class TestPolicyTypeGuard: + @pytest.mark.parametrize( + "evidence,policy", + [ + (CS_EVIDENCE, TinfoilAppraisalPolicy(expected_image_digest="sha256:a")), + (TINFOIL_EVIDENCE, AppraisalPolicy(expected_image_digest="sha256:a")), + ], + ) + def test_a_policy_for_the_other_target_is_refused(self, evidence, policy): + # Ignoring its fields would silently drop the caller's pinned digest + # and weaken the appraisal without telling them. + with pytest.raises(ValueError, match="cannot appraise"): + verify_evidence(evidence, policy=policy, verbose=False) + + def test_no_policy_is_allowed(self): + with patch("syft_enclaves.attestation_dispatch.verify_attestation_token"): + verify_evidence(CS_EVIDENCE, policy=None, verbose=False) + + +class TestAttestPeer: + def _client(self, version_info): + client = SyftEnclaveClient(rds=MagicMock()) + router = client._rds.peer_manager.connection_router + router.read_peer_version_file.return_value = version_info + return client + + def _peer_publishing(self, evidence_field): + """A peer whose version file carries this evidence in its extra bag.""" + return MagicMock(extra={"attestation": evidence_field}) + + def test_no_version_file_skips(self, capsys): + assert self._client(None).attest_peer("enclave@openmined.org") is None + assert "No version file" in capsys.readouterr().out + + def test_no_evidence_skips(self, capsys): + client = self._client(MagicMock(extra={})) + assert client.attest_peer("enclave@openmined.org") is None + assert "published no attestation evidence" in capsys.readouterr().out + + def test_malformed_evidence_raises_rather_than_skipping(self): + # Skipping here would let a peer disable attestation by publishing junk. + client = self._client(self._peer_publishing({"kind": "tinfoil"})) + with pytest.raises(ValueError): + client.attest_peer("enclave@openmined.org") + + def test_routes_to_the_verifier_for_the_published_kind(self): + client = self._client( + self._peer_publishing(TINFOIL_EVIDENCE.to_version_field()) + ) + with patch("syft_enclaves.client.verify_evidence") as verify: + client.attest_peer("enclave@openmined.org") + assert verify.call_args.args[0] == TINFOIL_EVIDENCE + + def test_expected_image_digest_builds_the_matching_policy(self): + client = self._client( + self._peer_publishing(TINFOIL_EVIDENCE.to_version_field()) + ) + with patch("syft_enclaves.client.verify_evidence") as verify: + client.attest_peer("enclave@openmined.org", expected_image_digest="sha256:a") + policy = verify.call_args.kwargs["policy"] + assert isinstance(policy, TinfoilAppraisalPolicy) + assert policy.expected_image_digest == "sha256:a" + + def test_digest_and_policy_together_are_refused(self): + client = self._client(MagicMock(extra={})) + with pytest.raises(ValueError, match="not both"): + client.attest_peer( + "enclave@openmined.org", + expected_image_digest="sha256:a", + policy=TinfoilAppraisalPolicy(), + ) + + +def test_importing_syft_enclaves_does_not_pull_in_the_tinfoil_sdk(): + """The optional SDK must stay off the always-imported path. + + A subprocess, because the test session may already have it imported. + """ + code = ( + "import sys, syft_enclaves;" + "from syft_enclaves.client import SyftEnclaveClient;" + "from syft_enclaves.attestation_tinfoil import TinfoilAppraisalPolicy;" + "TinfoilAppraisalPolicy();" + "assert not [m for m in sys.modules if m.split('.')[0] == 'tinfoil'], " + "sorted(m for m in sys.modules if m.split('.')[0] == 'tinfoil')" + ) + result = subprocess.run( + [sys.executable, "-c", code], capture_output=True, text=True + ) + assert result.returncode == 0, result.stderr + + +class TestAdoptingVerifiedKeys: + """attest_peer installs a peer's keys only when attestation bound them. + + A bundle read from Drive is unsigned; one delivered over a channel pinned + to the attested TLS key is not. Only the latter may be set for the peer. + """ + + def _client(self, bundle_on_drive=None): + client = SyftEnclaveClient(rds=MagicMock()) + router = client._rds.peer_manager.connection_router + router.read_peer_version_file.return_value = MagicMock( + extra={"attestation": TINFOIL_EVIDENCE.to_version_field()} + ) + store = client._rds.peer_manager.peer_store + store.has_peer_bundle.return_value = bundle_on_drive is not None + peer = MagicMock() + peer.public_encryption_bundle = bundle_on_drive + peer.state.value = "accepted" + store.get_cached_peer.return_value = peer + return client + + def _verified(self, bundle): + from syft_enclaves.attestation import AttestationResult + + return AttestationResult(verified_key_bundle=bundle) + + def test_a_bound_bundle_is_set_and_persisted(self): + client = self._client() + bundle = {"identity": "enclave@openmined.org"} + with patch( + "syft_enclaves.client.verify_evidence", return_value=self._verified(bundle) + ): + client.attest_peer("enclave@openmined.org") + store = client._rds.peer_manager.peer_store + store.set_peer_bundle.assert_called_once_with("enclave@openmined.org", bundle) + kwargs = client._rds.peer_manager.connection_router.update_peer_state.call_args.kwargs + assert kwargs["public_encryption_bundle"] == bundle + + def test_nothing_is_set_without_a_bound_bundle(self): + client = self._client() + with patch( + "syft_enclaves.client.verify_evidence", return_value=self._verified(None) + ): + client.attest_peer("enclave@openmined.org") + client._rds.peer_manager.peer_store.set_peer_bundle.assert_not_called() + + def test_a_drive_copy_that_disagrees_is_reported_and_overridden(self, capsys): + # A mismatch means the Drive copy was tampered with; the attested one + # is the one to use, and the operator should hear about it. + client = self._client(bundle_on_drive={"identity": "impostor"}) + bundle = {"identity": "enclave@openmined.org"} + with patch( + "syft_enclaves.client.verify_evidence", return_value=self._verified(bundle) + ): + client.attest_peer("enclave@openmined.org") + out = capsys.readouterr().out + assert "different key bundle" in out + client._rds.peer_manager.peer_store.set_peer_bundle.assert_called_once_with( + "enclave@openmined.org", bundle + ) diff --git a/packages/syft-enclave/tests/test_attestation_envelope.py b/packages/syft-enclave/tests/test_attestation_envelope.py new file mode 100644 index 00000000000..898792ee066 --- /dev/null +++ b/packages/syft-enclave/tests/test_attestation_envelope.py @@ -0,0 +1,142 @@ +"""Tests for the provider-agnostic attestation envelope.""" + +import pytest + +from syft.sync.version.version_info import VersionInfoV2 + +from syft_enclaves.attestation_envelope import ( + CONFIDENTIAL_SPACE_FORMAT, + EXTRA_KEY, + AttestationEvidence, + AttestationKind, + confidential_space_evidence, + tinfoil_evidence, +) + +TINFOIL_FORMAT = "https://tinfoil.sh/predicate/sev-snp-guest/v2" +TINFOIL_DOC = {"format": TINFOIL_FORMAT, "body": "H4sIAAAAAAAA/2JmgAEEixBgZGBg4A"} + + +def _version_info() -> VersionInfoV2: + return VersionInfoV2( + syft_client_version="0.1.117", + min_supported_syft_client_version="0.1.93", + protocol_version="1.0.0", + min_supported_protocol_version="1.0.0", + ) + + +class TestRoundTrip: + def test_confidential_space_round_trips(self): + evidence = confidential_space_evidence("header.payload.sig", "syft-attestation") + assert evidence.kind is AttestationKind.CONFIDENTIAL_SPACE + assert evidence.format == CONFIDENTIAL_SPACE_FORMAT + assert evidence.body == "header.payload.sig" + assert evidence.metadata == {"audience": "syft-attestation"} + + field = evidence.to_version_field() + assert AttestationEvidence.from_version_field(field) == evidence + + def test_tinfoil_round_trips(self): + evidence = tinfoil_evidence( + TINFOIL_DOC, repo="OpenMined/syft-enclave-tinfoil", release_tag="v0.0.1" + ) + assert evidence.kind is AttestationKind.TINFOIL + assert evidence.format == TINFOIL_FORMAT + assert evidence.metadata == { + "repo": "OpenMined/syft-enclave-tinfoil", + "release_tag": "v0.0.1", + } + + field = evidence.to_version_field() + assert AttestationEvidence.from_version_field(field) == evidence + + def test_both_kinds_use_the_same_envelope_keys(self): + cs = confidential_space_evidence("a.b.c", "syft-attestation").to_version_field() + tinfoil = tinfoil_evidence(TINFOIL_DOC).to_version_field() + assert cs.keys() == tinfoil.keys() + + def test_tinfoil_omits_absent_metadata(self): + assert tinfoil_evidence(TINFOIL_DOC).metadata == {} + + def test_survives_the_version_file(self): + # The envelope has to make it through VersionInfo's JSON serialization, + # which is the only channel that carries it to a peer. + evidence = tinfoil_evidence(TINFOIL_DOC, repo="OpenMined/x") + info = _version_info() + evidence.publish_to(info) + + reloaded = VersionInfoV2.model_validate_json(info.model_dump_json()) + assert AttestationEvidence.read_from(reloaded) == evidence + + def test_lives_under_this_package_s_own_key(self): + info = _version_info() + tinfoil_evidence(TINFOIL_DOC).publish_to(info) + assert list(info.extra) == [EXTRA_KEY] + + def test_leaves_other_packages_keys_alone(self): + info = _version_info() + info.extra["some-other-package"] = {"kept": True} + tinfoil_evidence(TINFOIL_DOC).publish_to(info) + assert info.extra["some-other-package"] == {"kept": True} + + def test_republishing_replaces_rather_than_accumulates(self): + info = _version_info() + tinfoil_evidence(TINFOIL_DOC).publish_to(info) + newer = confidential_space_evidence("a.b.c", "syft-attestation") + newer.publish_to(info) + assert AttestationEvidence.read_from(info) == newer + + +class TestParsing: + def test_no_attestation_is_none_not_an_error(self): + assert AttestationEvidence.from_version_field(None) is None + + def test_an_empty_extra_bag_means_no_evidence(self): + assert AttestationEvidence.read_from(_version_info()) is None + + def test_another_packages_key_is_not_mistaken_for_evidence(self): + info = _version_info() + info.extra["some-other-package"] = {"kind": "tinfoil"} + assert AttestationEvidence.read_from(info) is None + + @pytest.mark.parametrize( + "field", + [ + # A kind that disagrees with the format would let a peer choose + # which verifier appraises its evidence. + {"kind": "tinfoil", "format": CONFIDENTIAL_SPACE_FORMAT, "body": "x"}, + {"kind": "confidential_space", "format": TINFOIL_FORMAT, "body": "x"}, + # Unknown kind: no verifier to route to. + {"kind": "nitro", "format": TINFOIL_FORMAT, "body": "x"}, + # Missing pieces. + {"kind": "tinfoil", "format": TINFOIL_FORMAT}, + {"kind": "tinfoil", "body": "x"}, + # Present but empty evidence is not the same as absent evidence. + {"kind": "tinfoil", "format": TINFOIL_FORMAT, "body": " "}, + ], + ) + def test_malformed_envelope_raises(self, field): + with pytest.raises(ValueError): + AttestationEvidence.from_version_field(field) + + def test_unparseable_is_not_silently_treated_as_absent(self): + # The distinction matters: callers skip attestation when a peer + # published none, so a broken envelope must not look like that. + with pytest.raises(ValueError): + AttestationEvidence.from_version_field({"kind": "tinfoil"}) + + def test_unknown_metadata_keys_are_carried(self): + evidence = AttestationEvidence.from_version_field( + { + "kind": "tinfoil", + "format": TINFOIL_FORMAT, + "body": "x", + "metadata": {"repo": "a/b", "something_new": 1}, + } + ) + assert evidence.metadata["something_new"] == 1 + + def test_missing_tinfoil_document_keys_raise(self): + with pytest.raises(ValueError, match="'format' and 'body'"): + tinfoil_evidence({"format": TINFOIL_FORMAT}) diff --git a/packages/syft-enclave/tests/test_attestation_https.py b/packages/syft-enclave/tests/test_attestation_https.py new file mode 100644 index 00000000000..b18a77c176d --- /dev/null +++ b/packages/syft-enclave/tests/test_attestation_https.py @@ -0,0 +1,137 @@ +"""Tests for the pinned fetch itself.""" +import json +from unittest.mock import MagicMock + +import pytest + +from syft_enclaves.attestation_https import ( + AttestationFetchError, + fetch_attested_payload, + public_key_fp_from_cert, +) + +DOC = {"format": "https://tinfoil.sh/predicate/sev-snp-guest/v2", "body": "H4sIA"} + + +class _FakeResponse: + def __init__(self, payload, status=200): + self._payload = payload + self.status = status + + def read(self): + return self._payload if isinstance(self._payload, bytes) else json.dumps(self._payload).encode() + + +def _connection(responses, der=b"\x30\x00"): + conn = MagicMock() + conn.getresponse.side_effect = responses + conn.sock.getpeercert.return_value = der + return conn + + +@pytest.fixture +def patched(monkeypatch): + def _install(responses, der=b"\x30\x00"): + conn = _connection(responses, der) + monkeypatch.setattr( + "syft_enclaves.attestation_https.http.client.HTTPSConnection", + lambda *a, **k: conn, + ) + monkeypatch.setattr( + "syft_enclaves.attestation_https.public_key_fp_from_cert", + lambda der_bytes: "ab" * 32, + ) + return conn + + return _install + + +def test_returns_document_bundle_and_tls_fingerprint(patched): + bundle = {"identity": "enclave@openmined.org"} + patched([_FakeResponse({"evidence": {**DOC, "kind": "tinfoil"}, "key_bundle": bundle})]) + payload = fetch_attested_payload("enclave.example") + assert payload.document == DOC + assert payload.key_bundle == bundle + assert payload.tls_public_key_fp == "ab" * 32 + + +def test_falls_back_to_the_shim_well_known_path(patched): + # An enclave not running syft's endpoint still yields a verifiable report. + patched([_FakeResponse({"status": "weird"}), _FakeResponse(DOC)]) + payload = fetch_attested_payload("enclave.example") + assert payload.document == DOC + assert payload.key_bundle is None + + +def test_a_missing_certificate_is_an_error(patched): + patched([_FakeResponse({"evidence": DOC})], der=None) + with pytest.raises(AttestationFetchError): + fetch_attested_payload("enclave.example") + + +def test_an_error_from_both_endpoints_is_an_error(patched): + patched([_FakeResponse({}, status=503), _FakeResponse({}, status=503)]) + with pytest.raises(AttestationFetchError): + fetch_attested_payload("enclave.example") + + +def test_non_json_from_both_endpoints_is_an_error(patched): + patched([_FakeResponse(b"nope"), _FakeResponse(b"nope")]) + with pytest.raises(AttestationFetchError): + fetch_attested_payload("enclave.example") + + +def test_an_older_enclave_that_rejects_the_nonce_still_yields_a_report(patched): + """It 500s on /attestation?nonce=, so fall through to the shim endpoint. + + The nonce check then fails with "no signature", which names the problem + instead of hiding it behind a transport error. + """ + patched([_FakeResponse({}, status=500), _FakeResponse(DOC)]) + payload = fetch_attested_payload("enclave.example") + assert payload.document == DOC + assert payload.nonce_signature is None + + +def test_a_nonce_is_always_sent(patched): + conn = patched([_FakeResponse({"evidence": DOC})]) + payload = fetch_attested_payload("enclave.example") + assert payload.nonce + assert f"nonce={payload.nonce}" in conn.request.call_args_list[0].args[1] + + +def test_an_echoed_nonce_that_differs_is_refused(patched): + # Verifying against a nonce the responder chose would prove nothing. + patched([_FakeResponse({"evidence": DOC, "nonce": "not-the-one-we-sent"})]) + with pytest.raises(AttestationFetchError, match="different nonce"): + fetch_attested_payload("enclave.example") + + +def test_fingerprint_is_the_spki_sha256_of_a_real_certificate(): + # Not mocked: the fingerprint must match what the report encodes. + import datetime, hashlib + from cryptography import x509 + from cryptography.hazmat.primitives import hashes, serialization + from cryptography.hazmat.primitives.asymmetric import ec + from cryptography.x509.oid import NameOID + + key = ec.generate_private_key(ec.SECP256R1()) + name = x509.Name([x509.NameAttribute(NameOID.COMMON_NAME, "enclave")]) + now = datetime.datetime(2026, 1, 1, tzinfo=datetime.timezone.utc) + cert = ( + x509.CertificateBuilder() + .subject_name(name) + .issuer_name(name) + .public_key(key.public_key()) + .serial_number(1) + .not_valid_before(now) + .not_valid_after(now + datetime.timedelta(days=1)) + .sign(key, hashes.SHA256()) + ) + expected = hashlib.sha256( + key.public_key().public_bytes( + encoding=serialization.Encoding.DER, + format=serialization.PublicFormat.SubjectPublicKeyInfo, + ) + ).hexdigest() + assert public_key_fp_from_cert(cert.public_bytes(serialization.Encoding.DER)) == expected diff --git a/packages/syft-enclave/tests/test_attestation_tinfoil.py b/packages/syft-enclave/tests/test_attestation_tinfoil.py new file mode 100644 index 00000000000..bf2450b35cb --- /dev/null +++ b/packages/syft-enclave/tests/test_attestation_tinfoil.py @@ -0,0 +1,531 @@ +"""Tests for Tinfoil attestation verification. + +The real ``tinfoil`` SDK is an optional dependency and is not installed in the +test environment, so it is stubbed into ``sys.modules``. These tests cover the +syft-specific appraisal: which release we compare against, that the embedded +config is only trusted once it hashes to the signed digest, and that the +checklist reports the same way the Confidential Space verifier does. +""" + +import base64 +import hashlib +import json +import sys +import types +from unittest.mock import MagicMock + +import pytest + +from syft_enclaves.attestation import AttestationError +from syft_enclaves.attestation_envelope import tinfoil_evidence +from syft_enclaves.attestation_tinfoil import DEPLOYMENT_ASSET, HASH_ASSET +from syft_enclaves.optional_deps import MissingOptionalDependency + +TINFOIL_DOC = { + "format": "https://tinfoil.sh/predicate/sev-snp-guest/v2", + "body": "H4sIAAAAAAAA/2JmgAEEixBg", +} +TLS_FP = "ff" * 32 +HOST = "enclave.example" + + +def _real_keys(): + """A genuine syft keypair, so nonce signatures are really verified.""" + import syft_crypto_python as syc + + keys = syc.SyftRecoveryKey.generate().derive_keys() + bundle = keys.to_public_bundle().to_did_document("did:syft:enclave@openmined.org") + bundle["identity"] = "enclave@openmined.org" + return keys, bundle +IMAGE_DIGEST = "sha256:" + "ab" * 32 +IMAGE = f"docker.io/openminedreleasebot/syft-enclave@{IMAGE_DIGEST}" +SYFT_VERSION_IN_CONFIG = "9.9.9" +RELEASE_TAG = "v0.0.1" + + +def _config(image=IMAGE, name="syft-enclave", syft_version=SYFT_VERSION_IN_CONFIG): + env = [{"SYFT_ENCLAVE_ATTESTATION_PROVIDER": "tinfoil"}] + if syft_version: + env.append({"SYFT_VERSION": syft_version}) + return {"containers": [{"name": name, "image": image, "env": env}]} + + +def _deployment_bytes(config=None): + """A release's tinfoil-deployment.json, with the config embedded base64.""" + payload = { + "snp_measurement": "44c6" * 24, + "config": base64.b64encode( + json.dumps(config if config is not None else _config()).encode() + ).decode(), + } + return json.dumps(payload).encode() + + +class _FakeMeasurement: + """Stands in for tinfoil's Measurement, whose only job here is comparison.""" + + def __init__(self, registers, matches=True): + self.registers = registers + self._matches = matches + + def assert_equal(self, other): + if not self._matches or self.registers != other.registers: + raise ValueError("measurement mismatch") + + +@pytest.fixture +def sdk(monkeypatch): + """Stub the tinfoil SDK submodules and the release-asset HTTP fetch.""" + measurement = _FakeMeasurement(["deadbeef"]) + + attestation = types.ModuleType("tinfoil.attestation") + attestation.verify_attestation_json = MagicMock( + return_value=types.SimpleNamespace( + measurement=measurement, + public_key_fp=TLS_FP, + hpke_public_key="ee" * 32, + ) + ) + + github = types.ModuleType("tinfoil.github") + github.GITHUB_PROXY = "https://github-proxy.example" + deployment = _deployment_bytes() + digest = hashlib.sha256(deployment).hexdigest() + github.fetch_latest_release = MagicMock( + return_value=types.SimpleNamespace(tag=RELEASE_TAG, digest=digest) + ) + github.fetch_attestation_bundle = MagicMock(return_value=b"{}") + + sigstore = types.ModuleType("tinfoil.sigstore") + sigstore.verify_attestation = MagicMock(return_value=_FakeMeasurement(["deadbeef"])) + + root = types.ModuleType("tinfoil") + root.attestation, root.github, root.sigstore = attestation, github, sigstore + for name, module in { + "tinfoil": root, + "tinfoil.attestation": attestation, + "tinfoil.github": github, + "tinfoil.sigstore": sigstore, + }.items(): + monkeypatch.setitem(sys.modules, name, module) + + assets = {"tinfoil-deployment.json": deployment, "tinfoil.hash": digest.encode()} + state = {"digest": digest} + + def set_deployment(raw): + """Publish a different config, re-signing it (digest follows content).""" + assets["tinfoil-deployment.json"] = raw + state["digest"] = hashlib.sha256(raw).hexdigest() + assets["tinfoil.hash"] = state["digest"].encode() + github.fetch_latest_release.return_value = types.SimpleNamespace( + tag=RELEASE_TAG, digest=state["digest"] + ) + + def tamper_deployment(raw): + """Swap the file but leave the signed digest alone.""" + assets["tinfoil-deployment.json"] = raw + + # Mirrors the real world: tinfoil's proxy serves tinfoil.hash but 400s on + # tinfoil-deployment.json, which only github.com has. + proxy_blocks = {"tinfoil-deployment.json"} + fetched = [] + + def fake_get(url, timeout=None): + name = url.rsplit("/", 1)[-1] + fetched.append(url) + response = MagicMock() + if url.startswith(github.GITHUB_PROXY) and name in proxy_blocks: + response.raise_for_status.side_effect = RuntimeError("400 Bad Request") + return response + response.content = assets[name] + return response + + requests_stub = types.ModuleType("requests") + requests_stub.get = fake_get + monkeypatch.setitem(sys.modules, "requests", requests_stub) + + return types.SimpleNamespace( + attestation=attestation, + github=github, + sigstore=sigstore, + digest=digest, + assets=assets, + set_deployment=set_deployment, + tamper_deployment=tamper_deployment, + proxy_blocks=proxy_blocks, + fetched=fetched, + ) + + +@pytest.fixture +def pinned(monkeypatch): + """Patch the pinned fetch with a payload the enclave would really serve.""" + from syft_enclaves.attestation_https import AttestedPayload + from syft_enclaves.nonce_challenge import new_nonce, sign_challenge + + def _install( + *, + document=None, + bundle="real", + tls_fp=TLS_FP, + sign_with=None, + signature="valid", + unreachable=False, + ): + keys, real_bundle = _real_keys() + served = real_bundle if bundle == "real" else bundle + nonce = new_nonce() + if signature == "valid": + signer = sign_with or keys + nonce_signature = sign_challenge(signer.to_jwks(), nonce) + else: + nonce_signature = signature + + payload = AttestedPayload( + document=document or TINFOIL_DOC, + key_bundle=served, + tls_public_key_fp=tls_fp, + host=HOST, + nonce=nonce, + nonce_signature=nonce_signature, + ) + + def fetch(host, *a, **kw): + if unreachable: + from syft_enclaves.attestation_https import AttestationFetchError + + raise AttestationFetchError("connection refused") + return payload + + monkeypatch.setattr( + "syft_enclaves.attestation_tinfoil.fetch_attested_payload", fetch + ) + return payload + + return _install + + +@pytest.fixture +def verify(sdk, pinned): + """verify_tinfoil_evidence with the SDK and a pinned fetch stubbed, quiet.""" + from syft_enclaves.attestation_tinfoil import ( + TinfoilAppraisalPolicy, + verify_tinfoil_evidence, + ) + + def _run(policy=None, evidence=None, install_pinned=True, **policy_kwargs): + if install_pinned: + pinned() + policy_kwargs.setdefault("expected_syft_version", SYFT_VERSION_IN_CONFIG) + policy_kwargs.setdefault("host", HOST) + return verify_tinfoil_evidence( + evidence or tinfoil_evidence(TINFOIL_DOC), + policy=policy or TinfoilAppraisalPolicy(**policy_kwargs), + verbose=False, + ) + + return _run + + +def _names(result): + return [c.name for c in result.checks] + + +def _check(result, name): + return next(c for c in result.checks if c.name == name) + + +class TestHappyPath: + def test_all_checks_pass(self, verify): + result = verify(expected_image_digest=IMAGE_DIGEST) + assert _names(result) == [ + "hardware_report", + "key_binding", + "nonce_freshness", + "release_lookup", + "sigstore_bundle", + "measurement_match", + "image_digest", + "version_match", + ] + + def test_unpinned_release_uses_the_latest(self, verify, sdk): + verify(expected_image_digest=IMAGE_DIGEST) + sdk.github.fetch_latest_release.assert_called_once_with( + "OpenMined/syft-enclave-tinfoil" + ) + + def test_pinned_release_tag_is_enforced_in_the_signature_policy(self, verify, sdk): + verify(release_tag=RELEASE_TAG, expected_image_digest=IMAGE_DIGEST) + # A pinned tag must reach sigstore verification, else any tag's + # signature would be accepted. + assert ( + sdk.sigstore.verify_attestation.call_args.kwargs["expected_release_tag"] + == RELEASE_TAG + ) + sdk.github.fetch_latest_release.assert_not_called() + + +class TestPinnedChannel: + """Tinfoil evidence is always appraised from the live enclave. + + The report alone says what code is running. Only a pinned connection binds + the enclave's syft keys to it, and only a nonce shows the answer is fresh — + so the Drive copy is provenance, never a fallback. + """ + + def test_no_host_anywhere_is_refused(self, verify): + with pytest.raises(AttestationError, match="no host to reach it on"): + verify(host=None, evidence=tinfoil_evidence(TINFOIL_DOC)) + + def test_an_unreachable_enclave_is_refused(self, verify, pinned): + # Downgrading to Drive here would silently lose key binding. + pinned(unreachable=True) + with pytest.raises(AttestationError, match="unreachable"): + verify(install_pinned=False) + + def test_the_host_can_come_from_the_enclaves_own_evidence(self, verify, sdk): + result = verify( + host=None, evidence=tinfoil_evidence(TINFOIL_DOC, host="advertised.example") + ) + assert _check(result, "hardware_report").passed is True + + def test_the_fetched_document_is_used_not_the_drive_copy(self, verify, sdk, pinned): + fresh = {"format": TINFOIL_DOC["format"], "body": "FRESHER-THAN-DRIVE"} + pinned(document=fresh) + verify(install_pinned=False, expected_image_digest=IMAGE_DIGEST) + passed = json.loads(sdk.attestation.verify_attestation_json.call_args.args[0]) + assert passed == fresh + + +class TestKeyBinding: + """The report commits to the TLS key, so matching it proves where we are.""" + + def test_passes_when_the_served_key_matches_the_report(self, verify): + result = verify(expected_image_digest=IMAGE_DIGEST) + assert _check(result, "key_binding").passed is True + + def test_fails_when_the_served_key_is_not_the_attested_one(self, verify, pinned): + # A MITM would serve a replayed report plus its own certificate. + pinned(tls_fp="aa" * 32) + with pytest.raises(AttestationError) as excinfo: + verify(install_pinned=False, expected_image_digest=IMAGE_DIGEST) + check = _check(excinfo.value.result, "key_binding") + assert check.passed is False + assert "does not end in this enclave" in check.detail + assert excinfo.value.result.verified_key_bundle is None + + def test_a_bound_channel_without_a_bundle_still_binds(self, verify, pinned): + pinned(bundle=None) + result = verify(install_pinned=False, expected_image_digest=IMAGE_DIGEST) + assert _check(result, "key_binding").passed is True + # Nothing to adopt, and the nonce proof has no key to check. + assert _check(result, "nonce_freshness").passed is None + assert result.verified_key_bundle is None + + +class TestNonceFreshness: + """The enclave must prove it holds the key it served, for this exchange. + + The hardware report cannot carry a caller nonce, so this is what rules out + a bundle the responder cannot use and an answer produced earlier. + """ + + def test_a_valid_signature_over_our_nonce_passes(self, verify): + result = verify(expected_image_digest=IMAGE_DIGEST) + assert _check(result, "nonce_freshness").passed is True + + def test_the_bundle_is_adopted_only_once_possession_is_proven(self, verify, pinned): + payload = pinned() + result = verify(install_pinned=False, expected_image_digest=IMAGE_DIGEST) + assert result.verified_key_bundle == payload.key_bundle + + def test_a_signature_by_some_other_key_fails(self, verify, pinned): + import syft_crypto_python as syc + + # Signed with a key that is not the one in the served bundle. + pinned(sign_with=syc.SyftRecoveryKey.generate().derive_keys()) + with pytest.raises(AttestationError) as excinfo: + verify(install_pinned=False, expected_image_digest=IMAGE_DIGEST) + check = _check(excinfo.value.result, "nonce_freshness") + assert check.passed is False + assert "does not hold the private half" in check.detail + # An unproven bundle must never be adopted. + assert excinfo.value.result.verified_key_bundle is None + + def test_a_missing_signature_fails(self, verify, pinned): + pinned(signature=None) + with pytest.raises(AttestationError) as excinfo: + verify(install_pinned=False, expected_image_digest=IMAGE_DIGEST) + assert _check(excinfo.value.result, "nonce_freshness").passed is False + + def test_a_garbage_signature_fails(self, verify, pinned): + pinned(signature="not-base64-at-all!!") + with pytest.raises(AttestationError) as excinfo: + verify(install_pinned=False, expected_image_digest=IMAGE_DIGEST) + assert _check(excinfo.value.result, "nonce_freshness").passed is False + + +class TestHardwareReport: + def test_failure_fails_fast(self, verify, sdk): + sdk.attestation.verify_attestation_json.side_effect = ValueError("bad quote") + with pytest.raises(AttestationError) as excinfo: + verify() + # No later check may run: without a verified report there are no + # measurements to compare. + assert _names(excinfo.value.result) == ["hardware_report"] + + def test_the_document_is_passed_through_verbatim(self, verify, sdk): + verify(expected_image_digest=IMAGE_DIGEST) + passed = json.loads(sdk.attestation.verify_attestation_json.call_args.args[0]) + assert passed == TINFOIL_DOC + + +class TestMeasurementAndSignature: + def test_measurement_mismatch_fails(self, verify, sdk): + sdk.sigstore.verify_attestation.return_value = _FakeMeasurement(["other"]) + with pytest.raises(AttestationError, match="measurement_match"): + verify() + + def test_sigstore_failure_skips_the_comparison_rather_than_passing_it( + self, verify, sdk + ): + sdk.sigstore.verify_attestation.side_effect = ValueError("wrong repo") + with pytest.raises(AttestationError) as excinfo: + verify() + result = excinfo.value.result + assert _check(result, "sigstore_bundle").passed is False + assert _check(result, "measurement_match").passed is None + + def test_release_lookup_failure_is_reported(self, verify, sdk): + sdk.github.fetch_latest_release.side_effect = RuntimeError("404") + with pytest.raises(AttestationError) as excinfo: + verify() + assert _check(excinfo.value.result, "release_lookup").passed is False + + def test_verifier_policy_repo_wins_over_the_peers_claim(self, verify, sdk): + # The enclave writes its own evidence, so its claimed repo must not + # decide which releases are trusted. + evidence = tinfoil_evidence(TINFOIL_DOC, repo="attacker/repo") + verify(evidence=evidence, expected_image_digest=IMAGE_DIGEST) + assert ( + sdk.sigstore.verify_attestation.call_args.args[2] + == "OpenMined/syft-enclave-tinfoil" + ) + + +class TestConfigDerivedChecks: + def test_image_digest_skipped_when_not_pinned(self, verify): + result = verify() + assert _check(result, "image_digest").passed is None + assert "pass one via" in _check(result, "image_digest").detail + + def test_image_digest_mismatch_fails(self, verify): + with pytest.raises(AttestationError) as excinfo: + verify(expected_image_digest="sha256:" + "cd" * 32) + assert _check(excinfo.value.result, "image_digest").passed is False + + def test_a_tampered_deployment_json_is_not_trusted(self, verify, sdk): + # The embedded config is only trustworthy because the file hashes to + # the digest the Sigstore DSSE signed. + sdk.tamper_deployment( + _deployment_bytes(_config(image="docker.io/evil@sha256:00")) + ) + with pytest.raises(AttestationError) as excinfo: + verify(expected_image_digest=IMAGE_DIGEST) + result = excinfo.value.result + assert _check(result, "image_digest").passed is False + assert _check(result, "version_match").passed is None + + def test_falls_back_to_github_when_the_proxy_rejects_the_asset( + self, verify, sdk + ): + # Regression: tinfoil's proxy allowlists tinfoil.hash only, so reading + # the config must fall through to github.com. + result = verify(expected_image_digest=IMAGE_DIGEST) + assert _check(result, "image_digest").passed is True + assert any( + url.startswith("https://github.com") and url.endswith(DEPLOYMENT_ASSET) + for url in sdk.fetched + ) + + def test_unreachable_from_every_source_fails_the_check(self, verify, sdk): + sdk.proxy_blocks.add(HASH_ASSET) + sdk.assets.pop(DEPLOYMENT_ASSET) + with pytest.raises(AttestationError) as excinfo: + verify(expected_image_digest=IMAGE_DIGEST) + assert _check(excinfo.value.result, "image_digest").passed is False + + def test_missing_container_fails_the_digest_check(self, verify): + with pytest.raises(AttestationError) as excinfo: + verify( + expected_image_digest=IMAGE_DIGEST, container_name="not-in-the-config" + ) + assert _check(excinfo.value.result, "image_digest").passed is False + + def test_version_skipped_when_the_config_pins_none(self, verify, sdk): + sdk.set_deployment(_deployment_bytes(_config(syft_version=None))) + result = verify(expected_image_digest=IMAGE_DIGEST) + assert _check(result, "version_match").passed is None + + def test_version_skipped_when_the_policy_pins_none(self, verify): + result = verify(expected_image_digest=IMAGE_DIGEST, expected_syft_version=None) + assert _check(result, "version_match").passed is None + + def test_version_mismatch_fails(self, verify): + with pytest.raises(AttestationError) as excinfo: + verify(expected_image_digest=IMAGE_DIGEST, expected_syft_version="0.0.1") + assert _check(excinfo.value.result, "version_match").passed is False + + +class TestChecklistBehaviour: + def test_every_check_runs_after_a_non_fatal_failure(self, verify): + with pytest.raises(AttestationError) as excinfo: + verify(expected_image_digest="sha256:" + "cd" * 32) + # image_digest failed, but version_match must still have been appraised. + assert _check(excinfo.value.result, "version_match").passed is True + + def test_every_failure_is_named(self, verify, sdk): + sdk.set_deployment(_deployment_bytes(_config(image="docker.io/x@sha256:0bad"))) + with pytest.raises(AttestationError) as excinfo: + verify(expected_image_digest=IMAGE_DIGEST, expected_syft_version="0.0.1") + message = str(excinfo.value) + assert "image_digest" in message and "version_match" in message + + def test_error_carries_the_result(self, verify, sdk): + sdk.attestation.verify_attestation_json.side_effect = ValueError("nope") + with pytest.raises(AttestationError) as excinfo: + verify() + assert excinfo.value.result is not None + + +class TestOptionalDependency: + def test_missing_tinfoil_explains_how_to_install_it(self, monkeypatch, pinned): + from syft_enclaves.attestation_tinfoil import ( + TinfoilAppraisalPolicy, + verify_tinfoil_evidence, + ) + + pinned() + for name in list(sys.modules): + if name == "tinfoil" or name.startswith("tinfoil."): + monkeypatch.delitem(sys.modules, name, raising=False) + monkeypatch.setattr( + "syft_enclaves.optional_deps.importlib.import_module", + MagicMock(side_effect=ImportError("No module named 'tinfoil'")), + ) + with pytest.raises(MissingOptionalDependency) as excinfo: + verify_tinfoil_evidence( + tinfoil_evidence(TINFOIL_DOC), + policy=TinfoilAppraisalPolicy(host=HOST), + verbose=False, + ) + message = str(excinfo.value) + assert 'pip install "syft-enclave[tinfoil]"' in message + assert "docs/tinfoil.md" in message + + def test_the_policy_is_usable_without_the_sdk(self): + # Importing the module for its policy must not need the extra. + from syft_enclaves.attestation_tinfoil import TinfoilAppraisalPolicy + + assert TinfoilAppraisalPolicy().repo == "OpenMined/syft-enclave-tinfoil" diff --git a/packages/syft-enclave/tests/test_evidence_providers.py b/packages/syft-enclave/tests/test_evidence_providers.py new file mode 100644 index 00000000000..f42c76fecee --- /dev/null +++ b/packages/syft-enclave/tests/test_evidence_providers.py @@ -0,0 +1,248 @@ +"""Tests for provider selection and the two evidence providers.""" + +import json +from pathlib import Path +from unittest.mock import patch + +import pytest + +from syft_enclaves.attestation_envelope import AttestationKind +from syft_enclaves.evidence import ( + PROVIDERS, + probed_locations, + select_provider, +) +from syft_enclaves.providers.confidential_space import ( + ConfidentialSpaceProvider, + decode_jwt_payload, + structure_claims, +) +from syft_enclaves.providers.tinfoil import TinfoilProvider + +TINFOIL_DOC = { + "format": "https://tinfoil.sh/predicate/sev-snp-guest/v2", + "body": "H4sIAAAAAAAA/2JmgAEEixBg", +} + + +@pytest.fixture +def tinfoil_mount(tmp_path, monkeypatch): + """A fake /tinfoil mount, as Tinfoil presents it to the container.""" + (tmp_path / "attestation.json").write_text(json.dumps(TINFOIL_DOC)) + (tmp_path / "config.yml").write_text("cpus: 2\n") + (tmp_path / "container-status.json").write_text('{"syft-enclave": "running"}') + for name, attr in [ + ("attestation.json", "TINFOIL_ATTESTATION_PATH"), + ("config.yml", "TINFOIL_CONFIG_PATH"), + ("container-status.json", "TINFOIL_STATUS_PATH"), + ]: + monkeypatch.setattr( + f"syft_enclaves.providers.tinfoil.{attr}", tmp_path / name + ) + return tmp_path + + +class TestSelection: + def test_none_disables_attestation(self): + assert select_provider("none") is None + + def test_auto_finds_nothing_outside_a_tee(self): + assert select_provider("auto") is None + + def test_unknown_name_raises_and_lists_the_options(self): + with pytest.raises(ValueError, match="Unknown attestation provider"): + select_provider("nitro-enclave") + + def test_explicit_provider_that_does_not_detect_returns_none(self): + # A misconfigured deployment publishes nothing loudly rather than + # pretending some other provider applies. + assert select_provider("tinfoil") is None + + def test_auto_prefers_whichever_tee_is_present(self, tinfoil_mount): + provider = select_provider("auto") + assert isinstance(provider, TinfoilProvider) + assert provider.kind is AttestationKind.TINFOIL + + def test_probed_locations_names_both_targets(self): + assert "/run/container_launcher/teeserver.sock" in probed_locations() + assert "/tinfoil/attestation.json" in probed_locations() + + def test_every_registered_provider_implements_the_seam(self): + for provider_cls in PROVIDERS.values(): + assert hasattr(provider_cls, "kind") + assert hasattr(provider_cls, "probe_path") + for method in ("detect", "from_settings", "collect", "describe"): + assert callable(getattr(provider_cls, method)) + + +class TestTinfoilProvider: + def test_collects_the_mounted_document(self, tinfoil_mount): + evidence = TinfoilProvider(repo="OpenMined/x", release_tag="v0.0.1").collect() + assert evidence.kind is AttestationKind.TINFOIL + assert evidence.format == TINFOIL_DOC["format"] + assert evidence.body == TINFOIL_DOC["body"] + assert evidence.metadata == {"repo": "OpenMined/x", "release_tag": "v0.0.1"} + + def test_reads_repo_and_tag_from_settings(self, tinfoil_mount): + class Settings: + tinfoil_repo = "OpenMined/syft-enclave-tinfoil" + tinfoil_release_tag = "v1.2.3" + + provider = TinfoilProvider.from_settings(Settings()) + assert provider.repo == "OpenMined/syft-enclave-tinfoil" + assert provider.release_tag == "v1.2.3" + + def test_settings_are_optional(self): + provider = TinfoilProvider.from_settings(None) + assert provider.repo is None and provider.release_tag is None + + def test_a_caller_nonce_is_refused(self, tinfoil_mount): + # Accepting and ignoring it would imply a freshness guarantee that + # Tinfoil cannot provide. + with pytest.raises(ValueError, match="cannot carry a caller nonce"): + TinfoilProvider().collect(caller_nonce="abc123") + + def test_missing_document_explains_why(self, tmp_path, monkeypatch): + monkeypatch.setattr( + "syft_enclaves.providers.tinfoil.TINFOIL_ATTESTATION_PATH", + tmp_path / "absent.json", + ) + with pytest.raises(RuntimeError, match="not running inside a Tinfoil enclave"): + TinfoilProvider().collect() + + @pytest.mark.parametrize("content", ["not json at all", '["a", "list"]']) + def test_malformed_document_raises(self, tmp_path, monkeypatch, content): + path = tmp_path / "attestation.json" + path.write_text(content) + monkeypatch.setattr( + "syft_enclaves.providers.tinfoil.TINFOIL_ATTESTATION_PATH", path + ) + with pytest.raises(RuntimeError, match="Malformed"): + TinfoilProvider().collect() + + def test_document_without_format_or_body_raises(self, tmp_path, monkeypatch): + path = tmp_path / "attestation.json" + path.write_text('{"something": "else"}') + monkeypatch.setattr( + "syft_enclaves.providers.tinfoil.TINFOIL_ATTESTATION_PATH", path + ) + with pytest.raises(ValueError, match="'format' and 'body'"): + TinfoilProvider().collect() + + def test_describe_includes_the_booted_config(self, tinfoil_mount): + provider = TinfoilProvider(repo="OpenMined/x") + described = provider.describe(provider.collect()) + assert described["config"] == "cpus: 2\n" + assert described["container_status"] == {"syft-enclave": "running"} + assert described["repo"] == "OpenMined/x" + + def test_describe_tolerates_a_partial_mount(self, tmp_path, monkeypatch): + path = tmp_path / "attestation.json" + path.write_text(json.dumps(TINFOIL_DOC)) + monkeypatch.setattr( + "syft_enclaves.providers.tinfoil.TINFOIL_ATTESTATION_PATH", path + ) + monkeypatch.setattr( + "syft_enclaves.providers.tinfoil.TINFOIL_CONFIG_PATH", tmp_path / "gone.yml" + ) + provider = TinfoilProvider() + assert provider.describe(provider.collect())["config"] is None + + +class TestConfidentialSpaceProvider: + def test_detect_follows_the_launcher_socket(self, tmp_path, monkeypatch): + socket_path = tmp_path / "teeserver.sock" + monkeypatch.setattr( + "syft_enclaves.providers.confidential_space.TEE_SOCKET_PATH", socket_path + ) + assert ConfidentialSpaceProvider.detect() is False + socket_path.write_text("") + assert ConfidentialSpaceProvider.detect() is True + + def test_collect_wraps_the_launcher_token(self): + with patch( + "syft_enclaves.providers.confidential_space.fetch_attestation_token", + return_value="header.payload.signature", + ) as fetch: + evidence = ConfidentialSpaceProvider().collect() + + assert evidence.kind is AttestationKind.CONFIDENTIAL_SPACE + assert evidence.body == "header.payload.signature" + assert evidence.metadata == {"audience": "syft-attestation"} + # The version nonce is still what the launcher is asked for. + assert fetch.call_args.kwargs["eat_nonce"][0].startswith("syft-") + + def test_collect_passes_a_caller_nonce_through(self): + with patch( + "syft_enclaves.providers.confidential_space.fetch_attestation_token", + return_value="a.b.c", + ) as fetch: + ConfidentialSpaceProvider().collect(caller_nonce="freshness") + assert fetch.call_args.kwargs["eat_nonce"][1] == "freshness" + + def test_decode_jwt_payload_rejects_a_non_jwt(self): + with pytest.raises(ValueError, match="expected 3 parts"): + decode_jwt_payload("not-a-jwt") + + def test_structure_claims_sections(self): + structured = structure_claims( + { + "secboot": True, + "dbgstat": "disabled-since-boot", + "eat_nonce": ["syft-0.1.0"], + "submods": { + "container": {"image_digest": "sha256:abc"}, + "confidential_space": {"support_attributes": ["X"]}, + }, + "nvidia_gpu": {"mode": "on"}, + } + ) + assert structured["hardware"]["secboot"] is True + assert structured["container"]["image_digest"] == "sha256:abc" + assert structured["gpu"] == {"mode": "on"} + assert structured["confidential_space"] + assert structured["eat_nonce"] == ["syft-0.1.0"] + + def test_structure_claims_omits_absent_optional_sections(self): + structured = structure_claims({"secboot": True}) + for absent in ("gpu", "confidential_space", "eat_nonce"): + assert absent not in structured + + +class TestAttestationServerWiring: + """The /attestation endpoint must configure providers like the runner does. + + Regression: the endpoint called select_provider without settings, so a + Tinfoil enclave published evidence with an empty metadata block while the + runner published the repo and release tag. + """ + + def _app(self, monkeypatch): + monkeypatch.syspath_prepend( + str(Path(__file__).resolve().parents[1] / "docker") + ) + import attestation_server + + return attestation_server + + def test_endpoint_passes_settings_through_to_the_provider( + self, tinfoil_mount, monkeypatch + ): + monkeypatch.setenv("SYFT_ENCLAVE_ATTESTATION_PROVIDER", "tinfoil") + monkeypatch.setenv("SYFT_ENCLAVE_TINFOIL_REPO", "OpenMined/example") + monkeypatch.setenv("SYFT_ENCLAVE_TINFOIL_RELEASE_TAG", "v9.9.9") + + server = self._app(monkeypatch) + provider = server._detect_provider() + + assert provider.repo == "OpenMined/example" + assert provider.release_tag == "v9.9.9" + assert provider.collect().metadata == { + "repo": "OpenMined/example", + "release_tag": "v9.9.9", + } + + def test_endpoint_reports_no_provider_outside_a_tee(self, monkeypatch): + monkeypatch.setenv("SYFT_ENCLAVE_ATTESTATION_PROVIDER", "none") + server = self._app(monkeypatch) + assert server._detect_provider() is None diff --git a/packages/syft-enclave/tests/test_runner.py b/packages/syft-enclave/tests/test_runner.py index c9ce52edca8..f9cef397f25 100644 --- a/packages/syft-enclave/tests/test_runner.py +++ b/packages/syft-enclave/tests/test_runner.py @@ -1,9 +1,22 @@ -"""Tests for EnclaveRunner — focused on fresh_state init behavior.""" +"""Tests for EnclaveRunner — fresh_state init behavior and the attest phase.""" +from pathlib import Path from unittest.mock import MagicMock +import pytest + +from syft_enclaves.attestation_envelope import ( + AttestationEvidence, + AttestationKind, + tinfoil_evidence, +) from syft_enclaves.runner import EnclaveRunner +TINFOIL_DOC = { + "format": "https://tinfoil.sh/predicate/sev-snp-guest/v2", + "body": "H4sIAAAAAAAA/2JmgAEEixBg", +} + def _make_client(): """Build a stub SyftEnclaveClient just deep enough for the init phases.""" @@ -15,57 +28,154 @@ def _make_client(): return client -def test_fresh_state_true_invokes_delete_syftbox(tmp_path, monkeypatch): +def test_fresh_state_true_invokes_delete_syftbox(): """With fresh_state=True (default), _on_initializing must wipe state once.""" - # Make _on_attesting a no-op (no TEE socket present in unit tests). - monkeypatch.setattr( - "syft_enclaves.runner.TEE_SOCKET_PATH", tmp_path / "nonexistent" - ) - client = _make_client() - runner = EnclaveRunner(client=client, fresh_state=True) + runner = EnclaveRunner(client=client, fresh_state=True, attestation_provider="none") runner.init() client.delete_syftbox.assert_called_once_with() -def test_fresh_state_false_skips_delete_syftbox(tmp_path, monkeypatch): +def test_fresh_state_false_skips_delete_syftbox(): """Opting out (fresh_state=False) preserves state across init.""" - monkeypatch.setattr( - "syft_enclaves.runner.TEE_SOCKET_PATH", tmp_path / "nonexistent" - ) - client = _make_client() - runner = EnclaveRunner(client=client, fresh_state=False) + runner = EnclaveRunner( + client=client, fresh_state=False, attestation_provider="none" + ) runner.init() client.delete_syftbox.assert_not_called() -def test_fresh_state_default_is_true(tmp_path, monkeypatch): +def test_fresh_state_default_is_true(): """Constructor default unified with settings default — fresh state on by default.""" - monkeypatch.setattr( - "syft_enclaves.runner.TEE_SOCKET_PATH", tmp_path / "nonexistent" - ) - client = _make_client() - runner = EnclaveRunner(client=client) # no fresh_state arg + runner = EnclaveRunner(client=client, attestation_provider="none") assert runner.fresh_state is True runner.init() client.delete_syftbox.assert_called_once_with() -def test_fresh_state_uses_default_kwargs_on_delete(tmp_path, monkeypatch): +def test_fresh_state_uses_default_kwargs_on_delete(): """We rely on delete_syftbox's own defaults — no kwargs passed.""" - monkeypatch.setattr( - "syft_enclaves.runner.TEE_SOCKET_PATH", tmp_path / "nonexistent" - ) - client = _make_client() - EnclaveRunner(client=client, fresh_state=True).init() + EnclaveRunner(client=client, fresh_state=True, attestation_provider="none").init() # Must be called with no positional or keyword args — let the method's # own defaults handle broadcast_delete_events and verbose. call = client.delete_syftbox.call_args assert call.args == () assert call.kwargs == {} + + +class TestAttestPhase: + def test_no_tee_and_require_tee_names_every_probed_path(self): + runner = EnclaveRunner( + client=_make_client(), require_tee=True, attestation_provider="none" + ) + # "none" is not a TEE, so require_tee must refuse to start and say + # where it looked — an operator has two targets to check. + with pytest.raises(RuntimeError, match="No TEE detected") as excinfo: + runner.init() + message = str(excinfo.value) + assert "/run/container_launcher/teeserver.sock" in message + assert "/tinfoil/attestation.json" in message + + def test_no_tee_without_require_tee_publishes_nothing(self): + client = _make_client() + EnclaveRunner(client=client, attestation_provider="none").init() + client._rds.peer_manager.write_own_version.assert_not_called() + + def test_publishes_evidence_to_the_version_file(self, monkeypatch): + evidence = tinfoil_evidence(TINFOIL_DOC, repo="OpenMined/x") + provider = MagicMock() + provider.kind = AttestationKind.TINFOIL + provider.collect.return_value = evidence + monkeypatch.setattr( + "syft_enclaves.runner.select_provider", lambda name, settings: provider + ) + + client = _make_client() + version = MagicMock(extra={}) + client._rds.peer_manager.get_own_version.return_value = version + + EnclaveRunner(client=client, require_tee=True).init() + + assert AttestationEvidence.read_from(version) == evidence + client._rds.peer_manager.write_own_version.assert_called_once_with() + + def test_the_key_bundle_is_published_for_the_attestation_endpoint( + self, monkeypatch + ): + """Regression: the publish call was defined but never wired in. + + Without it the /attestation endpoint serves no key bundle, so a peer + gets a pinned channel with nothing bound to it. + """ + provider = MagicMock() + provider.kind = AttestationKind.TINFOIL + provider.collect.return_value = tinfoil_evidence(TINFOIL_DOC) + monkeypatch.setattr( + "syft_enclaves.runner.select_provider", lambda name, settings: provider + ) + written = {} + monkeypatch.setattr( + "syft_enclaves.runner.write_public_bundle", + lambda bundle, keys_path: written.update( + bundle=bundle, keys_path=keys_path + ), + ) + + client = _make_client() + client._rds.peer_manager.syftbox_folder = Path("/tmp/SyftBox_enclave") + store = client._rds.peer_manager.peer_store + store.email = "enclave@openmined.org" + store.use_encryption = True + store.has_my_keys.return_value = True + store.get_public_bundle.return_value = {"identity": "enclave@openmined.org"} + + EnclaveRunner(client=client, require_tee=True).init() + + assert written["bundle"] == {"identity": "enclave@openmined.org"} + # The endpoint needs the private key location to answer a nonce. + assert written["keys_path"].name == "crypto_keys.json" + # And the keys must be back on disk: fresh_state wiped the folder + # moments earlier, so the file the endpoint signs with is gone. + store.save_keys.assert_called_once_with(written["keys_path"]) + + def test_no_key_bundle_is_published_without_encryption(self, monkeypatch): + provider = MagicMock() + provider.kind = AttestationKind.TINFOIL + provider.collect.return_value = tinfoil_evidence(TINFOIL_DOC) + monkeypatch.setattr( + "syft_enclaves.runner.select_provider", lambda name, settings: provider + ) + calls = [] + monkeypatch.setattr( + "syft_enclaves.runner.write_public_bundle", + lambda bundle, keys_path: calls.append(bundle), + ) + + client = _make_client() + client._rds.peer_manager.syftbox_folder = Path("/tmp/SyftBox_enclave") + client._rds.peer_manager.peer_store.use_encryption = False + + EnclaveRunner(client=client, require_tee=True).init() + + assert calls == [] + + def test_provider_gets_the_settings_object(self, monkeypatch): + seen = {} + + def fake_select(name, settings): + seen["name"], seen["settings"] = name, settings + return None + + monkeypatch.setattr("syft_enclaves.runner.select_provider", fake_select) + settings = object() + EnclaveRunner( + client=_make_client(), attestation_provider="tinfoil", settings=settings + ).init() + + assert seen == {"name": "tinfoil", "settings": settings} diff --git a/packages/syft-enclave/tinfoil/.github/workflows/tinfoil-release-publish.yml b/packages/syft-enclave/tinfoil/.github/workflows/tinfoil-release-publish.yml new file mode 100644 index 00000000000..86e7b98d204 --- /dev/null +++ b/packages/syft-enclave/tinfoil/.github/workflows/tinfoil-release-publish.yml @@ -0,0 +1,35 @@ +# DO NOT RUN MANUALLY. This workflow is dispatched automatically by +# `tinfoil-release.yml` against the newly created release tag. +# +# Computes the expected CVM launch measurement for tinfoil-config.yml, creates +# the GitHub release carrying it, and signs it keylessly to Sigstore via GitHub +# OIDC. Nothing is built or pulled here: the measurement covers the CVM launch +# state and the config, and the container image appears only as a digest. +# +# Kept in step with tinfoilsh/tinfoil-containers-template — the supported path. +# This copy lives in PySyft for review; the live one is in the config repo. +name: Tinfoil Release - Publish (auto-triggered) + +run-name: Publish release ${{ github.ref_name }} + +on: + workflow_dispatch: + +concurrency: + group: tinfoil-container-release-${{ github.ref_name }} + +jobs: + measure-and-release: + runs-on: ubuntu-latest + permissions: + contents: write # create the GitHub release + packages: write # attach the attestation to the image + id-token: write # keyless attestation signing + attestations: write # publish the attestation + + steps: + - name: Measure image, attest, and publish the release + uses: tinfoilsh/measure-image-action@c33809b2a4a9ced570ab869765aecd44b4d3cbd5 # v0.11.0 + with: + config-file: ${{ github.workspace }}/tinfoil-config.yml + github-token: ${{ secrets.GITHUB_TOKEN }} diff --git a/packages/syft-enclave/tinfoil/.github/workflows/tinfoil-release.yml b/packages/syft-enclave/tinfoil/.github/workflows/tinfoil-release.yml new file mode 100644 index 00000000000..da5fcb37e29 --- /dev/null +++ b/packages/syft-enclave/tinfoil/.github/workflows/tinfoil-release.yml @@ -0,0 +1,57 @@ +# Creates and pushes the version tag, then hands off to the publish workflow. +# This is what `tinfoil repo build run --version vX.Y.Z` dispatches +# through the Tinfoil GitHub App; `just tinfoil-publish` wraps that. +# +# Kept in step with tinfoilsh/tinfoil-containers-template — the supported path. +# This copy lives in PySyft for review; the live one is in the config repo. +name: Tinfoil Release + +run-name: Release ${{ inputs.version }} + +on: + workflow_dispatch: + inputs: + version: + description: 'Version to release (e.g. v1.0.0)' + required: true + type: string + +concurrency: + group: tinfoil-container-release-${{ inputs.version }} + +jobs: + prepare-release: + runs-on: ubuntu-latest + permissions: + contents: write # push the release tag + actions: write # dispatch tinfoil-release-publish.yml on the new tag + + steps: + - name: Checkout repository + uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 + with: + persist-credentials: true # needed to push the release tag below + + - name: Check if version tag already exists + env: + VERSION: ${{ inputs.version }} + run: | + if git ls-remote --tags origin "refs/tags/$VERSION" | grep -q .; then + echo "::error::Tag $VERSION already exists" + exit 1 + fi + + - name: Create and push the release tag + env: + VERSION: ${{ inputs.version }} + run: | + git tag "$VERSION" "$GITHUB_SHA" + git push origin "refs/tags/$VERSION" + + - name: Dispatch the publish workflow on the new tag + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + GH_REPO: ${{ github.repository }} + VERSION: ${{ inputs.version }} + run: | + gh workflow run tinfoil-release-publish.yml --ref "$VERSION" diff --git a/packages/syft-enclave/tinfoil/tinfoil-config.yml b/packages/syft-enclave/tinfoil/tinfoil-config.yml new file mode 100644 index 00000000000..66c858f9a1c --- /dev/null +++ b/packages/syft-enclave/tinfoil/tinfoil-config.yml @@ -0,0 +1,98 @@ +# Tinfoil workload config for the syft enclave. +# +# This is the canonical copy. `just tinfoil-config-pr` opens a pull request +# that syncs it to the root of the public config repo, whose signed GitHub +# releases publish the expected launch measurement. Keeping it here means the +# image and the config that pins it are reviewed together. +# +# Everything in this file is MEASURED: its sha256 lands in the CVM's kernel +# command line, so any change needs a new config release. Deployment-specific +# values are therefore passed at deploy time with `--variable` instead (see +# `just tinfoil-deploy`), which also means they are NOT verifiable — same +# position as `tee-env-*` metadata on Confidential Spaces. +# +# Field reference: https://docs.tinfoil.sh/containers/configuration +cvm-version: 0.14.7 +cpus: 2 +memory: 8192 +gpus: 0 + +containers: + - name: syft-enclave + # Must be pinned by digest: this is what the attestation commits to and + # what a data owner verifies. `just tinfoil-release` fills it in from the + # image it just pushed. + image: docker.io/openminedreleasebot/syft-enclave@sha256:fa733440e581ad635f8ff6260b348a9ce127347c34b335fa29bbdc3736117bd2 + restart: always + networks: [egress] + # Tinfoil runs containers with a read-only root filesystem, but syft needs + # a writable /tmp before it can even import: portalocker calls + # tempfile.gettempdir() at import time and raises without one. + # + # Writable here means the CVM's ramdisk, which SEV-SNP encrypts in + # hardware and which never touches a host disk. There is no persistent + # volume to encrypt — Tinfoil Containers have none, and state is lost on + # restart. That suits us: SYFT_ENCLAVE_FRESH_STATE wipes it at boot anyway. + read_only: false + env: + # Key AND value pinned here, so both are measured and verifiable. + - SYFT_ENCLAVE_ATTESTATION_PROVIDER: "tinfoil" + - SYFT_ENCLAVE_TINFOIL_REPO: "OpenMined/syft-enclave-tinfoil" + # Tinfoil has no Secret Manager equivalent; the Drive token arrives as a + # deploy-time secret and this provider writes it to disk (0600). + - SYFT_BOOTSTRAP: "tinfoil" + # Declared as bare names: the key is measured, the VALUE comes from + # `--variable` at deploy time and is therefore NOT measured — a data + # owner cannot verify it. Declaring is mandatory even so: the shim + # silently drops a --variable whose key the config does not declare. + - SYFT_ENCLAVE_EMAIL + - SYFT_ENCLAVE_DATA_OWNERS + - SYFT_ENCLAVE_REQUIRE_TEE + - SYFT_ENCLAVE_USE_ENCRYPTION + - SYFT_ENCLAVE_TINFOIL_RELEASE_TAG + # Where peers fetch the report over a connection pinned to the key the + # report commits to. Untrusted: a wrong host either fails the pin or is + # this enclave. + - SYFT_ENCLAVE_TINFOIL_HOST + secrets: + # Value supplied by `tinfoil container create --secret`; only the NAME is + # measured, never the value. + - SYFT_ENCLAVE_TOKEN_CONTENT + +shim: + # Required — the config is rejected without it. + upstream-port: 8080 + upstream-container: syft-enclave + # Only these paths are reachable; everything else 404s. The shim serves + # /.well-known/tinfoil-attestation regardless of this list. + paths: + - / + - /health + - /attestation + publish-attestation: true + dummy-attestation: false + +# Keyed by network name (the schema is a map, not a list). Do NOT call a +# network "default" — Docker predefines that name and the firewall setup fails +# at deploy time with "operation is not permitted on predefined default +# network". The canonical schema only reserves "shim-net", so this passes +# validation and fails on the host. +networks: + egress: + # `open` allows any public address; RFC1918 and other private ranges stay + # blocked. `closed` (the default) would leave the enclave unable to reach + # Google Drive at all. + # + # Why not `allowlist`? It was tried and does not work for Google. The + # firewall resolves allowlisted hostnames to IPs once, at boot, and Google + # rotates them: with both www.googleapis.com and oauth2.googleapis.com + # listed, www stayed reachable while oauth2's current address + # (172.217.78.95) timed out, so every OAuth token refresh hung. Wildcards + # and IP literals are rejected by the schema, so there is no way to express + # a stable rule for these endpoints. + # + # The enclave's confidentiality does not rest on this: that comes from the + # measurement and the hardware. Egress filtering was defence-in-depth + # against a misbehaving workload, and it is the one property we give up + # here. Revisit if Tinfoil starts re-resolving allowlist entries. + egress: open diff --git a/syft/sync/version/version_info.py b/syft/sync/version/version_info.py index 2092e64145f..1273ba2e670 100644 --- a/syft/sync/version/version_info.py +++ b/syft/sync/version/version_info.py @@ -8,7 +8,7 @@ import logging from datetime import datetime, timezone from enum import Enum -from typing import Optional +from typing import Any, Optional from pydantic import Field from syft_migration import MigratableObject, ProtocolSchema @@ -57,6 +57,9 @@ class VersionInfoV1(MigratableObject, registry=client_registry): min_supported_protocol_version: str syft_client_install_source: Optional[str] = None updated_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc)) + # Deprecated, never written: superseded by VersionInfoV2.extra. The field + # stays declared because protocol-0 froze this class's JSON schema, so + # removing it would be schema drift on a released protocol. attestation_token: Optional[str] = None def compatibility_status_with( @@ -202,6 +205,15 @@ class VersionInfoV2(VersionInfoV1): # protocol name -> slim ProtocolSchema (no embedded object JSON schemas). protocol_schemas: dict[str, ProtocolSchema] = Field(default_factory=dict) + # Arbitrary JSON that other syft packages need to put on the bootstrap + # channel. Carried, never interpreted: syft neither defines nor reads any + # key in here, so a package can add one without touching this class. + # + # Each package owns one top-level key and namespaces everything under it + # (``syft_enclaves`` owns "attestation", for instance). Unrelated to + # pydantic's own ``extra`` config, which governs unknown *fields*. + extra: dict[str, Any] = Field(default_factory=dict) + @classmethod def current(cls) -> "VersionInfo": info = super().current() @@ -221,7 +233,9 @@ def _version_info_v1_to_v2(obj: VersionInfoV1) -> VersionInfoV2: @client_registry.migration("VersionInfo", "2", "1") def _version_info_v2_to_v1(obj: VersionInfoV2) -> VersionInfoV1: return VersionInfoV1.model_validate( - obj.model_dump(exclude={"canonical_name", "version", "protocol_schemas"}) + obj.model_dump( + exclude={"canonical_name", "version", "protocol_schemas", "extra"} + ) ) diff --git a/tests/migrations/unit/test_version_info_fields.py b/tests/migrations/unit/test_version_info_fields.py index 404b6bd3c26..de914d1950f 100644 --- a/tests/migrations/unit/test_version_info_fields.py +++ b/tests/migrations/unit/test_version_info_fields.py @@ -29,7 +29,7 @@ "attestation_token", } -V2_ADDS = {"protocol_schemas"} +V2_ADDS = {"protocol_schemas", "extra"} def test_v1_fields_are_frozen(): @@ -72,3 +72,34 @@ def test_a_file_without_the_v2_fields_still_parses(): loaded = VersionInfoV2.model_validate(written_by_an_older_client) assert loaded.protocol_schemas == {} + assert loaded.extra == {} + + +def test_extra_is_opaque_to_syft(): + # syft carries whatever another package puts here and never interprets it, + # so any JSON round-trips under any key. + payload = { + "attestation": {"kind": "made-up", "body": "anything"}, + "some-other-package": {"nested": [1, 2]}, + } + info = VersionInfoV2( + syft_client_version="0.1.117", + min_supported_syft_client_version="0.1.93", + protocol_version="1.0.0", + min_supported_protocol_version="1.0.0", + extra=payload, + ) + reloaded = VersionInfoV2.model_validate_json(info.model_dump_json()) + assert reloaded.extra == payload + + +def test_syft_defines_no_feature_specific_payload_field(): + """The point of the generic bag. + + Another package adding a payload to the bootstrap channel — a new TEE + provider, say — must not need a field here. ``attestation_token`` is the + one exception: protocol-0 froze it into V1, so it cannot be removed, and + it is deprecated and never written. + """ + generic = set(V1_FIELDS - {"attestation_token"}) | V2_ADDS + assert set(VersionInfoV2.model_fields) - {"attestation_token"} == generic diff --git a/tests/migrations/unit/test_version_info_serialization.py b/tests/migrations/unit/test_version_info_serialization.py index 144f28b7a7e..be0b7dd7fc2 100644 --- a/tests/migrations/unit/test_version_info_serialization.py +++ b/tests/migrations/unit/test_version_info_serialization.py @@ -56,6 +56,7 @@ def test_legacy_reader_tolerates_identity_fields(): data.pop("canonical_name") data.pop("version") data.pop("protocol_schemas") + data.pop("extra") # Additive-only invariant: current output minus the added fields is # exactly the legacy shape a 0.1.117 reader expects. assert set(data) == set(json.loads(LEGACY_FILE.read_text())) From 212c17d0097a8f4f08939a63263e72e3e50970b1 Mon Sep 17 00:00:00 2001 From: Koen van der Veen Date: Tue, 15 Sep 2026 21:13:44 +0200 Subject: [PATCH 02/21] chore: drop the duplicated tinfoil release workflows They only ever seeded OpenMined/syft-enclave-tinfoil, which now holds the live copies. Nothing in PySyft runs or reads them, so a second copy could only drift. The measured config stays: just tinfoil-release pins the image digest into it and PRs it to the config repo. Co-Authored-By: Claude Opus 5 (1M context) --- packages/syft-enclave/docs/tinfoil.md | 2 +- .../workflows/tinfoil-release-publish.yml | 35 ------------ .../.github/workflows/tinfoil-release.yml | 57 ------------------- .../syft-enclave/tinfoil/tinfoil-config.yml | 13 +++-- 4 files changed, 10 insertions(+), 97 deletions(-) delete mode 100644 packages/syft-enclave/tinfoil/.github/workflows/tinfoil-release-publish.yml delete mode 100644 packages/syft-enclave/tinfoil/.github/workflows/tinfoil-release.yml diff --git a/packages/syft-enclave/docs/tinfoil.md b/packages/syft-enclave/docs/tinfoil.md index c8d8d991344..f8b7ff3bbb9 100644 --- a/packages/syft-enclave/docs/tinfoil.md +++ b/packages/syft-enclave/docs/tinfoil.md @@ -7,7 +7,7 @@ Two repositories are in play: - **this one** builds and pushes the enclave image to Docker Hub; - **[`OpenMined/syft-enclave-tinfoil`](https://github.com/OpenMined/syft-enclave-tinfoil)** holds the measured `tinfoil-config.yml`, and its GitHub releases publish the expected launch measurement. -The canonical copy of that config lives here at [`tinfoil/tinfoil-config.yml`](../tinfoil/tinfoil-config.yml), so the image and the config that pins it are reviewed together. `just tinfoil-release` syncs it over. +The canonical copy of that config lives here at [`tinfoil/tinfoil-config.yml`](../tinfoil/tinfoil-config.yml), so the image and the config that pins it are reviewed together; `just tinfoil-release` syncs it over. The release workflows live only in the config repo — nothing in PySyft runs them. Run all commands from `packages/syft-enclave/`. diff --git a/packages/syft-enclave/tinfoil/.github/workflows/tinfoil-release-publish.yml b/packages/syft-enclave/tinfoil/.github/workflows/tinfoil-release-publish.yml deleted file mode 100644 index 86e7b98d204..00000000000 --- a/packages/syft-enclave/tinfoil/.github/workflows/tinfoil-release-publish.yml +++ /dev/null @@ -1,35 +0,0 @@ -# DO NOT RUN MANUALLY. This workflow is dispatched automatically by -# `tinfoil-release.yml` against the newly created release tag. -# -# Computes the expected CVM launch measurement for tinfoil-config.yml, creates -# the GitHub release carrying it, and signs it keylessly to Sigstore via GitHub -# OIDC. Nothing is built or pulled here: the measurement covers the CVM launch -# state and the config, and the container image appears only as a digest. -# -# Kept in step with tinfoilsh/tinfoil-containers-template — the supported path. -# This copy lives in PySyft for review; the live one is in the config repo. -name: Tinfoil Release - Publish (auto-triggered) - -run-name: Publish release ${{ github.ref_name }} - -on: - workflow_dispatch: - -concurrency: - group: tinfoil-container-release-${{ github.ref_name }} - -jobs: - measure-and-release: - runs-on: ubuntu-latest - permissions: - contents: write # create the GitHub release - packages: write # attach the attestation to the image - id-token: write # keyless attestation signing - attestations: write # publish the attestation - - steps: - - name: Measure image, attest, and publish the release - uses: tinfoilsh/measure-image-action@c33809b2a4a9ced570ab869765aecd44b4d3cbd5 # v0.11.0 - with: - config-file: ${{ github.workspace }}/tinfoil-config.yml - github-token: ${{ secrets.GITHUB_TOKEN }} diff --git a/packages/syft-enclave/tinfoil/.github/workflows/tinfoil-release.yml b/packages/syft-enclave/tinfoil/.github/workflows/tinfoil-release.yml deleted file mode 100644 index da5fcb37e29..00000000000 --- a/packages/syft-enclave/tinfoil/.github/workflows/tinfoil-release.yml +++ /dev/null @@ -1,57 +0,0 @@ -# Creates and pushes the version tag, then hands off to the publish workflow. -# This is what `tinfoil repo build run --version vX.Y.Z` dispatches -# through the Tinfoil GitHub App; `just tinfoil-publish` wraps that. -# -# Kept in step with tinfoilsh/tinfoil-containers-template — the supported path. -# This copy lives in PySyft for review; the live one is in the config repo. -name: Tinfoil Release - -run-name: Release ${{ inputs.version }} - -on: - workflow_dispatch: - inputs: - version: - description: 'Version to release (e.g. v1.0.0)' - required: true - type: string - -concurrency: - group: tinfoil-container-release-${{ inputs.version }} - -jobs: - prepare-release: - runs-on: ubuntu-latest - permissions: - contents: write # push the release tag - actions: write # dispatch tinfoil-release-publish.yml on the new tag - - steps: - - name: Checkout repository - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 - with: - persist-credentials: true # needed to push the release tag below - - - name: Check if version tag already exists - env: - VERSION: ${{ inputs.version }} - run: | - if git ls-remote --tags origin "refs/tags/$VERSION" | grep -q .; then - echo "::error::Tag $VERSION already exists" - exit 1 - fi - - - name: Create and push the release tag - env: - VERSION: ${{ inputs.version }} - run: | - git tag "$VERSION" "$GITHUB_SHA" - git push origin "refs/tags/$VERSION" - - - name: Dispatch the publish workflow on the new tag - env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - GH_REPO: ${{ github.repository }} - VERSION: ${{ inputs.version }} - run: | - gh workflow run tinfoil-release-publish.yml --ref "$VERSION" diff --git a/packages/syft-enclave/tinfoil/tinfoil-config.yml b/packages/syft-enclave/tinfoil/tinfoil-config.yml index 66c858f9a1c..0b9b0a99bb4 100644 --- a/packages/syft-enclave/tinfoil/tinfoil-config.yml +++ b/packages/syft-enclave/tinfoil/tinfoil-config.yml @@ -1,9 +1,14 @@ # Tinfoil workload config for the syft enclave. # -# This is the canonical copy. `just tinfoil-config-pr` opens a pull request -# that syncs it to the root of the public config repo, whose signed GitHub -# releases publish the expected launch measurement. Keeping it here means the -# image and the config that pins it are reviewed together. +# This is the canonical copy: `just tinfoil-release` pins the digest of the +# image it just pushed into this file, and `just tinfoil-config-pr` opens a +# pull request syncing it to the root of the public config repo, whose signed +# GitHub releases publish the expected launch measurement. Keeping it here +# means the image and the config that pins it are reviewed together. +# +# The release workflows themselves live only in that repo +# (OpenMined/syft-enclave-tinfoil), not here — nothing in PySyft runs or reads +# them, and a second copy would just drift. # # Everything in this file is MEASURED: its sha256 lands in the CVM's kernel # command line, so any change needs a new config release. Deployment-specific From be864016daab0e6df39ffc100d0e4de5fb7e5fe4 Mon Sep 17 00:00:00 2001 From: Koen van der Veen Date: Tue, 15 Sep 2026 21:32:43 +0200 Subject: [PATCH 03/21] refactor: group attestation and evidence into packages The attestation_ prefix on six modules was a filename doing a folder's job, and the flat package had grown from 14 modules to 23. Grouped along the seam this change is about: evidence/ is the producing side that runs inside the enclave (the provider registry, both providers, the key bundle the endpoint serves), attestation/ is the appraising side that runs in the client, plus the envelope both share. providers/ folds into evidence/ so there is one grouping rather than two. attestation/result.py holds the checklist types both verifiers report through, which is what lets the package re-export without importing itself. attestation/__init__.py re-exports the previous surface, so 'from syft_enclaves.attestation import AppraisalPolicy' and friends keep working for the existing tests and notebooks. Co-Authored-By: Claude Opus 5 (1M context) --- .../syft-enclave/docker/attestation_server.py | 2 +- packages/syft-enclave/docs/tinfoil.md | 62 +++++++++---------- .../syft-enclave/scripts/tinfoil_e2e_check.py | 14 +++-- .../syft-enclave/scripts/verify_tinfoil.py | 8 ++- .../src/syft_enclaves/attestation/__init__.py | 61 ++++++++++++++++++ .../confidential_space.py} | 53 ++-------------- .../dispatch.py} | 12 ++-- .../envelope.py} | 0 .../https.py} | 8 ++- .../nonce.py} | 0 .../src/syft_enclaves/attestation/result.py | 58 +++++++++++++++++ .../tinfoil.py} | 19 +++--- .../src/syft_enclaves/bootstrap.py | 2 +- .../syft-enclave/src/syft_enclaves/client.py | 8 +-- .../{evidence.py => evidence/__init__.py} | 8 +-- .../confidential_space.py | 2 +- .../{ => evidence}/key_bundle.py | 6 +- .../{providers => evidence}/tinfoil.py | 2 +- .../src/syft_enclaves/providers/__init__.py | 5 -- .../syft-enclave/src/syft_enclaves/runner.py | 2 +- ...=> test_attestation_confidential_space.py} | 6 +- .../tests/test_attestation_dispatch.py | 18 +++--- .../tests/test_attestation_envelope.py | 2 +- .../tests/test_attestation_https.py | 25 +++++--- .../tests/test_attestation_tinfoil.py | 24 +++---- ...evidence_providers.py => test_evidence.py} | 30 ++++----- packages/syft-enclave/tests/test_runner.py | 2 +- .../syft-enclave/tinfoil/tinfoil-config.yml | 6 +- 28 files changed, 270 insertions(+), 175 deletions(-) create mode 100644 packages/syft-enclave/src/syft_enclaves/attestation/__init__.py rename packages/syft-enclave/src/syft_enclaves/{attestation.py => attestation/confidential_space.py} (84%) rename packages/syft-enclave/src/syft_enclaves/{attestation_dispatch.py => attestation/dispatch.py} (87%) rename packages/syft-enclave/src/syft_enclaves/{attestation_envelope.py => attestation/envelope.py} (100%) rename packages/syft-enclave/src/syft_enclaves/{attestation_https.py => attestation/https.py} (96%) rename packages/syft-enclave/src/syft_enclaves/{nonce_challenge.py => attestation/nonce.py} (100%) create mode 100644 packages/syft-enclave/src/syft_enclaves/attestation/result.py rename packages/syft-enclave/src/syft_enclaves/{attestation_tinfoil.py => attestation/tinfoil.py} (97%) rename packages/syft-enclave/src/syft_enclaves/{evidence.py => evidence/__init__.py} (93%) rename packages/syft-enclave/src/syft_enclaves/{providers => evidence}/confidential_space.py (98%) rename packages/syft-enclave/src/syft_enclaves/{ => evidence}/key_bundle.py (95%) rename packages/syft-enclave/src/syft_enclaves/{providers => evidence}/tinfoil.py (98%) delete mode 100644 packages/syft-enclave/src/syft_enclaves/providers/__init__.py rename packages/syft-enclave/tests/{test_attestation.py => test_attestation_confidential_space.py} (98%) rename packages/syft-enclave/tests/{test_evidence_providers.py => test_evidence.py} (89%) diff --git a/packages/syft-enclave/docker/attestation_server.py b/packages/syft-enclave/docker/attestation_server.py index 6b72ebabc12..32fc845dc50 100644 --- a/packages/syft-enclave/docker/attestation_server.py +++ b/packages/syft-enclave/docker/attestation_server.py @@ -20,7 +20,7 @@ from fastapi import FastAPI from fastapi.responses import JSONResponse from syft_enclaves.evidence import probed_locations, select_provider -from syft_enclaves.key_bundle import read_public_bundle, sign_nonce +from syft_enclaves.evidence.key_bundle import read_public_bundle, sign_nonce from syft_enclaves.settings import AttestationSettings from syft_enclaves.tee_token import validate_nonce diff --git a/packages/syft-enclave/docs/tinfoil.md b/packages/syft-enclave/docs/tinfoil.md index f8b7ff3bbb9..26fd91340ba 100644 --- a/packages/syft-enclave/docs/tinfoil.md +++ b/packages/syft-enclave/docs/tinfoil.md @@ -17,18 +17,18 @@ Run all commands from `packages/syft-enclave/`. **Does not prove.** Which email or data owners the enclave was started with. Those are deploy-time `--variable`s, so they are outside the measurement and the attested code merely relays whatever its deployer handed it. Confidential Spaces is in the same position today (`tee-env-*` metadata is not checked either). -**Key binding, and how it is achieved.** A workload cannot inject a nonce into the report: its 64 bytes of user data are the sha256 of the shim's TLS public key followed by its HPKE public key. But that is exactly what makes binding possible — the report *commits to the key terminating a TLS connection to the enclave*. So the client: +**Key binding, and how it is achieved.** A workload cannot inject a nonce into the report: its 64 bytes of user data are the sha256 of the shim's TLS public key followed by its HPKE public key. But that is exactly what makes binding possible — the report _commits to the key terminating a TLS connection to the enclave_. So the client: 1. verifies the report, 2. opens HTTPS to the enclave and checks the certificate it is served carries that same key, 3. checks the enclave signed the client's nonce with the key bundle it served, 4. and then trusts that bundle, which came down the same connection. -No certificate authority is involved anywhere: the enclave's certificate is self-signed, and the *report* is what decides whether to trust it. `attest_peer` then sets those keys for the peer, so the enclave's public keys are no longer an unsigned Drive file. This is the binding `docs/security.md` §5 describes. +No certificate authority is involved anywhere: the enclave's certificate is self-signed, and the _report_ is what decides whether to trust it. `attest_peer` then sets those keys for the peer, so the enclave's public keys are no longer an unsigned Drive file. This is the binding `docs/security.md` §5 describes. It also gets freshness for free: a replayed report commits to a TLS key whose private half lives in an enclave the attacker does not control, so the pin fails. -**Freshness comes from a nonce, not from the report.** A workload cannot influence the report's user data, so the client sends a random nonce and the enclave signs it with the identity key from the bundle it just served. That proves two things the report cannot: the enclave *holds the private half* of the key we are about to encrypt to, and the answer was produced for *this* exchange rather than replayed. The bundle is adopted only when both `key_binding` and `nonce_freshness` pass. +**Freshness comes from a nonce, not from the report.** A workload cannot influence the report's user data, so the client sends a random nonce and the enclave signs it with the identity key from the bundle it just served. That proves two things the report cannot: the enclave _holds the private half_ of the key we are about to encrypt to, and the answer was produced for _this_ exchange rather than replayed. The bundle is adopted only when both `key_binding` and `nonce_freshness` pass. **Drive is not a fallback.** Evidence is still published to `SYFT_version.json` — as provenance, and so the path exists if it is ever needed again — but the client always appraises a Tinfoil enclave from the live API. An unreachable enclave is an error, not a downgrade: accepting the Drive copy would silently mean unbound keys and a replayable report. @@ -72,7 +72,7 @@ The key is needed for the control-plane operations: `container create`, `deploym [`tinfoil/tinfoil-config.yml`](../tinfoil/tinfoil-config.yml) declares everything inside the enclave. Two things about it matter more than the rest. -**Everything in the file is measured.** Its sha256 goes into the CVM's kernel command line, so any edit changes the measurement and needs a new config release. That is why the enclave email and data owners are *not* in it — see the table below. +**Everything in the file is measured.** Its sha256 goes into the CVM's kernel command line, so any edit changes the measurement and needs a new config release. That is why the enclave email and data owners are _not_ in it — see the table below. **Egress defaults to `closed`.** Without the `allowlist` the enclave cannot reach Google Drive at all and will sit there doing nothing. Only exact hostnames work — wildcards and IP literals are both rejected by the schema. The two the code actually needs are `www.googleapis.com` (Drive v3) and `oauth2.googleapis.com` (refreshing the OAuth token). `accounts.google.com` is deliberately absent so an accidental interactive OAuth flow fails loudly. @@ -80,15 +80,15 @@ Note that `networks` is a **map keyed by network name**, not a list of objects schema is `map[string]*NetworkSpec`. A container may attach to several networks but at most one of them may have egress other than `closed`. -| Setting | Where it lives | Verifiable by a data owner? | -|---|---|---| -| Container image digest | measured config | yes | -| `SYFT_ENCLAVE_ATTESTATION_PROVIDER`, `SYFT_BOOTSTRAP` | measured config | yes | -| CPU / memory / GPU shape | measured config | yes | -| Egress allowlist, exposed paths | measured config | yes | -| `SYFT_ENCLAVE_EMAIL`, `SYFT_ENCLAVE_DATA_OWNERS` | `--variable` at deploy | **no** | -| `SYFT_ENCLAVE_REQUIRE_TEE`, `SYFT_ENCLAVE_USE_ENCRYPTION` | `--variable` at deploy | **no** | -| Drive OAuth token | `--secret` (name measured, value not) | n/a | +| Setting | Where it lives | Verifiable by a data owner? | +| --------------------------------------------------------- | ------------------------------------- | --------------------------- | +| Container image digest | measured config | yes | +| `SYFT_ENCLAVE_ATTESTATION_PROVIDER`, `SYFT_BOOTSTRAP` | measured config | yes | +| CPU / memory / GPU shape | measured config | yes | +| Egress allowlist, exposed paths | measured config | yes | +| `SYFT_ENCLAVE_EMAIL`, `SYFT_ENCLAVE_DATA_OWNERS` | `--variable` at deploy | **no** | +| `SYFT_ENCLAVE_REQUIRE_TEE`, `SYFT_ENCLAVE_USE_ENCRYPTION` | `--variable` at deploy | **no** | +| Drive OAuth token | `--secret` (name measured, value not) | n/a | To make any of the deploy-time values verifiable, move them into the config's `env` block — at the cost of one config release per combination. @@ -163,7 +163,7 @@ That is the check that matters: `just tinfoil-verify` fetches the report over HT `attest_peer` reads it from `SYFT_version.json` on Drive, which is the path the syft flow actually uses. -Prefer `--tag` over the default "latest release" where you can. Unpinned, the release digest is fetched over the network, and a hostile source could substitute the digest of another *legitimately signed* release of the same repo — a rollback. A pinned tag makes the signature policy require that exact tag. +Prefer `--tag` over the default "latest release" where you can. Unpinned, the release digest is fetched over the network, and a hostile source could substitute the digest of another _legitimately signed_ release of the same repo — a rollback. A pinned tag makes the signature policy require that exact tag. ## Teardown @@ -224,23 +224,23 @@ Other knobs: Every row below was hit for real while bringing the first enclave up, in this order. -| Symptom | Cause and fix | -|---|---| -| `Firewall setup failed: creating docker network "default": operation is not permitted on predefined default network` | a network named `default` collides with Docker's predefined one. The canonical schema only reserves `shim-net`, so this **validates fine and fails on the host**. Name it anything else. | -| `FileNotFoundError: No usable temporary directory found in ['/tmp', ...]` and a crash-loop | containers run with a read-only rootfs, and `portalocker` (a syft dependency) calls `tempfile.gettempdir()` at import. Set `read_only: false`. | -| `2 validation errors for EnclaveSettings: email / data_owners Field required` — despite passing `--variable` | the shim only injects variables whose **key the measured config declares**. An undeclared `--variable` is silently dropped. Declare it as a bare name under `env:`. | -| The enclave hangs at "Building SyftEnclaveClient" | OAuth token refresh cannot reach `oauth2.googleapis.com`. See the egress note in `tinfoil-config.yml`: `allowlist` resolves hostnames to IPs once at boot and Google rotates them, so use `egress: open`. | -| `curl: (60) SSL certificate problem: self signed certificate` | expected. The enclave's TLS key is generated inside it and the report commits to that key, so there is no CA. Use `-k` (as `just tinfoil-attest` does) and get your trust from `just tinfoil-verify`. | -| `The server had an error while processing your request.` from the domain | the shim is up but your container is not serving on `upstream-port`. It has probably crashed — `just tinfoil-debug ` then `just tinfoil-logs`. | -| `ValueError: Serialization error: sender fingerprint mismatch` on a data owner's login | with `encryption=True` the keypair is persisted at `//private/crypto_keys.json`. A fresh `SYFTBOX_FOLDER` mints a new identity that cannot verify that account's own history on Drive. Point at the account's existing folder, or wipe its Drive state first. | -| `upstream port is not set` | the config's `shim.upstream-port` is missing; it is required | -| Config rejected: image must be a digest | `image:` uses a tag; it must be `repo@sha256:...` | -| A path 404s | it is not in `shim.paths`. `/.well-known/tinfoil-attestation` is exempt | -| `Tag vX.Y.Z already exists` | releases are permanent; pick the next version | -| `image_digest` check fails with a 400 from `github-proxy.tinfoil.sh` | expected — the proxy only serves `tinfoil.hash`; the verifier falls through to github.com | -| `MissingOptionalDependency: ... tinfoil` | `uv pip install "syft-enclave[tinfoil]"` | -| `measurement_match` fails | the running enclave is not the release you are verifying against — check the deployed tag with `just tinfoil-status` | -| Peer skipped with "published no attestation evidence" | the enclave booted outside a TEE, or with `attestation_provider=none` | +| Symptom | Cause and fix | +| -------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| `Firewall setup failed: creating docker network "default": operation is not permitted on predefined default network` | a network named `default` collides with Docker's predefined one. The canonical schema only reserves `shim-net`, so this **validates fine and fails on the host**. Name it anything else. | +| `FileNotFoundError: No usable temporary directory found in ['/tmp', ...]` and a crash-loop | containers run with a read-only rootfs, and `portalocker` (a syft dependency) calls `tempfile.gettempdir()` at import. Set `read_only: false`. | +| `2 validation errors for EnclaveSettings: email / data_owners Field required` — despite passing `--variable` | the shim only injects variables whose **key the measured config declares**. An undeclared `--variable` is silently dropped. Declare it as a bare name under `env:`. | +| The enclave hangs at "Building SyftEnclaveClient" | OAuth token refresh cannot reach `oauth2.googleapis.com`. See the egress note in `tinfoil-config.yml`: `allowlist` resolves hostnames to IPs once at boot and Google rotates them, so use `egress: open`. | +| `curl: (60) SSL certificate problem: self signed certificate` | expected. The enclave's TLS key is generated inside it and the report commits to that key, so there is no CA. Use `-k` (as `just tinfoil-attest` does) and get your trust from `just tinfoil-verify`. | +| `The server had an error while processing your request.` from the domain | the shim is up but your container is not serving on `upstream-port`. It has probably crashed — `just tinfoil-debug ` then `just tinfoil-logs`. | +| `ValueError: Serialization error: sender fingerprint mismatch` on a data owner's login | with `encryption=True` the keypair is persisted at `//private/crypto_keys.json`. A fresh `SYFTBOX_FOLDER` mints a new identity that cannot verify that account's own history on Drive. Point at the account's existing folder, or wipe its Drive state first. | +| `upstream port is not set` | the config's `shim.upstream-port` is missing; it is required | +| Config rejected: image must be a digest | `image:` uses a tag; it must be `repo@sha256:...` | +| A path 404s | it is not in `shim.paths`. `/.well-known/tinfoil-attestation` is exempt | +| `Tag vX.Y.Z already exists` | releases are permanent; pick the next version | +| `image_digest` check fails with a 400 from `github-proxy.tinfoil.sh` | expected — the proxy only serves `tinfoil.hash`; the verifier falls through to github.com | +| `MissingOptionalDependency: ... tinfoil` | `uv pip install "syft-enclave[tinfoil]"` | +| `measurement_match` fails | the running enclave is not the release you are verifying against — check the deployed tag with `just tinfoil-status` | +| Peer skipped with "published no attestation evidence" | the enclave booted outside a TEE, or with `attestation_provider=none` | Redeploying? Delete the accounts' syftboxes **first**, then redeploy — the enclave caches peer Drive folders at boot, so wiping state afterwards breaks the flow. diff --git a/packages/syft-enclave/scripts/tinfoil_e2e_check.py b/packages/syft-enclave/scripts/tinfoil_e2e_check.py index 4ca64345fbc..f3eec83a0c9 100644 --- a/packages/syft-enclave/scripts/tinfoil_e2e_check.py +++ b/packages/syft-enclave/scripts/tinfoil_e2e_check.py @@ -42,7 +42,9 @@ def parse_args() -> argparse.Namespace: parser.add_argument("--do-email", required=True) parser.add_argument("--token", required=True, help="Drive token for the data owner") parser.add_argument("--repo", default=DEFAULT_REPO) - parser.add_argument("--tag", default=None, help="pin the release tag being verified") + parser.add_argument( + "--tag", default=None, help="pin the release tag being verified" + ) parser.add_argument("--expected-image-digest", default=None) parser.add_argument("--dataset-name", default="tinfoil-e2e-dataset") parser.add_argument("--peer-attempts", type=int, default=15) @@ -81,7 +83,7 @@ def main() -> int: os.environ.setdefault("PRE_SYNC", "false") from syft_enclaves import login_do - from syft_enclaves.attestation_tinfoil import TinfoilAppraisalPolicy + from syft_enclaves.attestation.tinfoil import TinfoilAppraisalPolicy print("=== 1. login as the data owner ===", flush=True) client = login_do(email=args.do_email, token_path=args.token, encryption=True) @@ -110,7 +112,9 @@ def main() -> int: store = client._rds.peer_manager.peer_store bound = result.verified_key_bundle is not None print(f" key bundle bound to the report: {bound}", flush=True) - print(f" peer keys now set: {store.has_peer_bundle(args.enclave_email)}", flush=True) + print( + f" peer keys now set: {store.has_peer_bundle(args.enclave_email)}", flush=True + ) if not bound: print("FAILED: no attestation-bound key bundle", file=sys.stderr) return 1 @@ -118,7 +122,9 @@ def main() -> int: print("=== 4. upload a private dataset and share it ===", flush=True) private, mock = write_dataset_files(Path("/tmp/tinfoil-e2e/data")) existing = [ - d for d in client.datasets.get_all() if getattr(d, "name", None) == args.dataset_name + d + for d in client.datasets.get_all() + if getattr(d, "name", None) == args.dataset_name ] if existing: print(" dataset already exists, reusing", flush=True) diff --git a/packages/syft-enclave/scripts/verify_tinfoil.py b/packages/syft-enclave/scripts/verify_tinfoil.py index be21ec0e46a..c124f35385f 100644 --- a/packages/syft-enclave/scripts/verify_tinfoil.py +++ b/packages/syft-enclave/scripts/verify_tinfoil.py @@ -14,8 +14,8 @@ import sys from syft_enclaves.attestation import AttestationError -from syft_enclaves.attestation_envelope import tinfoil_evidence -from syft_enclaves.attestation_tinfoil import ( +from syft_enclaves.attestation.envelope import tinfoil_evidence +from syft_enclaves.attestation.tinfoil import ( DEFAULT_TINFOIL_CONFIG_REPO, TinfoilAppraisalPolicy, verify_tinfoil_evidence, @@ -27,7 +27,9 @@ def parse_args() -> argparse.Namespace: parser = argparse.ArgumentParser(description=__doc__) - parser.add_argument("host", help="enclave hostname, e.g. x.y.containers.tinfoil.dev") + parser.add_argument( + "host", help="enclave hostname, e.g. x.y.containers.tinfoil.dev" + ) parser.add_argument("--repo", default=DEFAULT_TINFOIL_CONFIG_REPO) parser.add_argument( "--tag", diff --git a/packages/syft-enclave/src/syft_enclaves/attestation/__init__.py b/packages/syft-enclave/src/syft_enclaves/attestation/__init__.py new file mode 100644 index 00000000000..19df19c7c2b --- /dev/null +++ b/packages/syft-enclave/src/syft_enclaves/attestation/__init__.py @@ -0,0 +1,61 @@ +"""Appraising an enclave's attestation evidence. + +The client side of the seam. One verifier per deployment target, both reporting +through the same checklist: + +- :mod:`~syft_enclaves.attestation.confidential_space` — a Google-signed JWT. +- :mod:`~syft_enclaves.attestation.tinfoil` — a SEV-SNP/TDX report, appraised + against the measurement a config repo published, over a connection pinned to + the TLS key that report commits to. + +:mod:`~syft_enclaves.attestation.envelope` is the wire format both targets +share; :mod:`~syft_enclaves.attestation.dispatch` routes evidence to the +verifier for its kind. The producing side lives in +:mod:`syft_enclaves.evidence`. + +Re-exported here so ``from syft_enclaves.attestation import ...`` keeps working +for callers that predate this package. +""" + +from syft_enclaves.attestation.confidential_space import ( + ATTESTATION_AUDIENCE, + CONFIDENTIAL_COMPUTING_CERTS_URL, + JWT_EXPIRY_GRACE_SECONDS, + AppraisalPolicy, + verify_attestation_token, +) +from syft_enclaves.attestation.dispatch import policy_for, verify_evidence +from syft_enclaves.attestation.envelope import ( + AttestationEvidence, + AttestationKind, + confidential_space_evidence, + tinfoil_evidence, +) +from syft_enclaves.attestation.result import ( + AttestationError, + AttestationResult, + CheckResult, +) +from syft_enclaves.attestation.tinfoil import ( + TinfoilAppraisalPolicy, + verify_tinfoil_evidence, +) + +__all__ = [ + "ATTESTATION_AUDIENCE", + "CONFIDENTIAL_COMPUTING_CERTS_URL", + "JWT_EXPIRY_GRACE_SECONDS", + "AppraisalPolicy", + "AttestationError", + "AttestationEvidence", + "AttestationKind", + "AttestationResult", + "CheckResult", + "TinfoilAppraisalPolicy", + "confidential_space_evidence", + "policy_for", + "tinfoil_evidence", + "verify_attestation_token", + "verify_evidence", + "verify_tinfoil_evidence", +] diff --git a/packages/syft-enclave/src/syft_enclaves/attestation.py b/packages/syft-enclave/src/syft_enclaves/attestation/confidential_space.py similarity index 84% rename from packages/syft-enclave/src/syft_enclaves/attestation.py rename to packages/syft-enclave/src/syft_enclaves/attestation/confidential_space.py index d0636070e4b..0090753a671 100644 --- a/packages/syft-enclave/src/syft_enclaves/attestation.py +++ b/packages/syft-enclave/src/syft_enclaves/attestation/confidential_space.py @@ -8,7 +8,6 @@ from __future__ import annotations -from dataclasses import dataclass, field from typing import Optional from google.auth.transport import requests as google_requests @@ -17,6 +16,11 @@ from syft.version import SYFT_VERSION +from syft_enclaves.attestation.result import ( + AttestationError, + AttestationResult, +) + ATTESTATION_AUDIENCE = "syft-attestation" CONFIDENTIAL_COMPUTING_CERTS_URL = ( "https://www.googleapis.com/service_accounts/v1/metadata/jwk/" @@ -53,53 +57,6 @@ class AppraisalPolicy(BaseModel): expected_syft_version: Optional[str] = SYFT_VERSION -class AttestationError(Exception): - """Raised when enclave attestation verification fails.""" - - def __init__(self, message: str, result: AttestationResult | None = None): - self.result = result - super().__init__(message) - - -@dataclass -class CheckResult: - name: str - label: str - passed: bool | None = None # None = not yet run - detail: str = "" - - -@dataclass -class AttestationResult: - checks: list[CheckResult] = field(default_factory=list) - #: The peer's syft public key bundle, when it arrived over a channel bound - #: to the attestation report (see ``attestation_https``). None whenever - #: there was no such channel — a bundle read from Drive is not bound to - #: anything and must not be set here. - verified_key_bundle: Optional[dict] = None - - def add(self, name: str, label: str, passed: bool, detail: str) -> None: - self.checks.append( - CheckResult(name=name, label=label, passed=passed, detail=detail) - ) - - def all_passed(self) -> bool: - return all(c.passed for c in self.checks) - - def first_failure(self) -> CheckResult | None: - return next((c for c in self.checks if not c.passed), None) - - def print_checklist(self) -> None: - for check in self.checks: - if check.passed is None: - icon = " ⏭️" - elif check.passed: - icon = " ✅" - else: - icon = " ❌" - print(f"{icon} {check.label:<20s} — {check.detail}") - - def verify_attestation_token( token: str, policy: AppraisalPolicy | None = None, diff --git a/packages/syft-enclave/src/syft_enclaves/attestation_dispatch.py b/packages/syft-enclave/src/syft_enclaves/attestation/dispatch.py similarity index 87% rename from packages/syft-enclave/src/syft_enclaves/attestation_dispatch.py rename to packages/syft-enclave/src/syft_enclaves/attestation/dispatch.py index e470f67eeff..c800231e6ab 100644 --- a/packages/syft-enclave/src/syft_enclaves/attestation_dispatch.py +++ b/packages/syft-enclave/src/syft_enclaves/attestation/dispatch.py @@ -1,7 +1,7 @@ """Routing attestation evidence to the verifier for its deployment target. The evidence itself says which kind it is, and the envelope refuses a ``kind`` -that disagrees with its ``format`` (see ``attestation_envelope``), so a peer +that disagrees with its ``format`` (see ``attestation.envelope``), so a peer cannot pick a weaker verifier for its own evidence. """ @@ -9,12 +9,12 @@ from typing import Optional, Union -from syft_enclaves.attestation import ( +from syft_enclaves.attestation.confidential_space import ( AppraisalPolicy, - AttestationResult, verify_attestation_token, ) -from syft_enclaves.attestation_envelope import AttestationEvidence, AttestationKind +from syft_enclaves.attestation.result import AttestationResult +from syft_enclaves.attestation.envelope import AttestationEvidence, AttestationKind Policy = Union[AppraisalPolicy, "object"] @@ -39,7 +39,7 @@ def verify_evidence( if evidence.kind is AttestationKind.TINFOIL: # Imported here so installs without the tinfoil extra can still verify # Confidential Space evidence. - from syft_enclaves.attestation_tinfoil import ( + from syft_enclaves.attestation.tinfoil import ( TinfoilAppraisalPolicy, verify_tinfoil_evidence, ) @@ -55,7 +55,7 @@ def policy_for(kind: AttestationKind, **kwargs) -> Policy: if kind is AttestationKind.CONFIDENTIAL_SPACE: return AppraisalPolicy(**kwargs) if kind is AttestationKind.TINFOIL: - from syft_enclaves.attestation_tinfoil import TinfoilAppraisalPolicy + from syft_enclaves.attestation.tinfoil import TinfoilAppraisalPolicy return TinfoilAppraisalPolicy(**kwargs) raise ValueError(f"No appraisal policy for attestation kind {kind!r}") diff --git a/packages/syft-enclave/src/syft_enclaves/attestation_envelope.py b/packages/syft-enclave/src/syft_enclaves/attestation/envelope.py similarity index 100% rename from packages/syft-enclave/src/syft_enclaves/attestation_envelope.py rename to packages/syft-enclave/src/syft_enclaves/attestation/envelope.py diff --git a/packages/syft-enclave/src/syft_enclaves/attestation_https.py b/packages/syft-enclave/src/syft_enclaves/attestation/https.py similarity index 96% rename from packages/syft-enclave/src/syft_enclaves/attestation_https.py rename to packages/syft-enclave/src/syft_enclaves/attestation/https.py index 237d92fb5e7..72b8ff21a44 100644 --- a/packages/syft-enclave/src/syft_enclaves/attestation_https.py +++ b/packages/syft-enclave/src/syft_enclaves/attestation/https.py @@ -27,7 +27,7 @@ from dataclasses import dataclass from typing import Any, Optional -from syft_enclaves.nonce_challenge import new_nonce +from syft_enclaves.attestation.nonce import new_nonce ATTESTATION_PATH = "/attestation" WELL_KNOWN_PATH = "/.well-known/tinfoil-attestation" @@ -38,7 +38,7 @@ class AttestedPayload: """What the enclave served, and the key that terminated the connection. - Untrusted until :mod:`syft_enclaves.attestation_tinfoil` has checked + Untrusted until :mod:`syft_enclaves.attestation.tinfoil` has checked ``tls_public_key_fp`` against the verified report and the signature over ``nonce`` against ``key_bundle``. """ @@ -81,7 +81,9 @@ def fetch_attested_payload( document, key_bundle = _split_payload(payload, connection, timeout) fingerprint = _peer_public_key_fp(connection) except (OSError, ssl.SSLError, ValueError) as e: - raise AttestationFetchError(f"Could not fetch attestation from {host}: {e}") from e + raise AttestationFetchError( + f"Could not fetch attestation from {host}: {e}" + ) from e finally: connection.close() diff --git a/packages/syft-enclave/src/syft_enclaves/nonce_challenge.py b/packages/syft-enclave/src/syft_enclaves/attestation/nonce.py similarity index 100% rename from packages/syft-enclave/src/syft_enclaves/nonce_challenge.py rename to packages/syft-enclave/src/syft_enclaves/attestation/nonce.py diff --git a/packages/syft-enclave/src/syft_enclaves/attestation/result.py b/packages/syft-enclave/src/syft_enclaves/attestation/result.py new file mode 100644 index 00000000000..42e0eaf3878 --- /dev/null +++ b/packages/syft-enclave/src/syft_enclaves/attestation/result.py @@ -0,0 +1,58 @@ +"""The shape every attestation verifier reports through. + +One checklist type, so a Confidential Space token and a Tinfoil report read the +same way to whoever is appraising them. Lives apart from either verifier so +both can import it without the package importing itself. +""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from typing import Optional + + +class AttestationError(Exception): + """Raised when enclave attestation verification fails.""" + + def __init__(self, message: str, result: AttestationResult | None = None): + self.result = result + super().__init__(message) + + +@dataclass +class CheckResult: + name: str + label: str + passed: bool | None = None # None = not yet run + detail: str = "" + + +@dataclass +class AttestationResult: + checks: list[CheckResult] = field(default_factory=list) + #: The peer's syft public key bundle, when it arrived over a channel bound + #: to the attestation report (see ``attestation.https``). None whenever + #: there was no such channel — a bundle read from Drive is not bound to + #: anything and must not be set here. + verified_key_bundle: Optional[dict] = None + + def add(self, name: str, label: str, passed: bool, detail: str) -> None: + self.checks.append( + CheckResult(name=name, label=label, passed=passed, detail=detail) + ) + + def all_passed(self) -> bool: + return all(c.passed for c in self.checks) + + def first_failure(self) -> CheckResult | None: + return next((c for c in self.checks if not c.passed), None) + + def print_checklist(self) -> None: + for check in self.checks: + if check.passed is None: + icon = " ⏭️" + elif check.passed: + icon = " ✅" + else: + icon = " ❌" + print(f"{icon} {check.label:<20s} — {check.detail}") diff --git a/packages/syft-enclave/src/syft_enclaves/attestation_tinfoil.py b/packages/syft-enclave/src/syft_enclaves/attestation/tinfoil.py similarity index 97% rename from packages/syft-enclave/src/syft_enclaves/attestation_tinfoil.py rename to packages/syft-enclave/src/syft_enclaves/attestation/tinfoil.py index 3e77fceeb11..621a80df2d6 100644 --- a/packages/syft-enclave/src/syft_enclaves/attestation_tinfoil.py +++ b/packages/syft-enclave/src/syft_enclaves/attestation/tinfoil.py @@ -17,8 +17,8 @@ sha256 of the shim's TLS public key followed by its HPKE public key. That is also what makes key binding possible, because the report commits to the key terminating a TLS connection to the enclave — see -``syft_enclaves.attestation_https``. Freshness for the syft key comes from a nonce the -enclave signs with that bundle (``nonce_challenge``), which the report cannot +``syft_enclaves.attestation.https``. Freshness for the syft key comes from a nonce the +enclave signs with that bundle (``attestation.nonce``), which the report cannot carry. Evidence stays published to Drive as provenance, but it is never a fallback here: appraising it instead would mean unbound keys and a replayable report, so an unreachable enclave is an error. @@ -35,14 +35,14 @@ from syft.version import SYFT_VERSION -from syft_enclaves.attestation import AttestationError, AttestationResult -from syft_enclaves.attestation_envelope import AttestationEvidence -from syft_enclaves.attestation_https import ( +from syft_enclaves.attestation.result import AttestationError, AttestationResult +from syft_enclaves.attestation.envelope import AttestationEvidence +from syft_enclaves.attestation.https import ( AttestationFetchError, AttestedPayload, fetch_attested_payload, ) -from syft_enclaves.nonce_challenge import NonceVerificationError, verify_challenge +from syft_enclaves.attestation.nonce import NonceVerificationError, verify_challenge from syft_enclaves.optional_deps import MissingOptionalDependency, require #: The config repo whose signed releases publish the expected measurement for @@ -85,7 +85,6 @@ class TinfoilAppraisalPolicy(BaseModel): host: Optional[str] = None - def verify_tinfoil_evidence( evidence: AttestationEvidence, policy: Optional[TinfoilAppraisalPolicy] = None, @@ -94,7 +93,7 @@ def verify_tinfoil_evidence( """Verify Tinfoil evidence and return the check checklist. Prefers a pinned HTTPS fetch from the enclave: that yields a fresh report - and binds the enclave's syft key bundle to it (see ``attestation_https``). + and binds the enclave's syft key bundle to it (see ``attestation.https``). Falls back to the evidence the enclave published to Drive, which still proves what code is running but gives no key binding and no freshness. @@ -103,7 +102,9 @@ def verify_tinfoil_evidence( no measurements to compare, so it fails fast. """ policy = policy or TinfoilAppraisalPolicy() - return _TinfoilVerifier(evidence, policy, verbose, _fetch_pinned(evidence, policy)).run() + return _TinfoilVerifier( + evidence, policy, verbose, _fetch_pinned(evidence, policy) + ).run() def _fetch_pinned( diff --git a/packages/syft-enclave/src/syft_enclaves/bootstrap.py b/packages/syft-enclave/src/syft_enclaves/bootstrap.py index 9afcc1dd6cb..2fc4838bdb0 100644 --- a/packages/syft-enclave/src/syft_enclaves/bootstrap.py +++ b/packages/syft-enclave/src/syft_enclaves/bootstrap.py @@ -31,7 +31,7 @@ import requests from syft_enclaves._unix_socket import UnixSocketConnection -from syft_enclaves.providers.tinfoil import TINFOIL_ATTESTATION_PATH +from syft_enclaves.evidence.tinfoil import TINFOIL_ATTESTATION_PATH logger = logging.getLogger(__name__) diff --git a/packages/syft-enclave/src/syft_enclaves/client.py b/packages/syft-enclave/src/syft_enclaves/client.py index c6a0b037b84..a98dad92899 100644 --- a/packages/syft-enclave/src/syft_enclaves/client.py +++ b/packages/syft-enclave/src/syft_enclaves/client.py @@ -17,8 +17,8 @@ PartyApprovalStatus, enclave_approval_file_name, ) -from syft_enclaves.attestation_dispatch import policy_for, verify_evidence -from syft_enclaves.attestation_envelope import AttestationEvidence +from syft_enclaves.attestation.dispatch import policy_for, verify_evidence +from syft_enclaves.attestation.envelope import AttestationEvidence from syft_perms.syftperm_context import SyftPermContext from syft_enclaves.enclave_job_client import EnclaveJobClient @@ -38,7 +38,7 @@ # Only for the attest_peer annotation: importing the tinfoil policy at # runtime would drag the optional SDK onto the always-imported path. from syft_enclaves.attestation import AppraisalPolicy - from syft_enclaves.attestation_tinfoil import TinfoilAppraisalPolicy + from syft_enclaves.attestation.tinfoil import TinfoilAppraisalPolicy class SyftEnclaveClient: @@ -130,7 +130,7 @@ def _adopt_verified_key_bundle(self, peer_email: str, result) -> None: """Trust the peer's keys when attestation bound them to its report. Only a bundle delivered over a channel pinned to the attested TLS key - gets here — see ``attestation_https``. The copy the peer publishes to + gets here — see ``attestation.https``. The copy the peer publishes to Drive is unsigned, so a mismatch means the Drive copy was tampered with; the bound one wins and we say so rather than failing, since the bound one is exactly what we should be using. diff --git a/packages/syft-enclave/src/syft_enclaves/evidence.py b/packages/syft-enclave/src/syft_enclaves/evidence/__init__.py similarity index 93% rename from packages/syft-enclave/src/syft_enclaves/evidence.py rename to packages/syft-enclave/src/syft_enclaves/evidence/__init__.py index 34ae1494d57..9e7425bfff6 100644 --- a/packages/syft-enclave/src/syft_enclaves/evidence.py +++ b/packages/syft-enclave/src/syft_enclaves/evidence/__init__.py @@ -4,7 +4,7 @@ running on its own kind of TEE (``detect``), how to obtain the evidence (``collect``), and how to summarise it for the operator-facing HTTP endpoint (``describe``). Nothing here verifies anything — an enclave never appraises its -own evidence; that is the verifier's job (``attestation_dispatch``). +own evidence; that is the verifier's job (``attestation.dispatch``). Adding a provider is: write a class with ``kind``/``probe_path``/``detect``/ ``from_settings``/``collect``/``describe``, and add it to ``PROVIDERS``. @@ -16,9 +16,9 @@ from pathlib import Path from typing import Any, Optional, Protocol, runtime_checkable -from syft_enclaves.attestation_envelope import AttestationEvidence, AttestationKind -from syft_enclaves.providers.confidential_space import ConfidentialSpaceProvider -from syft_enclaves.providers.tinfoil import TinfoilProvider +from syft_enclaves.attestation.envelope import AttestationEvidence, AttestationKind +from syft_enclaves.evidence.confidential_space import ConfidentialSpaceProvider +from syft_enclaves.evidence.tinfoil import TinfoilProvider logger = logging.getLogger(__name__) diff --git a/packages/syft-enclave/src/syft_enclaves/providers/confidential_space.py b/packages/syft-enclave/src/syft_enclaves/evidence/confidential_space.py similarity index 98% rename from packages/syft-enclave/src/syft_enclaves/providers/confidential_space.py rename to packages/syft-enclave/src/syft_enclaves/evidence/confidential_space.py index 7af828adace..3cd35581ec7 100644 --- a/packages/syft-enclave/src/syft_enclaves/providers/confidential_space.py +++ b/packages/syft-enclave/src/syft_enclaves/evidence/confidential_space.py @@ -12,7 +12,7 @@ from datetime import datetime, timezone from typing import Any, Optional -from syft_enclaves.attestation_envelope import ( +from syft_enclaves.attestation.envelope import ( AttestationEvidence, AttestationKind, confidential_space_evidence, diff --git a/packages/syft-enclave/src/syft_enclaves/key_bundle.py b/packages/syft-enclave/src/syft_enclaves/evidence/key_bundle.py similarity index 95% rename from packages/syft-enclave/src/syft_enclaves/key_bundle.py rename to packages/syft-enclave/src/syft_enclaves/evidence/key_bundle.py index 65950b13586..480d969af4f 100644 --- a/packages/syft-enclave/src/syft_enclaves/key_bundle.py +++ b/packages/syft-enclave/src/syft_enclaves/evidence/key_bundle.py @@ -25,7 +25,9 @@ #: Written by the runner, read by docker/attestation_server.py. Lives beside #: the Drive token, on a path that is writable in every deployment. PUBLIC_BUNDLE_PATH = Path( - os.environ.get("SYFT_ENCLAVE_PUBLIC_BUNDLE_PATH", "/run/syft-enclave/public_bundle.json") + os.environ.get( + "SYFT_ENCLAVE_PUBLIC_BUNDLE_PATH", "/run/syft-enclave/public_bundle.json" + ) ) @@ -78,7 +80,7 @@ def sign_nonce(nonce: str, path: Path = PUBLIC_BUNDLE_PATH) -> Optional[str]: try: import syft_crypto_python as syc - from syft_enclaves.nonce_challenge import sign_challenge + from syft_enclaves.attestation.nonce import sign_challenge keys = syc.SyftPrivateKeys.from_jwks( json.loads(Path(published["keys_path"]).read_text())["keys_jwk"] diff --git a/packages/syft-enclave/src/syft_enclaves/providers/tinfoil.py b/packages/syft-enclave/src/syft_enclaves/evidence/tinfoil.py similarity index 98% rename from packages/syft-enclave/src/syft_enclaves/providers/tinfoil.py rename to packages/syft-enclave/src/syft_enclaves/evidence/tinfoil.py index 6621f25ac95..54c7c05cca5 100644 --- a/packages/syft-enclave/src/syft_enclaves/providers/tinfoil.py +++ b/packages/syft-enclave/src/syft_enclaves/evidence/tinfoil.py @@ -14,7 +14,7 @@ from pathlib import Path from typing import Any, Optional -from syft_enclaves.attestation_envelope import ( +from syft_enclaves.attestation.envelope import ( AttestationEvidence, AttestationKind, tinfoil_evidence, diff --git a/packages/syft-enclave/src/syft_enclaves/providers/__init__.py b/packages/syft-enclave/src/syft_enclaves/providers/__init__.py deleted file mode 100644 index c19517bdad0..00000000000 --- a/packages/syft-enclave/src/syft_enclaves/providers/__init__.py +++ /dev/null @@ -1,5 +0,0 @@ -"""Per-deployment-target attestation evidence providers. - -See ``syft_enclaves.evidence`` for the interface they implement and the -registry that selects between them. -""" diff --git a/packages/syft-enclave/src/syft_enclaves/runner.py b/packages/syft-enclave/src/syft_enclaves/runner.py index b78761379d2..0e4ba3eb732 100644 --- a/packages/syft-enclave/src/syft_enclaves/runner.py +++ b/packages/syft-enclave/src/syft_enclaves/runner.py @@ -19,7 +19,7 @@ from syft.sync.peers.peer_store import datasite_crypto_keys_path from syft_enclaves.client import SyftEnclaveClient -from syft_enclaves.key_bundle import write_public_bundle +from syft_enclaves.evidence.key_bundle import write_public_bundle from syft_enclaves.evidence import ( AUTO, EvidenceProvider, diff --git a/packages/syft-enclave/tests/test_attestation.py b/packages/syft-enclave/tests/test_attestation_confidential_space.py similarity index 98% rename from packages/syft-enclave/tests/test_attestation.py rename to packages/syft-enclave/tests/test_attestation_confidential_space.py index a91854c48c3..f34c95df20b 100644 --- a/packages/syft-enclave/tests/test_attestation.py +++ b/packages/syft-enclave/tests/test_attestation_confidential_space.py @@ -49,8 +49,10 @@ def mock_verify(): targeting image_digest pass their own policy. """ with ( - patch("syft_enclaves.attestation.id_token.verify_token") as mock_vt, - patch("syft_enclaves.attestation.google_requests.Request"), + patch( + "syft_enclaves.attestation.confidential_space.id_token.verify_token" + ) as mock_vt, + patch("syft_enclaves.attestation.confidential_space.google_requests.Request"), ): mock_vt.return_value = _valid_claims() yield mock_vt diff --git a/packages/syft-enclave/tests/test_attestation_dispatch.py b/packages/syft-enclave/tests/test_attestation_dispatch.py index 606c082832c..9003472e922 100644 --- a/packages/syft-enclave/tests/test_attestation_dispatch.py +++ b/packages/syft-enclave/tests/test_attestation_dispatch.py @@ -7,13 +7,13 @@ import pytest from syft_enclaves.attestation import AppraisalPolicy -from syft_enclaves.attestation_dispatch import policy_for, verify_evidence -from syft_enclaves.attestation_envelope import ( +from syft_enclaves.attestation.dispatch import policy_for, verify_evidence +from syft_enclaves.attestation.envelope import ( AttestationKind, confidential_space_evidence, tinfoil_evidence, ) -from syft_enclaves.attestation_tinfoil import TinfoilAppraisalPolicy +from syft_enclaves.attestation.tinfoil import TinfoilAppraisalPolicy from syft_enclaves.client import SyftEnclaveClient TINFOIL_DOC = { @@ -27,14 +27,14 @@ class TestRouting: def test_confidential_space_goes_to_the_jwt_verifier(self): with patch( - "syft_enclaves.attestation_dispatch.verify_attestation_token" + "syft_enclaves.attestation.dispatch.verify_attestation_token" ) as verify: verify_evidence(CS_EVIDENCE, verbose=False) assert verify.call_args.args[0] == "header.payload.sig" def test_tinfoil_goes_to_the_tinfoil_verifier(self): with patch( - "syft_enclaves.attestation_tinfoil.verify_tinfoil_evidence" + "syft_enclaves.attestation.tinfoil.verify_tinfoil_evidence" ) as verify: verify_evidence(TINFOIL_EVIDENCE, verbose=False) assert verify.call_args.args[0] is TINFOIL_EVIDENCE @@ -61,7 +61,7 @@ def test_a_policy_for_the_other_target_is_refused(self, evidence, policy): verify_evidence(evidence, policy=policy, verbose=False) def test_no_policy_is_allowed(self): - with patch("syft_enclaves.attestation_dispatch.verify_attestation_token"): + with patch("syft_enclaves.attestation.dispatch.verify_attestation_token"): verify_evidence(CS_EVIDENCE, policy=None, verbose=False) @@ -104,7 +104,9 @@ def test_expected_image_digest_builds_the_matching_policy(self): self._peer_publishing(TINFOIL_EVIDENCE.to_version_field()) ) with patch("syft_enclaves.client.verify_evidence") as verify: - client.attest_peer("enclave@openmined.org", expected_image_digest="sha256:a") + client.attest_peer( + "enclave@openmined.org", expected_image_digest="sha256:a" + ) policy = verify.call_args.kwargs["policy"] assert isinstance(policy, TinfoilAppraisalPolicy) assert policy.expected_image_digest == "sha256:a" @@ -127,7 +129,7 @@ def test_importing_syft_enclaves_does_not_pull_in_the_tinfoil_sdk(): code = ( "import sys, syft_enclaves;" "from syft_enclaves.client import SyftEnclaveClient;" - "from syft_enclaves.attestation_tinfoil import TinfoilAppraisalPolicy;" + "from syft_enclaves.attestation.tinfoil import TinfoilAppraisalPolicy;" "TinfoilAppraisalPolicy();" "assert not [m for m in sys.modules if m.split('.')[0] == 'tinfoil'], " "sorted(m for m in sys.modules if m.split('.')[0] == 'tinfoil')" diff --git a/packages/syft-enclave/tests/test_attestation_envelope.py b/packages/syft-enclave/tests/test_attestation_envelope.py index 898792ee066..4f8ca5a5ec4 100644 --- a/packages/syft-enclave/tests/test_attestation_envelope.py +++ b/packages/syft-enclave/tests/test_attestation_envelope.py @@ -4,7 +4,7 @@ from syft.sync.version.version_info import VersionInfoV2 -from syft_enclaves.attestation_envelope import ( +from syft_enclaves.attestation.envelope import ( CONFIDENTIAL_SPACE_FORMAT, EXTRA_KEY, AttestationEvidence, diff --git a/packages/syft-enclave/tests/test_attestation_https.py b/packages/syft-enclave/tests/test_attestation_https.py index b18a77c176d..63ed9513d23 100644 --- a/packages/syft-enclave/tests/test_attestation_https.py +++ b/packages/syft-enclave/tests/test_attestation_https.py @@ -1,10 +1,11 @@ """Tests for the pinned fetch itself.""" + import json from unittest.mock import MagicMock import pytest -from syft_enclaves.attestation_https import ( +from syft_enclaves.attestation.https import ( AttestationFetchError, fetch_attested_payload, public_key_fp_from_cert, @@ -19,7 +20,11 @@ def __init__(self, payload, status=200): self.status = status def read(self): - return self._payload if isinstance(self._payload, bytes) else json.dumps(self._payload).encode() + return ( + self._payload + if isinstance(self._payload, bytes) + else json.dumps(self._payload).encode() + ) def _connection(responses, der=b"\x30\x00"): @@ -34,11 +39,11 @@ def patched(monkeypatch): def _install(responses, der=b"\x30\x00"): conn = _connection(responses, der) monkeypatch.setattr( - "syft_enclaves.attestation_https.http.client.HTTPSConnection", + "syft_enclaves.attestation.https.http.client.HTTPSConnection", lambda *a, **k: conn, ) monkeypatch.setattr( - "syft_enclaves.attestation_https.public_key_fp_from_cert", + "syft_enclaves.attestation.https.public_key_fp_from_cert", lambda der_bytes: "ab" * 32, ) return conn @@ -48,7 +53,9 @@ def _install(responses, der=b"\x30\x00"): def test_returns_document_bundle_and_tls_fingerprint(patched): bundle = {"identity": "enclave@openmined.org"} - patched([_FakeResponse({"evidence": {**DOC, "kind": "tinfoil"}, "key_bundle": bundle})]) + patched( + [_FakeResponse({"evidence": {**DOC, "kind": "tinfoil"}, "key_bundle": bundle})] + ) payload = fetch_attested_payload("enclave.example") assert payload.document == DOC assert payload.key_bundle == bundle @@ -109,7 +116,8 @@ def test_an_echoed_nonce_that_differs_is_refused(patched): def test_fingerprint_is_the_spki_sha256_of_a_real_certificate(): # Not mocked: the fingerprint must match what the report encodes. - import datetime, hashlib + import datetime + import hashlib from cryptography import x509 from cryptography.hazmat.primitives import hashes, serialization from cryptography.hazmat.primitives.asymmetric import ec @@ -134,4 +142,7 @@ def test_fingerprint_is_the_spki_sha256_of_a_real_certificate(): format=serialization.PublicFormat.SubjectPublicKeyInfo, ) ).hexdigest() - assert public_key_fp_from_cert(cert.public_bytes(serialization.Encoding.DER)) == expected + assert ( + public_key_fp_from_cert(cert.public_bytes(serialization.Encoding.DER)) + == expected + ) diff --git a/packages/syft-enclave/tests/test_attestation_tinfoil.py b/packages/syft-enclave/tests/test_attestation_tinfoil.py index bf2450b35cb..37d0b96e39c 100644 --- a/packages/syft-enclave/tests/test_attestation_tinfoil.py +++ b/packages/syft-enclave/tests/test_attestation_tinfoil.py @@ -17,8 +17,8 @@ import pytest from syft_enclaves.attestation import AttestationError -from syft_enclaves.attestation_envelope import tinfoil_evidence -from syft_enclaves.attestation_tinfoil import DEPLOYMENT_ASSET, HASH_ASSET +from syft_enclaves.attestation.envelope import tinfoil_evidence +from syft_enclaves.attestation.tinfoil import DEPLOYMENT_ASSET, HASH_ASSET from syft_enclaves.optional_deps import MissingOptionalDependency TINFOIL_DOC = { @@ -37,6 +37,8 @@ def _real_keys(): bundle = keys.to_public_bundle().to_did_document("did:syft:enclave@openmined.org") bundle["identity"] = "enclave@openmined.org" return keys, bundle + + IMAGE_DIGEST = "sha256:" + "ab" * 32 IMAGE = f"docker.io/openminedreleasebot/syft-enclave@{IMAGE_DIGEST}" SYFT_VERSION_IN_CONFIG = "9.9.9" @@ -160,8 +162,8 @@ def fake_get(url, timeout=None): @pytest.fixture def pinned(monkeypatch): """Patch the pinned fetch with a payload the enclave would really serve.""" - from syft_enclaves.attestation_https import AttestedPayload - from syft_enclaves.nonce_challenge import new_nonce, sign_challenge + from syft_enclaves.attestation.https import AttestedPayload + from syft_enclaves.attestation.nonce import new_nonce, sign_challenge def _install( *, @@ -192,13 +194,13 @@ def _install( def fetch(host, *a, **kw): if unreachable: - from syft_enclaves.attestation_https import AttestationFetchError + from syft_enclaves.attestation.https import AttestationFetchError raise AttestationFetchError("connection refused") return payload monkeypatch.setattr( - "syft_enclaves.attestation_tinfoil.fetch_attested_payload", fetch + "syft_enclaves.attestation.tinfoil.fetch_attested_payload", fetch ) return payload @@ -208,7 +210,7 @@ def fetch(host, *a, **kw): @pytest.fixture def verify(sdk, pinned): """verify_tinfoil_evidence with the SDK and a pinned fetch stubbed, quiet.""" - from syft_enclaves.attestation_tinfoil import ( + from syft_enclaves.attestation.tinfoil import ( TinfoilAppraisalPolicy, verify_tinfoil_evidence, ) @@ -437,9 +439,7 @@ def test_a_tampered_deployment_json_is_not_trusted(self, verify, sdk): assert _check(result, "image_digest").passed is False assert _check(result, "version_match").passed is None - def test_falls_back_to_github_when_the_proxy_rejects_the_asset( - self, verify, sdk - ): + def test_falls_back_to_github_when_the_proxy_rejects_the_asset(self, verify, sdk): # Regression: tinfoil's proxy allowlists tinfoil.hash only, so reading # the config must fall through to github.com. result = verify(expected_image_digest=IMAGE_DIGEST) @@ -501,7 +501,7 @@ def test_error_carries_the_result(self, verify, sdk): class TestOptionalDependency: def test_missing_tinfoil_explains_how_to_install_it(self, monkeypatch, pinned): - from syft_enclaves.attestation_tinfoil import ( + from syft_enclaves.attestation.tinfoil import ( TinfoilAppraisalPolicy, verify_tinfoil_evidence, ) @@ -526,6 +526,6 @@ def test_missing_tinfoil_explains_how_to_install_it(self, monkeypatch, pinned): def test_the_policy_is_usable_without_the_sdk(self): # Importing the module for its policy must not need the extra. - from syft_enclaves.attestation_tinfoil import TinfoilAppraisalPolicy + from syft_enclaves.attestation.tinfoil import TinfoilAppraisalPolicy assert TinfoilAppraisalPolicy().repo == "OpenMined/syft-enclave-tinfoil" diff --git a/packages/syft-enclave/tests/test_evidence_providers.py b/packages/syft-enclave/tests/test_evidence.py similarity index 89% rename from packages/syft-enclave/tests/test_evidence_providers.py rename to packages/syft-enclave/tests/test_evidence.py index f42c76fecee..c8993cb1fdf 100644 --- a/packages/syft-enclave/tests/test_evidence_providers.py +++ b/packages/syft-enclave/tests/test_evidence.py @@ -6,18 +6,18 @@ import pytest -from syft_enclaves.attestation_envelope import AttestationKind +from syft_enclaves.attestation.envelope import AttestationKind from syft_enclaves.evidence import ( PROVIDERS, probed_locations, select_provider, ) -from syft_enclaves.providers.confidential_space import ( +from syft_enclaves.evidence.confidential_space import ( ConfidentialSpaceProvider, decode_jwt_payload, structure_claims, ) -from syft_enclaves.providers.tinfoil import TinfoilProvider +from syft_enclaves.evidence.tinfoil import TinfoilProvider TINFOIL_DOC = { "format": "https://tinfoil.sh/predicate/sev-snp-guest/v2", @@ -36,9 +36,7 @@ def tinfoil_mount(tmp_path, monkeypatch): ("config.yml", "TINFOIL_CONFIG_PATH"), ("container-status.json", "TINFOIL_STATUS_PATH"), ]: - monkeypatch.setattr( - f"syft_enclaves.providers.tinfoil.{attr}", tmp_path / name - ) + monkeypatch.setattr(f"syft_enclaves.evidence.tinfoil.{attr}", tmp_path / name) return tmp_path @@ -104,7 +102,7 @@ def test_a_caller_nonce_is_refused(self, tinfoil_mount): def test_missing_document_explains_why(self, tmp_path, monkeypatch): monkeypatch.setattr( - "syft_enclaves.providers.tinfoil.TINFOIL_ATTESTATION_PATH", + "syft_enclaves.evidence.tinfoil.TINFOIL_ATTESTATION_PATH", tmp_path / "absent.json", ) with pytest.raises(RuntimeError, match="not running inside a Tinfoil enclave"): @@ -115,7 +113,7 @@ def test_malformed_document_raises(self, tmp_path, monkeypatch, content): path = tmp_path / "attestation.json" path.write_text(content) monkeypatch.setattr( - "syft_enclaves.providers.tinfoil.TINFOIL_ATTESTATION_PATH", path + "syft_enclaves.evidence.tinfoil.TINFOIL_ATTESTATION_PATH", path ) with pytest.raises(RuntimeError, match="Malformed"): TinfoilProvider().collect() @@ -124,7 +122,7 @@ def test_document_without_format_or_body_raises(self, tmp_path, monkeypatch): path = tmp_path / "attestation.json" path.write_text('{"something": "else"}') monkeypatch.setattr( - "syft_enclaves.providers.tinfoil.TINFOIL_ATTESTATION_PATH", path + "syft_enclaves.evidence.tinfoil.TINFOIL_ATTESTATION_PATH", path ) with pytest.raises(ValueError, match="'format' and 'body'"): TinfoilProvider().collect() @@ -140,10 +138,10 @@ def test_describe_tolerates_a_partial_mount(self, tmp_path, monkeypatch): path = tmp_path / "attestation.json" path.write_text(json.dumps(TINFOIL_DOC)) monkeypatch.setattr( - "syft_enclaves.providers.tinfoil.TINFOIL_ATTESTATION_PATH", path + "syft_enclaves.evidence.tinfoil.TINFOIL_ATTESTATION_PATH", path ) monkeypatch.setattr( - "syft_enclaves.providers.tinfoil.TINFOIL_CONFIG_PATH", tmp_path / "gone.yml" + "syft_enclaves.evidence.tinfoil.TINFOIL_CONFIG_PATH", tmp_path / "gone.yml" ) provider = TinfoilProvider() assert provider.describe(provider.collect())["config"] is None @@ -153,7 +151,7 @@ class TestConfidentialSpaceProvider: def test_detect_follows_the_launcher_socket(self, tmp_path, monkeypatch): socket_path = tmp_path / "teeserver.sock" monkeypatch.setattr( - "syft_enclaves.providers.confidential_space.TEE_SOCKET_PATH", socket_path + "syft_enclaves.evidence.confidential_space.TEE_SOCKET_PATH", socket_path ) assert ConfidentialSpaceProvider.detect() is False socket_path.write_text("") @@ -161,7 +159,7 @@ def test_detect_follows_the_launcher_socket(self, tmp_path, monkeypatch): def test_collect_wraps_the_launcher_token(self): with patch( - "syft_enclaves.providers.confidential_space.fetch_attestation_token", + "syft_enclaves.evidence.confidential_space.fetch_attestation_token", return_value="header.payload.signature", ) as fetch: evidence = ConfidentialSpaceProvider().collect() @@ -174,7 +172,7 @@ def test_collect_wraps_the_launcher_token(self): def test_collect_passes_a_caller_nonce_through(self): with patch( - "syft_enclaves.providers.confidential_space.fetch_attestation_token", + "syft_enclaves.evidence.confidential_space.fetch_attestation_token", return_value="a.b.c", ) as fetch: ConfidentialSpaceProvider().collect(caller_nonce="freshness") @@ -218,9 +216,7 @@ class TestAttestationServerWiring: """ def _app(self, monkeypatch): - monkeypatch.syspath_prepend( - str(Path(__file__).resolve().parents[1] / "docker") - ) + monkeypatch.syspath_prepend(str(Path(__file__).resolve().parents[1] / "docker")) import attestation_server return attestation_server diff --git a/packages/syft-enclave/tests/test_runner.py b/packages/syft-enclave/tests/test_runner.py index f9cef397f25..28a6799edb6 100644 --- a/packages/syft-enclave/tests/test_runner.py +++ b/packages/syft-enclave/tests/test_runner.py @@ -5,7 +5,7 @@ import pytest -from syft_enclaves.attestation_envelope import ( +from syft_enclaves.attestation.envelope import ( AttestationEvidence, AttestationKind, tinfoil_evidence, diff --git a/packages/syft-enclave/tinfoil/tinfoil-config.yml b/packages/syft-enclave/tinfoil/tinfoil-config.yml index 0b9b0a99bb4..f8de4b33baf 100644 --- a/packages/syft-enclave/tinfoil/tinfoil-config.yml +++ b/packages/syft-enclave/tinfoil/tinfoil-config.yml @@ -41,11 +41,11 @@ containers: read_only: false env: # Key AND value pinned here, so both are measured and verifiable. - - SYFT_ENCLAVE_ATTESTATION_PROVIDER: "tinfoil" - - SYFT_ENCLAVE_TINFOIL_REPO: "OpenMined/syft-enclave-tinfoil" + - SYFT_ENCLAVE_ATTESTATION_PROVIDER: 'tinfoil' + - SYFT_ENCLAVE_TINFOIL_REPO: 'OpenMined/syft-enclave-tinfoil' # Tinfoil has no Secret Manager equivalent; the Drive token arrives as a # deploy-time secret and this provider writes it to disk (0600). - - SYFT_BOOTSTRAP: "tinfoil" + - SYFT_BOOTSTRAP: 'tinfoil' # Declared as bare names: the key is measured, the VALUE comes from # `--variable` at deploy time and is therefore NOT measured — a data # owner cannot verify it. Declaring is mandatory even so: the shim From f42a02ff3c434f2a0fba820fa0c594355cfd9382 Mon Sep 17 00:00:00 2001 From: Koen van der Veen Date: Tue, 15 Sep 2026 21:37:58 +0200 Subject: [PATCH 04/21] refactor: move tee_token into evidence/ MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit It is the Confidential Space launcher client — one half of producing evidence on that target, used only by evidence/confidential_space.py. Sitting at the top level was what made the grouping look half-finished. Co-Authored-By: Claude Opus 5 (1M context) --- packages/syft-enclave/docker/attestation_server.py | 2 +- .../syft_enclaves/attestation/confidential_space.py | 2 +- .../src/syft_enclaves/evidence/confidential_space.py | 2 +- .../src/syft_enclaves/{ => evidence}/tee_token.py | 10 ++++++---- 4 files changed, 9 insertions(+), 7 deletions(-) rename packages/syft-enclave/src/syft_enclaves/{ => evidence}/tee_token.py (85%) diff --git a/packages/syft-enclave/docker/attestation_server.py b/packages/syft-enclave/docker/attestation_server.py index 32fc845dc50..78ec5abbecc 100644 --- a/packages/syft-enclave/docker/attestation_server.py +++ b/packages/syft-enclave/docker/attestation_server.py @@ -22,7 +22,7 @@ from syft_enclaves.evidence import probed_locations, select_provider from syft_enclaves.evidence.key_bundle import read_public_bundle, sign_nonce from syft_enclaves.settings import AttestationSettings -from syft_enclaves.tee_token import validate_nonce +from syft_enclaves.evidence.tee_token import validate_nonce app = FastAPI(title="Syft Client Enclave", version="0.1.0") diff --git a/packages/syft-enclave/src/syft_enclaves/attestation/confidential_space.py b/packages/syft-enclave/src/syft_enclaves/attestation/confidential_space.py index 0090753a671..2c03f5233a0 100644 --- a/packages/syft-enclave/src/syft_enclaves/attestation/confidential_space.py +++ b/packages/syft-enclave/src/syft_enclaves/attestation/confidential_space.py @@ -165,7 +165,7 @@ def verify_attestation_token( if isinstance(eat_nonce, str): eat_nonce = [eat_nonce] actual_version_nonce = eat_nonce[0] if eat_nonce else None - # Must match the format produced by syft_enclaves.tee_token.build_eat_nonce. + # Must match the format produced by syft_enclaves.evidence.tee_token.build_eat_nonce. expected_version_nonce = f"syft-{expected_syft_version}" if not actual_version_nonce: result.add( diff --git a/packages/syft-enclave/src/syft_enclaves/evidence/confidential_space.py b/packages/syft-enclave/src/syft_enclaves/evidence/confidential_space.py index 3cd35581ec7..1d7e9db7bc8 100644 --- a/packages/syft-enclave/src/syft_enclaves/evidence/confidential_space.py +++ b/packages/syft-enclave/src/syft_enclaves/evidence/confidential_space.py @@ -17,7 +17,7 @@ AttestationKind, confidential_space_evidence, ) -from syft_enclaves.tee_token import ( +from syft_enclaves.evidence.tee_token import ( TEE_SOCKET_PATH, TOKEN_AUDIENCE, build_eat_nonce, diff --git a/packages/syft-enclave/src/syft_enclaves/tee_token.py b/packages/syft-enclave/src/syft_enclaves/evidence/tee_token.py similarity index 85% rename from packages/syft-enclave/src/syft_enclaves/tee_token.py rename to packages/syft-enclave/src/syft_enclaves/evidence/tee_token.py index 5de75b3b0f4..de242fa3b3f 100644 --- a/packages/syft-enclave/src/syft_enclaves/tee_token.py +++ b/packages/syft-enclave/src/syft_enclaves/evidence/tee_token.py @@ -1,8 +1,10 @@ -"""TEE attestation token client. +"""Confidential Space launcher client. -Talks directly to the Confidential Spaces launcher via Unix socket -to fetch signed attestation JWTs. Shared by the attestation HTTP -server (``docker/attestation_server.py``) and the enclave runner. +Talks directly to the Confidential Spaces launcher over a Unix socket to fetch +signed attestation JWTs. Lives here because it is one half of *producing* +evidence on that target: ``evidence/confidential_space.py`` is what the +provider seam sees, and this is how it reaches the launcher. The attestation +HTTP server also borrows ``validate_nonce`` from it. """ from __future__ import annotations From 1418100ff0f5a6143bfba47c8004af46d6b2b99b Mon Sep 17 00:00:00 2001 From: Koen van der Veen Date: Wed, 16 Sep 2026 10:50:01 +0200 Subject: [PATCH 05/21] docs: split the enclave deployment docs by concern terraform.md -> terraform_cs.md and tinfoil.md -> tinfoil_deployment.md, so the filenames say which target they cover. 'What this proves, and what it does not' moves into security.md as section 6: it is a statement of guarantees, not deployment mechanics, and it belongs next to the section 5 claim it qualifies. That also let me repair section 5, where an earlier status note had been inserted mid-paragraph and swallowed the tail of a sentence. Troubleshooting moves to tinfoil_troubleshooting.md. Co-Authored-By: Claude Opus 5 (1M context) --- packages/syft-enclave/Justfile | 10 ++-- packages/syft-enclave/README.md | 13 ++--- .../syft-enclave/docker/attestation_server.py | 4 +- packages/syft-enclave/docs/api.md | 2 +- packages/syft-enclave/docs/dev.md | 2 +- packages/syft-enclave/docs/security.md | 43 ++++++++++++++-- .../docs/{terraform.md => terraform_cs.md} | 0 .../{tinfoil.md => tinfoil_deployment.md} | 50 ++----------------- .../docs/tinfoil_troubleshooting.md | 31 ++++++++++++ .../syft-enclave/scripts/verify_tinfoil.py | 2 +- .../src/syft_enclaves/attestation/tinfoil.py | 2 +- .../syft-enclave/src/syft_enclaves/client.py | 2 +- .../tests/test_attestation_tinfoil.py | 2 +- 13 files changed, 95 insertions(+), 68 deletions(-) rename packages/syft-enclave/docs/{terraform.md => terraform_cs.md} (100%) rename packages/syft-enclave/docs/{tinfoil.md => tinfoil_deployment.md} (55%) create mode 100644 packages/syft-enclave/docs/tinfoil_troubleshooting.md diff --git a/packages/syft-enclave/Justfile b/packages/syft-enclave/Justfile index dc8502eb32b..d8a74235927 100644 --- a/packages/syft-enclave/Justfile +++ b/packages/syft-enclave/Justfile @@ -48,13 +48,13 @@ ensure_dockerhub_login := ''' # Preflight for terraform recipes: terraform installed + tfvars present. tf_preflight := ''' command -v terraform >/dev/null 2>&1 || { echo "Error: terraform not found. Install: https://developer.hashicorp.com/terraform/install" >&2; exit 1; } - [ -f terraform/terraform.tfvars ] || { echo "Error: terraform/terraform.tfvars not found. Copy terraform/terraform.tfvars.example and fill it in (see docs/terraform.md — Confidential Spaces deployment)." >&2; exit 1; } + [ -f terraform/terraform.tfvars ] || { echo "Error: terraform/terraform.tfvars not found. Copy terraform/terraform.tfvars.example and fill it in (see docs/terraform_cs.md — Confidential Spaces deployment)." >&2; exit 1; } ''' # Preflight for tinfoil recipes: CLI installed + authenticated. tinfoil_preflight := ''' command -v tinfoil >/dev/null 2>&1 || { echo "Error: tinfoil CLI not found. Install: curl -fsSL https://github.com/tinfoilsh/tinfoil-cli/raw/main/install.sh | sh" >&2; exit 1; } - [ -f "$HOME/.tinfoil/config.json" ] || [ -n "${TINFOIL_API_KEY:-}" ] || { echo "Error: not authenticated with Tinfoil. Run: tinfoil login (or set TINFOIL_API_KEY). See docs/tinfoil.md." >&2; exit 1; } + [ -f "$HOME/.tinfoil/config.json" ] || [ -n "${TINFOIL_API_KEY:-}" ] || { echo "Error: not authenticated with Tinfoil. Run: tinfoil login (or set TINFOIL_API_KEY). See docs/tinfoil_deployment.md." >&2; exit 1; } ''' # List all available commands @@ -558,7 +558,7 @@ credentials-to-token credentials_path output_path: # --------------------------------------------------------------------------------------------------------------------- # Terraform # -# Declarative alternative to the gcloud recipes above — see docs/terraform.md. +# Declarative alternative to the gcloud recipes above — see docs/terraform_cs.md. # Config lives in terraform/terraform.tfvars (gitignored, copy the .example); # these recipes never read ~/.syft-enclaves/settings.json. dev_mode is always # forced on the CLI by tf-apply / tf-apply-dev so tfvars can't override it. @@ -677,7 +677,7 @@ tf-ssh: # measured config whose signed GitHub releases publish the expected # measurement. `tinfoil-release` is what keeps the two in step. # -# Full walkthrough: docs/tinfoil.md +# Full walkthrough: docs/tinfoil_deployment.md # --------------------------------------------------------------------------------------------------------------------- # Who am I logged in to Tinfoil as @@ -752,7 +752,7 @@ tinfoil-release version: # EMAIL and DATA_OWNERS are deploy-time variables, so they are NOT part of the # measurement and a data owner cannot verify them — same as tee-env-* metadata -# on Confidential Spaces. See docs/tinfoil.md. +# on Confidential Spaces. See docs/tinfoil_deployment.md. # Deploy a container from a published release [group('tinfoil')] tinfoil-deploy tag email data_owners="" extra_args="": diff --git a/packages/syft-enclave/README.md b/packages/syft-enclave/README.md index 2a35479d2e0..fa2ddd33eba 100644 --- a/packages/syft-enclave/README.md +++ b/packages/syft-enclave/README.md @@ -8,8 +8,9 @@ Enclave support for syft, enabling secure computation in Trusted Execution Envir - [Security Overview](./docs/security.md) - [Enclave Architecture](./docs/enclave_architecture.md) - [API](./docs/api.md) -- [Confidential Spaces Deployment (Terraform)](./docs/terraform.md) -- [Tinfoil Deployment](./docs/tinfoil.md) +- [Confidential Spaces Deployment (Terraform)](./docs/terraform_cs.md) +- [Tinfoil Deployment](./docs/tinfoil_deployment.md) +- [Tinfoil Troubleshooting](./docs/tinfoil_troubleshooting.md) ## Prerequisites @@ -23,13 +24,13 @@ For Confidential Spaces (the rest of this README): - `gcloud` [CLI installed](https://docs.cloud.google.com/sdk/docs/install-sdk) - A GCP project with billing enabled -For Tinfoil, see [docs/tinfoil.md](./docs/tinfoil.md) — no GCP needed. +For Tinfoil, see [docs/tinfoil_deployment.md](./docs/tinfoil_deployment.md) — no GCP needed. All commands are defined in the [`Justfile`](./Justfile). Run them from this directory. -Prefer declarative deploys? The same stack can be managed with Terraform — see [Confidential Spaces Deployment](./docs/terraform.md) (`just tf-apply` / `just tf-apply-dev`). +Prefer declarative deploys? The same stack can be managed with Terraform — see [Confidential Spaces Deployment](./docs/terraform_cs.md) (`just tf-apply` / `just tf-apply-dev`). -Deploying without GCP? See [Tinfoil Deployment](./docs/tinfoil.md) (`just tinfoil-release` / `just tinfoil-deploy`), which needs the `tinfoil` CLI instead of `gcloud`. +Deploying without GCP? See [Tinfoil Deployment](./docs/tinfoil_deployment.md) (`just tinfoil-release` / `just tinfoil-deploy`), which needs the `tinfoil` CLI instead of `gcloud`. ## One-time setup @@ -79,7 +80,7 @@ just hardware=gpu start EMAIL # production just hardware=gpu start-debug EMAIL # debug ``` -GPU enclaves use flex-start provisioning: the create call may wait for H100 capacity (up to 2h), then the VM runs `gpu_run_duration_seconds` (default 2 days). Details: [Confidential Spaces — GPU deployments](docs/terraform.md#gpu-deployments). +GPU enclaves use flex-start provisioning: the create call may wait for H100 capacity (up to 2h), then the VM runs `gpu_run_duration_seconds` (default 2 days). Details: [Confidential Spaces — GPU deployments](docs/terraform_cs.md#gpu-deployments). ## Inspect a running VM diff --git a/packages/syft-enclave/docker/attestation_server.py b/packages/syft-enclave/docker/attestation_server.py index 78ec5abbecc..d37d7ffc8a2 100644 --- a/packages/syft-enclave/docker/attestation_server.py +++ b/packages/syft-enclave/docker/attestation_server.py @@ -138,10 +138,10 @@ def _not_in_a_tee(version: str) -> dict: "build": "docker build -t syft-enclave -f docker/Dockerfile .", "confidential_space": ( "Deploy on a Confidential VM with the Confidential Space image " - "— see docs/terraform.md." + "— see docs/terraform_cs.md." ), "tinfoil": ( - "Deploy a Tinfoil container from the config repo — see docs/tinfoil.md." + "Deploy a Tinfoil container from the config repo — see docs/tinfoil_deployment.md." ), }, } diff --git a/packages/syft-enclave/docs/api.md b/packages/syft-enclave/docs/api.md index 2d44d2a0b1c..35dd9fdd101 100644 --- a/packages/syft-enclave/docs/api.md +++ b/packages/syft-enclave/docs/api.md @@ -37,7 +37,7 @@ The same `python -m syft_enclaves` entry point runs unchanged locally, inside Docker, in Confidential Spaces, and in a Tinfoil CVM — only the environment differs. Which attestation provider is used is detected from the environment unless `SYFT_ENCLAVE_ATTESTATION_PROVIDER` says otherwise; see -[Tinfoil Deployment](./tinfoil.md). +[Tinfoil Deployment](./tinfoil_deployment.md). ## Example: Fetching the attestation report diff --git a/packages/syft-enclave/docs/dev.md b/packages/syft-enclave/docs/dev.md index 88db6785c74..467ec261be5 100644 --- a/packages/syft-enclave/docs/dev.md +++ b/packages/syft-enclave/docs/dev.md @@ -22,7 +22,7 @@ The debug image allows SSH access and container log redirection to serial output ```bash just start-debug # cpu (default) -just hardware=gpu start-debug # gpu (a3-highgpu-1g, 1x H100) — see docs/terraform.md (Confidential Spaces) "GPU deployments" +just hardware=gpu start-debug # gpu (a3-highgpu-1g, 1x H100) — see docs/terraform_cs.md (Confidential Spaces) "GPU deployments" ``` Debug enclaves run with encryption off — data owner clients must match: `login_do(encryption=False)`. diff --git a/packages/syft-enclave/docs/security.md b/packages/syft-enclave/docs/security.md index 3d54551af57..1ccd96f34f6 100644 --- a/packages/syft-enclave/docs/security.md +++ b/packages/syft-enclave/docs/security.md @@ -93,10 +93,11 @@ have access to the account. > pins its connection to that key can trust the key bundle served over it. `attest_peer` does this > and sets the peer's keys from the result. On **Confidential Spaces** it is still unimplemented — > the channel exists (a workload can inject nonces into the token) but is unused, so there the key -> bundle remains an unsigned Drive file. See -> [Tinfoil Deployment](./tinfoil.md#what-this-proves-and-what-it-does-not). The data owners and the DS then download the enclave's verified keys (and -> share their own), and from that point on there is a **trusted, end-to-end secure channel** between the -> enclave and every participant. +> bundle remains an unsigned Drive file. Section 6 has the detail. + +The data owners and the DS then download the enclave's verified keys (and +share their own), and from that point on there is a **trusted, end-to-end secure channel** between the +enclave and every participant. Crucially, Google Drive is treated purely as an **untrusted transport** — a message-passing channel and nothing more. The threat model assumes a fully adversarial transport: an attacker (or Google @@ -110,3 +111,37 @@ forge an accepted message. This is what lets Steps 1–5 of the [Enclave Flow](./flow.md) happen without anyone trusting Google Drive, the network, or each other. + +## 6. What attestation proves on each target + +Section 5 describes the intent. What each deployment target actually delivers differs, so this is +the honest accounting. It is written for **Tinfoil**; where Confidential Spaces differs, it is +called out. + +**Proves.** The enclave is genuine SEV-SNP/TDX hardware with debug disabled and its firmware TCB at or above minimum; it booted the exact CVM image and config published in a named release of the config repo; that release was signed by GitHub OIDC from that repo's tag via Sigstore; and the container image digest matches the one you pinned. + +**Does not prove.** Which email or data owners the enclave was started with. Those are deploy-time `--variable`s, so they are outside the measurement and the attested code merely relays whatever its deployer handed it. Confidential Spaces is in the same position today (`tee-env-*` metadata is not checked either). + +**Key binding, and how it is achieved.** A workload cannot inject a nonce into the report: its 64 bytes of user data are the sha256 of the shim's TLS public key followed by its HPKE public key. But that is exactly what makes binding possible — the report _commits to the key terminating a TLS connection to the enclave_. So the client: + +1. verifies the report, +2. opens HTTPS to the enclave and checks the certificate it is served carries that same key, +3. checks the enclave signed the client's nonce with the key bundle it served, +4. and then trusts that bundle, which came down the same connection. + +No certificate authority is involved anywhere: the enclave's certificate is self-signed, and the _report_ is what decides whether to trust it. `attest_peer` then sets those keys for the peer, so the enclave's public keys are no longer an unsigned Drive file. This is the binding section 5 describes. + +It also gets freshness for free: a replayed report commits to a TLS key whose private half lives in an enclave the attacker does not control, so the pin fails. + +**Freshness comes from a nonce, not from the report.** A workload cannot influence the report's user data, so the client sends a random nonce and the enclave signs it with the identity key from the bundle it just served. That proves two things the report cannot: the enclave _holds the private half_ of the key we are about to encrypt to, and the answer was produced for _this_ exchange rather than replayed. The bundle is adopted only when both `key_binding` and `nonce_freshness` pass. + +**Drive is not a fallback.** Evidence is still published to `SYFT_version.json` — as provenance, and so the path exists if it is ever needed again — but the client always appraises a Tinfoil enclave from the live API. An unreachable enclave is an error, not a downgrade: accepting the Drive copy would silently mean unbound keys and a replayable report. + +**On Confidential Spaces**, none of the binding above is implemented. The report is a Google-signed +JWT fetched at boot and written once to `SYFT_version.json`, and `build_eat_nonce()` is called with +no caller nonce — so the enclave's public keys are not committed into it, and a captured token stays +acceptable for the length of the expiry grace window. The channel for fixing this exists (a workload +_can_ inject nonces into the token) but is unused. + +For how to deploy either target, see [Confidential Spaces Deployment](./terraform_cs.md) and +[Tinfoil Deployment](./tinfoil_deployment.md). diff --git a/packages/syft-enclave/docs/terraform.md b/packages/syft-enclave/docs/terraform_cs.md similarity index 100% rename from packages/syft-enclave/docs/terraform.md rename to packages/syft-enclave/docs/terraform_cs.md diff --git a/packages/syft-enclave/docs/tinfoil.md b/packages/syft-enclave/docs/tinfoil_deployment.md similarity index 55% rename from packages/syft-enclave/docs/tinfoil.md rename to packages/syft-enclave/docs/tinfoil_deployment.md index 26fd91340ba..37432693a95 100644 --- a/packages/syft-enclave/docs/tinfoil.md +++ b/packages/syft-enclave/docs/tinfoil_deployment.md @@ -1,6 +1,6 @@ # Tinfoil Deployment -An alternative to [Confidential Spaces](./terraform.md) with no GCP involved. The enclave runs in an AMD SEV-SNP or Intel TDX confidential VM managed by [Tinfoil](https://docs.tinfoil.sh), and a data owner verifies it against a measurement published in a Sigstore-signed GitHub release. +An alternative to [Confidential Spaces](./terraform_cs.md) with no GCP involved. The enclave runs in an AMD SEV-SNP or Intel TDX confidential VM managed by [Tinfoil](https://docs.tinfoil.sh), and a data owner verifies it against a measurement published in a Sigstore-signed GitHub release. Two repositories are in play: @@ -11,26 +11,10 @@ The canonical copy of that config lives here at [`tinfoil/tinfoil-config.yml`](. Run all commands from `packages/syft-enclave/`. -## What this proves, and what it does not - -**Proves.** The enclave is genuine SEV-SNP/TDX hardware with debug disabled and its firmware TCB at or above minimum; it booted the exact CVM image and config published in a named release of the config repo; that release was signed by GitHub OIDC from that repo's tag via Sigstore; and the container image digest matches the one you pinned. - -**Does not prove.** Which email or data owners the enclave was started with. Those are deploy-time `--variable`s, so they are outside the measurement and the attested code merely relays whatever its deployer handed it. Confidential Spaces is in the same position today (`tee-env-*` metadata is not checked either). - -**Key binding, and how it is achieved.** A workload cannot inject a nonce into the report: its 64 bytes of user data are the sha256 of the shim's TLS public key followed by its HPKE public key. But that is exactly what makes binding possible — the report _commits to the key terminating a TLS connection to the enclave_. So the client: - -1. verifies the report, -2. opens HTTPS to the enclave and checks the certificate it is served carries that same key, -3. checks the enclave signed the client's nonce with the key bundle it served, -4. and then trusts that bundle, which came down the same connection. - -No certificate authority is involved anywhere: the enclave's certificate is self-signed, and the _report_ is what decides whether to trust it. `attest_peer` then sets those keys for the peer, so the enclave's public keys are no longer an unsigned Drive file. This is the binding `docs/security.md` §5 describes. - -It also gets freshness for free: a replayed report commits to a TLS key whose private half lives in an enclave the attacker does not control, so the pin fails. - -**Freshness comes from a nonce, not from the report.** A workload cannot influence the report's user data, so the client sends a random nonce and the enclave signs it with the identity key from the bundle it just served. That proves two things the report cannot: the enclave _holds the private half_ of the key we are about to encrypt to, and the answer was produced for _this_ exchange rather than replayed. The bundle is adopted only when both `key_binding` and `nonce_freshness` pass. - -**Drive is not a fallback.** Evidence is still published to `SYFT_version.json` — as provenance, and so the path exists if it is ever needed again — but the client always appraises a Tinfoil enclave from the live API. An unreachable enclave is an error, not a downgrade: accepting the Drive copy would silently mean unbound keys and a replayable report. +When something fails, see [Tinfoil Troubleshooting](./tinfoil_troubleshooting.md). For what the +attestation does and does not prove, see +[Security Overview](./security.md#6-what-attestation-proves-on-each-target) — this doc covers the +mechanics, not the guarantees. ## Prerequisites @@ -220,30 +204,6 @@ Other knobs: The enclave will publish that document as its own evidence; verification against our config repo will of course fail the measurement check. Useful for exercising the publish path, not the verify path. - `SYFT_ENCLAVE_ATTESTATION_PROVIDER=none` disables attestation entirely. -## Troubleshooting - -Every row below was hit for real while bringing the first enclave up, in this order. - -| Symptom | Cause and fix | -| -------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -| `Firewall setup failed: creating docker network "default": operation is not permitted on predefined default network` | a network named `default` collides with Docker's predefined one. The canonical schema only reserves `shim-net`, so this **validates fine and fails on the host**. Name it anything else. | -| `FileNotFoundError: No usable temporary directory found in ['/tmp', ...]` and a crash-loop | containers run with a read-only rootfs, and `portalocker` (a syft dependency) calls `tempfile.gettempdir()` at import. Set `read_only: false`. | -| `2 validation errors for EnclaveSettings: email / data_owners Field required` — despite passing `--variable` | the shim only injects variables whose **key the measured config declares**. An undeclared `--variable` is silently dropped. Declare it as a bare name under `env:`. | -| The enclave hangs at "Building SyftEnclaveClient" | OAuth token refresh cannot reach `oauth2.googleapis.com`. See the egress note in `tinfoil-config.yml`: `allowlist` resolves hostnames to IPs once at boot and Google rotates them, so use `egress: open`. | -| `curl: (60) SSL certificate problem: self signed certificate` | expected. The enclave's TLS key is generated inside it and the report commits to that key, so there is no CA. Use `-k` (as `just tinfoil-attest` does) and get your trust from `just tinfoil-verify`. | -| `The server had an error while processing your request.` from the domain | the shim is up but your container is not serving on `upstream-port`. It has probably crashed — `just tinfoil-debug ` then `just tinfoil-logs`. | -| `ValueError: Serialization error: sender fingerprint mismatch` on a data owner's login | with `encryption=True` the keypair is persisted at `//private/crypto_keys.json`. A fresh `SYFTBOX_FOLDER` mints a new identity that cannot verify that account's own history on Drive. Point at the account's existing folder, or wipe its Drive state first. | -| `upstream port is not set` | the config's `shim.upstream-port` is missing; it is required | -| Config rejected: image must be a digest | `image:` uses a tag; it must be `repo@sha256:...` | -| A path 404s | it is not in `shim.paths`. `/.well-known/tinfoil-attestation` is exempt | -| `Tag vX.Y.Z already exists` | releases are permanent; pick the next version | -| `image_digest` check fails with a 400 from `github-proxy.tinfoil.sh` | expected — the proxy only serves `tinfoil.hash`; the verifier falls through to github.com | -| `MissingOptionalDependency: ... tinfoil` | `uv pip install "syft-enclave[tinfoil]"` | -| `measurement_match` fails | the running enclave is not the release you are verifying against — check the deployed tag with `just tinfoil-status` | -| Peer skipped with "published no attestation evidence" | the enclave booted outside a TEE, or with `attestation_provider=none` | - -Redeploying? Delete the accounts' syftboxes **first**, then redeploy — the enclave caches peer Drive folders at boot, so wiping state afterwards breaks the flow. - ## Formatting and validation ```bash diff --git a/packages/syft-enclave/docs/tinfoil_troubleshooting.md b/packages/syft-enclave/docs/tinfoil_troubleshooting.md new file mode 100644 index 00000000000..78a912ab9e4 --- /dev/null +++ b/packages/syft-enclave/docs/tinfoil_troubleshooting.md @@ -0,0 +1,31 @@ +# Tinfoil Troubleshooting + +Failures seen while running a Tinfoil enclave, and what each one means. Every +row in the table was hit for real while bringing the first enclave up, in the +order listed. + +For the deployment flow itself see [Tinfoil Deployment](./tinfoil_deployment.md); +for what the attestation does and does not prove, see +[Security Overview](./security.md). + +## Symptoms + +| Symptom | Cause and fix | +| -------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| `Firewall setup failed: creating docker network "default": operation is not permitted on predefined default network` | a network named `default` collides with Docker's predefined one. The canonical schema only reserves `shim-net`, so this **validates fine and fails on the host**. Name it anything else. | +| `FileNotFoundError: No usable temporary directory found in ['/tmp', ...]` and a crash-loop | containers run with a read-only rootfs, and `portalocker` (a syft dependency) calls `tempfile.gettempdir()` at import. Set `read_only: false`. | +| `2 validation errors for EnclaveSettings: email / data_owners Field required` — despite passing `--variable` | the shim only injects variables whose **key the measured config declares**. An undeclared `--variable` is silently dropped. Declare it as a bare name under `env:`. | +| The enclave hangs at "Building SyftEnclaveClient" | OAuth token refresh cannot reach `oauth2.googleapis.com`. See the egress note in `tinfoil-config.yml`: `allowlist` resolves hostnames to IPs once at boot and Google rotates them, so use `egress: open`. | +| `curl: (60) SSL certificate problem: self signed certificate` | expected. The enclave's TLS key is generated inside it and the report commits to that key, so there is no CA. Use `-k` (as `just tinfoil-attest` does) and get your trust from `just tinfoil-verify`. | +| `The server had an error while processing your request.` from the domain | the shim is up but your container is not serving on `upstream-port`. It has probably crashed — `just tinfoil-debug ` then `just tinfoil-logs`. | +| `ValueError: Serialization error: sender fingerprint mismatch` on a data owner's login | with `encryption=True` the keypair is persisted at `//private/crypto_keys.json`. A fresh `SYFTBOX_FOLDER` mints a new identity that cannot verify that account's own history on Drive. Point at the account's existing folder, or wipe its Drive state first. | +| `upstream port is not set` | the config's `shim.upstream-port` is missing; it is required | +| Config rejected: image must be a digest | `image:` uses a tag; it must be `repo@sha256:...` | +| A path 404s | it is not in `shim.paths`. `/.well-known/tinfoil-attestation` is exempt | +| `Tag vX.Y.Z already exists` | releases are permanent; pick the next version | +| `image_digest` check fails with a 400 from `github-proxy.tinfoil.sh` | expected — the proxy only serves `tinfoil.hash`; the verifier falls through to github.com | +| `MissingOptionalDependency: ... tinfoil` | `uv pip install "syft-enclave[tinfoil]"` | +| `measurement_match` fails | the running enclave is not the release you are verifying against — check the deployed tag with `just tinfoil-status` | +| Peer skipped with "published no attestation evidence" | the enclave booted outside a TEE, or with `attestation_provider=none` | + +Redeploying? Delete the accounts' syftboxes **first**, then redeploy — the enclave caches peer Drive folders at boot, so wiping state afterwards breaks the flow. diff --git a/packages/syft-enclave/scripts/verify_tinfoil.py b/packages/syft-enclave/scripts/verify_tinfoil.py index c124f35385f..ca1fc06ec96 100644 --- a/packages/syft-enclave/scripts/verify_tinfoil.py +++ b/packages/syft-enclave/scripts/verify_tinfoil.py @@ -50,7 +50,7 @@ def fetch_document(host: str) -> dict: "requests", extra="tinfoil", feature="Verifying a Tinfoil enclave", - docs="packages/syft-enclave/docs/tinfoil.md", + docs="packages/syft-enclave/docs/tinfoil_deployment.md", ) response = requests.get(f"https://{host}{ATTESTATION_PATH}", timeout=30) response.raise_for_status() diff --git a/packages/syft-enclave/src/syft_enclaves/attestation/tinfoil.py b/packages/syft-enclave/src/syft_enclaves/attestation/tinfoil.py index 621a80df2d6..068f22564aa 100644 --- a/packages/syft-enclave/src/syft_enclaves/attestation/tinfoil.py +++ b/packages/syft-enclave/src/syft_enclaves/attestation/tinfoil.py @@ -55,7 +55,7 @@ #: Tinfoil's GitHub proxy only serves some asset paths (``tinfoil.hash`` yes, #: ``tinfoil-deployment.json`` no), so fall through to GitHub itself. GITHUB_RELEASES = "https://github.com" -DOCS = "packages/syft-enclave/docs/tinfoil.md" +DOCS = "packages/syft-enclave/docs/tinfoil_deployment.md" REQUEST_TIMEOUT_SECONDS = 30 diff --git a/packages/syft-enclave/src/syft_enclaves/client.py b/packages/syft-enclave/src/syft_enclaves/client.py index a98dad92899..c7f3ee766f4 100644 --- a/packages/syft-enclave/src/syft_enclaves/client.py +++ b/packages/syft-enclave/src/syft_enclaves/client.py @@ -100,7 +100,7 @@ def attest_peer( The peer's evidence says which TEE produced it, and it is routed to that target's verifier (Confidential Space or Tinfoil). Verifying Tinfoil evidence needs the optional ``tinfoil`` package; see - ``docs/tinfoil.md``. + ``docs/tinfoil_deployment.md``. Args: peer_email: the enclave peer to attest. diff --git a/packages/syft-enclave/tests/test_attestation_tinfoil.py b/packages/syft-enclave/tests/test_attestation_tinfoil.py index 37d0b96e39c..f3f615cea87 100644 --- a/packages/syft-enclave/tests/test_attestation_tinfoil.py +++ b/packages/syft-enclave/tests/test_attestation_tinfoil.py @@ -522,7 +522,7 @@ def test_missing_tinfoil_explains_how_to_install_it(self, monkeypatch, pinned): ) message = str(excinfo.value) assert 'pip install "syft-enclave[tinfoil]"' in message - assert "docs/tinfoil.md" in message + assert "docs/tinfoil_deployment.md" in message def test_the_policy_is_usable_without_the_sdk(self): # Importing the module for its policy must not need the extra. From 00b2084f502c44ef38d77791d1da99bc3a6068d5 Mon Sep 17 00:00:00 2001 From: Koen van der Veen Date: Wed, 16 Sep 2026 11:00:00 +0200 Subject: [PATCH 06/21] docs: split the attestation guarantees by deployment target MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Section 6 now takes the two targets one at a time, because they reach key binding by genuinely different routes: Tinfoil binds over a connection the report vouches for, Confidential Spaces would bind inside the file via a workload-chosen nonce in the Google-signed token. Also corrects the Confidential Spaces story. What would make it work is that only code inside the measured container can get the launcher to sign bytes of its choosing — the unforgeable signature, not any secrecy of the measurement, which is public on both targets by design. And it is not implemented: build_eat_nonce() is called with no caller nonce in the publishing path, so the key bundle is still a swappable Drive file (H1 in the security review). Co-Authored-By: Claude Opus 5 (1M context) --- packages/syft-enclave/docs/security.md | 94 +++++++++++++++++--------- 1 file changed, 62 insertions(+), 32 deletions(-) diff --git a/packages/syft-enclave/docs/security.md b/packages/syft-enclave/docs/security.md index 1ccd96f34f6..f831ec45434 100644 --- a/packages/syft-enclave/docs/security.md +++ b/packages/syft-enclave/docs/security.md @@ -88,12 +88,9 @@ Because any peer can **verify the attestation report**, they know those public k produced by an enclave running the expected open-source container — not by some person who happens to have access to the account. -> **Status on each deployment target.** On **Tinfoil** this binding is implemented, by a different -> route than nonces: the report commits to the TLS key of the enclave's own endpoint, so a peer that -> pins its connection to that key can trust the key bundle served over it. `attest_peer` does this -> and sets the peer's keys from the result. On **Confidential Spaces** it is still unimplemented — -> the channel exists (a workload can inject nonces into the token) but is unused, so there the key -> bundle remains an unsigned Drive file. Section 6 has the detail. +> **Status.** This binding is implemented on **Tinfoil** (§6.1) and not yet on **Confidential +> Spaces** (§6.2), where the key bundle is still an unsigned Drive file. The two targets reach it by +> different routes, so §6 takes them one at a time. The data owners and the DS then download the enclave's verified keys (and share their own), and from that point on there is a **trusted, end-to-end secure channel** between the @@ -114,34 +111,67 @@ anyone trusting Google Drive, the network, or each other. ## 6. What attestation proves on each target -Section 5 describes the intent. What each deployment target actually delivers differs, so this is -the honest accounting. It is written for **Tinfoil**; where Confidential Spaces differs, it is -called out. - -**Proves.** The enclave is genuine SEV-SNP/TDX hardware with debug disabled and its firmware TCB at or above minimum; it booted the exact CVM image and config published in a named release of the config repo; that release was signed by GitHub OIDC from that repo's tag via Sigstore; and the container image digest matches the one you pinned. - -**Does not prove.** Which email or data owners the enclave was started with. Those are deploy-time `--variable`s, so they are outside the measurement and the attested code merely relays whatever its deployer handed it. Confidential Spaces is in the same position today (`tee-env-*` metadata is not checked either). - -**Key binding, and how it is achieved.** A workload cannot inject a nonce into the report: its 64 bytes of user data are the sha256 of the shim's TLS public key followed by its HPKE public key. But that is exactly what makes binding possible — the report _commits to the key terminating a TLS connection to the enclave_. So the client: - -1. verifies the report, -2. opens HTTPS to the enclave and checks the certificate it is served carries that same key, -3. checks the enclave signed the client's nonce with the key bundle it served, +Section 5 describes the intent. Both targets deliver the first half of it — proof of _what code is +running_ — but they differ on the second half, whether the enclave's keys are bound to that proof. +So this section is split by target. + +**Common to both.** A verified report proves the workload runs on genuine confidential-computing +hardware with debug disabled, and that the code and configuration are the ones published: on +Tinfoil, the CVM image and `tinfoil-config.yml` of a named, Sigstore-signed release of the config +repo, including the container image digest it pins; on Confidential Spaces, the image digest in the +token's claims. Neither proves anything about values supplied at deploy time — the enclave's email +and its configured data owners are deploy-time inputs on both targets, and attested code faithfully +relays whatever its deployer handed it. + +### 6.1 Tinfoil: the keys come down a connection the report vouches for + +A workload cannot inject a nonce into a Tinfoil report — its 64 bytes of user data are the sha256 of +the shim's TLS public key followed by its HPKE public key. But that is exactly what makes binding +possible, because the report **commits to the key terminating a TLS connection to the enclave**. So +the client: + +1. verifies the report; +2. opens HTTPS to the enclave and checks the certificate it is served carries that same key; +3. sends a random nonce and checks the enclave signed it with the identity key from the bundle it + served; 4. and then trusts that bundle, which came down the same connection. -No certificate authority is involved anywhere: the enclave's certificate is self-signed, and the _report_ is what decides whether to trust it. `attest_peer` then sets those keys for the peer, so the enclave's public keys are no longer an unsigned Drive file. This is the binding section 5 describes. - -It also gets freshness for free: a replayed report commits to a TLS key whose private half lives in an enclave the attacker does not control, so the pin fails. - -**Freshness comes from a nonce, not from the report.** A workload cannot influence the report's user data, so the client sends a random nonce and the enclave signs it with the identity key from the bundle it just served. That proves two things the report cannot: the enclave _holds the private half_ of the key we are about to encrypt to, and the answer was produced for _this_ exchange rather than replayed. The bundle is adopted only when both `key_binding` and `nonce_freshness` pass. - -**Drive is not a fallback.** Evidence is still published to `SYFT_version.json` — as provenance, and so the path exists if it is ever needed again — but the client always appraises a Tinfoil enclave from the live API. An unreachable enclave is an error, not a downgrade: accepting the Drive copy would silently mean unbound keys and a replayable report. - -**On Confidential Spaces**, none of the binding above is implemented. The report is a Google-signed -JWT fetched at boot and written once to `SYFT_version.json`, and `build_eat_nonce()` is called with -no caller nonce — so the enclave's public keys are not committed into it, and a captured token stays -acceptable for the length of the expiry grace window. The channel for fixing this exists (a workload -_can_ inject nonces into the token) but is unused. +No certificate authority is involved anywhere: the enclave's certificate is self-signed, and the +_report_ is what decides whether to trust it. Step 3 adds what the report cannot carry — that the +enclave **holds the private half** of the key we are about to encrypt to, and that the answer was +produced for this exchange rather than replayed. `attest_peer` then sets those keys for the peer, so +the enclave's public keys are no longer an unsigned Drive file. The bundle is adopted only when both +the pin and the nonce check pass. + +Replay is ruled out as a side effect: a captured report commits to a TLS key whose private half +lives in an enclave the attacker does not control, so the pin fails. + +The cost is that this needs the enclave **online**. Evidence is still published to +`SYFT_version.json` as provenance, but it is never appraised in place of the live exchange — doing +so would silently mean unbound keys and a replayable report. + +### 6.2 Confidential Spaces: the keys are not bound yet + +The same guarantee is reachable here, by a different route, and it is **not implemented**. + +Confidential Space gives a workload something Tinfoil does not: it can ask the launcher to embed +bytes of its choosing into the Google-signed token, via `eat_nonce`. Only code running inside the +measured container can do that, and the token's signature is unforgeable — so an enclave that put a +hash of its key bundle in a nonce slot would let any peer confirm the bundle came from inside that +container and was not swapped afterwards. Note that it is the _signature_ doing the work, not +secrecy: measurements and image digests are public on both targets, deliberately, because that is +what makes them auditable. + +Unlike Tinfoil, that binding would travel **inside the file**, needing no connection to the enclave +— which suits the offline-first transport better. + +Today the channel is unused. `build_eat_nonce()` is called with no caller nonce in the publishing +path, so the token carries only the syft version, and the key bundle remains a separate unsigned +Drive file that whoever controls the enclave's Drive account can replace. A Confidential Space +attestation therefore proves that _a_ genuine enclave running the expected image exists — not that +the keys you are about to encrypt to are that enclave's. The room is there (two nonce slots, 74 +characters each; a sha256 hex digest is 64), so the fix is small and tracked as H1 in the +[protocol security review](../../../research/protocol-security-review/SUMMARY.md). For how to deploy either target, see [Confidential Spaces Deployment](./terraform_cs.md) and [Tinfoil Deployment](./tinfoil_deployment.md). From fed35a1736385e425285fbe8a842772023a656bd Mon Sep 17 00:00:00 2001 From: Koen van der Veen Date: Wed, 16 Sep 2026 11:17:48 +0200 Subject: [PATCH 07/21] feat: bind the enclave's runtime facts into its Confidential Space token Confidential Space lets a workload put bytes of its choosing into the Google-signed token via eat_nonce, and only code inside the measured container can do that. Until now the channel was unused: build_eat_nonce() was called with no caller nonce, so the enclave's email, its configured data owners and its public key bundle were all self-asserted, and the key bundle was a Drive file whoever held the enclave's Drive account could swap. The enclave now publishes a claims document - email, data owners, syft version, key bundle - and commits to its sha256 in the token. The verifier recomputes the digest and compares; the document itself is untrusted, the digest is what makes it true. There is room for exactly one, since slot 0 carries the version and a nonce is capped at 74 chars of [a-zA-Z0-9_.-], which also rules out carrying an email literally. This reaches further than the Tinfoil binding: it covers the data owners, which are the approval gate, and it travels inside the file so it needs no connection to the enclave. AppraisalPolicy gains expected_email and expected_data_owners, because binding proves the enclave was started with those values while only the caller knows whether they are the right ones. Also fixes AttestationResult.all_passed and first_failure, which treated a skipped check as a failure. The verifiers raise on 'passed is False', so a successful appraisal with anything unpinned was reported as failed and first_failure() could point at a check that never ran. Co-Authored-By: Claude Opus 5 (1M context) --- packages/syft-enclave/docs/security.md | 65 +++-- .../src/syft_enclaves/attestation/claims.py | 81 ++++++ .../attestation/confidential_space.py | 121 ++++++++- .../src/syft_enclaves/attestation/dispatch.py | 7 +- .../src/syft_enclaves/attestation/envelope.py | 19 +- .../src/syft_enclaves/attestation/result.py | 12 +- .../evidence/confidential_space.py | 26 +- .../src/syft_enclaves/evidence/tinfoil.py | 13 +- .../syft-enclave/src/syft_enclaves/runner.py | 40 ++- .../tests/test_attestation_claims.py | 231 ++++++++++++++++++ .../test_attestation_confidential_space.py | 43 +++- packages/syft-enclave/tests/test_runner.py | 60 +++++ 12 files changed, 673 insertions(+), 45 deletions(-) create mode 100644 packages/syft-enclave/src/syft_enclaves/attestation/claims.py create mode 100644 packages/syft-enclave/tests/test_attestation_claims.py diff --git a/packages/syft-enclave/docs/security.md b/packages/syft-enclave/docs/security.md index f831ec45434..a6b4313a754 100644 --- a/packages/syft-enclave/docs/security.md +++ b/packages/syft-enclave/docs/security.md @@ -81,16 +81,19 @@ enclave's Google Drive account, so that account cannot be trusted the way a pers To get around this, the enclave builds a **secure channel out of the insecure account** using **attestation**. An attestation report is a cryptographically signed statement from the confidential-compute hardware that says, in effect, _"this exact, open-source docker container (with syft inside it) is what is -running here."_ The enclave generates a fresh encryption and signing keypair, **embeds its public -keys into that attestation report**, and shares the report on Google Drive with all peers. +running here."_ The enclave generates a fresh encryption and signing keypair and **binds its public keys to that +attestation report**, then shares the report on Google Drive with all peers. How the binding is +achieved differs per target — section 6 — but the effect is the same: the keys cannot be swapped +without the report failing to verify. Because any peer can **verify the attestation report**, they know those public keys were genuinely produced by an enclave running the expected open-source container — not by some person who happens to have access to the account. -> **Status.** This binding is implemented on **Tinfoil** (§6.1) and not yet on **Confidential -> Spaces** (§6.2), where the key bundle is still an unsigned Drive file. The two targets reach it by -> different routes, so §6 takes them one at a time. +> **Status.** This binding is implemented on both targets, by different routes: **Tinfoil** binds +> over a connection its report vouches for (§6.1), **Confidential Spaces** commits to the facts +> inside the signed token itself (§6.2). The Confidential Space route reaches further, covering the +> enclave's email and configured data owners as well as its keys. The data owners and the DS then download the enclave's verified keys (and share their own), and from that point on there is a **trusted, end-to-end secure channel** between the @@ -150,28 +153,42 @@ The cost is that this needs the enclave **online**. Evidence is still published `SYFT_version.json` as provenance, but it is never appraised in place of the live exchange — doing so would silently mean unbound keys and a replayable report. -### 6.2 Confidential Spaces: the keys are not bound yet - -The same guarantee is reachable here, by a different route, and it is **not implemented**. +### 6.2 Confidential Spaces: the facts are committed to inside the token Confidential Space gives a workload something Tinfoil does not: it can ask the launcher to embed bytes of its choosing into the Google-signed token, via `eat_nonce`. Only code running inside the -measured container can do that, and the token's signature is unforgeable — so an enclave that put a -hash of its key bundle in a nonce slot would let any peer confirm the bundle came from inside that -container and was not swapped afterwards. Note that it is the _signature_ doing the work, not -secrecy: measurements and image digests are public on both targets, deliberately, because that is -what makes them auditable. - -Unlike Tinfoil, that binding would travel **inside the file**, needing no connection to the enclave -— which suits the offline-first transport better. - -Today the channel is unused. `build_eat_nonce()` is called with no caller nonce in the publishing -path, so the token carries only the syft version, and the key bundle remains a separate unsigned -Drive file that whoever controls the enclave's Drive account can replace. A Confidential Space -attestation therefore proves that _a_ genuine enclave running the expected image exists — not that -the keys you are about to encrypt to are that enclave's. The room is there (two nonce slots, 74 -characters each; a sha256 hex digest is 64), so the fix is small and tracked as H1 in the -[protocol security review](../../../research/protocol-security-review/SUMMARY.md). +measured container can do that, and the token's signature is unforgeable. Note that it is the +_signature_ doing the work, not secrecy — measurements and image digests are public on both targets, +deliberately, because that is what makes them auditable. + +So the enclave publishes a **claims document** — its email, its configured data owners, its syft +version and its public key bundle — and commits to that document's sha256 in the token. A verifier +recomputes the digest from the published document and compares. The document travels in the clear +and is untrusted; the digest inside the signed token is what makes it true. + +There is room for exactly one digest. Slot 0 carries the syft version in plain text, and a nonce is +capped at 74 characters matching `[a-zA-Z0-9_.-]` — which rules out carrying an email address +literally, and leaves a 64-character sha256 hex comfortably inside. Hence one digest over one +document rather than a field per fact. + +This closes the gap section 5 describes, and closes it more cheaply than Tinfoil does: the binding +travels **inside the file**, so it needs no connection to the enclave, which suits the offline-first +transport. It also reaches further. Tinfoil binds the enclave's keys; here the same commitment +covers the enclave's **email and its configured data owners**, which are deploy-time inputs and +otherwise unverifiable. That matters most for the data owners, because that list is the approval gate +— a job runs only once all of them approve, so an operator who could change it unobserved could +approve work on their own. + +Binding proves the enclave really was started with those values. Whether they are the _right_ values +is the verifier's call, so `AppraisalPolicy` takes an optional `expected_email` and +`expected_data_owners`: left unset the attested values are reported, set they are required. + +What it does not give is freshness. The token is minted once at boot and written to +`SYFT_version.json`, so a captured one stays acceptable for the length of the expiry grace window; +the second nonce slot is spent on the claims digest, so there is no room for a per-verifier +challenge as well. Tinfoil gets freshness from its live connection instead. Bounded staleness — +re-publishing periodically and narrowing the grace window — is the remaining work, tracked as H16 in +the [protocol security review](../../../research/protocol-security-review/SUMMARY.md). For how to deploy either target, see [Confidential Spaces Deployment](./terraform_cs.md) and [Tinfoil Deployment](./tinfoil_deployment.md). diff --git a/packages/syft-enclave/src/syft_enclaves/attestation/claims.py b/packages/syft-enclave/src/syft_enclaves/attestation/claims.py new file mode 100644 index 00000000000..9f7f14d72e3 --- /dev/null +++ b/packages/syft-enclave/src/syft_enclaves/attestation/claims.py @@ -0,0 +1,81 @@ +"""What a Confidential Space enclave commits to in its attestation token. + +Confidential Space lets a workload put bytes of its choosing into the +Google-signed token, via ``eat_nonce``. Only code running inside the measured +container can do that, and the signature is unforgeable — so anything the +enclave commits to there is as trustworthy as the measurement itself. + +That is the one channel for binding *runtime* facts to the report. The enclave's +email, its configured data owners and its public key bundle are all deploy-time +or runtime values, outside the measurement, and until they are bound a verifier +has only the enclave's unsigned word for them. + +There is room for exactly one digest: slot 0 carries the syft version in plain +text, and a nonce is capped at 74 characters matching ``[a-zA-Z0-9_.-]``, which +rules out an email address and leaves a 64-character sha256 hex comfortably +inside. So the enclave publishes a claims document alongside its token and +commits to its digest. The document itself is untrusted — the digest is what +makes it true. + +Tinfoil cannot do this: its report's user data is the shim's own keys, with no +workload channel. It reaches the same guarantee over a pinned connection +instead; see ``attestation.https``. +""" + +from __future__ import annotations + +import hashlib +import json +from typing import Any, Optional + +CLAIMS_VERSION = 1 + + +class ClaimsBindingError(Exception): + """The published claims are not the ones the token commits to.""" + + +def build_claims( + email: str, + data_owners: list[str], + syft_version: str, + key_bundle: Optional[dict[str, Any]] = None, +) -> dict[str, Any]: + """The runtime facts an enclave commits to. + + ``data_owners`` is sorted so the digest does not depend on the order the + operator happened to pass them in. + """ + return { + "claims_version": CLAIMS_VERSION, + "email": email, + "data_owners": sorted(data_owners), + "syft_version": syft_version, + "key_bundle": key_bundle, + } + + +def claims_digest(claims: dict[str, Any]) -> str: + """The sha256 the token's nonce carries, hex encoded. + + Canonical JSON (sorted keys, no whitespace) so both sides compute the same + bytes from the same facts. + """ + canonical = json.dumps(claims, sort_keys=True, separators=(",", ":")) + return hashlib.sha256(canonical.encode()).hexdigest() + + +def verify_claims_digest(claims: dict[str, Any], digest: str) -> None: + """Check *claims* is what *digest* commits to. + + Raises :class:`ClaimsBindingError` on mismatch: the claims were altered + after the token was minted, or they belong to a different token. + """ + if not digest: + raise ClaimsBindingError("the token commits to no claims digest") + actual = claims_digest(claims) + if actual != digest: + raise ClaimsBindingError( + f"published claims hash to {actual[:16]}… but the token commits to " + f"{digest[:16]}… — they have been altered or do not belong together" + ) diff --git a/packages/syft-enclave/src/syft_enclaves/attestation/confidential_space.py b/packages/syft-enclave/src/syft_enclaves/attestation/confidential_space.py index 2c03f5233a0..28201466ee5 100644 --- a/packages/syft-enclave/src/syft_enclaves/attestation/confidential_space.py +++ b/packages/syft-enclave/src/syft_enclaves/attestation/confidential_space.py @@ -1,9 +1,14 @@ -"""Attestation verification for enclave peers. +"""Appraising Confidential Space evidence. -When a researcher calls ``add_peer(enclave_email)``, the enclave's -``SYFT_version.json`` may contain an ``attestation_token`` — a Google-signed -JWT from Confidential Spaces. This module verifies that token and checks -the claims inside it to ensure the enclave is trustworthy. +The enclave publishes a Google-signed JWT from the Confidential Space launcher. +This module verifies that token, checks the hardware and container claims +inside it, and checks the **claims binding**: the enclave commits to a digest of +its own runtime facts — email, configured data owners, key bundle — in the +token's spare nonce slot, which is the only thing that makes those facts +trustworthy rather than self-asserted. See ``attestation.claims``. + +Whether those facts are the ones the verifier wanted is a separate question, +answered by ``AppraisalPolicy.expected_email`` and ``expected_data_owners``. """ from __future__ import annotations @@ -16,6 +21,7 @@ from syft.version import SYFT_VERSION +from syft_enclaves.attestation.claims import ClaimsBindingError, verify_claims_digest from syft_enclaves.attestation.result import ( AttestationError, AttestationResult, @@ -55,12 +61,111 @@ class AppraisalPolicy(BaseModel): expected_image_digest: Optional[str] = None # By default, the enclave must run the same version of syft as the verifier. expected_syft_version: Optional[str] = SYFT_VERSION + # Runtime facts the enclave commits to in its token. None → the value is + # reported but not required; set one to refuse an enclave that was started + # with anything else. These are only meaningful because the token binds + # them (see attestation.claims); without the binding they would be the + # enclave's unsigned word. + expected_email: Optional[str] = None + expected_data_owners: Optional[list[str]] = None + + +def _nonce_slots(claims: dict) -> list[str]: + """The token's eat_nonce as a list; Google returns a bare string for one.""" + nonce = claims.get("eat_nonce", []) + return [nonce] if isinstance(nonce, str) else list(nonce) + + +def _check_claims_binding( + result: AttestationResult, + claims: dict, + published_claims: Optional[dict], + policy: AppraisalPolicy, + verbose: bool, +) -> None: + """Check the published runtime facts are the ones the token commits to. + + This is what turns the enclave's email, its data owners and its key bundle + from unsigned assertions into attested ones: only code inside the measured + container can get the launcher to sign a digest of them. + """ + if verbose: + print(" ⏳ Claims binding ...") + if published_claims is None: + result.add( + "claims_binding", + "Claims binding", + None, + "the enclave published no claims, so its email, data owners and " + "keys are unattested (skipped)", + ) + return + + slots = _nonce_slots(claims) + digest = slots[1] if len(slots) > 1 else "" + try: + verify_claims_digest(published_claims, digest) + except ClaimsBindingError as e: + result.add("claims_binding", "Claims binding", False, str(e)) + return + + owners = published_claims.get("data_owners") or [] + result.add( + "claims_binding", + "Claims binding", + True, + f"token commits to email={published_claims.get('email')!r} and " + f"{len(owners)} data owner(s)", + ) + if published_claims.get("key_bundle"): + result.verified_key_bundle = published_claims["key_bundle"] + _check_expected_claims(result, published_claims, policy, verbose) + + +def _check_expected_claims( + result: AttestationResult, + published_claims: dict, + policy: AppraisalPolicy, + verbose: bool, +) -> None: + """Compare the now-attested facts against what the verifier expected. + + Binding proves the enclave really was started with these values; only the + caller knows whether they are the right ones. + """ + for name, label, expected, actual in [ + ( + "enclave_email", + "Enclave email", + policy.expected_email, + published_claims.get("email"), + ), + ( + "data_owners", + "Data owners", + sorted(policy.expected_data_owners) + if policy.expected_data_owners is not None + else None, + published_claims.get("data_owners"), + ), + ]: + if verbose: + print(f" ⏳ {label} ...") + if expected is None: + result.add(name, label, None, f"not pinned; enclave reports {actual!r}") + elif expected == actual: + result.add(name, label, True, f"matches {actual!r}") + else: + result.add( + name, label, False, f"enclave reports {actual!r}, expected {expected!r}" + ) def verify_attestation_token( token: str, policy: AppraisalPolicy | None = None, verbose: bool = True, + published_claims: Optional[dict] = None, ) -> AttestationResult: """Verify an attestation JWT and return the result checklist. @@ -127,7 +232,11 @@ def verify_attestation_token( ) raise AttestationError("JWT signature verification failed", result) from e - # 2. Secure boot + # 2. Claims binding — the enclave's runtime facts, committed to in the + # token's spare nonce slot. Skipped when the enclave published none. + _check_claims_binding(result, claims, published_claims, policy, verbose) + + # 3. Secure boot if verbose: print(" ⏳ Secure boot ...") secboot = claims.get("secboot") diff --git a/packages/syft-enclave/src/syft_enclaves/attestation/dispatch.py b/packages/syft-enclave/src/syft_enclaves/attestation/dispatch.py index c800231e6ab..67a6c5552f1 100644 --- a/packages/syft-enclave/src/syft_enclaves/attestation/dispatch.py +++ b/packages/syft-enclave/src/syft_enclaves/attestation/dispatch.py @@ -34,7 +34,12 @@ def verify_evidence( """Appraise *evidence* with the verifier for its kind.""" if evidence.kind is AttestationKind.CONFIDENTIAL_SPACE: _require_policy_type(evidence.kind, policy, AppraisalPolicy) - return verify_attestation_token(evidence.body, policy=policy, verbose=verbose) + return verify_attestation_token( + evidence.body, + policy=policy, + verbose=verbose, + published_claims=evidence.metadata.get("claims"), + ) if evidence.kind is AttestationKind.TINFOIL: # Imported here so installs without the tinfoil extra can still verify diff --git a/packages/syft-enclave/src/syft_enclaves/attestation/envelope.py b/packages/syft-enclave/src/syft_enclaves/attestation/envelope.py index 482ace12a85..e9592278206 100644 --- a/packages/syft-enclave/src/syft_enclaves/attestation/envelope.py +++ b/packages/syft-enclave/src/syft_enclaves/attestation/envelope.py @@ -114,13 +114,26 @@ def read_from(cls, version_info: Any) -> Optional["AttestationEvidence"]: return cls.from_version_field((version_info.extra or {}).get(EXTRA_KEY)) -def confidential_space_evidence(token: str, audience: str) -> AttestationEvidence: - """Wrap a Confidential Space attestation JWT.""" +def confidential_space_evidence( + token: str, + audience: str, + claims: Optional[dict[str, Any]] = None, +) -> AttestationEvidence: + """Wrap a Confidential Space attestation JWT. + + ``claims`` are the runtime facts the token's nonce commits to — the + enclave's email, its data owners and its key bundle. Carried in the clear + and untrusted: the digest inside the signed token is what makes them true. + See ``attestation.claims``. + """ + metadata: dict[str, Any] = {"audience": audience} + if claims is not None: + metadata["claims"] = claims return AttestationEvidence( kind=AttestationKind.CONFIDENTIAL_SPACE, format=CONFIDENTIAL_SPACE_FORMAT, body=token, - metadata={"audience": audience}, + metadata=metadata, ) diff --git a/packages/syft-enclave/src/syft_enclaves/attestation/result.py b/packages/syft-enclave/src/syft_enclaves/attestation/result.py index 42e0eaf3878..a05164cade8 100644 --- a/packages/syft-enclave/src/syft_enclaves/attestation/result.py +++ b/packages/syft-enclave/src/syft_enclaves/attestation/result.py @@ -42,10 +42,18 @@ def add(self, name: str, label: str, passed: bool, detail: str) -> None: ) def all_passed(self) -> bool: - return all(c.passed for c in self.checks) + """Whether nothing failed. A *skipped* check is not a failure. + + Matches how the verifiers decide to raise: they look for + ``passed is False``. Treating ``None`` as failure would report a + successful appraisal as failed whenever the policy left something + unpinned, which is the default for several checks. + """ + return all(check.passed is not False for check in self.checks) def first_failure(self) -> CheckResult | None: - return next((c for c in self.checks if not c.passed), None) + """The first check that actually failed, skipping the skipped ones.""" + return next((check for check in self.checks if check.passed is False), None) def print_checklist(self) -> None: for check in self.checks: diff --git a/packages/syft-enclave/src/syft_enclaves/evidence/confidential_space.py b/packages/syft-enclave/src/syft_enclaves/evidence/confidential_space.py index 1d7e9db7bc8..8635d006cb4 100644 --- a/packages/syft-enclave/src/syft_enclaves/evidence/confidential_space.py +++ b/packages/syft-enclave/src/syft_enclaves/evidence/confidential_space.py @@ -12,6 +12,7 @@ from datetime import datetime, timezone from typing import Any, Optional +from syft_enclaves.attestation.claims import claims_digest from syft_enclaves.attestation.envelope import ( AttestationEvidence, AttestationKind, @@ -42,9 +43,28 @@ def from_settings(cls, settings: Any = None) -> "ConfidentialSpaceProvider": # Nothing to configure: the launcher socket and audience are fixed. return cls() - def collect(self, caller_nonce: Optional[str] = None) -> AttestationEvidence: - token = fetch_attestation_token(eat_nonce=build_eat_nonce(caller_nonce)) - return confidential_space_evidence(token, audience=TOKEN_AUDIENCE) + def collect( + self, + caller_nonce: Optional[str] = None, + claims: Optional[dict] = None, + ) -> AttestationEvidence: + """Mint a token, committing to *claims* in its spare nonce slot. + + ``caller_nonce`` and ``claims`` are alternatives: there is only one + spare slot, so a caller asking for a freshness nonce gets that instead + of a claims binding. The runner binds claims; the HTTP endpoint answers + a caller's nonce. + """ + if caller_nonce and claims: + raise ValueError( + "Confidential Space has one spare nonce slot: pass either a " + "caller nonce or claims to bind, not both." + ) + nonce = caller_nonce or (claims_digest(claims) if claims else None) + token = fetch_attestation_token(eat_nonce=build_eat_nonce(nonce)) + return confidential_space_evidence( + token, audience=TOKEN_AUDIENCE, claims=claims + ) def describe(self, evidence: AttestationEvidence) -> dict[str, Any]: return structure_claims(decode_jwt_payload(evidence.body)) diff --git a/packages/syft-enclave/src/syft_enclaves/evidence/tinfoil.py b/packages/syft-enclave/src/syft_enclaves/evidence/tinfoil.py index 54c7c05cca5..3ccea7122cc 100644 --- a/packages/syft-enclave/src/syft_enclaves/evidence/tinfoil.py +++ b/packages/syft-enclave/src/syft_enclaves/evidence/tinfoil.py @@ -59,7 +59,18 @@ def from_settings(cls, settings: Any = None) -> "TinfoilProvider": host=getattr(settings, "tinfoil_host", None), ) - def collect(self, caller_nonce: Optional[str] = None) -> AttestationEvidence: + def collect( + self, + caller_nonce: Optional[str] = None, + claims: Optional[dict] = None, + ) -> AttestationEvidence: + if claims is not None: + raise ValueError( + "Tinfoil evidence cannot commit to claims: the report's user " + "data is the shim's own keys, with no workload channel. The " + "equivalent guarantee comes from a pinned connection instead " + "(see attestation.https)." + ) if caller_nonce is not None: raise ValueError( "Tinfoil evidence cannot carry a caller nonce: the report's 64 " diff --git a/packages/syft-enclave/src/syft_enclaves/runner.py b/packages/syft-enclave/src/syft_enclaves/runner.py index 0e4ba3eb732..1ad26f1c485 100644 --- a/packages/syft-enclave/src/syft_enclaves/runner.py +++ b/packages/syft-enclave/src/syft_enclaves/runner.py @@ -17,6 +17,9 @@ from typing import Callable, Optional from syft.sync.peers.peer_store import datasite_crypto_keys_path +from syft.version import SYFT_VERSION + +from syft_enclaves.attestation.claims import build_claims from syft_enclaves.client import SyftEnclaveClient from syft_enclaves.evidence.key_bundle import write_public_bundle @@ -141,12 +144,14 @@ def _on_attesting(self) -> None: logger.info( "TEE detected (%s) — collecting attestation evidence", provider.kind.value ) - self._publish_attestation(provider) + # The key bundle first: on a target that can bind claims, the token + # commits to a digest covering it, so it has to exist before minting. self._publish_key_bundle() + self._publish_attestation(provider) def _publish_attestation(self, provider: EvidenceProvider) -> None: """Write the provider's evidence into the peer-visible version file.""" - evidence = provider.collect() + evidence = provider.collect(**self._binding_for(provider)) peer_manager = self.client._rds.peer_manager evidence.publish_to(peer_manager.get_own_version()) peer_manager.write_own_version() @@ -155,6 +160,37 @@ def _publish_attestation(self, provider: EvidenceProvider) -> None: evidence.kind.value, ) + def _binding_for(self, provider: EvidenceProvider) -> dict: + """The runtime facts to commit to, on targets that can commit to any. + + Confidential Space lets the workload put a digest into the signed + token, which is the only way a peer can trust the enclave's email, its + configured data owners or its key bundle — all runtime values outside + the measurement. Tinfoil has no such channel and binds over a pinned + connection instead, so it gets nothing here. + """ + if not getattr(provider, "accepts_caller_nonce", False): + return {} + peer_store = self.client._rds.peer_manager.peer_store + bundle = ( + peer_store.get_public_bundle() + if peer_store.use_encryption and peer_store.has_my_keys() + else None + ) + claims = build_claims( + email=self.client.email, + data_owners=self.client.data_owners, + syft_version=SYFT_VERSION, + key_bundle=bundle, + ) + logger.info( + "Binding runtime claims into the attestation token: email, " + "%d data owner(s), key bundle %s", + len(claims["data_owners"]), + "included" if bundle else "absent", + ) + return {"claims": claims} + def _publish_key_bundle(self) -> None: """Expose our public key bundle on the attestation endpoint. diff --git a/packages/syft-enclave/tests/test_attestation_claims.py b/packages/syft-enclave/tests/test_attestation_claims.py new file mode 100644 index 00000000000..2ca1275c808 --- /dev/null +++ b/packages/syft-enclave/tests/test_attestation_claims.py @@ -0,0 +1,231 @@ +"""Tests for binding an enclave's runtime facts into its Confidential Space token. + +The point of the binding: the enclave's email, its configured data owners and +its key bundle are runtime values outside the measurement. Only code inside the +measured container can get the launcher to sign a digest of them, so the digest +is what turns them from the enclave's unsigned word into attested facts. +""" + +from unittest.mock import MagicMock, patch + +import pytest + +from syft.version import SYFT_VERSION + +from syft_enclaves.attestation import AttestationError, verify_evidence +from syft_enclaves.attestation.claims import ( + ClaimsBindingError, + build_claims, + claims_digest, + verify_claims_digest, +) +from syft_enclaves.attestation.confidential_space import AppraisalPolicy +from syft_enclaves.attestation.envelope import confidential_space_evidence +from syft_enclaves.evidence.tee_token import validate_nonce + +EMAIL = "enclave@openmined.org" +OWNERS = ["model_owner@openmined.org", "benchmark_owner@openmined.org"] +BUNDLE = {"identity": EMAIL, "verificationMethod": [{"id": "#key"}]} + + +def _claims(**overrides): + claims = build_claims( + email=EMAIL, data_owners=OWNERS, syft_version=SYFT_VERSION, key_bundle=BUNDLE + ) + claims.update(overrides) + return claims + + +class TestClaimsDocument: + def test_the_digest_fits_a_confidential_space_nonce(self): + # The hard constraint: 74 chars max, [a-zA-Z0-9_.-] only, which is why + # an email cannot be carried raw. + digest = claims_digest(_claims()) + assert len(digest) == 64 + assert validate_nonce(digest) is None + + def test_data_owner_order_does_not_change_the_digest(self): + forward = build_claims(EMAIL, OWNERS, SYFT_VERSION, BUNDLE) + reversed_ = build_claims(EMAIL, list(reversed(OWNERS)), SYFT_VERSION, BUNDLE) + assert claims_digest(forward) == claims_digest(reversed_) + + def test_round_trip_verifies(self): + claims = _claims() + verify_claims_digest(claims, claims_digest(claims)) + + @pytest.mark.parametrize( + "tampered", + [ + {"email": "attacker@evil.com"}, + {"data_owners": ["attacker@evil.com"]}, + {"key_bundle": {"identity": "attacker@evil.com"}}, + {"syft_version": "0.0.1"}, + ], + ) + def test_any_alteration_is_caught(self, tampered): + claims = _claims() + digest = claims_digest(claims) + with pytest.raises(ClaimsBindingError): + verify_claims_digest(_claims(**tampered), digest) + + def test_a_missing_digest_is_refused(self): + with pytest.raises(ClaimsBindingError, match="commits to no claims"): + verify_claims_digest(_claims(), "") + + +@pytest.fixture +def token_with(monkeypatch): + """A verified CS token whose nonce slots we control.""" + + def _install(version_nonce=f"syft-{SYFT_VERSION}", claims_nonce=None): + nonces = [version_nonce] + ([claims_nonce] if claims_nonce else []) + verified = { + "secboot": True, + "dbgstat": "disabled-since-boot", + "eat_nonce": nonces, + "submods": {"container": {"image_digest": "sha256:abc"}}, + } + monkeypatch.setattr( + "syft_enclaves.attestation.confidential_space.id_token.verify_token", + MagicMock(return_value=verified), + ) + monkeypatch.setattr( + "syft_enclaves.attestation.confidential_space.google_requests.Request", + MagicMock(), + ) + + return _install + + +def _check(result, name): + return next(c for c in result.checks if c.name == name) + + +class TestBindingThroughTheToken: + def test_bound_claims_are_accepted_and_the_bundle_adopted(self, token_with): + claims = _claims() + token_with(claims_nonce=claims_digest(claims)) + evidence = confidential_space_evidence("a.b.c", "syft-attestation", claims) + + result = verify_evidence(evidence, verbose=False) + + assert _check(result, "claims_binding").passed is True + assert EMAIL in _check(result, "claims_binding").detail + # The bundle is attested, so it may be trusted for the peer. + assert result.verified_key_bundle == BUNDLE + + def test_claims_altered_after_minting_are_rejected(self, token_with): + # The digest is over the real claims; the enclave (or whoever holds its + # Drive account) publishes different ones. + token_with(claims_nonce=claims_digest(_claims())) + evidence = confidential_space_evidence( + "a.b.c", "syft-attestation", _claims(data_owners=["attacker@evil.com"]) + ) + + with pytest.raises(AttestationError) as excinfo: + verify_evidence(evidence, verbose=False) + + assert _check(excinfo.value.result, "claims_binding").passed is False + # An unbound bundle must never be adopted. + assert excinfo.value.result.verified_key_bundle is None + + def test_claims_with_no_digest_in_the_token_are_rejected(self, token_with): + # An enclave that publishes claims but binds nothing proves nothing. + token_with(claims_nonce=None) + evidence = confidential_space_evidence("a.b.c", "syft-attestation", _claims()) + + with pytest.raises(AttestationError) as excinfo: + verify_evidence(evidence, verbose=False) + + assert _check(excinfo.value.result, "claims_binding").passed is False + + def test_no_claims_at_all_is_skipped_not_failed(self, token_with): + # Older enclaves publish no claims; that is a missing guarantee, not a + # failed verification. + token_with() + evidence = confidential_space_evidence("a.b.c", "syft-attestation") + + result = verify_evidence(evidence, verbose=False) + + assert _check(result, "claims_binding").passed is None + assert result.all_passed() + + +class TestExpectedValues: + """Binding proves the enclave was started with these; only the caller + knows whether they are the right ones.""" + + def _verify(self, token_with, policy, claims=None): + claims = claims or _claims() + token_with(claims_nonce=claims_digest(claims)) + evidence = confidential_space_evidence("a.b.c", "syft-attestation", claims) + return verify_evidence(evidence, policy=policy, verbose=False) + + def test_matching_email_and_owners_pass(self, token_with): + result = self._verify( + token_with, + AppraisalPolicy(expected_email=EMAIL, expected_data_owners=OWNERS), + ) + assert _check(result, "enclave_email").passed is True + assert _check(result, "data_owners").passed is True + + def test_unpinned_values_are_reported_not_required(self, token_with): + result = self._verify(token_with, AppraisalPolicy()) + assert _check(result, "enclave_email").passed is None + assert EMAIL in _check(result, "enclave_email").detail + + def test_a_different_email_fails(self, token_with): + with pytest.raises(AttestationError): + self._verify( + token_with, AppraisalPolicy(expected_email="someone-else@openmined.org") + ) + + def test_an_unexpected_data_owner_fails(self, token_with): + # The load-bearing one: data_owners gates job approval. + with pytest.raises(AttestationError) as excinfo: + self._verify( + token_with, + AppraisalPolicy(expected_data_owners=["model_owner@openmined.org"]), + ) + assert _check(excinfo.value.result, "data_owners").passed is False + + def test_expected_owner_order_does_not_matter(self, token_with): + result = self._verify( + token_with, AppraisalPolicy(expected_data_owners=list(reversed(OWNERS))) + ) + assert _check(result, "data_owners").passed is True + + +class TestProviderBinding: + def test_the_confidential_space_provider_binds_the_digest(self): + from syft_enclaves.evidence.confidential_space import ConfidentialSpaceProvider + + claims = _claims() + with patch( + "syft_enclaves.evidence.confidential_space.fetch_attestation_token", + return_value="a.b.c", + ) as fetch: + evidence = ConfidentialSpaceProvider().collect(claims=claims) + + assert fetch.call_args.kwargs["eat_nonce"][1] == claims_digest(claims) + assert evidence.metadata["claims"] == claims + + def test_one_slot_means_a_nonce_and_claims_are_exclusive(self): + from syft_enclaves.evidence.confidential_space import ConfidentialSpaceProvider + + with pytest.raises(ValueError, match="one spare nonce slot"): + ConfidentialSpaceProvider().collect(caller_nonce="abc", claims=_claims()) + + def test_tinfoil_cannot_bind_claims(self, tmp_path, monkeypatch): + """Tinfoil has no workload channel, so it refuses rather than ignores.""" + import json + + from syft_enclaves.evidence.tinfoil import TinfoilProvider + + path = tmp_path / "attestation.json" + path.write_text(json.dumps({"format": "x", "body": "y"})) + monkeypatch.setattr( + "syft_enclaves.evidence.tinfoil.TINFOIL_ATTESTATION_PATH", path + ) + with pytest.raises(ValueError, match="cannot commit to claims"): + TinfoilProvider().collect(claims=_claims()) diff --git a/packages/syft-enclave/tests/test_attestation_confidential_space.py b/packages/syft-enclave/tests/test_attestation_confidential_space.py index f34c95df20b..256b758dbbd 100644 --- a/packages/syft-enclave/tests/test_attestation_confidential_space.py +++ b/packages/syft-enclave/tests/test_attestation_confidential_space.py @@ -64,8 +64,11 @@ def test_all_checks_pass(self, mock_verify): "fake-token", policy=DEFAULT_TEST_POLICY, verbose=False ) assert result.all_passed() - assert len(result.checks) == 5 - assert all(c.passed for c in result.checks) + assert len(result.checks) == 6 + # claims_binding skips here: this fixture publishes no claims, so + # there is nothing for the token to be checked against. + assert [c.name for c in result.checks if c.passed is None] == ["claims_binding"] + assert all(c.passed is not False for c in result.checks) def test_jwt_signature_failure(self, mock_verify): mock_verify.side_effect = ValueError("bad signature") @@ -174,9 +177,10 @@ def test_runs_all_checks_after_failure(self, mock_verify): with pytest.raises(AttestationError) as exc_info: verify_attestation_token("fake-token", verbose=False) check_names = [c.name for c in exc_info.value.result.checks] - # All five checks should appear, even though secure_boot failed early. + # Every check should appear, even though secure_boot failed early. assert check_names == [ "jwt_signature", + "claims_binding", "secure_boot", "debug_disabled", "version_match", @@ -237,3 +241,36 @@ def test_first_failure_none_when_all_pass(self): result = AttestationResult() result.add("a", "A", True, "ok") assert result.first_failure() is None + + +class TestSkippedIsNotFailed: + """A skipped check must not read as a failure. + + Several checks skip by default when the policy pins nothing, so conflating + skipped with failed would report a successful appraisal as failed — and + make first_failure() point at a check that never ran. + """ + + def test_all_passed_ignores_skipped(self): + result = AttestationResult() + result.add("a", "A", True, "") + result.add("b", "B", None, "skipped") + assert result.all_passed() + + def test_all_passed_is_false_on_a_real_failure(self): + result = AttestationResult() + result.add("a", "A", None, "skipped") + result.add("b", "B", False, "failed") + assert not result.all_passed() + + def test_first_failure_skips_the_skipped(self): + result = AttestationResult() + result.add("a", "A", None, "skipped") + result.add("b", "B", False, "failed") + assert result.first_failure().name == "b" + + def test_first_failure_is_none_when_only_skips(self): + result = AttestationResult() + result.add("a", "A", True, "") + result.add("b", "B", None, "skipped") + assert result.first_failure() is None diff --git a/packages/syft-enclave/tests/test_runner.py b/packages/syft-enclave/tests/test_runner.py index 28a6799edb6..7c08c4bf3c5 100644 --- a/packages/syft-enclave/tests/test_runner.py +++ b/packages/syft-enclave/tests/test_runner.py @@ -179,3 +179,63 @@ def fake_select(name, settings): ).init() assert seen == {"name": "tinfoil", "settings": settings} + + +class TestClaimsBinding: + """The runner commits runtime facts on targets that can commit to any.""" + + def _provider(self, monkeypatch, *, accepts_nonce): + provider = MagicMock() + provider.kind = AttestationKind.CONFIDENTIAL_SPACE + provider.accepts_caller_nonce = accepts_nonce + provider.collect.return_value = tinfoil_evidence(TINFOIL_DOC) + monkeypatch.setattr( + "syft_enclaves.runner.select_provider", lambda name, settings: provider + ) + monkeypatch.setattr( + "syft_enclaves.runner.write_public_bundle", lambda bundle, keys_path: None + ) + return provider + + def _client(self): + client = _make_client() + client._rds.peer_manager.syftbox_folder = Path("/tmp/SyftBox_enclave") + client.email = "enclave@openmined.org" + client.data_owners = ["b@openmined.org", "a@openmined.org"] + store = client._rds.peer_manager.peer_store + store.email = "enclave@openmined.org" + store.use_encryption = True + store.has_my_keys.return_value = True + store.get_public_bundle.return_value = {"identity": "enclave@openmined.org"} + return client + + def test_the_email_and_data_owners_are_bound(self, monkeypatch): + provider = self._provider(monkeypatch, accepts_nonce=True) + EnclaveRunner(client=self._client(), require_tee=True).init() + + claims = provider.collect.call_args.kwargs["claims"] + assert claims["email"] == "enclave@openmined.org" + assert claims["data_owners"] == ["a@openmined.org", "b@openmined.org"] + assert claims["key_bundle"] == {"identity": "enclave@openmined.org"} + + def test_the_key_bundle_exists_before_the_token_is_minted(self, monkeypatch): + # Ordering matters: the token commits to a digest covering the bundle. + provider = self._provider(monkeypatch, accepts_nonce=True) + client = self._client() + order = [] + client._rds.peer_manager.peer_store.save_keys.side_effect = ( + lambda p: order.append("keys") + ) + provider.collect.side_effect = lambda **kw: ( + order.append("mint"), + tinfoil_evidence(TINFOIL_DOC), + )[1] + + EnclaveRunner(client=client, require_tee=True).init() + + assert order == ["keys", "mint"] + + def test_a_target_without_a_nonce_channel_binds_nothing(self, monkeypatch): + provider = self._provider(monkeypatch, accepts_nonce=False) + EnclaveRunner(client=self._client(), require_tee=True).init() + assert provider.collect.call_args.kwargs == {} From b1b9126fa2af259755fb6efa6c4164213a30083a Mon Sep 17 00:00:00 2001 From: Koen van der Veen Date: Wed, 16 Sep 2026 11:41:30 +0200 Subject: [PATCH 08/21] feat: attest the enclave's runtime facts on Tinfoil too MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Confidential Space commits to the claims document's digest inside its signed token. Tinfoil has no workload channel into its report, so it signs the same document with the key the report already binds and serves it over the pinned connection. The nonce signature now covers the nonce AND the claims as one statement, so a single signature proves the enclave holds the key, that the answer is for this exchange, and that these are the facts it asserts. Swapping the claims breaks that signature. Both targets now bind keys, email and configured data owners — the last being the load-bearing one, since that list is the approval gate. The appraisal is shared: claims.check_expected is used by both verifiers, and both policies take expected_email and expected_data_owners. security.md section 6 now explains what has to be bound and why, then each target's mechanism and its remaining weakness: Confidential Space has no freshness (token minted once at boot, its one spare nonce slot spent on the digest), Tinfoil needs the enclave online. Co-Authored-By: Claude Opus 5 (1M context) --- .../syft-enclave/docker/attestation_server.py | 17 +- packages/syft-enclave/docs/security.md | 156 ++++++++++-------- .../syft-enclave/docs/tinfoil_deployment.md | 4 +- .../src/syft_enclaves/attestation/claims.py | 45 ++++- .../attestation/confidential_space.py | 39 ++--- .../src/syft_enclaves/attestation/https.py | 6 +- .../src/syft_enclaves/attestation/nonce.py | 39 ++++- .../src/syft_enclaves/attestation/tinfoil.py | 75 ++++++++- .../src/syft_enclaves/evidence/key_bundle.py | 24 ++- .../syft-enclave/src/syft_enclaves/runner.py | 47 +++--- .../tests/test_attestation_tinfoil.py | 75 ++++++++- packages/syft-enclave/tests/test_runner.py | 13 +- 12 files changed, 386 insertions(+), 154 deletions(-) diff --git a/packages/syft-enclave/docker/attestation_server.py b/packages/syft-enclave/docker/attestation_server.py index d37d7ffc8a2..53087c7a5f7 100644 --- a/packages/syft-enclave/docker/attestation_server.py +++ b/packages/syft-enclave/docker/attestation_server.py @@ -20,7 +20,11 @@ from fastapi import FastAPI from fastapi.responses import JSONResponse from syft_enclaves.evidence import probed_locations, select_provider -from syft_enclaves.evidence.key_bundle import read_public_bundle, sign_nonce +from syft_enclaves.evidence.key_bundle import ( + read_claims, + read_public_bundle, + sign_nonce, +) from syft_enclaves.settings import AttestationSettings from syft_enclaves.evidence.tee_token import validate_nonce @@ -109,9 +113,14 @@ def attestation(nonce: str | None = None): # over an unpinned connection they are worth nothing. None when # encryption is disabled or the runner has not started yet. "key_bundle": read_public_bundle(), - # Proof the enclave holds the private half of that bundle, and - # that this response was produced for this exchange: a signature - # over the caller's nonce by the bundle's identity key. + # The runtime facts this enclave asserts about itself — email, + # data owners, key bundle. Untrusted on their own; the signature + # below is what makes them trustworthy on Tinfoil, and on + # Confidential Space the token's nonce commits to them too. + "claims": read_claims(), + # One signature over the caller's nonce AND the claims, by the + # bundle's identity key: proof the enclave holds that key, that + # this answer is for this exchange, and that these are its facts. "nonce": nonce, "nonce_signature": sign_nonce(nonce) if nonce else None, } diff --git a/packages/syft-enclave/docs/security.md b/packages/syft-enclave/docs/security.md index a6b4313a754..22c605d684e 100644 --- a/packages/syft-enclave/docs/security.md +++ b/packages/syft-enclave/docs/security.md @@ -90,10 +90,10 @@ Because any peer can **verify the attestation report**, they know those public k produced by an enclave running the expected open-source container — not by some person who happens to have access to the account. -> **Status.** This binding is implemented on both targets, by different routes: **Tinfoil** binds -> over a connection its report vouches for (§6.1), **Confidential Spaces** commits to the facts -> inside the signed token itself (§6.2). The Confidential Space route reaches further, covering the -> enclave's email and configured data owners as well as its keys. +> **Status.** Implemented on both targets, binding the same facts — keys, email and configured data +> owners — by different mechanisms: **Confidential Spaces** commits to a digest of them inside the +> signed token (§6.1), **Tinfoil** signs them with a key its report vouches for, over a connection +> pinned to that key (§6.2). Each route has a different remaining weakness; §6.3 compares them. The data owners and the DS then download the enclave's verified keys (and share their own), and from that point on there is a **trusted, end-to-end secure channel** between the @@ -114,81 +114,97 @@ anyone trusting Google Drive, the network, or each other. ## 6. What attestation proves on each target -Section 5 describes the intent. Both targets deliver the first half of it — proof of _what code is -running_ — but they differ on the second half, whether the enclave's keys are bound to that proof. -So this section is split by target. +Section 5 describes the intent. Both targets deliver it, but by different routes, and each route has +a different remaining weakness. This section is the honest accounting. -**Common to both.** A verified report proves the workload runs on genuine confidential-computing -hardware with debug disabled, and that the code and configuration are the ones published: on -Tinfoil, the CVM image and `tinfoil-config.yml` of a named, Sigstore-signed release of the config -repo, including the container image digest it pins; on Confidential Spaces, the image digest in the -token's claims. Neither proves anything about values supplied at deploy time — the enclave's email -and its configured data owners are deploy-time inputs on both targets, and attested code faithfully -relays whatever its deployer handed it. +### 6.0 What has to be bound, and why -### 6.1 Tinfoil: the keys come down a connection the report vouches for +A verified report proves the workload runs on genuine confidential-computing hardware with debug +disabled, and that the **code and configuration** are the ones published: on Tinfoil, the CVM image +and `tinfoil-config.yml` of a named Sigstore-signed release, including the container image digest it +pins; on Confidential Spaces, the image digest in the token's claims. -A workload cannot inject a nonce into a Tinfoil report — its 64 bytes of user data are the sha256 of -the shim's TLS public key followed by its HPKE public key. But that is exactly what makes binding +That is not enough on its own. Three things a peer needs are _runtime_ values, outside the +measurement: + +| Fact | Why it matters | +| ----------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| the enclave's **public key bundle** | you are about to encrypt private data to it. An unbound bundle can be swapped by whoever controls the enclave's Drive account, and they then read everything. | +| the enclave's **email** | it identifies the datasite you are talking to. | +| its configured **data owners** | this list is the approval gate — a job runs only once _all_ of them approve, so anyone who could change it unobserved could approve work on their own. | + +Both targets therefore bind the same document — email, data owners, syft version, key bundle — to +the report. The document always travels in the clear and is never trusted on its own; what differs +is the mechanism that makes it true. + +### 6.1 Confidential Spaces: committed to inside the signed token + +Confidential Space lets a workload ask the launcher to embed bytes of its choosing into the +Google-signed token, via `eat_nonce`. Only code running inside the measured container can do that, +and the signature is unforgeable. So the enclave commits to the **sha256 of the claims document** in +the token, and a verifier recomputes that digest from the published document and compares. + +It is the _signature_ doing the work, not secrecy — measurements and image digests are public on +both targets, deliberately, because that is what makes them auditable. + +There is room for exactly one digest: slot 0 carries the syft version in plain text, and a nonce is +capped at 74 characters matching `[a-zA-Z0-9_.-]`, which rules out carrying an email literally and +leaves a 64-character sha256 hex comfortably inside. Hence one digest over one document rather than +a field per fact. + +The binding travels **inside the file**, so it needs no connection to the enclave, which suits the +offline-first transport. + +**Weakness: no freshness.** The token is minted once at boot and written to `SYFT_version.json`, so +a captured one stays acceptable for the length of the expiry grace window. The single spare nonce +slot is spent on the claims digest, leaving no room for a per-verifier challenge. Bounded staleness +— re-publishing periodically and narrowing the grace window — is the remaining work, tracked as H16 +in the [protocol security review](../../../research/protocol-security-review/SUMMARY.md). + +### 6.2 Tinfoil: bound to a connection, then signed over it + +Tinfoil has no workload channel at all: its report's 64 bytes of user data are the sha256 of the +shim's TLS public key followed by its HPKE public key. But that is exactly what makes binding possible, because the report **commits to the key terminating a TLS connection to the enclave**. So the client: 1. verifies the report; -2. opens HTTPS to the enclave and checks the certificate it is served carries that same key; -3. sends a random nonce and checks the enclave signed it with the identity key from the bundle it - served; -4. and then trusts that bundle, which came down the same connection. +2. opens HTTPS to the enclave and checks the certificate it is served carries that same key — the + channel therefore provably ends inside the attested enclave; +3. takes the key bundle served over that channel, which is now authentic; +4. sends a random nonce, and checks the enclave signed **the nonce together with the claims + document** using the identity key from that bundle. No certificate authority is involved anywhere: the enclave's certificate is self-signed, and the -_report_ is what decides whether to trust it. Step 3 adds what the report cannot carry — that the -enclave **holds the private half** of the key we are about to encrypt to, and that the answer was -produced for this exchange rather than replayed. `attest_peer` then sets those keys for the peer, so -the enclave's public keys are no longer an unsigned Drive file. The bundle is adopted only when both -the pin and the nonce check pass. - -Replay is ruled out as a side effect: a captured report commits to a TLS key whose private half -lives in an enclave the attacker does not control, so the pin fails. - -The cost is that this needs the enclave **online**. Evidence is still published to -`SYFT_version.json` as provenance, but it is never appraised in place of the live exchange — doing -so would silently mean unbound keys and a replayable report. - -### 6.2 Confidential Spaces: the facts are committed to inside the token - -Confidential Space gives a workload something Tinfoil does not: it can ask the launcher to embed -bytes of its choosing into the Google-signed token, via `eat_nonce`. Only code running inside the -measured container can do that, and the token's signature is unforgeable. Note that it is the -_signature_ doing the work, not secrecy — measurements and image digests are public on both targets, -deliberately, because that is what makes them auditable. - -So the enclave publishes a **claims document** — its email, its configured data owners, its syft -version and its public key bundle — and commits to that document's sha256 in the token. A verifier -recomputes the digest from the published document and compares. The document travels in the clear -and is untrusted; the digest inside the signed token is what makes it true. - -There is room for exactly one digest. Slot 0 carries the syft version in plain text, and a nonce is -capped at 74 characters matching `[a-zA-Z0-9_.-]` — which rules out carrying an email address -literally, and leaves a 64-character sha256 hex comfortably inside. Hence one digest over one -document rather than a field per fact. - -This closes the gap section 5 describes, and closes it more cheaply than Tinfoil does: the binding -travels **inside the file**, so it needs no connection to the enclave, which suits the offline-first -transport. It also reaches further. Tinfoil binds the enclave's keys; here the same commitment -covers the enclave's **email and its configured data owners**, which are deploy-time inputs and -otherwise unverifiable. That matters most for the data owners, because that list is the approval gate -— a job runs only once all of them approve, so an operator who could change it unobserved could -approve work on their own. - -Binding proves the enclave really was started with those values. Whether they are the _right_ values -is the verifier's call, so `AppraisalPolicy` takes an optional `expected_email` and -`expected_data_owners`: left unset the attested values are reported, set they are required. - -What it does not give is freshness. The token is minted once at boot and written to -`SYFT_version.json`, so a captured one stays acceptable for the length of the expiry grace window; -the second nonce slot is spent on the claims digest, so there is no room for a per-verifier -challenge as well. Tinfoil gets freshness from its live connection instead. Bounded staleness — -re-publishing periodically and narrowing the grace window — is the remaining work, tracked as H16 in -the [protocol security review](../../../research/protocol-security-review/SUMMARY.md). +_report_ is what decides whether to trust it. Step 4 does three jobs in one signature — it proves +the enclave **holds the private half** of the key we are about to encrypt to, that the answer was +produced for _this_ exchange, and that the claims are the facts it meant to assert. The bundle and +the claims are accepted only if steps 2 and 4 both pass. + +**Freshness comes free**, unlike on Confidential Spaces: a captured report commits to a TLS key +whose private half lives in an enclave the attacker does not control, so the pin fails, and the +nonce is ours and new each time. + +**Weakness: it needs the enclave online.** Evidence is still published to `SYFT_version.json` as +provenance and to advertise the host, but it is never appraised in place of the live exchange — +doing so would silently mean unbound keys and a replayable report. An unreachable enclave is an +error, not a downgrade. + +### 6.3 Side by side + +| | Confidential Spaces | Tinfoil | +| ------------------------------ | ----------------------------- | ----------------------------------------------- | +| hardware, code, config | ✅ | ✅ | +| key bundle bound | ✅ digest in the signed token | ✅ served over a channel the report vouches for | +| email and data owners attested | ✅ same digest | ✅ signed with the bound key | +| freshness | ❌ minted once at boot | ✅ live connection and a per-request nonce | +| works offline | ✅ binding rides in the file | ❌ needs the enclave reachable | + +Whichever route bound them, the attested facts are appraised identically: `AppraisalPolicy` and +`TinfoilAppraisalPolicy` both take an optional `expected_email` and `expected_data_owners`, and both +run the same comparison. Left unset the attested values are reported; set, they are required. +Binding proves the enclave really was started with those values — whether they are the _right_ ones +is the verifier's call. For how to deploy either target, see [Confidential Spaces Deployment](./terraform_cs.md) and [Tinfoil Deployment](./tinfoil_deployment.md). diff --git a/packages/syft-enclave/docs/tinfoil_deployment.md b/packages/syft-enclave/docs/tinfoil_deployment.md index 37432693a95..8dced2f01ef 100644 --- a/packages/syft-enclave/docs/tinfoil_deployment.md +++ b/packages/syft-enclave/docs/tinfoil_deployment.md @@ -12,8 +12,8 @@ The canonical copy of that config lives here at [`tinfoil/tinfoil-config.yml`](. Run all commands from `packages/syft-enclave/`. When something fails, see [Tinfoil Troubleshooting](./tinfoil_troubleshooting.md). For what the -attestation does and does not prove, see -[Security Overview](./security.md#6-what-attestation-proves-on-each-target) — this doc covers the +attestation proves — and how Tinfoil's route to it differs from Confidential Spaces' — see +[Security Overview §6](./security.md#6-what-attestation-proves-on-each-target). This doc covers the mechanics, not the guarantees. ## Prerequisites diff --git a/packages/syft-enclave/src/syft_enclaves/attestation/claims.py b/packages/syft-enclave/src/syft_enclaves/attestation/claims.py index 9f7f14d72e3..d05234f4188 100644 --- a/packages/syft-enclave/src/syft_enclaves/attestation/claims.py +++ b/packages/syft-enclave/src/syft_enclaves/attestation/claims.py @@ -17,9 +17,11 @@ commits to its digest. The document itself is untrusted — the digest is what makes it true. -Tinfoil cannot do this: its report's user data is the shim's own keys, with no -workload channel. It reaches the same guarantee over a pinned connection -instead; see ``attestation.https``. +Tinfoil has no such channel — its report's user data is the shim's own keys — +so it reaches the same guarantee a third way: the enclave signs the same claims +document with the key the report already binds, and serves it over the pinned +connection. Different route, same document, same digest, so the expectation +checks below are shared. """ from __future__ import annotations @@ -79,3 +81,40 @@ def verify_claims_digest(claims: dict[str, Any], digest: str) -> None: f"published claims hash to {actual[:16]}… but the token commits to " f"{digest[:16]}… — they have been altered or do not belong together" ) + + +def check_expected( + claims: dict[str, Any], + expected_email: Optional[str], + expected_data_owners: Optional[list[str]], +) -> list[tuple[str, str, Optional[bool], str]]: + """Compare attested facts against what the verifier expected. + + Binding proves the enclave really was started with these values; only the + caller knows whether they are the right ones. Returns + ``(name, label, passed, detail)`` rows for the caller's checklist — + ``passed=None`` where nothing was pinned, so an unpinned value is reported + rather than demanded. + + Shared by both targets: they bind the document differently, but once it is + trustworthy the appraisal is identical. + """ + return [ + _compare("enclave_email", "Enclave email", expected_email, claims.get("email")), + _compare( + "data_owners", + "Data owners", + sorted(expected_data_owners) if expected_data_owners is not None else None, + claims.get("data_owners"), + ), + ] + + +def _compare( + name: str, label: str, expected: Any, actual: Any +) -> tuple[str, str, Optional[bool], str]: + if expected is None: + return (name, label, None, f"not pinned; enclave reports {actual!r}") + if expected == actual: + return (name, label, True, f"matches {actual!r}") + return (name, label, False, f"enclave reports {actual!r}, expected {expected!r}") diff --git a/packages/syft-enclave/src/syft_enclaves/attestation/confidential_space.py b/packages/syft-enclave/src/syft_enclaves/attestation/confidential_space.py index 28201466ee5..048c55d89b7 100644 --- a/packages/syft-enclave/src/syft_enclaves/attestation/confidential_space.py +++ b/packages/syft-enclave/src/syft_enclaves/attestation/confidential_space.py @@ -21,7 +21,11 @@ from syft.version import SYFT_VERSION -from syft_enclaves.attestation.claims import ClaimsBindingError, verify_claims_digest +from syft_enclaves.attestation.claims import ( + ClaimsBindingError, + check_expected, + verify_claims_digest, +) from syft_enclaves.attestation.result import ( AttestationError, AttestationResult, @@ -130,35 +134,16 @@ def _check_expected_claims( ) -> None: """Compare the now-attested facts against what the verifier expected. - Binding proves the enclave really was started with these values; only the - caller knows whether they are the right ones. + Delegates to ``attestation.claims.check_expected``, shared with Tinfoil: + the two targets bind the document differently, but once it is trustworthy + the appraisal is identical. """ - for name, label, expected, actual in [ - ( - "enclave_email", - "Enclave email", - policy.expected_email, - published_claims.get("email"), - ), - ( - "data_owners", - "Data owners", - sorted(policy.expected_data_owners) - if policy.expected_data_owners is not None - else None, - published_claims.get("data_owners"), - ), - ]: + for name, label, passed, detail in check_expected( + published_claims, policy.expected_email, policy.expected_data_owners + ): if verbose: print(f" ⏳ {label} ...") - if expected is None: - result.add(name, label, None, f"not pinned; enclave reports {actual!r}") - elif expected == actual: - result.add(name, label, True, f"matches {actual!r}") - else: - result.add( - name, label, False, f"enclave reports {actual!r}, expected {expected!r}" - ) + result.add(name, label, passed, detail) def verify_attestation_token( diff --git a/packages/syft-enclave/src/syft_enclaves/attestation/https.py b/packages/syft-enclave/src/syft_enclaves/attestation/https.py index 72b8ff21a44..804382ee17f 100644 --- a/packages/syft-enclave/src/syft_enclaves/attestation/https.py +++ b/packages/syft-enclave/src/syft_enclaves/attestation/https.py @@ -48,9 +48,12 @@ class AttestedPayload: #: sha256 of the served certificate's DER SubjectPublicKeyInfo. tls_public_key_fp: str host: str - #: The nonce we sent, and the enclave's signature over it. + #: The nonce we sent, and the enclave's signature over it *and* the + #: claims below — one statement, so all three are proven together. nonce: str = "" nonce_signature: Optional[str] = None + #: The runtime facts the enclave asserts: email, data owners, key bundle. + claims: Optional[dict[str, Any]] = None class AttestationFetchError(RuntimeError): @@ -100,6 +103,7 @@ def fetch_attested_payload( host=host, nonce=nonce, nonce_signature=payload.get("nonce_signature"), + claims=payload.get("claims"), ) diff --git a/packages/syft-enclave/src/syft_enclaves/attestation/nonce.py b/packages/syft-enclave/src/syft_enclaves/attestation/nonce.py index 00dacb115fa..0aad87afbde 100644 --- a/packages/syft-enclave/src/syft_enclaves/attestation/nonce.py +++ b/packages/syft-enclave/src/syft_enclaves/attestation/nonce.py @@ -22,12 +22,15 @@ from __future__ import annotations import base64 +import json import secrets -from typing import Any +from typing import Any, Optional #: Domain separator, so a signature made here can never be replayed as a #: signature for some other syft protocol that also signs with the identity key. -CHALLENGE_PREFIX = b"syft-enclave-attestation-nonce-v1:" +#: v2 covers the claims document as well as the nonce; a v1 enclave's signature +#: will simply fail to verify, which is the outcome we want from a mismatch. +CHALLENGE_PREFIX = b"syft-enclave-attestation-challenge-v2:" NONCE_BYTES = 32 @@ -40,12 +43,23 @@ def new_nonce() -> str: return secrets.token_hex(NONCE_BYTES) -def challenge_message(nonce: str) -> bytes: - """Exactly the bytes both sides sign and verify.""" - return CHALLENGE_PREFIX + nonce.encode() +def challenge_message(nonce: str, claims: Optional[dict[str, Any]] = None) -> bytes: + """Exactly the bytes both sides sign and verify. + + Covers the claims document as well as the nonce, so a single signature + proves three things at once: the enclave holds the key, the answer is for + this exchange, and these are the facts it meant to assert. Canonical JSON + so both sides hash the same bytes from the same values. + """ + payload = json.dumps( + {"nonce": nonce, "claims": claims}, sort_keys=True, separators=(",", ":") + ) + return CHALLENGE_PREFIX + payload.encode() -def sign_challenge(private_jwks: dict[str, Any], nonce: str) -> str: +def sign_challenge( + private_jwks: dict[str, Any], nonce: str, claims: Optional[dict[str, Any]] = None +) -> str: """Sign *nonce* with the identity key from a JWKS, base64 encoded. Runs inside the enclave. Takes the JWKS rather than a ``PeerStore`` so the @@ -57,10 +71,16 @@ def sign_challenge(private_jwks: dict[str, Any], nonce: str) -> str: if identity.get("crv") != "Ed25519": raise ValueError(f"identity key is not Ed25519: {identity.get('crv')!r}") private_key = Ed25519PrivateKey.from_private_bytes(_b64url_decode(identity["d"])) - return base64.b64encode(private_key.sign(challenge_message(nonce))).decode() + message = challenge_message(nonce, claims) + return base64.b64encode(private_key.sign(message)).decode() -def verify_challenge(bundle: dict[str, Any], nonce: str, signature_b64: str) -> None: +def verify_challenge( + bundle: dict[str, Any], + nonce: str, + signature_b64: str, + claims: Optional[dict[str, Any]] = None, +) -> None: """Check the enclave signed *our* nonce with the bundle's identity key. Raises :class:`NonceVerificationError` on any failure — a bad signature is @@ -74,7 +94,8 @@ def verify_challenge(bundle: dict[str, Any], nonce: str, signature_b64: str) -> raise NonceVerificationError("the enclave returned no nonce signature") try: public_key = Ed25519PublicKey.from_public_bytes(identity_key_bytes(bundle)) - public_key.verify(base64.b64decode(signature_b64), challenge_message(nonce)) + message = challenge_message(nonce, claims) + public_key.verify(base64.b64decode(signature_b64), message) except InvalidSignature as e: raise NonceVerificationError( "the nonce signature does not match the key bundle: the responder " diff --git a/packages/syft-enclave/src/syft_enclaves/attestation/tinfoil.py b/packages/syft-enclave/src/syft_enclaves/attestation/tinfoil.py index 068f22564aa..7272195a3a9 100644 --- a/packages/syft-enclave/src/syft_enclaves/attestation/tinfoil.py +++ b/packages/syft-enclave/src/syft_enclaves/attestation/tinfoil.py @@ -17,9 +17,12 @@ sha256 of the shim's TLS public key followed by its HPKE public key. That is also what makes key binding possible, because the report commits to the key terminating a TLS connection to the enclave — see -``syft_enclaves.attestation.https``. Freshness for the syft key comes from a nonce the -enclave signs with that bundle (``attestation.nonce``), which the report cannot -carry. Evidence stays published to Drive as provenance, but it is never a +``syft_enclaves.attestation.https``. Freshness, and the enclave's own runtime facts +(email, configured data owners), come from a nonce the enclave signs *together +with* its claims document using that bundle — see ``attestation.nonce`` and +``attestation.claims``. Neither is something the report itself can carry, and +Confidential Space reaches the same guarantee by committing to that document's +digest inside its signed token instead. Evidence stays published to Drive as provenance, but it is never a fallback here: appraising it instead would mean unbound keys and a replayable report, so an unreachable enclave is an error. """ @@ -36,6 +39,7 @@ from syft.version import SYFT_VERSION from syft_enclaves.attestation.result import AttestationError, AttestationResult +from syft_enclaves.attestation.claims import check_expected from syft_enclaves.attestation.envelope import AttestationEvidence from syft_enclaves.attestation.https import ( AttestationFetchError, @@ -80,9 +84,13 @@ class TinfoilAppraisalPolicy(BaseModel): # Which container in the config carries the enclave. container_name: str = "syft-enclave" # Where to fetch the report over a connection pinned to the key the report - # commits to. None -> fall back to the host the enclave advertised in its - # evidence; still None -> Drive-only, with no key binding. + # commits to. None -> use the host the enclave advertised in its evidence. host: Optional[str] = None + # Runtime facts the enclave asserts and signs. None -> the value is + # reported but not required; set one to refuse an enclave started with + # anything else. Same fields, and the same checks, as Confidential Space. + expected_email: Optional[str] = None + expected_data_owners: Optional[list[str]] = None def verify_tinfoil_evidence( @@ -141,6 +149,11 @@ def _fetch_pinned( ) from e +def _passed(result: AttestationResult, name: str) -> bool: + """Whether a check already recorded passed outright.""" + return any(c.name == name and c.passed is True for c in result.checks) + + class _TinfoilVerifier: """One verification run. Holds the state the checks hand to each other.""" @@ -174,6 +187,7 @@ def run(self) -> AttestationResult: self._check_hardware_report() self._check_key_binding() self._check_nonce_freshness() + self._check_claims() self._check_release_lookup() self._check_sigstore_bundle() self._check_measurement_match() @@ -272,7 +286,12 @@ def _check_nonce_freshness(self) -> None: ) return try: - verify_challenge(bundle, self.payload.nonce, self.payload.nonce_signature) + verify_challenge( + bundle, + self.payload.nonce, + self.payload.nonce_signature, + self.payload.claims, + ) except NonceVerificationError as e: self.result.add("nonce_freshness", "Nonce freshness", False, str(e)) return @@ -280,8 +299,50 @@ def _check_nonce_freshness(self) -> None: "nonce_freshness", "Nonce freshness", True, - "the enclave signed our nonce with the key it served", + "the enclave signed our nonce, and its claims, with the key it served", + ) + + def _check_claims(self) -> None: + """The runtime facts the enclave asserts, and whether we wanted them. + + Trustworthy because the signature checked in ``nonce_freshness`` + covers this document as well as the nonce — which is Tinfoil's route + to the guarantee Confidential Space gets from the token's nonce slot. + """ + self._progress("Claims binding") + claims = self.payload.claims + proven = _passed(self.result, "nonce_freshness") + if not claims: + self.result.add( + "claims_binding", + "Claims binding", + None, + "the enclave asserted no claims, so its email and data owners " + "are unattested (skipped)", + ) + return + if not proven: + # The signature covering them did not verify, so they are words. + self.result.add( + "claims_binding", + "Claims binding", + False, + "claims are not covered by a verified signature", + ) + return + owners = claims.get("data_owners") or [] + self.result.add( + "claims_binding", + "Claims binding", + True, + f"signed claims: email={claims.get('email')!r}, " + f"{len(owners)} data owner(s)", ) + for name, label, passed, detail in check_expected( + claims, self.policy.expected_email, self.policy.expected_data_owners + ): + self._progress(label) + self.result.add(name, label, passed, detail) def _check_release_lookup(self) -> None: """Resolve which published release we appraise against.""" diff --git a/packages/syft-enclave/src/syft_enclaves/evidence/key_bundle.py b/packages/syft-enclave/src/syft_enclaves/evidence/key_bundle.py index 480d969af4f..dbc8f7a8038 100644 --- a/packages/syft-enclave/src/syft_enclaves/evidence/key_bundle.py +++ b/packages/syft-enclave/src/syft_enclaves/evidence/key_bundle.py @@ -32,7 +32,10 @@ def write_public_bundle( - bundle: dict[str, Any], keys_path: Path, path: Path = PUBLIC_BUNDLE_PATH + bundle: dict[str, Any], + keys_path: Path, + claims: Optional[dict[str, Any]] = None, + path: Path = PUBLIC_BUNDLE_PATH, ) -> None: """Record the public bundle and where its private half lives. @@ -43,7 +46,9 @@ def write_public_bundle( """ path.parent.mkdir(parents=True, exist_ok=True) tmp = path.with_name(f".{path.name}.tmp") - tmp.write_text(json.dumps({"bundle": bundle, "keys_path": str(keys_path)})) + tmp.write_text( + json.dumps({"bundle": bundle, "keys_path": str(keys_path), "claims": claims}) + ) os.replace(tmp, path) logger.info("Published public key bundle to %s", path) @@ -67,8 +72,19 @@ def read_public_bundle(path: Path = PUBLIC_BUNDLE_PATH) -> Optional[dict[str, An return published.get("bundle") if published else None +def read_claims(path: Path = PUBLIC_BUNDLE_PATH) -> Optional[dict[str, Any]]: + """The runtime facts the enclave asserts: email, data owners, keys.""" + published = read_published(path) + return published.get("claims") if published else None + + def sign_nonce(nonce: str, path: Path = PUBLIC_BUNDLE_PATH) -> Optional[str]: - """Sign a caller's nonce with the enclave's identity key. + """Sign a caller's nonce, together with the claims, as one statement. + + One signature therefore carries three things: the enclave holds the key it + served, the answer is for this exchange, and these are the runtime facts it + asserts. On Tinfoil this is the *only* route to the last one, since its + report has no workload channel to commit to them. None when there is nothing to sign with, so the endpoint degrades to "served a bundle but proved nothing" rather than failing outright — the @@ -85,7 +101,7 @@ def sign_nonce(nonce: str, path: Path = PUBLIC_BUNDLE_PATH) -> Optional[str]: keys = syc.SyftPrivateKeys.from_jwks( json.loads(Path(published["keys_path"]).read_text())["keys_jwk"] ) - return sign_challenge(keys.to_jwks(), nonce) + return sign_challenge(keys.to_jwks(), nonce, published.get("claims")) except Exception as e: logger.warning("Could not sign the attestation nonce: %s", e) return None diff --git a/packages/syft-enclave/src/syft_enclaves/runner.py b/packages/syft-enclave/src/syft_enclaves/runner.py index 1ad26f1c485..89f5db6413c 100644 --- a/packages/syft-enclave/src/syft_enclaves/runner.py +++ b/packages/syft-enclave/src/syft_enclaves/runner.py @@ -144,14 +144,18 @@ def _on_attesting(self) -> None: logger.info( "TEE detected (%s) — collecting attestation evidence", provider.kind.value ) - # The key bundle first: on a target that can bind claims, the token - # commits to a digest covering it, so it has to exist before minting. - self._publish_key_bundle() - self._publish_attestation(provider) - - def _publish_attestation(self, provider: EvidenceProvider) -> None: + # The key bundle and claims first: on a target that commits to them in + # the token, the digest has to exist before minting. + claims = self._build_claims() + self._publish_key_bundle(claims) + self._publish_attestation(provider, claims) + + def _publish_attestation( + self, provider: EvidenceProvider, claims: Optional[dict] + ) -> None: """Write the provider's evidence into the peer-visible version file.""" - evidence = provider.collect(**self._binding_for(provider)) + binding = {"claims": claims} if claims and provider.accepts_caller_nonce else {} + evidence = provider.collect(**binding) peer_manager = self.client._rds.peer_manager evidence.publish_to(peer_manager.get_own_version()) peer_manager.write_own_version() @@ -160,17 +164,16 @@ def _publish_attestation(self, provider: EvidenceProvider) -> None: evidence.kind.value, ) - def _binding_for(self, provider: EvidenceProvider) -> dict: - """The runtime facts to commit to, on targets that can commit to any. + def _build_claims(self) -> Optional[dict]: + """The runtime facts this enclave asserts about itself. - Confidential Space lets the workload put a digest into the signed - token, which is the only way a peer can trust the enclave's email, its - configured data owners or its key bundle — all runtime values outside - the measurement. Tinfoil has no such channel and binds over a pinned - connection instead, so it gets nothing here. + Email, configured data owners and public key bundle are all runtime or + deploy-time values, outside the measurement, so a peer has only the + enclave's word for them until they are bound to the report. Both + targets bind this same document — Confidential Space commits to its + digest inside the signed token, Tinfoil signs it with the key the + report already binds and serves it over the pinned connection. """ - if not getattr(provider, "accepts_caller_nonce", False): - return {} peer_store = self.client._rds.peer_manager.peer_store bundle = ( peer_store.get_public_bundle() @@ -184,14 +187,14 @@ def _binding_for(self, provider: EvidenceProvider) -> dict: key_bundle=bundle, ) logger.info( - "Binding runtime claims into the attestation token: email, " - "%d data owner(s), key bundle %s", + "Asserting runtime claims: email=%s, %d data owner(s), key bundle %s", + claims["email"], len(claims["data_owners"]), "included" if bundle else "absent", ) - return {"claims": claims} + return claims - def _publish_key_bundle(self) -> None: + def _publish_key_bundle(self, claims: Optional[dict] = None) -> None: """Expose our public key bundle on the attestation endpoint. A peer fetching the report over a pinned connection gets the bundle @@ -214,7 +217,9 @@ def _publish_key_bundle(self) -> None: # before advertising where they are. Without this the endpoint # serves a bundle it cannot sign a nonce with. peer_store.save_keys(keys_path) - write_public_bundle(peer_store.get_public_bundle(), keys_path=keys_path) + write_public_bundle( + peer_store.get_public_bundle(), keys_path=keys_path, claims=claims + ) except OSError as e: # Not fatal: peers can still fall back to the Drive-published # bundle, they just lose the attestation binding. diff --git a/packages/syft-enclave/tests/test_attestation_tinfoil.py b/packages/syft-enclave/tests/test_attestation_tinfoil.py index f3f615cea87..0a608a648f2 100644 --- a/packages/syft-enclave/tests/test_attestation_tinfoil.py +++ b/packages/syft-enclave/tests/test_attestation_tinfoil.py @@ -173,15 +173,21 @@ def _install( sign_with=None, signature="valid", unreachable=False, + claims=None, + claims_served="same", ): keys, real_bundle = _real_keys() served = real_bundle if bundle == "real" else bundle nonce = new_nonce() if signature == "valid": signer = sign_with or keys - nonce_signature = sign_challenge(signer.to_jwks(), nonce) + # Signed over the claims the enclave meant; claims_served lets a + # test serve different ones, as a tamperer would. + nonce_signature = sign_challenge(signer.to_jwks(), nonce, claims) else: nonce_signature = signature + if claims_served != "same": + claims = claims_served payload = AttestedPayload( document=document or TINFOIL_DOC, @@ -190,6 +196,7 @@ def _install( host=HOST, nonce=nonce, nonce_signature=nonce_signature, + claims=claims, ) def fetch(host, *a, **kw): @@ -244,6 +251,7 @@ def test_all_checks_pass(self, verify): "hardware_report", "key_binding", "nonce_freshness", + "claims_binding", "release_lookup", "sigstore_bundle", "measurement_match", @@ -529,3 +537,68 @@ def test_the_policy_is_usable_without_the_sdk(self): from syft_enclaves.attestation.tinfoil import TinfoilAppraisalPolicy assert TinfoilAppraisalPolicy().repo == "OpenMined/syft-enclave-tinfoil" + + +class TestSignedClaims: + """Tinfoil's route to attested runtime facts. + + Its report has no workload channel, so the enclave signs the same claims + document Confidential Space commits to in its token, with the key the + report already binds, and serves it over the pinned connection. + """ + + OWNERS = ["model_owner@openmined.org", "benchmark_owner@openmined.org"] + + def _claims(self, **overrides): + from syft.version import SYFT_VERSION + from syft_enclaves.attestation.claims import build_claims + + claims = build_claims( + "enclave@openmined.org", self.OWNERS, SYFT_VERSION, {"identity": "e"} + ) + claims.update(overrides) + return claims + + def test_signed_claims_are_accepted_and_reported(self, verify, pinned): + claims = self._claims() + pinned(claims=claims) + result = verify(install_pinned=False, expected_image_digest=IMAGE_DIGEST) + check = _check(result, "claims_binding") + assert check.passed is True + assert "enclave@openmined.org" in check.detail + + def test_claims_altered_after_signing_are_rejected(self, verify, pinned): + # The signature covers nonce AND claims, so swapping the claims breaks + # the one signature — nonce_freshness fails, and the claims with it. + pinned(claims=self._claims(), claims_served=self._claims(email="evil@x.com")) + with pytest.raises(AttestationError) as excinfo: + verify(install_pinned=False, expected_image_digest=IMAGE_DIGEST) + assert _check(excinfo.value.result, "nonce_freshness").passed is False + assert _check(excinfo.value.result, "claims_binding").passed is False + + def test_no_claims_is_skipped_not_failed(self, verify, pinned): + pinned(claims=None) + result = verify(install_pinned=False, expected_image_digest=IMAGE_DIGEST) + assert _check(result, "claims_binding").passed is None + + def test_expected_email_and_owners_are_enforced(self, verify, pinned): + pinned(claims=self._claims()) + result = verify( + install_pinned=False, + expected_image_digest=IMAGE_DIGEST, + expected_email="enclave@openmined.org", + expected_data_owners=list(reversed(self.OWNERS)), + ) + assert _check(result, "enclave_email").passed is True + assert _check(result, "data_owners").passed is True + + def test_an_unexpected_data_owner_fails(self, verify, pinned): + # The load-bearing one: this list is the approval gate. + pinned(claims=self._claims()) + with pytest.raises(AttestationError) as excinfo: + verify( + install_pinned=False, + expected_image_digest=IMAGE_DIGEST, + expected_data_owners=["someone-else@openmined.org"], + ) + assert _check(excinfo.value.result, "data_owners").passed is False diff --git a/packages/syft-enclave/tests/test_runner.py b/packages/syft-enclave/tests/test_runner.py index 7c08c4bf3c5..f679fd20d90 100644 --- a/packages/syft-enclave/tests/test_runner.py +++ b/packages/syft-enclave/tests/test_runner.py @@ -122,8 +122,8 @@ def test_the_key_bundle_is_published_for_the_attestation_endpoint( written = {} monkeypatch.setattr( "syft_enclaves.runner.write_public_bundle", - lambda bundle, keys_path: written.update( - bundle=bundle, keys_path=keys_path + lambda bundle, keys_path, claims=None: written.update( + bundle=bundle, keys_path=keys_path, claims=claims ), ) @@ -154,7 +154,7 @@ def test_no_key_bundle_is_published_without_encryption(self, monkeypatch): calls = [] monkeypatch.setattr( "syft_enclaves.runner.write_public_bundle", - lambda bundle, keys_path: calls.append(bundle), + lambda bundle, keys_path, claims=None: calls.append(bundle), ) client = _make_client() @@ -193,7 +193,8 @@ def _provider(self, monkeypatch, *, accepts_nonce): "syft_enclaves.runner.select_provider", lambda name, settings: provider ) monkeypatch.setattr( - "syft_enclaves.runner.write_public_bundle", lambda bundle, keys_path: None + "syft_enclaves.runner.write_public_bundle", + lambda bundle, keys_path, claims=None: None, ) return provider @@ -235,7 +236,9 @@ def test_the_key_bundle_exists_before_the_token_is_minted(self, monkeypatch): assert order == ["keys", "mint"] - def test_a_target_without_a_nonce_channel_binds_nothing(self, monkeypatch): + def test_a_target_without_a_nonce_channel_gets_no_token_binding(self, monkeypatch): + # Tinfoil still asserts the same claims, but over its pinned channel — + # nothing goes into the report, so collect() is given nothing. provider = self._provider(monkeypatch, accepts_nonce=False) EnclaveRunner(client=self._client(), require_tee=True).init() assert provider.collect.call_args.kwargs == {} From 97d5621ff831ee8c30d463a53d1710793f3524dc Mon Sep 17 00:00:00 2001 From: Koen van der Veen Date: Wed, 16 Sep 2026 12:10:59 +0200 Subject: [PATCH 09/21] fix: read the pushed image digest reliably in tinfoil-release docker buildx imagetools inspect --format '{{.Manifest.Digest}}' prints the whole inspect listing for a multi-platform index, not the digest. The recipe's guard caught it (the substitution then found nothing to replace), but the by-hand equivalent I ran did not, and pushed a config pinning an empty digest. --raw piped through sha256 is the index digest by definition, and the guard now checks the shape before anything is written. Also pins v0.1.14. Co-Authored-By: Claude Opus 5 (1M context) --- packages/syft-enclave/Justfile | 7 +++++-- packages/syft-enclave/tinfoil/tinfoil-config.yml | 2 +- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/packages/syft-enclave/Justfile b/packages/syft-enclave/Justfile index d8a74235927..0d03b8c7096 100644 --- a/packages/syft-enclave/Justfile +++ b/packages/syft-enclave/Justfile @@ -737,8 +737,11 @@ tinfoil-release version: set -e {{tinfoil_preflight}} just build-push-amd {{version}} - digest=$(docker buildx imagetools inspect {{image_base}}:{{version}} --format '{{{{.Manifest.Digest}}}}') - [ -n "$digest" ] || { echo "Error: could not read the pushed image digest" >&2; exit 1; } + # --raw is the manifest bytes, and an index digest is by definition their + # sha256. Do NOT use --format '{{{{.Manifest.Digest}}}}': for a + # multi-platform index it prints the whole inspect listing, not the digest. + digest=$(docker buildx imagetools inspect {{image_base}}:{{version}} --raw | shasum -a 256 | awk '{print "sha256:"$1}') + echo "$digest" | grep -qE '^sha256:[0-9a-f]{64}$' || { echo "Error: could not read a valid image digest (got '$digest')" >&2; exit 1; } echo "Pushed {{image_base}}:{{version}} @ $digest" tmp=$(mktemp) # Replace whichever digest the config currently pins (placeholder or real). diff --git a/packages/syft-enclave/tinfoil/tinfoil-config.yml b/packages/syft-enclave/tinfoil/tinfoil-config.yml index f8de4b33baf..c8ca13e3ef7 100644 --- a/packages/syft-enclave/tinfoil/tinfoil-config.yml +++ b/packages/syft-enclave/tinfoil/tinfoil-config.yml @@ -27,7 +27,7 @@ containers: # Must be pinned by digest: this is what the attestation commits to and # what a data owner verifies. `just tinfoil-release` fills it in from the # image it just pushed. - image: docker.io/openminedreleasebot/syft-enclave@sha256:fa733440e581ad635f8ff6260b348a9ce127347c34b335fa29bbdc3736117bd2 + image: docker.io/openminedreleasebot/syft-enclave@sha256:d0bd57f22af80b9dcd0dc151fb68d89cca65b65fcbbd1d2e4586cdfa9d7daebc restart: always networks: [egress] # Tinfoil runs containers with a read-only root filesystem, but syft needs From e5d100e08b608f305edfb9eeabcedab28d021db8 Mon Sep 17 00:00:00 2001 From: Koen van der Veen Date: Wed, 16 Sep 2026 12:42:03 +0200 Subject: [PATCH 10/21] docs: drop the offline row from the target comparison Co-Authored-By: Claude Opus 5 (1M context) --- packages/syft-enclave/docs/security.md | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/syft-enclave/docs/security.md b/packages/syft-enclave/docs/security.md index 22c605d684e..5bccdb65f71 100644 --- a/packages/syft-enclave/docs/security.md +++ b/packages/syft-enclave/docs/security.md @@ -198,7 +198,6 @@ error, not a downgrade. | key bundle bound | ✅ digest in the signed token | ✅ served over a channel the report vouches for | | email and data owners attested | ✅ same digest | ✅ signed with the bound key | | freshness | ❌ minted once at boot | ✅ live connection and a per-request nonce | -| works offline | ✅ binding rides in the file | ❌ needs the enclave reachable | Whichever route bound them, the attested facts are appraised identically: `AppraisalPolicy` and `TinfoilAppraisalPolicy` both take an optional `expected_email` and `expected_data_owners`, and both From 1c325505124c732d7d14bc2a8bca3e155e584528 Mon Sep 17 00:00:00 2001 From: Koen van der Veen Date: Wed, 16 Sep 2026 12:50:32 +0200 Subject: [PATCH 11/21] docs: drop the tinfoil online-requirement weakness paragraph Co-Authored-By: Claude Opus 5 (1M context) --- packages/syft-enclave/docs/security.md | 5 ----- 1 file changed, 5 deletions(-) diff --git a/packages/syft-enclave/docs/security.md b/packages/syft-enclave/docs/security.md index 5bccdb65f71..3c4f84bfddb 100644 --- a/packages/syft-enclave/docs/security.md +++ b/packages/syft-enclave/docs/security.md @@ -185,11 +185,6 @@ the claims are accepted only if steps 2 and 4 both pass. whose private half lives in an enclave the attacker does not control, so the pin fails, and the nonce is ours and new each time. -**Weakness: it needs the enclave online.** Evidence is still published to `SYFT_version.json` as -provenance and to advertise the host, but it is never appraised in place of the live exchange — -doing so would silently mean unbound keys and a replayable report. An unreachable enclave is an -error, not a downgrade. - ### 6.3 Side by side | | Confidential Spaces | Tinfoil | From 3a071ad95c8fffa6837b6d54b58f41437a5ae89f Mon Sep 17 00:00:00 2001 From: Koen van der Veen Date: Wed, 16 Sep 2026 13:02:22 +0200 Subject: [PATCH 12/21] docs: frame the freshness gap as revocation, and list the todo MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit It read as a confidentiality problem. It is not: the token commits to the key bundle, so replaying an old token replays a bundle whose private half never left that enclave — a replayer can cause a denial of service, not a read. What is actually lost is the ability to retire a key. Adds a short 6.4 Todo for the remaining work: re-publish the token and narrow the grace window (H16), and pin by default. Co-Authored-By: Claude Opus 5 (1M context) --- packages/syft-enclave/docs/security.md | 52 +++++++++++++++++++------- 1 file changed, 38 insertions(+), 14 deletions(-) diff --git a/packages/syft-enclave/docs/security.md b/packages/syft-enclave/docs/security.md index 3c4f84bfddb..de8b762e11e 100644 --- a/packages/syft-enclave/docs/security.md +++ b/packages/syft-enclave/docs/security.md @@ -155,11 +155,24 @@ a field per fact. The binding travels **inside the file**, so it needs no connection to the enclave, which suits the offline-first transport. -**Weakness: no freshness.** The token is minted once at boot and written to `SYFT_version.json`, so -a captured one stays acceptable for the length of the expiry grace window. The single spare nonce -slot is spent on the claims digest, leaving no room for a per-verifier challenge. Bounded staleness -— re-publishing periodically and narrowing the grace window — is the remaining work, tracked as H16 -in the [protocol security review](../../../research/protocol-security-review/SUMMARY.md). +**Limitation: no freshness, which means no revocation.** The token is minted once at boot and +written to `SYFT_version.json`, so a captured one stays acceptable for the length of the expiry +grace window (we widened it to a month, because the enclave does not yet re-publish). + +This is narrower than it sounds. The token commits to the key bundle, so replaying an old token +replays an old _bundle_ — and its private half never left that enclave. A replayer can make you +encrypt to a dead key, which is a denial of service, but cannot read what you send. Confidentiality +holds. + +What you lose is the ability to **retire a key**. If some past instance's private key ever does +become known, its token stays acceptable for ever, so that instance can be impersonated +indefinitely — and then decryption really is possible. A staleness check is revocation by another +name. Two things narrow it further: a replayer must control the enclave's Drive account to put the +old token where you read it, and a debug-mode enclave (where an operator could read the key over +SSH) is already rejected by the `dbgstat` check. + +Pinning covers the rest — `expected_image_digest` and `expected_data_owners` reject a token from a +superseded configuration — but pinning is optional and off by default. ### 6.2 Tinfoil: bound to a connection, then signed over it @@ -181,18 +194,18 @@ the enclave **holds the private half** of the key we are about to encrypt to, th produced for _this_ exchange, and that the claims are the facts it meant to assert. The bundle and the claims are accepted only if steps 2 and 4 both pass. -**Freshness comes free**, unlike on Confidential Spaces: a captured report commits to a TLS key -whose private half lives in an enclave the attacker does not control, so the pin fails, and the -nonce is ours and new each time. +**Freshness comes free**, unlike on Confidential Spaces, so keys can be retired here: a captured +report commits to a TLS key whose private half lives in an enclave the attacker does not control, +so the pin fails, and the nonce is ours and new each time. ### 6.3 Side by side -| | Confidential Spaces | Tinfoil | -| ------------------------------ | ----------------------------- | ----------------------------------------------- | -| hardware, code, config | ✅ | ✅ | -| key bundle bound | ✅ digest in the signed token | ✅ served over a channel the report vouches for | -| email and data owners attested | ✅ same digest | ✅ signed with the bound key | -| freshness | ❌ minted once at boot | ✅ live connection and a per-request nonce | +| | Confidential Spaces | Tinfoil | +| --------------------------------- | ----------------------------- | ----------------------------------------------- | +| hardware, code, config | ✅ | ✅ | +| key bundle bound | ✅ digest in the signed token | ✅ served over a channel the report vouches for | +| email and data owners attested | ✅ same digest | ✅ signed with the bound key | +| freshness, so keys can be retired | ❌ minted once at boot | ✅ live connection and a per-request nonce | Whichever route bound them, the attested facts are appraised identically: `AppraisalPolicy` and `TinfoilAppraisalPolicy` both take an optional `expected_email` and `expected_data_owners`, and both @@ -200,5 +213,16 @@ run the same comparison. Left unset the attested values are reported; set, they Binding proves the enclave really was started with those values — whether they are the _right_ ones is the verifier's call. +### 6.4 Todo + +- **Confidential Spaces: re-publish the token and narrow the expiry grace window** (H16 in the + [protocol security review](../../../research/protocol-security-review/SUMMARY.md)). Google mints + these with roughly a 30-minute life; `JWT_EXPIRY_GRACE_SECONDS` widens that to a month only + because the enclave writes its token once at boot. Re-publishing on a timer and cutting the + window gives bounded staleness, which is enough for revocation and does not need the spare nonce + slot. +- **Pin by default.** `expected_image_digest` and `expected_data_owners` are what reject a + superseded configuration, and both currently default to unset. + For how to deploy either target, see [Confidential Spaces Deployment](./terraform_cs.md) and [Tinfoil Deployment](./tinfoil_deployment.md). From d4c751a8ea3dd4402963cd78226c49ec585a3b20 Mon Sep 17 00:00:00 2001 From: Koen van der Veen Date: Wed, 16 Sep 2026 13:05:22 +0200 Subject: [PATCH 13/21] docs: replace the undefined phrase "workload channel" MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit It was jargon of my own invention, never defined. What it meant is simply whether the code inside the enclave can put bytes of its own choosing into the hardware-signed report — Confidential Space can, via eat_nonce; Tinfoil cannot, because the shim fills all 64 bytes with its own keys. Co-Authored-By: Claude Opus 5 (1M context) --- packages/syft-enclave/docs/security.md | 7 ++++--- .../syft-enclave/src/syft_enclaves/evidence/key_bundle.py | 2 +- .../syft-enclave/src/syft_enclaves/evidence/tinfoil.py | 3 ++- packages/syft-enclave/tests/test_attestation_claims.py | 2 +- packages/syft-enclave/tests/test_attestation_tinfoil.py | 3 ++- 5 files changed, 10 insertions(+), 7 deletions(-) diff --git a/packages/syft-enclave/docs/security.md b/packages/syft-enclave/docs/security.md index de8b762e11e..6a4df22c7f5 100644 --- a/packages/syft-enclave/docs/security.md +++ b/packages/syft-enclave/docs/security.md @@ -139,8 +139,8 @@ is the mechanism that makes it true. ### 6.1 Confidential Spaces: committed to inside the signed token -Confidential Space lets a workload ask the launcher to embed bytes of its choosing into the -Google-signed token, via `eat_nonce`. Only code running inside the measured container can do that, +Confidential Space lets the code running inside the enclave ask the launcher to embed bytes of its +choosing into the Google-signed token, via a field called `eat_nonce`. Only code running inside the measured container can do that, and the signature is unforgeable. So the enclave commits to the **sha256 of the claims document** in the token, and a verifier recomputes that digest from the published document and compares. @@ -176,7 +176,8 @@ superseded configuration — but pinning is optional and off by default. ### 6.2 Tinfoil: bound to a connection, then signed over it -Tinfoil has no workload channel at all: its report's 64 bytes of user data are the sha256 of the +Tinfoil gives the code inside the enclave no way to put anything of its own into the report: its +64 bytes of user data are the sha256 of the shim's TLS public key followed by its HPKE public key. But that is exactly what makes binding possible, because the report **commits to the key terminating a TLS connection to the enclave**. So the client: diff --git a/packages/syft-enclave/src/syft_enclaves/evidence/key_bundle.py b/packages/syft-enclave/src/syft_enclaves/evidence/key_bundle.py index dbc8f7a8038..c87c4ebac69 100644 --- a/packages/syft-enclave/src/syft_enclaves/evidence/key_bundle.py +++ b/packages/syft-enclave/src/syft_enclaves/evidence/key_bundle.py @@ -84,7 +84,7 @@ def sign_nonce(nonce: str, path: Path = PUBLIC_BUNDLE_PATH) -> Optional[str]: One signature therefore carries three things: the enclave holds the key it served, the answer is for this exchange, and these are the runtime facts it asserts. On Tinfoil this is the *only* route to the last one, since its - report has no workload channel to commit to them. + report has no room for the enclave to commit to them itself. None when there is nothing to sign with, so the endpoint degrades to "served a bundle but proved nothing" rather than failing outright — the diff --git a/packages/syft-enclave/src/syft_enclaves/evidence/tinfoil.py b/packages/syft-enclave/src/syft_enclaves/evidence/tinfoil.py index 3ccea7122cc..66ee2c3bd5d 100644 --- a/packages/syft-enclave/src/syft_enclaves/evidence/tinfoil.py +++ b/packages/syft-enclave/src/syft_enclaves/evidence/tinfoil.py @@ -67,7 +67,8 @@ def collect( if claims is not None: raise ValueError( "Tinfoil evidence cannot commit to claims: the report's user " - "data is the shim's own keys, with no workload channel. The " + "data is the shim's own keys, leaving the enclave no room " + "to commit to anything of its own. The " "equivalent guarantee comes from a pinned connection instead " "(see attestation.https)." ) diff --git a/packages/syft-enclave/tests/test_attestation_claims.py b/packages/syft-enclave/tests/test_attestation_claims.py index 2ca1275c808..0a0c3ce4934 100644 --- a/packages/syft-enclave/tests/test_attestation_claims.py +++ b/packages/syft-enclave/tests/test_attestation_claims.py @@ -217,7 +217,7 @@ def test_one_slot_means_a_nonce_and_claims_are_exclusive(self): ConfidentialSpaceProvider().collect(caller_nonce="abc", claims=_claims()) def test_tinfoil_cannot_bind_claims(self, tmp_path, monkeypatch): - """Tinfoil has no workload channel, so it refuses rather than ignores.""" + """Tinfoil cannot commit to claims in its report, so it refuses.""" import json from syft_enclaves.evidence.tinfoil import TinfoilProvider diff --git a/packages/syft-enclave/tests/test_attestation_tinfoil.py b/packages/syft-enclave/tests/test_attestation_tinfoil.py index 0a608a648f2..2050e42e739 100644 --- a/packages/syft-enclave/tests/test_attestation_tinfoil.py +++ b/packages/syft-enclave/tests/test_attestation_tinfoil.py @@ -542,7 +542,8 @@ def test_the_policy_is_usable_without_the_sdk(self): class TestSignedClaims: """Tinfoil's route to attested runtime facts. - Its report has no workload channel, so the enclave signs the same claims + Its report leaves no room for the enclave to commit to anything itself, + so instead it signs the same claims document Confidential Space commits to in its token, with the key the report already binds, and serves it over the pinned connection. """ From 9828fe66130061cbccac07eea80f756056f93d21 Mon Sep 17 00:00:00 2001 From: Koen van der Veen Date: Wed, 16 Sep 2026 13:34:07 +0200 Subject: [PATCH 14/21] docs: rewrite security.md section 6 with the write-doc rules Same facts, different prose. Merges the run of one-sentence paragraphs (6.1 had eight, now five), drops the metaphors ("travels inside the file", "revocation by another name", "freshness comes free"), and replaces the bare back-references ("narrow it further", "covers the rest") with the thing they pointed at. Also defines replay where it is first used, says which policy class expected_image_digest belongs to, attributes the rejection to the check rather than the values, and swaps "superseded" for plain wording. Co-Authored-By: Claude Opus 5 (1M context) --- packages/syft-enclave/docs/security.md | 154 ++++++++++++------------- 1 file changed, 77 insertions(+), 77 deletions(-) diff --git a/packages/syft-enclave/docs/security.md b/packages/syft-enclave/docs/security.md index 6a4df22c7f5..e9c8c4d01da 100644 --- a/packages/syft-enclave/docs/security.md +++ b/packages/syft-enclave/docs/security.md @@ -120,84 +120,84 @@ a different remaining weakness. This section is the honest accounting. ### 6.0 What has to be bound, and why A verified report proves the workload runs on genuine confidential-computing hardware with debug -disabled, and that the **code and configuration** are the ones published: on Tinfoil, the CVM image -and `tinfoil-config.yml` of a named Sigstore-signed release, including the container image digest it -pins; on Confidential Spaces, the image digest in the token's claims. +disabled, and that the **code and configuration** are the ones published. On Tinfoil that means the +CVM image and the `tinfoil-config.yml` of a named Sigstore-signed release, including the container +image digest the config pins. On Confidential Spaces it means the image digest in the token's +claims. -That is not enough on its own. Three things a peer needs are _runtime_ values, outside the -measurement: +A verified report is not enough on its own. Three of the things a peer needs are runtime values, +which sit outside the measurement: -| Fact | Why it matters | -| ----------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| the enclave's **public key bundle** | you are about to encrypt private data to it. An unbound bundle can be swapped by whoever controls the enclave's Drive account, and they then read everything. | -| the enclave's **email** | it identifies the datasite you are talking to. | -| its configured **data owners** | this list is the approval gate — a job runs only once _all_ of them approve, so anyone who could change it unobserved could approve work on their own. | +| Fact | Why it matters | +| ----------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------- | +| the enclave's **public key bundle** | you are about to encrypt private data to it. Whoever controls the enclave's Drive account can swap an unbound bundle, and then read everything you send. | +| the enclave's **email** | it identifies the datasite you are talking to. | +| its configured **data owners** | this list is the approval gate. A job runs only once _all_ of them approve, so anyone who can change the list unseen can approve work on their own. | -Both targets therefore bind the same document — email, data owners, syft version, key bundle — to -the report. The document always travels in the clear and is never trusted on its own; what differs -is the mechanism that makes it true. +Both targets bind the same document to the report: email, data owners, syft version and key bundle. +The document always travels in the clear, and a verifier never trusts it on its own. The two +targets differ only in how they make the document trustworthy. ### 6.1 Confidential Spaces: committed to inside the signed token -Confidential Space lets the code running inside the enclave ask the launcher to embed bytes of its -choosing into the Google-signed token, via a field called `eat_nonce`. Only code running inside the measured container can do that, -and the signature is unforgeable. So the enclave commits to the **sha256 of the claims document** in -the token, and a verifier recomputes that digest from the published document and compares. - -It is the _signature_ doing the work, not secrecy — measurements and image digests are public on -both targets, deliberately, because that is what makes them auditable. - -There is room for exactly one digest: slot 0 carries the syft version in plain text, and a nonce is -capped at 74 characters matching `[a-zA-Z0-9_.-]`, which rules out carrying an email literally and -leaves a 64-character sha256 hex comfortably inside. Hence one digest over one document rather than -a field per fact. - -The binding travels **inside the file**, so it needs no connection to the enclave, which suits the -offline-first transport. - -**Limitation: no freshness, which means no revocation.** The token is minted once at boot and -written to `SYFT_version.json`, so a captured one stays acceptable for the length of the expiry -grace window (we widened it to a month, because the enclave does not yet re-publish). - -This is narrower than it sounds. The token commits to the key bundle, so replaying an old token -replays an old _bundle_ — and its private half never left that enclave. A replayer can make you -encrypt to a dead key, which is a denial of service, but cannot read what you send. Confidentiality -holds. - -What you lose is the ability to **retire a key**. If some past instance's private key ever does -become known, its token stays acceptable for ever, so that instance can be impersonated -indefinitely — and then decryption really is possible. A staleness check is revocation by another -name. Two things narrow it further: a replayer must control the enclave's Drive account to put the -old token where you read it, and a debug-mode enclave (where an operator could read the key over -SSH) is already rejected by the `dbgstat` check. - -Pinning covers the rest — `expected_image_digest` and `expected_data_owners` reject a token from a -superseded configuration — but pinning is optional and off by default. +Confidential Space lets the code inside the enclave ask the launcher to put bytes of its choosing +into the Google-signed token, in a field called `eat_nonce`. Only code inside the measured container +can ask for this, and nobody can forge Google's signature. So the enclave puts the sha256 of its +claims document there, and a verifier recomputes that hash from the published document and compares +the two. The signature is what makes this work, not secrecy: measurements and image digests are +public on both targets, on purpose, because that is what lets anyone audit them. + +There is room for one hash and no more. Slot 0 already holds the syft version as plain text, and +the launcher caps a nonce at 74 characters drawn from `[a-zA-Z0-9_.-]`. An email address cannot go +in, because `@` is not in that set, while a 64-character sha256 fits. That is why the enclave +commits to one hash over one document, rather than one field per fact. The token carries the hash, +so a verifier needs no connection to the enclave, which suits a transport built out of files. + +**Limitation: the token is never refreshed, so a key can never be retired.** The enclave asks for +one token at boot and writes it to `SYFT_version.json`. Google issues these tokens with a short +life, but we widened the window we accept to a month, because the enclave does not yet ask for a +new one. Anyone who keeps a copy of an old token can present it later as if it were current. +Presenting an old, captured token is called a replay. + +A replay is less useful than it sounds. The token commits to the enclave's key bundle, so replaying +an old token also replays an old bundle, and the private half of that bundle never left the enclave +that made it. A replayer can make you encrypt data to a key nobody holds any more, which stops the +work, but cannot read what you send. Confidentiality holds. What a replay does cost you is the +ability to retire a key. Say a past enclave's private key +becomes known to an attacker. That enclave's token stays acceptable for ever, so the attacker can +keep presenting it, and can then decrypt what you send. Checking that a token is recent is the only +way to say "stop trusting that enclave", so without such a check there is no way to revoke one. + +Two things make a replay harder. A replayer has to control the enclave's Drive account, because +that is where a verifier reads the token from. And a token from a debug-mode enclave, where an +operator can log in over SSH and read the key, already fails the `dbgstat` check. A verifier can +also refuse an old token by saying what it expects. `AppraisalPolicy` takes +`expected_image_digest` and `expected_data_owners`. When they are set, the check fails if the +enclave runs a different image or lists different data owners. Both are unset by default. ### 6.2 Tinfoil: bound to a connection, then signed over it -Tinfoil gives the code inside the enclave no way to put anything of its own into the report: its -64 bytes of user data are the sha256 of the -shim's TLS public key followed by its HPKE public key. But that is exactly what makes binding -possible, because the report **commits to the key terminating a TLS connection to the enclave**. So -the client: +Tinfoil gives the code inside the enclave no way to put anything of its own into the report. All 64 +bytes of user data are already in use: the sha256 of the shim's TLS public key, followed by the +shim's HPKE public key. That turns out to be what makes binding possible, because the report +commits to the key that terminates a TLS connection to the enclave. So the client: 1. verifies the report; -2. opens HTTPS to the enclave and checks the certificate it is served carries that same key — the - channel therefore provably ends inside the attested enclave; -3. takes the key bundle served over that channel, which is now authentic; +2. opens HTTPS to the enclave and checks the certificate it is served carries that same key, which + proves the connection ends inside the attested enclave; +3. takes the key bundle served over that connection, which is now authentic; 4. sends a random nonce, and checks the enclave signed **the nonce together with the claims document** using the identity key from that bundle. -No certificate authority is involved anywhere: the enclave's certificate is self-signed, and the -_report_ is what decides whether to trust it. Step 4 does three jobs in one signature — it proves -the enclave **holds the private half** of the key we are about to encrypt to, that the answer was -produced for _this_ exchange, and that the claims are the facts it meant to assert. The bundle and -the claims are accepted only if steps 2 and 4 both pass. +No certificate authority takes part. The enclave's certificate is self-signed, and the report is +what decides whether to trust the key inside it. The signature in step 4 proves three separate +things: the enclave holds the private half of the key you are about to encrypt to, the answer was +produced for this exchange rather than an earlier one, and the claims are the facts the enclave +meant to assert. The client accepts the bundle and the claims only if step 2 and step 4 both pass. -**Freshness comes free**, unlike on Confidential Spaces, so keys can be retired here: a captured -report commits to a TLS key whose private half lives in an enclave the attacker does not control, -so the pin fails, and the nonce is ours and new each time. +Keys can be retired here, because every check is live. A captured report commits to a TLS key whose +private half sits in an enclave the attacker does not control, so the certificate check fails, and +the nonce is new on every request. ### 6.3 Side by side @@ -206,24 +206,24 @@ so the pin fails, and the nonce is ours and new each time. | hardware, code, config | ✅ | ✅ | | key bundle bound | ✅ digest in the signed token | ✅ served over a channel the report vouches for | | email and data owners attested | ✅ same digest | ✅ signed with the bound key | -| freshness, so keys can be retired | ❌ minted once at boot | ✅ live connection and a per-request nonce | +| freshness, so keys can be retired | ❌ issued once at boot | ✅ live connection and a per-request nonce | -Whichever route bound them, the attested facts are appraised identically: `AppraisalPolicy` and -`TinfoilAppraisalPolicy` both take an optional `expected_email` and `expected_data_owners`, and both -run the same comparison. Left unset the attested values are reported; set, they are required. -Binding proves the enclave really was started with those values — whether they are the _right_ ones -is the verifier's call. +Both targets appraise the facts the same way, once the document is trustworthy. `AppraisalPolicy` +for Confidential Spaces and `TinfoilAppraisalPolicy` for Tinfoil each take an optional +`expected_email` and `expected_data_owners`, and both run the same comparison. Left unset, a +verifier reports the attested values. Set, a verifier requires them. Binding proves the enclave +started with those values. Whether they are the right values is the verifier's call. ### 6.4 Todo -- **Confidential Spaces: re-publish the token and narrow the expiry grace window** (H16 in the - [protocol security review](../../../research/protocol-security-review/SUMMARY.md)). Google mints - these with roughly a 30-minute life; `JWT_EXPIRY_GRACE_SECONDS` widens that to a month only - because the enclave writes its token once at boot. Re-publishing on a timer and cutting the - window gives bounded staleness, which is enough for revocation and does not need the spare nonce - slot. -- **Pin by default.** `expected_image_digest` and `expected_data_owners` are what reject a - superseded configuration, and both currently default to unset. +- **Confidential Spaces: ask for a new token periodically, and narrow the window we accept** (H16 + in the [protocol security review](../../../research/protocol-security-review/SUMMARY.md)). Google + issues these tokens with roughly a 30-minute life. `JWT_EXPIRY_GRACE_SECONDS` in the verifier + widens that to a month, only because the enclave writes its token once at boot. Asking for a new + token on a timer, and cutting the window, bounds how old a token can be. That is enough to + revoke one, and it does not need the spare nonce slot. +- **Pin by default.** Setting `expected_image_digest` and `expected_data_owners` is what makes a + verifier refuse a token from an older configuration. Both are unset today. For how to deploy either target, see [Confidential Spaces Deployment](./terraform_cs.md) and [Tinfoil Deployment](./tinfoil_deployment.md). From 77997ccb325f2388f80dc93c7fc98857fd89de67 Mon Sep 17 00:00:00 2001 From: Koen van der Veen Date: Wed, 16 Sep 2026 14:04:55 +0200 Subject: [PATCH 15/21] docs: rewrite security.md section 6 again, with the updated rules Same facts. Section 5's point is restated instead of cross-referenced, so the reader does not have to hold "the intent" or "both targets" in their head. The 6.1 and 6.2 headings now name what the section is about rather than how it works. The claims document is described in the order it happens: the enclave writes the facts down, then binds the document to the report. Also splits the works-because-of-A-not-B sentence into a plain statement (the token is public, we do not hide it) followed by what makes the hash trustworthy, drops the mention of Tinfoil from a Confidential Spaces section, gives "room" and "slot 0" their owner (the eat_nonce field), and says how the token relates to the attestation report on first mention. Drops "weakness" and "honest accounting" throughout, including in the section 5 status note. Limitations are still stated plainly. Co-Authored-By: Claude Opus 5 (1M context) --- packages/syft-enclave/docs/security.md | 156 ++++++++++++++----------- 1 file changed, 85 insertions(+), 71 deletions(-) diff --git a/packages/syft-enclave/docs/security.md b/packages/syft-enclave/docs/security.md index e9c8c4d01da..e5da6b512c3 100644 --- a/packages/syft-enclave/docs/security.md +++ b/packages/syft-enclave/docs/security.md @@ -93,7 +93,7 @@ have access to the account. > **Status.** Implemented on both targets, binding the same facts — keys, email and configured data > owners — by different mechanisms: **Confidential Spaces** commits to a digest of them inside the > signed token (§6.1), **Tinfoil** signs them with a key its report vouches for, over a connection -> pinned to that key (§6.2). Each route has a different remaining weakness; §6.3 compares them. +> pinned to that key (§6.2). Section 6 sets out what each one proves, and §6.3 compares them. The data owners and the DS then download the enclave's verified keys (and share their own), and from that point on there is a **trusted, end-to-end secure channel** between the @@ -114,19 +114,20 @@ anyone trusting Google Drive, the network, or each other. ## 6. What attestation proves on each target -Section 5 describes the intent. Both targets deliver it, but by different routes, and each route has -a different remaining weakness. This section is the honest accounting. +Section 5 says the enclave binds its own public keys to its attestation report, so that nobody can +swap those keys for their own. Confidential Spaces and Tinfoil both do that, by different means. +This section sets out what each one proves about an enclave, and what each one leaves open. ### 6.0 What has to be bound, and why A verified report proves the workload runs on genuine confidential-computing hardware with debug disabled, and that the **code and configuration** are the ones published. On Tinfoil that means the CVM image and the `tinfoil-config.yml` of a named Sigstore-signed release, including the container -image digest the config pins. On Confidential Spaces it means the image digest in the token's +image digest that the config pins. On Confidential Spaces it means the image digest in the report's claims. A verified report is not enough on its own. Three of the things a peer needs are runtime values, -which sit outside the measurement: +which sit outside what the report measures: | Fact | Why it matters | | ----------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------- | @@ -134,53 +135,64 @@ which sit outside the measurement: | the enclave's **email** | it identifies the datasite you are talking to. | | its configured **data owners** | this list is the approval gate. A job runs only once _all_ of them approve, so anyone who can change the list unseen can approve work on their own. | -Both targets bind the same document to the report: email, data owners, syft version and key bundle. -The document always travels in the clear, and a verifier never trusts it on its own. The two -targets differ only in how they make the document trustworthy. - -### 6.1 Confidential Spaces: committed to inside the signed token - -Confidential Space lets the code inside the enclave ask the launcher to put bytes of its choosing -into the Google-signed token, in a field called `eat_nonce`. Only code inside the measured container -can ask for this, and nobody can forge Google's signature. So the enclave puts the sha256 of its -claims document there, and a verifier recomputes that hash from the published document and compares -the two. The signature is what makes this work, not secrecy: measurements and image digests are -public on both targets, on purpose, because that is what lets anyone audit them. - -There is room for one hash and no more. Slot 0 already holds the syft version as plain text, and -the launcher caps a nonce at 74 characters drawn from `[a-zA-Z0-9_.-]`. An email address cannot go -in, because `@` is not in that set, while a 64-character sha256 fits. That is why the enclave -commits to one hash over one document, rather than one field per fact. The token carries the hash, -so a verifier needs no connection to the enclave, which suits a transport built out of files. - -**Limitation: the token is never refreshed, so a key can never be retired.** The enclave asks for -one token at boot and writes it to `SYFT_version.json`. Google issues these tokens with a short -life, but we widened the window we accept to a month, because the enclave does not yet ask for a -new one. Anyone who keeps a copy of an old token can present it later as if it were current. -Presenting an old, captured token is called a replay. - -A replay is less useful than it sounds. The token commits to the enclave's key bundle, so replaying -an old token also replays an old bundle, and the private half of that bundle never left the enclave -that made it. A replayer can make you encrypt data to a key nobody holds any more, which stops the -work, but cannot read what you send. Confidentiality holds. What a replay does cost you is the -ability to retire a key. Say a past enclave's private key -becomes known to an attacker. That enclave's token stays acceptable for ever, so the attacker can -keep presenting it, and can then decrypt what you send. Checking that a token is recent is the only -way to say "stop trusting that enclave", so without such a check there is no way to revoke one. - -Two things make a replay harder. A replayer has to control the enclave's Drive account, because -that is where a verifier reads the token from. And a token from a debug-mode enclave, where an -operator can log in over SSH and read the key, already fails the `dbgstat` check. A verifier can -also refuse an old token by saying what it expects. `AppraisalPolicy` takes -`expected_image_digest` and `expected_data_owners`. When they are set, the check fails if the -enclave runs a different image or lists different data owners. Both are unset by default. - -### 6.2 Tinfoil: bound to a connection, then signed over it - -Tinfoil gives the code inside the enclave no way to put anything of its own into the report. All 64 -bytes of user data are already in use: the sha256 of the shim's TLS public key, followed by the -shim's HPKE public key. That turns out to be what makes binding possible, because the report -commits to the key that terminates a TLS connection to the enclave. So the client: +So the enclave writes those facts into one document — its email, its data owners, its syft version +and its public key bundle — and then binds that document to its attestation report. The document +itself always travels in the clear, and a verifier never trusts the document on its own. +Confidential Spaces and Tinfoil differ only in how each one makes the document trustworthy. + +### 6.1 Binding extra facts on Confidential Spaces + +On Confidential Spaces the attestation report is a token: a signed statement that Google's launcher +issues to the enclave at boot. The launcher lets the code inside the enclave add a few values of its +own to that token, in a field called `eat_nonce`. Only code inside the measured container can ask +for this, and nobody can forge Google's signature on the result. + +The enclave hashes its claims document with sha256, then asks the launcher to put that hash in the +token. A verifier reads the published document, hashes it the same way, and compares the two hashes. +If anyone alters the document, the two hashes no longer match. + +The token is public, and we do not try to hide it. Anyone can read the measurements and the image +digest inside the token, which is what lets anyone audit what the enclave runs. Google's signature +is what makes the hash trustworthy, not secrecy. + +The `eat_nonce` field holds a short list of values. Slot 0 of that list already holds the syft +version as plain text, which leaves one spare slot, and the launcher caps each value at 74 +characters drawn from `[a-zA-Z0-9_.-]`. An email address does not fit, because `@` is not in that +set, while a 64-character sha256 hash does. That is why the enclave binds one hash of one document, +rather than one value per fact. The token carries the hash, so a verifier needs no connection to the +enclave, which suits a transport built out of files. + +**Limitation: the enclave never asks for a new token, so a key can never be retired.** The enclave +asks for one token at boot and writes it to `SYFT_version.json`. Google issues these tokens with a +short life, but the verifier accepts a token up to a month old, because the enclave does not yet ask +for a new one. Anyone who keeps a copy of an old token can present it later as though it were +current. Presenting an old, captured token is called a replay. + +A replay does not let an attacker read your data. The token binds the enclave's key bundle, so +replaying an old token also replays an old bundle, and the private half of that bundle never left +the enclave that made it. A replayer can make you encrypt data to a key that nobody holds any more, +which stops the work, but cannot read what you send. What a replay does cost you is the ability to +retire a key. Say a past enclave's private key becomes known to an attacker. That enclave's token +stays acceptable for ever, so the attacker can keep presenting the token, and can then decrypt what +you send. Checking that a token is recent is the only way to say "stop trusting that enclave", so +without such a check there is no way to revoke one. + +Two things make a replay harder. A replayer has to control the enclave's Drive account, because that +is where a verifier reads the token from. And a token from a debug-mode enclave, where an operator +can log in over SSH and read the key, already fails the `dbgstat` check. A verifier can also refuse +an old token by saying what it expects: `AppraisalPolicy` takes `expected_image_digest` and +`expected_data_owners`, and when they are set, the check fails if the enclave runs a different image +or lists different data owners. Both are unset by default. + +### 6.2 Binding extra facts on Tinfoil + +Tinfoil gives the code inside the enclave no way to add anything to the report, so the enclave binds +its claims document a different way: it signs the document, over a connection that the report +vouches for. + +All 64 bytes of user data in a Tinfoil report are already in use. Those bytes hold the sha256 of the +shim's TLS public key, followed by the shim's HPKE public key. That is what makes binding possible, +because the report commits to the key that terminates a TLS connection to the enclave. So the client: 1. verifies the report; 2. opens HTTPS to the enclave and checks the certificate it is served carries that same key, which @@ -190,23 +202,24 @@ commits to the key that terminates a TLS connection to the enclave. So the clien document** using the identity key from that bundle. No certificate authority takes part. The enclave's certificate is self-signed, and the report is -what decides whether to trust the key inside it. The signature in step 4 proves three separate -things: the enclave holds the private half of the key you are about to encrypt to, the answer was -produced for this exchange rather than an earlier one, and the claims are the facts the enclave -meant to assert. The client accepts the bundle and the claims only if step 2 and step 4 both pass. +what decides whether to trust the key inside that certificate. The signature in step 4 proves three +separate things: the enclave holds the private half of the key you are about to encrypt to, the +answer was produced for this exchange rather than an earlier one, and the claims are the facts the +enclave meant to assert. The client accepts the bundle and the claims only if step 2 and step 4 both +pass. -Keys can be retired here, because every check is live. A captured report commits to a TLS key whose +Tinfoil can retire a key, because every check is live. A captured report commits to a TLS key whose private half sits in an enclave the attacker does not control, so the certificate check fails, and the nonce is new on every request. ### 6.3 Side by side -| | Confidential Spaces | Tinfoil | -| --------------------------------- | ----------------------------- | ----------------------------------------------- | -| hardware, code, config | ✅ | ✅ | -| key bundle bound | ✅ digest in the signed token | ✅ served over a channel the report vouches for | -| email and data owners attested | ✅ same digest | ✅ signed with the bound key | -| freshness, so keys can be retired | ❌ issued once at boot | ✅ live connection and a per-request nonce | +| | Confidential Spaces | Tinfoil | +| --------------------------------- | ------------------------------- | -------------------------------------------------- | +| hardware, code, config | ✅ | ✅ | +| key bundle bound | ✅ hash inside the signed token | ✅ served over a connection the report vouches for | +| email and data owners attested | ✅ same hash | ✅ signed with the bound key | +| freshness, so keys can be retired | ❌ one token, issued at boot | ✅ live connection and a per-request nonce | Both targets appraise the facts the same way, once the document is trustworthy. `AppraisalPolicy` for Confidential Spaces and `TinfoilAppraisalPolicy` for Tinfoil each take an optional @@ -216,14 +229,15 @@ started with those values. Whether they are the right values is the verifier's c ### 6.4 Todo -- **Confidential Spaces: ask for a new token periodically, and narrow the window we accept** (H16 - in the [protocol security review](../../../research/protocol-security-review/SUMMARY.md)). Google - issues these tokens with roughly a 30-minute life. `JWT_EXPIRY_GRACE_SECONDS` in the verifier - widens that to a month, only because the enclave writes its token once at boot. Asking for a new - token on a timer, and cutting the window, bounds how old a token can be. That is enough to - revoke one, and it does not need the spare nonce slot. -- **Pin by default.** Setting `expected_image_digest` and `expected_data_owners` is what makes a - verifier refuse a token from an older configuration. Both are unset today. +- **Confidential Spaces: ask for a new token periodically, and narrow the window the verifier + accepts** (H16 in the + [protocol security review](../../../research/protocol-security-review/SUMMARY.md)). Google issues + these tokens with roughly a 30-minute life. `JWT_EXPIRY_GRACE_SECONDS` in the verifier widens that + to a month, because the enclave writes its token once at boot. Asking for a new token on a timer, + and cutting the window, bounds how old a token can be. That is enough to revoke one, and it does + not need the spare nonce slot. +- **Pin by default.** Setting `expected_image_digest` and `expected_data_owners` on the policy is + what makes a verifier refuse a token from an older configuration. Both are unset today. For how to deploy either target, see [Confidential Spaces Deployment](./terraform_cs.md) and [Tinfoil Deployment](./tinfoil_deployment.md). From abd16cc80a5640bae5c52871ffc53f07111d050a Mon Sep 17 00:00:00 2001 From: Koen van der Veen Date: Wed, 16 Sep 2026 14:15:12 +0200 Subject: [PATCH 16/21] docs: name the replay limitation as current, with its assumptions Says plainly that the replay window is a limitation we plan to remove, and states the two assumptions it rests on today: the enclave's private key never leaves the enclave, and an enclave is short-lived. Co-Authored-By: Claude Opus 5 (1M context) --- packages/syft-enclave/docs/security.md | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/packages/syft-enclave/docs/security.md b/packages/syft-enclave/docs/security.md index e5da6b512c3..c1175d9ab47 100644 --- a/packages/syft-enclave/docs/security.md +++ b/packages/syft-enclave/docs/security.md @@ -184,6 +184,11 @@ an old token by saying what it expects: `AppraisalPolicy` takes `expected_image_ `expected_data_owners`, and when they are set, the check fails if the enclave runs a different image or lists different data owners. Both are unset by default. +This is a current limitation, and 6.4 lists the plan for removing it. Two assumptions make the +limitation acceptable for now. The enclave's private key never leaves the enclave, which is a +reasonable thing to rest on, so an attacker holds no old key to pair with a replayed token. And an +enclave is short-lived, so few old tokens exist to replay. + ### 6.2 Binding extra facts on Tinfoil Tinfoil gives the code inside the enclave no way to add anything to the report, so the enclave binds From 146832d4dc1249e1afde6effa10ad506e51fbf53 Mon Sep 17 00:00:00 2001 From: Koen van der Veen Date: Wed, 16 Sep 2026 14:26:12 +0200 Subject: [PATCH 17/21] feat: an appraisal policy must pin the image digest and data owners A policy that pinned neither silently skipped the two checks that say which code the enclave runs and who has to approve a job on it, leaving an appraisal that only proves some genuine enclave exists. Both policy classes now refuse to be built without expected_image_digest and expected_data_owners, and the error names both fields, says what is lost, and points at allow_unpinned=True for accepting that on purpose. The rule lives in one place, attestation.claims.Expectations, which both AppraisalPolicy and TinfoilAppraisalPolicy now inherit, so it cannot drift between the two targets. attest_peer takes expected_data_owners alongside expected_image_digest, since the digest alone can no longer build a valid policy. Both helper scripts opt out explicitly, because they are often run before either value is known. Co-Authored-By: Claude Opus 5 (1M context) --- packages/syft-enclave/docs/security.md | 16 ++-- .../syft-enclave/docs/tinfoil_deployment.md | 8 +- .../syft-enclave/scripts/tinfoil_e2e_check.py | 11 +++ .../syft-enclave/scripts/verify_tinfoil.py | 11 +++ .../src/syft_enclaves/attestation/claims.py | 55 ++++++++++++ .../attestation/confidential_space.py | 32 ++----- .../src/syft_enclaves/attestation/tinfoil.py | 33 +++----- .../syft-enclave/src/syft_enclaves/client.py | 30 +++++-- .../tests/test_attestation_claims.py | 83 +++++++++++++++++-- .../test_attestation_confidential_space.py | 38 +++++---- .../tests/test_attestation_dispatch.py | 35 ++++++-- .../tests/test_attestation_tinfoil.py | 11 ++- 12 files changed, 264 insertions(+), 99 deletions(-) diff --git a/packages/syft-enclave/docs/security.md b/packages/syft-enclave/docs/security.md index c1175d9ab47..2738d4c7b83 100644 --- a/packages/syft-enclave/docs/security.md +++ b/packages/syft-enclave/docs/security.md @@ -181,8 +181,9 @@ Two things make a replay harder. A replayer has to control the enclave's Drive a is where a verifier reads the token from. And a token from a debug-mode enclave, where an operator can log in over SSH and read the key, already fails the `dbgstat` check. A verifier can also refuse an old token by saying what it expects: `AppraisalPolicy` takes `expected_image_digest` and -`expected_data_owners`, and when they are set, the check fails if the enclave runs a different image -or lists different data owners. Both are unset by default. +`expected_data_owners`, and the check fails if the enclave runs a different image or lists +different data owners. A policy refuses to be built without both of them, so a verifier cannot skip +these two checks by accident. To verify without pinning, say so with `allow_unpinned=True`. This is a current limitation, and 6.4 lists the plan for removing it. Two assumptions make the limitation acceptable for now. The enclave's private key never leaves the enclave, which is a @@ -227,10 +228,11 @@ the nonce is new on every request. | freshness, so keys can be retired | ❌ one token, issued at boot | ✅ live connection and a per-request nonce | Both targets appraise the facts the same way, once the document is trustworthy. `AppraisalPolicy` -for Confidential Spaces and `TinfoilAppraisalPolicy` for Tinfoil each take an optional -`expected_email` and `expected_data_owners`, and both run the same comparison. Left unset, a -verifier reports the attested values. Set, a verifier requires them. Binding proves the enclave -started with those values. Whether they are the right values is the verifier's call. +for Confidential Spaces and `TinfoilAppraisalPolicy` for Tinfoil take the same expectations and run +the same comparison. Each policy has to pin `expected_image_digest` and `expected_data_owners`, or +say `allow_unpinned=True`; `expected_email` stays optional, because a peer already addresses the +enclave by email. Binding proves the enclave started with those values. Whether they are the right +values is the verifier's call. ### 6.4 Todo @@ -241,8 +243,6 @@ started with those values. Whether they are the right values is the verifier's c to a month, because the enclave writes its token once at boot. Asking for a new token on a timer, and cutting the window, bounds how old a token can be. That is enough to revoke one, and it does not need the spare nonce slot. -- **Pin by default.** Setting `expected_image_digest` and `expected_data_owners` on the policy is - what makes a verifier refuse a token from an older configuration. Both are unset today. For how to deploy either target, see [Confidential Spaces Deployment](./terraform_cs.md) and [Tinfoil Deployment](./tinfoil_deployment.md). diff --git a/packages/syft-enclave/docs/tinfoil_deployment.md b/packages/syft-enclave/docs/tinfoil_deployment.md index 8dced2f01ef..56758a7b713 100644 --- a/packages/syft-enclave/docs/tinfoil_deployment.md +++ b/packages/syft-enclave/docs/tinfoil_deployment.md @@ -122,10 +122,14 @@ Step 3 onwards is where the admin API key becomes mandatory. Then from a data owner's client, against the evidence the enclave published to Drive: ```python -do.attest_peer(ENCLAVE_EMAIL, expected_image_digest="sha256:...") +do.attest_peer( + ENCLAVE_EMAIL, + expected_image_digest="sha256:...", + expected_data_owners=["do1@openmined.org", "do2@openmined.org"], +) ``` -Pass the digest `tinfoil-release` printed. Without it the image-digest check is **skipped**, not failed — the attestation then proves a genuine enclave booted a signed config, but not that the config pinned the image you reviewed. +Pass the digest `tinfoil-release` printed. Both arguments are required: without them the attestation would prove a genuine enclave booted a signed config, but not that the config pinned the image you reviewed, nor who has to approve a job. To skip them on purpose, pass a policy with `allow_unpinned=True`. The whole data-owner side (peer, attest over Drive, upload a dataset) is scripted: diff --git a/packages/syft-enclave/scripts/tinfoil_e2e_check.py b/packages/syft-enclave/scripts/tinfoil_e2e_check.py index f3eec83a0c9..2737aa17632 100644 --- a/packages/syft-enclave/scripts/tinfoil_e2e_check.py +++ b/packages/syft-enclave/scripts/tinfoil_e2e_check.py @@ -46,6 +46,12 @@ def parse_args() -> argparse.Namespace: "--tag", default=None, help="pin the release tag being verified" ) parser.add_argument("--expected-image-digest", default=None) + parser.add_argument( + "--expected-data-owners", + default=None, + type=lambda v: [e.strip() for e in v.split(",") if e.strip()], + help="comma-separated emails whose approval must gate a job", + ) parser.add_argument("--dataset-name", default="tinfoil-e2e-dataset") parser.add_argument("--peer-attempts", type=int, default=15) parser.add_argument("--peer-interval", type=int, default=15) @@ -101,9 +107,14 @@ def main() -> int: repo=args.repo, release_tag=args.tag, expected_image_digest=args.expected_image_digest, + expected_data_owners=args.expected_data_owners, # The config pins no SYFT_VERSION, so leave this unset rather than # failing a check the deployment cannot satisfy. expected_syft_version=None, + # A policy has to pin an image digest and a data-owner list. This + # script is often run before either is known, so say so explicitly + # rather than let the run fail at policy construction. + allow_unpinned=not (args.expected_image_digest and args.expected_data_owners), ) result = client.attest_peer(args.enclave_email, policy=policy) if result is None: diff --git a/packages/syft-enclave/scripts/verify_tinfoil.py b/packages/syft-enclave/scripts/verify_tinfoil.py index ca1fc06ec96..a87689a210e 100644 --- a/packages/syft-enclave/scripts/verify_tinfoil.py +++ b/packages/syft-enclave/scripts/verify_tinfoil.py @@ -41,6 +41,12 @@ def parse_args() -> argparse.Namespace: default=None, help="'sha256:...' digest to pin; omit to skip the image check", ) + parser.add_argument( + "--expected-data-owners", + default=None, + type=lambda v: [e.strip() for e in v.split(",") if e.strip()], + help="comma-separated emails whose approval must gate a job", + ) parser.add_argument("--container-name", default="syft-enclave") return parser.parse_args() @@ -63,7 +69,12 @@ def main() -> int: repo=args.repo, release_tag=args.tag, expected_image_digest=args.expected_image_digest, + expected_data_owners=args.expected_data_owners, container_name=args.container_name, + # This script checks a live host, often before the digest and the + # data-owner list are known, so it opts out rather than refusing to + # build a policy. + allow_unpinned=not (args.expected_image_digest and args.expected_data_owners), ) try: verify_tinfoil_evidence(tinfoil_evidence(fetch_document(args.host)), policy) diff --git a/packages/syft-enclave/src/syft_enclaves/attestation/claims.py b/packages/syft-enclave/src/syft_enclaves/attestation/claims.py index d05234f4188..86b6d643831 100644 --- a/packages/syft-enclave/src/syft_enclaves/attestation/claims.py +++ b/packages/syft-enclave/src/syft_enclaves/attestation/claims.py @@ -30,6 +30,10 @@ import json from typing import Any, Optional +from pydantic import BaseModel, model_validator + +from syft.version import SYFT_VERSION + CLAIMS_VERSION = 1 @@ -118,3 +122,54 @@ def _compare( if expected == actual: return (name, label, True, f"matches {actual!r}") return (name, label, False, f"enclave reports {actual!r}, expected {expected!r}") + + +class Expectations(BaseModel): + """The reference values a verifier appraises an enclave against. + + Shared by both targets, so the rule below cannot drift between them. + + A verifier that pins nothing learns only that *some* genuine enclave + exists. It does not learn which code that enclave runs, nor who has to + approve a job on it, because those checks are skipped. Skipping them + silently is the dangerous case, so a policy refuses to be built without + them. Pass ``allow_unpinned=True`` to say you accept that on purpose. + """ + + model_config = {"frozen": True} + + # A "sha256:..." container image digest you trust. + expected_image_digest: Optional[str] = None + # The data owners whose approval must gate a job on this enclave. + expected_data_owners: Optional[list[str]] = None + # The datasite the enclave should be running as. Optional: a peer already + # addresses the enclave by email, so it is a cross-check rather than the + # thing at stake. + expected_email: Optional[str] = None + # By default the enclave must run the same version of syft as the verifier. + expected_syft_version: Optional[str] = SYFT_VERSION + # Verify without pinning. The image-digest and data-owner checks then + # report as skipped, and prove nothing. + allow_unpinned: bool = False + + @model_validator(mode="after") + def _require_pinning(self) -> "Expectations": + if self.allow_unpinned: + return self + missing = [ + name + for name, value in ( + ("expected_image_digest", self.expected_image_digest), + ("expected_data_owners", self.expected_data_owners), + ) + if value is None + ] + if missing: + raise ValueError( + f"{type(self).__name__} needs {' and '.join(missing)}. Without " + "them the attestation proves that some genuine enclave exists, " + "but not which code it runs or who approves a job on it. Pass " + "the values you independently confirmed, or " + "allow_unpinned=True to accept that on purpose." + ) + return self diff --git a/packages/syft-enclave/src/syft_enclaves/attestation/confidential_space.py b/packages/syft-enclave/src/syft_enclaves/attestation/confidential_space.py index 048c55d89b7..d822cc96e71 100644 --- a/packages/syft-enclave/src/syft_enclaves/attestation/confidential_space.py +++ b/packages/syft-enclave/src/syft_enclaves/attestation/confidential_space.py @@ -17,12 +17,11 @@ from google.auth.transport import requests as google_requests from google.oauth2 import id_token -from pydantic import BaseModel -from syft.version import SYFT_VERSION from syft_enclaves.attestation.claims import ( ClaimsBindingError, + Expectations, check_expected, verify_claims_digest, ) @@ -47,32 +46,15 @@ JWT_EXPIRY_GRACE_SECONDS = 30 * 24 * 60 * 60 # ~1 month -class AppraisalPolicy(BaseModel): - """Reference values the verifier appraises attestation evidence against. +class AppraisalPolicy(Expectations): + """Reference values a Confidential Space enclave is appraised against. - In RATS terms this is the *appraisal policy*: the - set of trusted reference values the enclave's evidence is compared to. - - The image digest is intentionally not shipped as a constant — the data - owner supplies the digest they independently confirmed. Left unset - (``None``), the image-digest check is skipped and the image is not pinned. + In RATS terms this is the *appraisal policy*: the set of trusted reference + values the enclave's evidence is compared to. The fields, and the rule that + a policy must pin an image digest and a data-owner list, come from + ``attestation.claims.Expectations``. """ - model_config = {"frozen": True} - - # None → image-digest check skipped (no image pinned). Set a "sha256:..." - # digest to pin, and require, a specific enclave image. - expected_image_digest: Optional[str] = None - # By default, the enclave must run the same version of syft as the verifier. - expected_syft_version: Optional[str] = SYFT_VERSION - # Runtime facts the enclave commits to in its token. None → the value is - # reported but not required; set one to refuse an enclave that was started - # with anything else. These are only meaningful because the token binds - # them (see attestation.claims); without the binding they would be the - # enclave's unsigned word. - expected_email: Optional[str] = None - expected_data_owners: Optional[list[str]] = None - def _nonce_slots(claims: dict) -> list[str]: """The token's eat_nonce as a list; Google returns a bare string for one.""" diff --git a/packages/syft-enclave/src/syft_enclaves/attestation/tinfoil.py b/packages/syft-enclave/src/syft_enclaves/attestation/tinfoil.py index 7272195a3a9..a006042693b 100644 --- a/packages/syft-enclave/src/syft_enclaves/attestation/tinfoil.py +++ b/packages/syft-enclave/src/syft_enclaves/attestation/tinfoil.py @@ -34,12 +34,9 @@ import json from typing import Any, NoReturn, Optional -from pydantic import BaseModel - -from syft.version import SYFT_VERSION from syft_enclaves.attestation.result import AttestationError, AttestationResult -from syft_enclaves.attestation.claims import check_expected +from syft_enclaves.attestation.claims import Expectations, check_expected from syft_enclaves.attestation.envelope import AttestationEvidence from syft_enclaves.attestation.https import ( AttestationFetchError, @@ -63,34 +60,26 @@ REQUEST_TIMEOUT_SECONDS = 30 -class TinfoilAppraisalPolicy(BaseModel): - """Reference values a Tinfoil enclave's evidence is appraised against. +class TinfoilAppraisalPolicy(Expectations): + """Reference values a Tinfoil enclave is appraised against. + + The expectation fields, and the rule that a policy must pin an image + digest and a data-owner list, come from + ``attestation.claims.Expectations``. ``repo`` deliberately has a shipped default and is never taken from the - peer: the enclave (and whoever controls its transport) writes its own - evidence, so letting it name the repo would let it choose which releases - are trusted. + peer: the enclave writes its own evidence, so letting the enclave name the + repo would let the enclave choose which releases are trusted. """ - model_config = {"frozen": True} - repo: str = DEFAULT_TINFOIL_CONFIG_REPO # None -> appraise against the repo's latest release. release_tag: Optional[str] = None - # None -> the image-digest check is skipped and the image is not pinned. - expected_image_digest: Optional[str] = None - # None -> skipped. Only meaningful when the config pins SYFT_VERSION. - expected_syft_version: Optional[str] = SYFT_VERSION - # Which container in the config carries the enclave. - container_name: str = "syft-enclave" # Where to fetch the report over a connection pinned to the key the report # commits to. None -> use the host the enclave advertised in its evidence. host: Optional[str] = None - # Runtime facts the enclave asserts and signs. None -> the value is - # reported but not required; set one to refuse an enclave started with - # anything else. Same fields, and the same checks, as Confidential Space. - expected_email: Optional[str] = None - expected_data_owners: Optional[list[str]] = None + # Which container in the config carries the enclave. + container_name: str = "syft-enclave" def verify_tinfoil_evidence( diff --git a/packages/syft-enclave/src/syft_enclaves/client.py b/packages/syft-enclave/src/syft_enclaves/client.py index c7f3ee766f4..03839e1a139 100644 --- a/packages/syft-enclave/src/syft_enclaves/client.py +++ b/packages/syft-enclave/src/syft_enclaves/client.py @@ -90,6 +90,7 @@ def attest_peer( self, peer_email: str, expected_image_digest: str | None = None, + expected_data_owners: list[str] | None = None, policy: "AppraisalPolicy | TinfoilAppraisalPolicy | None" = None, ): """Verify an enclave peer's attestation by re-reading SYFT_version.json @@ -102,26 +103,39 @@ def attest_peer( evidence needs the optional ``tinfoil`` package; see ``docs/tinfoil_deployment.md``. + A policy has to pin an image digest and a data-owner list, so pass both + shorthands or build a policy yourself. Without them the attestation + would prove that some genuine enclave exists, but not which code it + runs or who approves a job on it. To accept that on purpose, pass a + policy with ``allow_unpinned=True``. + Args: peer_email: the enclave peer to attest. expected_image_digest: a "sha256:..." container image digest you - trust. When set, the attestation is appraised against it. + trust. + expected_data_owners: the emails whose approval must gate a job on + this enclave. policy: a full appraisal policy for finer control — an ``AppraisalPolicy`` for Confidential Space or a ``TinfoilAppraisalPolicy`` for Tinfoil. Mutually exclusive with - ``expected_image_digest``. + the two shorthands. """ - if expected_image_digest is not None and policy is not None: - raise ValueError("Pass either expected_image_digest or policy, not both.") + shorthands = { + "expected_image_digest": expected_image_digest, + "expected_data_owners": expected_data_owners, + } + given = {name: value for name, value in shorthands.items() if value is not None} + if given and policy is not None: + raise ValueError( + f"Pass either {' / '.join(shorthands)} or policy, not both." + ) evidence = self._peer_evidence(peer_email) if evidence is None: return None - if expected_image_digest is not None: - policy = policy_for( - evidence.kind, expected_image_digest=expected_image_digest - ) + if given: + policy = policy_for(evidence.kind, **given) result = verify_evidence(evidence, policy=policy) self._adopt_verified_key_bundle(peer_email, result) return result diff --git a/packages/syft-enclave/tests/test_attestation_claims.py b/packages/syft-enclave/tests/test_attestation_claims.py index 0a0c3ce4934..063ce098663 100644 --- a/packages/syft-enclave/tests/test_attestation_claims.py +++ b/packages/syft-enclave/tests/test_attestation_claims.py @@ -107,7 +107,9 @@ def test_bound_claims_are_accepted_and_the_bundle_adopted(self, token_with): token_with(claims_nonce=claims_digest(claims)) evidence = confidential_space_evidence("a.b.c", "syft-attestation", claims) - result = verify_evidence(evidence, verbose=False) + result = verify_evidence( + evidence, policy=AppraisalPolicy(allow_unpinned=True), verbose=False + ) assert _check(result, "claims_binding").passed is True assert EMAIL in _check(result, "claims_binding").detail @@ -123,7 +125,9 @@ def test_claims_altered_after_minting_are_rejected(self, token_with): ) with pytest.raises(AttestationError) as excinfo: - verify_evidence(evidence, verbose=False) + verify_evidence( + evidence, policy=AppraisalPolicy(allow_unpinned=True), verbose=False + ) assert _check(excinfo.value.result, "claims_binding").passed is False # An unbound bundle must never be adopted. @@ -135,7 +139,9 @@ def test_claims_with_no_digest_in_the_token_are_rejected(self, token_with): evidence = confidential_space_evidence("a.b.c", "syft-attestation", _claims()) with pytest.raises(AttestationError) as excinfo: - verify_evidence(evidence, verbose=False) + verify_evidence( + evidence, policy=AppraisalPolicy(allow_unpinned=True), verbose=False + ) assert _check(excinfo.value.result, "claims_binding").passed is False @@ -145,7 +151,9 @@ def test_no_claims_at_all_is_skipped_not_failed(self, token_with): token_with() evidence = confidential_space_evidence("a.b.c", "syft-attestation") - result = verify_evidence(evidence, verbose=False) + result = verify_evidence( + evidence, policy=AppraisalPolicy(allow_unpinned=True), verbose=False + ) assert _check(result, "claims_binding").passed is None assert result.all_passed() @@ -164,20 +172,27 @@ def _verify(self, token_with, policy, claims=None): def test_matching_email_and_owners_pass(self, token_with): result = self._verify( token_with, - AppraisalPolicy(expected_email=EMAIL, expected_data_owners=OWNERS), + AppraisalPolicy( + expected_email=EMAIL, + expected_data_owners=OWNERS, + expected_image_digest="sha256:abc", + ), ) assert _check(result, "enclave_email").passed is True assert _check(result, "data_owners").passed is True def test_unpinned_values_are_reported_not_required(self, token_with): - result = self._verify(token_with, AppraisalPolicy()) + result = self._verify(token_with, AppraisalPolicy(allow_unpinned=True)) assert _check(result, "enclave_email").passed is None assert EMAIL in _check(result, "enclave_email").detail def test_a_different_email_fails(self, token_with): with pytest.raises(AttestationError): self._verify( - token_with, AppraisalPolicy(expected_email="someone-else@openmined.org") + token_with, + AppraisalPolicy( + expected_email="someone-else@openmined.org", allow_unpinned=True + ), ) def test_an_unexpected_data_owner_fails(self, token_with): @@ -185,13 +200,19 @@ def test_an_unexpected_data_owner_fails(self, token_with): with pytest.raises(AttestationError) as excinfo: self._verify( token_with, - AppraisalPolicy(expected_data_owners=["model_owner@openmined.org"]), + AppraisalPolicy( + expected_data_owners=["model_owner@openmined.org"], + allow_unpinned=True, + ), ) assert _check(excinfo.value.result, "data_owners").passed is False def test_expected_owner_order_does_not_matter(self, token_with): result = self._verify( - token_with, AppraisalPolicy(expected_data_owners=list(reversed(OWNERS))) + token_with, + AppraisalPolicy( + expected_data_owners=list(reversed(OWNERS)), allow_unpinned=True + ), ) assert _check(result, "data_owners").passed is True @@ -229,3 +250,47 @@ def test_tinfoil_cannot_bind_claims(self, tmp_path, monkeypatch): ) with pytest.raises(ValueError, match="cannot commit to claims"): TinfoilProvider().collect(claims=_claims()) + + +class TestPolicyMustPin: + """A policy refuses to exist without pinning, on both targets. + + Skipping the image-digest and data-owner checks silently is the dangerous + case: the appraisal then says only that some genuine enclave exists. + """ + + def _classes(self): + from syft_enclaves.attestation.tinfoil import TinfoilAppraisalPolicy + + return [AppraisalPolicy, TinfoilAppraisalPolicy] + + def test_nothing_pinned_is_refused(self): + for cls in self._classes(): + with pytest.raises(ValueError, match="expected_image_digest"): + cls() + + def test_a_half_pinned_policy_is_refused(self): + for cls in self._classes(): + with pytest.raises(ValueError, match="expected_data_owners"): + cls(expected_image_digest="sha256:abc") + with pytest.raises(ValueError, match="expected_image_digest"): + cls(expected_data_owners=["do@openmined.org"]) + + def test_both_pinned_is_accepted(self): + for cls in self._classes(): + policy = cls( + expected_image_digest="sha256:abc", + expected_data_owners=["do@openmined.org"], + ) + assert policy.allow_unpinned is False + + def test_opting_out_has_to_be_explicit(self): + for cls in self._classes(): + assert cls(allow_unpinned=True).expected_image_digest is None + + def test_the_error_says_how_to_proceed(self): + with pytest.raises(ValueError) as excinfo: + AppraisalPolicy() + message = str(excinfo.value) + assert "allow_unpinned=True" in message + assert "who approves a job" in message diff --git a/packages/syft-enclave/tests/test_attestation_confidential_space.py b/packages/syft-enclave/tests/test_attestation_confidential_space.py index 256b758dbbd..9950a82eba8 100644 --- a/packages/syft-enclave/tests/test_attestation_confidential_space.py +++ b/packages/syft-enclave/tests/test_attestation_confidential_space.py @@ -20,7 +20,11 @@ # The image digest is not shipped as a constant — it's supplied per-call via an # AppraisalPolicy. A policy pinning the fake token's digest is used by the # tests that need the image-digest check to pass. -DEFAULT_TEST_POLICY = AppraisalPolicy(expected_image_digest=FAKE_IMAGE_DIGEST) +DEFAULT_TEST_POLICY = AppraisalPolicy( + expected_image_digest=FAKE_IMAGE_DIGEST, expected_data_owners=["do@openmined.org"] +) +# For checks that are not about pinning. +UNPINNED = AppraisalPolicy(allow_unpinned=True) def _valid_claims(**overrides): @@ -73,12 +77,12 @@ def test_all_checks_pass(self, mock_verify): def test_jwt_signature_failure(self, mock_verify): mock_verify.side_effect = ValueError("bad signature") with pytest.raises(AttestationError, match="JWT signature"): - verify_attestation_token("fake-token", verbose=False) + verify_attestation_token("fake-token", policy=UNPINNED, verbose=False) def test_jwt_expiry_grace_passed_through(self, mock_verify): """The enclave doesn't yet refresh its token, so the verifier accepts an expired token for a grace window (~1 month) via clock_skew_in_seconds.""" - verify_attestation_token("fake-token", verbose=False) + verify_attestation_token("fake-token", policy=UNPINNED, verbose=False) _, kwargs = mock_verify.call_args assert kwargs["clock_skew_in_seconds"] == JWT_EXPIRY_GRACE_SECONDS assert JWT_EXPIRY_GRACE_SECONDS == 30 * 24 * 60 * 60 @@ -86,36 +90,36 @@ def test_jwt_expiry_grace_passed_through(self, mock_verify): def test_secure_boot_disabled(self, mock_verify): mock_verify.return_value = _valid_claims(secboot=False) with pytest.raises(AttestationError, match="secure_boot"): - verify_attestation_token("fake-token", verbose=False) + verify_attestation_token("fake-token", policy=UNPINNED, verbose=False) def test_secure_boot_missing(self, mock_verify): claims = _valid_claims() del claims["secboot"] mock_verify.return_value = claims with pytest.raises(AttestationError, match="secure_boot"): - verify_attestation_token("fake-token", verbose=False) + verify_attestation_token("fake-token", policy=UNPINNED, verbose=False) def test_debug_enabled(self, mock_verify): mock_verify.return_value = _valid_claims(dbgstat="enabled") with pytest.raises(AttestationError, match="debug_disabled"): - verify_attestation_token("fake-token", verbose=False) + verify_attestation_token("fake-token", policy=UNPINNED, verbose=False) def test_version_mismatch(self, mock_verify): # Older enclave version sent in the correct (prefixed) format. mock_verify.return_value = _valid_claims(eat_nonce=["syft-0.0.1"]) with pytest.raises(AttestationError, match="version_match"): - verify_attestation_token("fake-token", verbose=False) + verify_attestation_token("fake-token", policy=UNPINNED, verbose=False) def test_version_unprefixed_rejected(self, mock_verify): """A bare version (pre-fix sender) must be rejected, not accepted.""" mock_verify.return_value = _valid_claims(eat_nonce=[SYFT_VERSION]) with pytest.raises(AttestationError, match="version_match"): - verify_attestation_token("fake-token", verbose=False) + verify_attestation_token("fake-token", policy=UNPINNED, verbose=False) def test_version_missing(self, mock_verify): """Missing version is logged but doesn't abort verification (skip semantics).""" mock_verify.return_value = _valid_claims(eat_nonce=[]) - result = verify_attestation_token("fake-token", verbose=False) + result = verify_attestation_token("fake-token", policy=UNPINNED, verbose=False) version_check = next(c for c in result.checks if c.name == "version_match") assert version_check.passed is None assert "no version" in version_check.detail.lower() @@ -123,12 +127,14 @@ def test_version_missing(self, mock_verify): def test_version_as_string(self, mock_verify): """Google returns eat_nonce as a string for single nonce.""" mock_verify.return_value = _valid_claims(eat_nonce=EXPECTED_VERSION_NONCE) - result = verify_attestation_token("fake-token", verbose=False) + result = verify_attestation_token("fake-token", policy=UNPINNED, verbose=False) version_check = next(c for c in result.checks if c.name == "version_match") assert version_check.passed is True def test_image_digest_mismatch(self, mock_verify): - policy = AppraisalPolicy(expected_image_digest="sha256:expected") + policy = AppraisalPolicy( + expected_image_digest="sha256:expected", allow_unpinned=True + ) with pytest.raises(AttestationError, match="image_digest"): verify_attestation_token("fake-token", policy=policy, verbose=False) @@ -136,7 +142,7 @@ def test_image_digest_skipped_when_not_supplied(self, mock_verify): """No expected digest supplied → the image-digest check is skipped (passed=None), not failed. The default policy pins no image.""" result = verify_attestation_token( - "fake-token", policy=AppraisalPolicy(), verbose=False + "fake-token", policy=AppraisalPolicy(allow_unpinned=True), verbose=False ) image_check = next(c for c in result.checks if c.name == "image_digest") assert image_check.passed is None @@ -164,7 +170,7 @@ def test_image_digest_matches(self, mock_verify): def test_error_carries_result(self, mock_verify): mock_verify.return_value = _valid_claims(secboot=False) with pytest.raises(AttestationError) as exc_info: - verify_attestation_token("fake-token", verbose=False) + verify_attestation_token("fake-token", policy=UNPINNED, verbose=False) assert exc_info.value.result is not None assert exc_info.value.result.first_failure().name == "secure_boot" @@ -175,7 +181,7 @@ def test_runs_all_checks_after_failure(self, mock_verify): to inspect for the remaining checks).""" mock_verify.return_value = _valid_claims(secboot=False) with pytest.raises(AttestationError) as exc_info: - verify_attestation_token("fake-token", verbose=False) + verify_attestation_token("fake-token", policy=UNPINNED, verbose=False) check_names = [c.name for c in exc_info.value.result.checks] # Every check should appear, even though secure_boot failed early. assert check_names == [ @@ -196,7 +202,7 @@ def test_multiple_failures_listed(self, mock_verify): eat_nonce=["syft-0.0.1"], ) with pytest.raises(AttestationError) as exc_info: - verify_attestation_token("fake-token", verbose=False) + verify_attestation_token("fake-token", policy=UNPINNED, verbose=False) failed = {c.name for c in exc_info.value.result.checks if c.passed is False} assert failed == {"secure_boot", "debug_disabled", "version_match"} @@ -211,7 +217,7 @@ def test_jwt_failure_fails_fast(self, mock_verify): """JWT signature failure is the one exception to 'run all checks'.""" mock_verify.side_effect = ValueError("bad signature") with pytest.raises(AttestationError) as exc_info: - verify_attestation_token("fake-token", verbose=False) + verify_attestation_token("fake-token", policy=UNPINNED, verbose=False) # Only the JWT check ran; nothing downstream could inspect claims. check_names = [c.name for c in exc_info.value.result.checks] assert check_names == ["jwt_signature"] diff --git a/packages/syft-enclave/tests/test_attestation_dispatch.py b/packages/syft-enclave/tests/test_attestation_dispatch.py index 9003472e922..f29b18e9d34 100644 --- a/packages/syft-enclave/tests/test_attestation_dispatch.py +++ b/packages/syft-enclave/tests/test_attestation_dispatch.py @@ -41,17 +41,35 @@ def test_tinfoil_goes_to_the_tinfoil_verifier(self): def test_policy_for_builds_the_matching_class(self): assert isinstance( - policy_for(AttestationKind.CONFIDENTIAL_SPACE), AppraisalPolicy + policy_for(AttestationKind.CONFIDENTIAL_SPACE, allow_unpinned=True), + AppraisalPolicy, ) - assert isinstance(policy_for(AttestationKind.TINFOIL), TinfoilAppraisalPolicy) + assert isinstance( + policy_for(AttestationKind.TINFOIL, allow_unpinned=True), + TinfoilAppraisalPolicy, + ) + + def test_policy_for_refuses_to_build_an_unpinned_policy(self): + # policy_for passes straight through to the policy class, so the + # pinning rule holds there too. + with pytest.raises(ValueError, match="expected_image_digest"): + policy_for(AttestationKind.CONFIDENTIAL_SPACE) class TestPolicyTypeGuard: @pytest.mark.parametrize( "evidence,policy", [ - (CS_EVIDENCE, TinfoilAppraisalPolicy(expected_image_digest="sha256:a")), - (TINFOIL_EVIDENCE, AppraisalPolicy(expected_image_digest="sha256:a")), + ( + CS_EVIDENCE, + TinfoilAppraisalPolicy( + expected_image_digest="sha256:a", allow_unpinned=True + ), + ), + ( + TINFOIL_EVIDENCE, + AppraisalPolicy(expected_image_digest="sha256:a", allow_unpinned=True), + ), ], ) def test_a_policy_for_the_other_target_is_refused(self, evidence, policy): @@ -105,11 +123,14 @@ def test_expected_image_digest_builds_the_matching_policy(self): ) with patch("syft_enclaves.client.verify_evidence") as verify: client.attest_peer( - "enclave@openmined.org", expected_image_digest="sha256:a" + "enclave@openmined.org", + expected_image_digest="sha256:a", + expected_data_owners=["do@openmined.org"], ) policy = verify.call_args.kwargs["policy"] assert isinstance(policy, TinfoilAppraisalPolicy) assert policy.expected_image_digest == "sha256:a" + assert policy.expected_data_owners == ["do@openmined.org"] def test_digest_and_policy_together_are_refused(self): client = self._client(MagicMock(extra={})) @@ -117,7 +138,7 @@ def test_digest_and_policy_together_are_refused(self): client.attest_peer( "enclave@openmined.org", expected_image_digest="sha256:a", - policy=TinfoilAppraisalPolicy(), + policy=TinfoilAppraisalPolicy(allow_unpinned=True), ) @@ -130,7 +151,7 @@ def test_importing_syft_enclaves_does_not_pull_in_the_tinfoil_sdk(): "import sys, syft_enclaves;" "from syft_enclaves.client import SyftEnclaveClient;" "from syft_enclaves.attestation.tinfoil import TinfoilAppraisalPolicy;" - "TinfoilAppraisalPolicy();" + "TinfoilAppraisalPolicy(allow_unpinned=True);" "assert not [m for m in sys.modules if m.split('.')[0] == 'tinfoil'], " "sorted(m for m in sys.modules if m.split('.')[0] == 'tinfoil')" ) diff --git a/packages/syft-enclave/tests/test_attestation_tinfoil.py b/packages/syft-enclave/tests/test_attestation_tinfoil.py index 2050e42e739..3b77e07538c 100644 --- a/packages/syft-enclave/tests/test_attestation_tinfoil.py +++ b/packages/syft-enclave/tests/test_attestation_tinfoil.py @@ -227,6 +227,10 @@ def _run(policy=None, evidence=None, install_pinned=True, **policy_kwargs): pinned() policy_kwargs.setdefault("expected_syft_version", SYFT_VERSION_IN_CONFIG) policy_kwargs.setdefault("host", HOST) + # A policy must pin unless it opts out; these tests pin only when the + # check under test needs it. + if "expected_data_owners" not in policy_kwargs: + policy_kwargs.setdefault("allow_unpinned", True) return verify_tinfoil_evidence( evidence or tinfoil_evidence(TINFOIL_DOC), policy=policy or TinfoilAppraisalPolicy(**policy_kwargs), @@ -525,7 +529,7 @@ def test_missing_tinfoil_explains_how_to_install_it(self, monkeypatch, pinned): with pytest.raises(MissingOptionalDependency) as excinfo: verify_tinfoil_evidence( tinfoil_evidence(TINFOIL_DOC), - policy=TinfoilAppraisalPolicy(host=HOST), + policy=TinfoilAppraisalPolicy(host=HOST, allow_unpinned=True), verbose=False, ) message = str(excinfo.value) @@ -536,7 +540,10 @@ def test_the_policy_is_usable_without_the_sdk(self): # Importing the module for its policy must not need the extra. from syft_enclaves.attestation.tinfoil import TinfoilAppraisalPolicy - assert TinfoilAppraisalPolicy().repo == "OpenMined/syft-enclave-tinfoil" + assert ( + TinfoilAppraisalPolicy(allow_unpinned=True).repo + == "OpenMined/syft-enclave-tinfoil" + ) class TestSignedClaims: From 86641d67d2d98cf852fe190279cd1c5de4020b5c Mon Sep 17 00:00:00 2001 From: Koen van der Veen Date: Wed, 16 Sep 2026 14:35:09 +0200 Subject: [PATCH 18/21] feat: require expected_email on an appraisal policy too All three expectations are now mandatory: expected_image_digest, expected_data_owners and expected_email. Without the email an attestation does not say which datasite the enclave runs as. attest_peer takes the third shorthand, and both helper scripts take --expected-enclave-email and fold it into their opt-out condition. Co-Authored-By: Claude Opus 5 (1M context) --- packages/syft-enclave/docs/security.md | 14 +++++------ .../syft-enclave/docs/tinfoil_deployment.md | 3 ++- .../syft-enclave/scripts/tinfoil_e2e_check.py | 12 ++++++++- .../syft-enclave/scripts/verify_tinfoil.py | 12 ++++++++- .../src/syft_enclaves/attestation/claims.py | 25 ++++++++++--------- .../syft-enclave/src/syft_enclaves/client.py | 16 +++++++----- .../tests/test_attestation_claims.py | 16 ++++++++++-- .../test_attestation_confidential_space.py | 4 ++- .../tests/test_attestation_dispatch.py | 2 ++ .../tests/test_attestation_tinfoil.py | 1 + 10 files changed, 74 insertions(+), 31 deletions(-) diff --git a/packages/syft-enclave/docs/security.md b/packages/syft-enclave/docs/security.md index 2738d4c7b83..2dae473a1f8 100644 --- a/packages/syft-enclave/docs/security.md +++ b/packages/syft-enclave/docs/security.md @@ -181,9 +181,10 @@ Two things make a replay harder. A replayer has to control the enclave's Drive a is where a verifier reads the token from. And a token from a debug-mode enclave, where an operator can log in over SSH and read the key, already fails the `dbgstat` check. A verifier can also refuse an old token by saying what it expects: `AppraisalPolicy` takes `expected_image_digest` and -`expected_data_owners`, and the check fails if the enclave runs a different image or lists -different data owners. A policy refuses to be built without both of them, so a verifier cannot skip -these two checks by accident. To verify without pinning, say so with `allow_unpinned=True`. +`expected_data_owners` and `expected_email`, and a check fails if the enclave runs a different +image, lists different data owners, or runs as a different datasite. A policy refuses to be built +without all three, so a verifier cannot skip those checks by accident. To verify without pinning, +say so with `allow_unpinned=True`. This is a current limitation, and 6.4 lists the plan for removing it. Two assumptions make the limitation acceptable for now. The enclave's private key never leaves the enclave, which is a @@ -229,10 +230,9 @@ the nonce is new on every request. Both targets appraise the facts the same way, once the document is trustworthy. `AppraisalPolicy` for Confidential Spaces and `TinfoilAppraisalPolicy` for Tinfoil take the same expectations and run -the same comparison. Each policy has to pin `expected_image_digest` and `expected_data_owners`, or -say `allow_unpinned=True`; `expected_email` stays optional, because a peer already addresses the -enclave by email. Binding proves the enclave started with those values. Whether they are the right -values is the verifier's call. +the same comparison. Each policy has to pin `expected_image_digest`, `expected_data_owners` and +`expected_email`, or say `allow_unpinned=True`. Binding proves the enclave started with those +values. Whether they are the right values is the verifier's call. ### 6.4 Todo diff --git a/packages/syft-enclave/docs/tinfoil_deployment.md b/packages/syft-enclave/docs/tinfoil_deployment.md index 56758a7b713..c28c5eec608 100644 --- a/packages/syft-enclave/docs/tinfoil_deployment.md +++ b/packages/syft-enclave/docs/tinfoil_deployment.md @@ -126,10 +126,11 @@ do.attest_peer( ENCLAVE_EMAIL, expected_image_digest="sha256:...", expected_data_owners=["do1@openmined.org", "do2@openmined.org"], + expected_email=ENCLAVE_EMAIL, ) ``` -Pass the digest `tinfoil-release` printed. Both arguments are required: without them the attestation would prove a genuine enclave booted a signed config, but not that the config pinned the image you reviewed, nor who has to approve a job. To skip them on purpose, pass a policy with `allow_unpinned=True`. +Pass the digest `tinfoil-release` printed. All three arguments are required: without them the attestation would prove a genuine enclave booted a signed config, but not that the config pinned the image you reviewed, which datasite the enclave runs as, or who has to approve a job. To skip them on purpose, pass a policy with `allow_unpinned=True`. The whole data-owner side (peer, attest over Drive, upload a dataset) is scripted: diff --git a/packages/syft-enclave/scripts/tinfoil_e2e_check.py b/packages/syft-enclave/scripts/tinfoil_e2e_check.py index 2737aa17632..7d7da637724 100644 --- a/packages/syft-enclave/scripts/tinfoil_e2e_check.py +++ b/packages/syft-enclave/scripts/tinfoil_e2e_check.py @@ -46,6 +46,11 @@ def parse_args() -> argparse.Namespace: "--tag", default=None, help="pin the release tag being verified" ) parser.add_argument("--expected-image-digest", default=None) + parser.add_argument( + "--expected-enclave-email", + default=None, + help="the datasite the enclave should be running as", + ) parser.add_argument( "--expected-data-owners", default=None, @@ -108,13 +113,18 @@ def main() -> int: release_tag=args.tag, expected_image_digest=args.expected_image_digest, expected_data_owners=args.expected_data_owners, + expected_email=args.expected_enclave_email, # The config pins no SYFT_VERSION, so leave this unset rather than # failing a check the deployment cannot satisfy. expected_syft_version=None, # A policy has to pin an image digest and a data-owner list. This # script is often run before either is known, so say so explicitly # rather than let the run fail at policy construction. - allow_unpinned=not (args.expected_image_digest and args.expected_data_owners), + allow_unpinned=not ( + args.expected_image_digest + and args.expected_data_owners + and args.expected_enclave_email + ), ) result = client.attest_peer(args.enclave_email, policy=policy) if result is None: diff --git a/packages/syft-enclave/scripts/verify_tinfoil.py b/packages/syft-enclave/scripts/verify_tinfoil.py index a87689a210e..3654faff7f3 100644 --- a/packages/syft-enclave/scripts/verify_tinfoil.py +++ b/packages/syft-enclave/scripts/verify_tinfoil.py @@ -41,6 +41,11 @@ def parse_args() -> argparse.Namespace: default=None, help="'sha256:...' digest to pin; omit to skip the image check", ) + parser.add_argument( + "--expected-enclave-email", + default=None, + help="the datasite the enclave should be running as", + ) parser.add_argument( "--expected-data-owners", default=None, @@ -70,11 +75,16 @@ def main() -> int: release_tag=args.tag, expected_image_digest=args.expected_image_digest, expected_data_owners=args.expected_data_owners, + expected_email=args.expected_enclave_email, container_name=args.container_name, # This script checks a live host, often before the digest and the # data-owner list are known, so it opts out rather than refusing to # build a policy. - allow_unpinned=not (args.expected_image_digest and args.expected_data_owners), + allow_unpinned=not ( + args.expected_image_digest + and args.expected_data_owners + and args.expected_enclave_email + ), ) try: verify_tinfoil_evidence(tinfoil_evidence(fetch_document(args.host)), policy) diff --git a/packages/syft-enclave/src/syft_enclaves/attestation/claims.py b/packages/syft-enclave/src/syft_enclaves/attestation/claims.py index 86b6d643831..fad50be5ce4 100644 --- a/packages/syft-enclave/src/syft_enclaves/attestation/claims.py +++ b/packages/syft-enclave/src/syft_enclaves/attestation/claims.py @@ -130,10 +130,11 @@ class Expectations(BaseModel): Shared by both targets, so the rule below cannot drift between them. A verifier that pins nothing learns only that *some* genuine enclave - exists. It does not learn which code that enclave runs, nor who has to - approve a job on it, because those checks are skipped. Skipping them - silently is the dangerous case, so a policy refuses to be built without - them. Pass ``allow_unpinned=True`` to say you accept that on purpose. + exists. It does not learn which code that enclave runs, which datasite it + runs as, nor who has to approve a job on it, because those checks are + skipped. Skipping them silently is the dangerous case, so a policy refuses + to be built without all three. Pass ``allow_unpinned=True`` to say you + accept that on purpose. """ model_config = {"frozen": True} @@ -142,9 +143,7 @@ class Expectations(BaseModel): expected_image_digest: Optional[str] = None # The data owners whose approval must gate a job on this enclave. expected_data_owners: Optional[list[str]] = None - # The datasite the enclave should be running as. Optional: a peer already - # addresses the enclave by email, so it is a cross-check rather than the - # thing at stake. + # The datasite the enclave should be running as. expected_email: Optional[str] = None # By default the enclave must run the same version of syft as the verifier. expected_syft_version: Optional[str] = SYFT_VERSION @@ -161,15 +160,17 @@ def _require_pinning(self) -> "Expectations": for name, value in ( ("expected_image_digest", self.expected_image_digest), ("expected_data_owners", self.expected_data_owners), + ("expected_email", self.expected_email), ) if value is None ] if missing: raise ValueError( - f"{type(self).__name__} needs {' and '.join(missing)}. Without " - "them the attestation proves that some genuine enclave exists, " - "but not which code it runs or who approves a job on it. Pass " - "the values you independently confirmed, or " - "allow_unpinned=True to accept that on purpose." + f"{type(self).__name__} needs {', '.join(missing)}. Without " + "all three the attestation proves that some genuine enclave " + "exists, but not which code it runs, which datasite it runs " + "as, or who approves a job on it. Pass the values you " + "independently confirmed, or allow_unpinned=True to accept " + "that on purpose." ) return self diff --git a/packages/syft-enclave/src/syft_enclaves/client.py b/packages/syft-enclave/src/syft_enclaves/client.py index 03839e1a139..97163ca9673 100644 --- a/packages/syft-enclave/src/syft_enclaves/client.py +++ b/packages/syft-enclave/src/syft_enclaves/client.py @@ -91,6 +91,7 @@ def attest_peer( peer_email: str, expected_image_digest: str | None = None, expected_data_owners: list[str] | None = None, + expected_email: str | None = None, policy: "AppraisalPolicy | TinfoilAppraisalPolicy | None" = None, ): """Verify an enclave peer's attestation by re-reading SYFT_version.json @@ -103,11 +104,12 @@ def attest_peer( evidence needs the optional ``tinfoil`` package; see ``docs/tinfoil_deployment.md``. - A policy has to pin an image digest and a data-owner list, so pass both - shorthands or build a policy yourself. Without them the attestation - would prove that some genuine enclave exists, but not which code it - runs or who approves a job on it. To accept that on purpose, pass a - policy with ``allow_unpinned=True``. + A policy has to pin an image digest, a data-owner list and the enclave's + email, so pass all three shorthands or build a policy yourself. Without + them the attestation would prove that some genuine enclave exists, but + not which code it runs, which datasite it runs as, or who approves a + job on it. To accept that on purpose, pass a policy with + ``allow_unpinned=True``. Args: peer_email: the enclave peer to attest. @@ -115,15 +117,17 @@ def attest_peer( trust. expected_data_owners: the emails whose approval must gate a job on this enclave. + expected_email: the datasite the enclave should be running as. policy: a full appraisal policy for finer control — an ``AppraisalPolicy`` for Confidential Space or a ``TinfoilAppraisalPolicy`` for Tinfoil. Mutually exclusive with - the two shorthands. + the shorthands. """ shorthands = { "expected_image_digest": expected_image_digest, "expected_data_owners": expected_data_owners, + "expected_email": expected_email, } given = {name: value for name, value in shorthands.items() if value is not None} if given and policy is not None: diff --git a/packages/syft-enclave/tests/test_attestation_claims.py b/packages/syft-enclave/tests/test_attestation_claims.py index 063ce098663..6b09c9cdbf8 100644 --- a/packages/syft-enclave/tests/test_attestation_claims.py +++ b/packages/syft-enclave/tests/test_attestation_claims.py @@ -269,18 +269,24 @@ def test_nothing_pinned_is_refused(self): with pytest.raises(ValueError, match="expected_image_digest"): cls() - def test_a_half_pinned_policy_is_refused(self): + def test_a_part_pinned_policy_is_refused(self): for cls in self._classes(): with pytest.raises(ValueError, match="expected_data_owners"): cls(expected_image_digest="sha256:abc") with pytest.raises(ValueError, match="expected_image_digest"): cls(expected_data_owners=["do@openmined.org"]) + with pytest.raises(ValueError, match="expected_email"): + cls( + expected_image_digest="sha256:abc", + expected_data_owners=["do@openmined.org"], + ) - def test_both_pinned_is_accepted(self): + def test_all_three_pinned_is_accepted(self): for cls in self._classes(): policy = cls( expected_image_digest="sha256:abc", expected_data_owners=["do@openmined.org"], + expected_email="enclave@openmined.org", ) assert policy.allow_unpinned is False @@ -294,3 +300,9 @@ def test_the_error_says_how_to_proceed(self): message = str(excinfo.value) assert "allow_unpinned=True" in message assert "who approves a job" in message + for field in ( + "expected_image_digest", + "expected_data_owners", + "expected_email", + ): + assert field in message diff --git a/packages/syft-enclave/tests/test_attestation_confidential_space.py b/packages/syft-enclave/tests/test_attestation_confidential_space.py index 9950a82eba8..e0b92e2084a 100644 --- a/packages/syft-enclave/tests/test_attestation_confidential_space.py +++ b/packages/syft-enclave/tests/test_attestation_confidential_space.py @@ -21,7 +21,9 @@ # AppraisalPolicy. A policy pinning the fake token's digest is used by the # tests that need the image-digest check to pass. DEFAULT_TEST_POLICY = AppraisalPolicy( - expected_image_digest=FAKE_IMAGE_DIGEST, expected_data_owners=["do@openmined.org"] + expected_image_digest=FAKE_IMAGE_DIGEST, + expected_data_owners=["do@openmined.org"], + expected_email="enclave@openmined.org", ) # For checks that are not about pinning. UNPINNED = AppraisalPolicy(allow_unpinned=True) diff --git a/packages/syft-enclave/tests/test_attestation_dispatch.py b/packages/syft-enclave/tests/test_attestation_dispatch.py index f29b18e9d34..24165e8b3a2 100644 --- a/packages/syft-enclave/tests/test_attestation_dispatch.py +++ b/packages/syft-enclave/tests/test_attestation_dispatch.py @@ -126,11 +126,13 @@ def test_expected_image_digest_builds_the_matching_policy(self): "enclave@openmined.org", expected_image_digest="sha256:a", expected_data_owners=["do@openmined.org"], + expected_email="enclave@openmined.org", ) policy = verify.call_args.kwargs["policy"] assert isinstance(policy, TinfoilAppraisalPolicy) assert policy.expected_image_digest == "sha256:a" assert policy.expected_data_owners == ["do@openmined.org"] + assert policy.expected_email == "enclave@openmined.org" def test_digest_and_policy_together_are_refused(self): client = self._client(MagicMock(extra={})) diff --git a/packages/syft-enclave/tests/test_attestation_tinfoil.py b/packages/syft-enclave/tests/test_attestation_tinfoil.py index 3b77e07538c..32cc516d089 100644 --- a/packages/syft-enclave/tests/test_attestation_tinfoil.py +++ b/packages/syft-enclave/tests/test_attestation_tinfoil.py @@ -608,5 +608,6 @@ def test_an_unexpected_data_owner_fails(self, verify, pinned): install_pinned=False, expected_image_digest=IMAGE_DIGEST, expected_data_owners=["someone-else@openmined.org"], + allow_unpinned=True, ) assert _check(excinfo.value.result, "data_owners").passed is False From ed2c47b1ce1b614af7ca31b0f25fb4685bf3a5cd Mon Sep 17 00:00:00 2001 From: Koen van der Veen Date: Wed, 16 Sep 2026 14:37:58 +0200 Subject: [PATCH 19/21] docs: move the appraisal rules into 6.0 The paragraph describes how a verifier checks the facts, which belongs with what has to be bound and why, not with the side-by-side comparison. The reader now has the whole appraisal model before either mechanism, and 6.3 is just the table. The sentence bridging into 6.1 and 6.2 moves to the end of 6.0. Co-Authored-By: Claude Opus 5 (1M context) --- packages/syft-enclave/docs/security.md | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/packages/syft-enclave/docs/security.md b/packages/syft-enclave/docs/security.md index 2dae473a1f8..861b1db27f1 100644 --- a/packages/syft-enclave/docs/security.md +++ b/packages/syft-enclave/docs/security.md @@ -138,6 +138,13 @@ which sit outside what the report measures: So the enclave writes those facts into one document — its email, its data owners, its syft version and its public key bundle — and then binds that document to its attestation report. The document itself always travels in the clear, and a verifier never trusts the document on its own. + +Both targets appraise those facts the same way, once the document is trustworthy. +`AppraisalPolicy` for Confidential Spaces and `TinfoilAppraisalPolicy` for Tinfoil take the same +expectations and run the same comparison. Each policy has to pin `expected_image_digest`, +`expected_data_owners` and `expected_email`, or say `allow_unpinned=True`. Binding proves the +enclave started with those values. Whether they are the right values is the verifier's call. + Confidential Spaces and Tinfoil differ only in how each one makes the document trustworthy. ### 6.1 Binding extra facts on Confidential Spaces @@ -228,12 +235,6 @@ the nonce is new on every request. | email and data owners attested | ✅ same hash | ✅ signed with the bound key | | freshness, so keys can be retired | ❌ one token, issued at boot | ✅ live connection and a per-request nonce | -Both targets appraise the facts the same way, once the document is trustworthy. `AppraisalPolicy` -for Confidential Spaces and `TinfoilAppraisalPolicy` for Tinfoil take the same expectations and run -the same comparison. Each policy has to pin `expected_image_digest`, `expected_data_owners` and -`expected_email`, or say `allow_unpinned=True`. Binding proves the enclave started with those -values. Whether they are the right values is the verifier's call. - ### 6.4 Todo - **Confidential Spaces: ask for a new token periodically, and narrow the window the verifier From b3a448f5f7a1dc7cb54b491d8c5a8efab56557b3 Mon Sep 17 00:00:00 2001 From: Koen van der Veen Date: Wed, 16 Sep 2026 14:43:24 +0200 Subject: [PATCH 20/21] docs: fix the order of the Tinfoil steps, and write the joins The numbered list had verification first and the connection second. The real order is the other way round: the client opens the connection, receives the report, key bundle and nonce signature over it, and only then verifies. Step 3 now names what it compares against step 1, and the accept condition points at the right two steps. Also writes the joins the reader was left to infer. The 64-bytes sentence now says it is unlike Confidential Spaces, and the twist that follows starts with "but", because the bytes being full is what makes binding possible. Co-Authored-By: Claude Opus 5 (1M context) --- packages/syft-enclave/docs/security.md | 50 ++++++++++++++------------ 1 file changed, 27 insertions(+), 23 deletions(-) diff --git a/packages/syft-enclave/docs/security.md b/packages/syft-enclave/docs/security.md index 861b1db27f1..9ad46af4854 100644 --- a/packages/syft-enclave/docs/security.md +++ b/packages/syft-enclave/docs/security.md @@ -200,31 +200,35 @@ enclave is short-lived, so few old tokens exist to replay. ### 6.2 Binding extra facts on Tinfoil -Tinfoil gives the code inside the enclave no way to add anything to the report, so the enclave binds -its claims document a different way: it signs the document, over a connection that the report -vouches for. - -All 64 bytes of user data in a Tinfoil report are already in use. Those bytes hold the sha256 of the -shim's TLS public key, followed by the shim's HPKE public key. That is what makes binding possible, -because the report commits to the key that terminates a TLS connection to the enclave. So the client: - -1. verifies the report; -2. opens HTTPS to the enclave and checks the certificate it is served carries that same key, which - proves the connection ends inside the attested enclave; -3. takes the key bundle served over that connection, which is now authentic; -4. sends a random nonce, and checks the enclave signed **the nonce together with the claims - document** using the identity key from that bundle. - -No certificate authority takes part. The enclave's certificate is self-signed, and the report is -what decides whether to trust the key inside that certificate. The signature in step 4 proves three -separate things: the enclave holds the private half of the key you are about to encrypt to, the -answer was produced for this exchange rather than an earlier one, and the claims are the facts the -enclave meant to assert. The client accepts the bundle and the claims only if step 2 and step 4 both -pass. +Unlike Confidential Spaces, Tinfoil gives the code inside the enclave no way to add anything to the +report. All 64 bytes of user data in a Tinfoil report are already in use: they hold the sha256 of +the shim's TLS public key, followed by the shim's HPKE public key. But those bytes are exactly what +makes binding possible, because they mean the report commits to the key that terminates a TLS +connection to the enclave. So the enclave binds its claims document the other way round: it signs +the document, and serves the document over a connection that the report vouches for. + +The client does four things, in this order: + +1. generates a random nonce, then opens HTTPS to the enclave and asks for its attestation. The + enclave's certificate is self-signed, so the client does not try to validate the certificate + against a certificate authority. The client records the public key inside the certificate + instead; +2. receives the report, the enclave's key bundle, and a signature over the nonce and the claims + document, all over that one connection; +3. verifies the report against the CPU vendor's trust root. A verified report gives the fingerprint + of the key that terminates the connection, and the client checks that fingerprint against the + certificate from step 1. If the two match, the connection ends inside the attested enclave; +4. checks the signature from step 2 against the identity key in the key bundle. + +The report is what decides whether to trust the key in the certificate, which is why no certificate +authority takes part. The signature in step 4 then proves three separate things: the enclave holds +the private half of the key you are about to encrypt to, the answer was produced for this exchange +rather than an earlier one, and the claims are the facts the enclave meant to assert. The client +accepts the key bundle and the claims only if step 3 and step 4 both pass. Tinfoil can retire a key, because every check is live. A captured report commits to a TLS key whose -private half sits in an enclave the attacker does not control, so the certificate check fails, and -the nonce is new on every request. +private half sits in an enclave the attacker does not control, so the check in step 3 fails, and the +nonce is new on every request. ### 6.3 Side by side From 59d6dea9a22a4e04c7b99ba55f95ed8b20c990b7 Mon Sep 17 00:00:00 2001 From: Koen van der Veen Date: Wed, 16 Sep 2026 16:17:59 +0200 Subject: [PATCH 21/21] docs: correct why Tinfoil needs a different binding MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The claim that Tinfoil gives the enclave no way to add anything to the report was wrong. The shim's /.well-known/tinfoil-attestation takes ?nonce=<64 hex> and mints a fresh report whose report data is derived from that nonce, which I confirmed against a live enclave. The real reason the two targets differ is who picks the nonce. On Tinfoil whoever calls the endpoint picks it, so an attacker can ask the same enclave for a report over a nonce of their own choosing and get an equally genuine report — the enclave cannot assert anything with it. On Confidential Spaces only code inside the container can set eat_nonce. A Tinfoil nonce is a question the verifier asks; a Confidential Space nonce is a statement the enclave makes. Also corrects the same claim in four code comments. Co-Authored-By: Claude Opus 5 (1M context) --- packages/syft-enclave/docs/security.md | 19 +++++++++++++------ .../src/syft_enclaves/attestation/claims.py | 11 ++++++----- .../src/syft_enclaves/evidence/key_bundle.py | 3 ++- .../src/syft_enclaves/evidence/tinfoil.py | 10 +++++----- .../tests/test_attestation_tinfoil.py | 4 ++-- 5 files changed, 28 insertions(+), 19 deletions(-) diff --git a/packages/syft-enclave/docs/security.md b/packages/syft-enclave/docs/security.md index 9ad46af4854..472c43dcae2 100644 --- a/packages/syft-enclave/docs/security.md +++ b/packages/syft-enclave/docs/security.md @@ -200,12 +200,19 @@ enclave is short-lived, so few old tokens exist to replay. ### 6.2 Binding extra facts on Tinfoil -Unlike Confidential Spaces, Tinfoil gives the code inside the enclave no way to add anything to the -report. All 64 bytes of user data in a Tinfoil report are already in use: they hold the sha256 of -the shim's TLS public key, followed by the shim's HPKE public key. But those bytes are exactly what -makes binding possible, because they mean the report commits to the key that terminates a TLS -connection to the enclave. So the enclave binds its claims document the other way round: it signs -the document, and serves the document over a connection that the report vouches for. +Tinfoil can put extra bytes in a report, but the enclave does not get to choose them. Ask the +enclave for its attestation with `?nonce=<64 hex chars>` and the shim mints a fresh report whose +report data is derived from that nonce. Whoever calls the endpoint picks the nonce, though, so the +enclave cannot use the nonce to assert anything: an attacker can ask the same enclave for a report +over a nonce of their own choosing, and that report is just as genuine. This is the opposite of +Confidential Spaces, where only code inside the container can set `eat_nonce`, which is why the two +targets need different solutions. A Tinfoil nonce is a question the verifier asks; a Confidential +Space nonce is a statement the enclave makes. + +So the enclave binds its claims document the other way round: it signs the document, and serves the +document over a connection that the report vouches for. The report commits to the sha256 of the +shim's TLS public key, followed by the shim's HPKE public key, which is what lets a client tie a +connection to the enclave the report describes. The client does four things, in this order: diff --git a/packages/syft-enclave/src/syft_enclaves/attestation/claims.py b/packages/syft-enclave/src/syft_enclaves/attestation/claims.py index fad50be5ce4..97defce0e24 100644 --- a/packages/syft-enclave/src/syft_enclaves/attestation/claims.py +++ b/packages/syft-enclave/src/syft_enclaves/attestation/claims.py @@ -17,11 +17,12 @@ commits to its digest. The document itself is untrusted — the digest is what makes it true. -Tinfoil has no such channel — its report's user data is the shim's own keys — -so it reaches the same guarantee a third way: the enclave signs the same claims -document with the key the report already binds, and serves it over the pinned -connection. Different route, same document, same digest, so the expectation -checks below are shared. +Tinfoil cannot use its report this way. Its report can carry a nonce, but +whoever asks for the report picks that nonce, so the enclave cannot assert +anything with it. Tinfoil therefore reaches the same guarantee a third way: the +enclave signs the same claims document with the key the report already binds, +and serves the document over the pinned connection. Different route, same +document, same digest, so the expectation checks below are shared. """ from __future__ import annotations diff --git a/packages/syft-enclave/src/syft_enclaves/evidence/key_bundle.py b/packages/syft-enclave/src/syft_enclaves/evidence/key_bundle.py index c87c4ebac69..406ff6623bc 100644 --- a/packages/syft-enclave/src/syft_enclaves/evidence/key_bundle.py +++ b/packages/syft-enclave/src/syft_enclaves/evidence/key_bundle.py @@ -84,7 +84,8 @@ def sign_nonce(nonce: str, path: Path = PUBLIC_BUNDLE_PATH) -> Optional[str]: One signature therefore carries three things: the enclave holds the key it served, the answer is for this exchange, and these are the runtime facts it asserts. On Tinfoil this is the *only* route to the last one, since its - report has no room for the enclave to commit to them itself. + report cannot carry facts the enclave asserts: a Tinfoil report can hold a + nonce, but the caller picks it, not the enclave. None when there is nothing to sign with, so the endpoint degrades to "served a bundle but proved nothing" rather than failing outright — the diff --git a/packages/syft-enclave/src/syft_enclaves/evidence/tinfoil.py b/packages/syft-enclave/src/syft_enclaves/evidence/tinfoil.py index 66ee2c3bd5d..499ff083826 100644 --- a/packages/syft-enclave/src/syft_enclaves/evidence/tinfoil.py +++ b/packages/syft-enclave/src/syft_enclaves/evidence/tinfoil.py @@ -66,11 +66,11 @@ def collect( ) -> AttestationEvidence: if claims is not None: raise ValueError( - "Tinfoil evidence cannot commit to claims: the report's user " - "data is the shim's own keys, leaving the enclave no room " - "to commit to anything of its own. The " - "equivalent guarantee comes from a pinned connection instead " - "(see attestation.https)." + "Tinfoil evidence cannot commit to claims. The report can " + "carry a nonce, but whoever asks for the report picks it, so " + "the enclave cannot assert anything with it. The equivalent " + "guarantee comes from signing the claims over a pinned " + "connection instead (see attestation.https)." ) if caller_nonce is not None: raise ValueError( diff --git a/packages/syft-enclave/tests/test_attestation_tinfoil.py b/packages/syft-enclave/tests/test_attestation_tinfoil.py index 32cc516d089..ddcd4067c13 100644 --- a/packages/syft-enclave/tests/test_attestation_tinfoil.py +++ b/packages/syft-enclave/tests/test_attestation_tinfoil.py @@ -549,8 +549,8 @@ def test_the_policy_is_usable_without_the_sdk(self): class TestSignedClaims: """Tinfoil's route to attested runtime facts. - Its report leaves no room for the enclave to commit to anything itself, - so instead it signs the same claims + Its report can carry a nonce, but the caller picks that nonce, so the + enclave cannot assert anything with it. Instead it signs the same claims document Confidential Space commits to in its token, with the key the report already binds, and serves it over the pinned connection. """