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..0d03b8c7096 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_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_deployment.md." >&2; exit 1; } ''' # List all available commands @@ -548,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. @@ -656,3 +666,272 @@ 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_deployment.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}} + # --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). + 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_deployment.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..fa2ddd33eba 100644 --- a/packages/syft-enclave/README.md +++ b/packages/syft-enclave/README.md @@ -8,18 +8,29 @@ 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_cs.md) +- [Tinfoil Deployment](./docs/tinfoil_deployment.md) +- [Tinfoil Troubleshooting](./docs/tinfoil_troubleshooting.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_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 [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_cs.md) (`just tf-apply` / `just tf-apply-dev`). + +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 @@ -69,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: [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_cs.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..53087c7a5f7 100644 --- a/packages/syft-enclave/docker/attestation_server.py +++ b/packages/syft-enclave/docker/attestation_server.py @@ -1,38 +1,32 @@ """ 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.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 app = FastAPI(title="Syft Client Enclave", version="0.1.0") @@ -46,101 +40,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 +73,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 +89,73 @@ 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(), + # 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, } - 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_cs.md." + ), + "tinfoil": ( + "Deploy a Tinfoil container from the config repo — see docs/tinfoil_deployment.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..35dd9fdd101 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_deployment.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..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 "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 a896ab1ef59..472c43dcae2 100644 --- a/packages/syft-enclave/docs/security.md +++ b/packages/syft-enclave/docs/security.md @@ -81,12 +81,21 @@ 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. The data owners and the DS then download the enclave's verified keys (and +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). 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 enclave and every participant. @@ -102,3 +111,150 @@ 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 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 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 what the report measures: + +| 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. | + +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 + +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 `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 +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 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: + +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 check in step 3 fails, and the +nonce is new on every request. + +### 6.3 Side by side + +| | 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 | + +### 6.4 Todo + +- **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. + +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 99% rename from packages/syft-enclave/docs/terraform.md rename to packages/syft-enclave/docs/terraform_cs.md index f5da8d5ffb5..ef533783fc7 100644 --- a/packages/syft-enclave/docs/terraform.md +++ b/packages/syft-enclave/docs/terraform_cs.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_deployment.md b/packages/syft-enclave/docs/tinfoil_deployment.md new file mode 100644 index 00000000000..c28c5eec608 --- /dev/null +++ b/packages/syft-enclave/docs/tinfoil_deployment.md @@ -0,0 +1,229 @@ +# Tinfoil Deployment + +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: + +- **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 release workflows live only in the config repo — nothing in PySyft runs them. + +Run all commands from `packages/syft-enclave/`. + +When something fails, see [Tinfoil Troubleshooting](./tinfoil_troubleshooting.md). For what 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 + +- 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:...", + expected_data_owners=["do1@openmined.org", "do2@openmined.org"], + expected_email=ENCLAVE_EMAIL, +) +``` + +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: + +```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. + +## 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/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/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..7d7da637724 --- /dev/null +++ b/packages/syft-enclave/scripts/tinfoil_e2e_check.py @@ -0,0 +1,170 @@ +"""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( + "--expected-enclave-email", + default=None, + help="the datasite the enclave should be running as", + ) + 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) + 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, + 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 + and args.expected_enclave_email + ), + ) + 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..3654faff7f3 --- /dev/null +++ b/packages/syft-enclave/scripts/verify_tinfoil.py @@ -0,0 +1,98 @@ +"""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( + "--expected-enclave-email", + default=None, + help="the datasite the enclave should be running as", + ) + 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() + + +def fetch_document(host: str) -> dict: + requests = require( + "requests", + extra="tinfoil", + feature="Verifying a Tinfoil enclave", + docs="packages/syft-enclave/docs/tinfoil_deployment.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, + 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 + and args.expected_enclave_email + ), + ) + 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/__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/claims.py b/packages/syft-enclave/src/syft_enclaves/attestation/claims.py new file mode 100644 index 00000000000..97defce0e24 --- /dev/null +++ b/packages/syft-enclave/src/syft_enclaves/attestation/claims.py @@ -0,0 +1,177 @@ +"""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 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 + +import hashlib +import json +from typing import Any, Optional + +from pydantic import BaseModel, model_validator + +from syft.version import SYFT_VERSION + +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" + ) + + +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}") + + +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, 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} + + # 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. + 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), + ("expected_email", self.expected_email), + ) + if value is None + ] + if missing: + raise ValueError( + 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/attestation.py b/packages/syft-enclave/src/syft_enclaves/attestation/confidential_space.py similarity index 64% rename from packages/syft-enclave/src/syft_enclaves/attestation.py rename to packages/syft-enclave/src/syft_enclaves/attestation/confidential_space.py index da1733d3af6..d822cc96e71 100644 --- a/packages/syft-enclave/src/syft_enclaves/attestation.py +++ b/packages/syft-enclave/src/syft_enclaves/attestation/confidential_space.py @@ -1,21 +1,34 @@ -"""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 -from dataclasses import dataclass, field from typing import Optional 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, +) +from syft_enclaves.attestation.result import ( + AttestationError, + AttestationResult, +) ATTESTATION_AUDIENCE = "syft-attestation" CONFIDENTIAL_COMPUTING_CERTS_URL = ( @@ -33,72 +46,93 @@ JWT_EXPIRY_GRACE_SECONDS = 30 * 24 * 60 * 60 # ~1 month -class AppraisalPolicy(BaseModel): - """Reference values the verifier appraises attestation evidence against. - - In RATS terms this is the *appraisal policy*: the - set of trusted reference values the enclave's evidence is compared to. +class AppraisalPolicy(Expectations): + """Reference values a Confidential Space enclave is appraised against. - 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 - - -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 = "" +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) -@dataclass -class AttestationResult: - checks: list[CheckResult] = field(default_factory=list) +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. - def add(self, name: str, label: str, passed: bool, detail: str) -> None: - self.checks.append( - CheckResult(name=name, label=label, passed=passed, detail=detail) + 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 - 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}") + 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. + + 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, passed, detail in check_expected( + published_claims, policy.expected_email, policy.expected_data_owners + ): + if verbose: + print(f" ⏳ {label} ...") + result.add(name, label, passed, detail) 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. @@ -165,7 +199,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") @@ -203,7 +241,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/attestation/dispatch.py b/packages/syft-enclave/src/syft_enclaves/attestation/dispatch.py new file mode 100644 index 00000000000..67a6c5552f1 --- /dev/null +++ b/packages/syft-enclave/src/syft_enclaves/attestation/dispatch.py @@ -0,0 +1,82 @@ +"""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.confidential_space import ( + AppraisalPolicy, + verify_attestation_token, +) +from syft_enclaves.attestation.result import AttestationResult +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, + published_claims=evidence.metadata.get("claims"), + ) + + 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..e9592278206 --- /dev/null +++ b/packages/syft-enclave/src/syft_enclaves/attestation/envelope.py @@ -0,0 +1,169 @@ +"""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, + 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=metadata, + ) + + +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..804382ee17f --- /dev/null +++ b/packages/syft-enclave/src/syft_enclaves/attestation/https.py @@ -0,0 +1,192 @@ +"""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.attestation.nonce 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 *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): + """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"), + claims=payload.get("claims"), + ) + + +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/nonce.py b/packages/syft-enclave/src/syft_enclaves/attestation/nonce.py new file mode 100644 index 00000000000..0aad87afbde --- /dev/null +++ b/packages/syft-enclave/src/syft_enclaves/attestation/nonce.py @@ -0,0 +1,118 @@ +"""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 json +import secrets +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. +#: 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 + + +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, 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, 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 + 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"])) + 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, + 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 + 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)) + 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 " + "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/attestation/result.py b/packages/syft-enclave/src/syft_enclaves/attestation/result.py new file mode 100644 index 00000000000..a05164cade8 --- /dev/null +++ b/packages/syft-enclave/src/syft_enclaves/attestation/result.py @@ -0,0 +1,66 @@ +"""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: + """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: + """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: + 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 new file mode 100644 index 00000000000..a006042693b --- /dev/null +++ b/packages/syft-enclave/src/syft_enclaves/attestation/tinfoil.py @@ -0,0 +1,648 @@ +"""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, 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. +""" + +from __future__ import annotations + +import base64 +import hashlib +import json +from typing import Any, NoReturn, Optional + + +from syft_enclaves.attestation.result import AttestationError, AttestationResult +from syft_enclaves.attestation.claims import Expectations, check_expected +from syft_enclaves.attestation.envelope import AttestationEvidence +from syft_enclaves.attestation.https import ( + AttestationFetchError, + AttestedPayload, + fetch_attested_payload, +) +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 +#: 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_deployment.md" +REQUEST_TIMEOUT_SECONDS = 30 + + +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 writes its own evidence, so letting the enclave name the + repo would let the enclave choose which releases are trusted. + """ + + repo: str = DEFAULT_TINFOIL_CONFIG_REPO + # None -> appraise against the repo's latest release. + release_tag: Optional[str] = None + # 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 + # Which container in the config carries the enclave. + container_name: str = "syft-enclave" + + +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 + + +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.""" + + 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_claims() + 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, + self.payload.claims, + ) + 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, 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.""" + 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..2fc4838bdb0 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.evidence.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..97163ca9673 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,96 @@ def attest_peer( self, peer_email: str, expected_image_digest: str | None = None, - policy: "AppraisalPolicy | 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 - 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_deployment.md``. + + 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. 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 - ``expected_image_digest``. + 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 shorthands. """ - if expected_image_digest is not None and policy is not None: - raise ValueError("Pass either expected_image_digest or policy, not both.") - if expected_image_digest is not None: - policy = AppraisalPolicy(expected_image_digest=expected_image_digest) + 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: + raise ValueError( + f"Pass either {' / '.join(shorthands)} or policy, not both." + ) + + evidence = self._peer_evidence(peer_email) + if evidence is None: + return None + if given: + policy = policy_for(evidence.kind, **given) + 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 +188,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/__init__.py b/packages/syft-enclave/src/syft_enclaves/evidence/__init__.py new file mode 100644 index 00000000000..9e7425bfff6 --- /dev/null +++ b/packages/syft-enclave/src/syft_enclaves/evidence/__init__.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.evidence.confidential_space import ConfidentialSpaceProvider +from syft_enclaves.evidence.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/evidence/confidential_space.py b/packages/syft-enclave/src/syft_enclaves/evidence/confidential_space.py new file mode 100644 index 00000000000..8635d006cb4 --- /dev/null +++ b/packages/syft-enclave/src/syft_enclaves/evidence/confidential_space.py @@ -0,0 +1,148 @@ +"""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.claims import claims_digest +from syft_enclaves.attestation.envelope import ( + AttestationEvidence, + AttestationKind, + confidential_space_evidence, +) +from syft_enclaves.evidence.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, + 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)) + + +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/evidence/key_bundle.py b/packages/syft-enclave/src/syft_enclaves/evidence/key_bundle.py new file mode 100644 index 00000000000..406ff6623bc --- /dev/null +++ b/packages/syft-enclave/src/syft_enclaves/evidence/key_bundle.py @@ -0,0 +1,108 @@ +"""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, + claims: Optional[dict[str, Any]] = None, + 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), "claims": claims}) + ) + 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 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, 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 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 + 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.attestation.nonce 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, 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/tee_token.py b/packages/syft-enclave/src/syft_enclaves/evidence/tee_token.py similarity index 75% rename from packages/syft-enclave/src/syft_enclaves/tee_token.py rename to packages/syft-enclave/src/syft_enclaves/evidence/tee_token.py index d6c6acfaec2..de242fa3b3f 100644 --- a/packages/syft-enclave/src/syft_enclaves/tee_token.py +++ b/packages/syft-enclave/src/syft_enclaves/evidence/tee_token.py @@ -1,20 +1,22 @@ -"""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 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 +55,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/src/syft_enclaves/evidence/tinfoil.py b/packages/syft-enclave/src/syft_enclaves/evidence/tinfoil.py new file mode 100644 index 00000000000..499ff083826 --- /dev/null +++ b/packages/syft-enclave/src/syft_enclaves/evidence/tinfoil.py @@ -0,0 +1,133 @@ +"""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, + claims: Optional[dict] = None, + ) -> AttestationEvidence: + if claims is not None: + raise ValueError( + "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( + "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/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/runner.py b/packages/syft-enclave/src/syft_enclaves/runner.py index 96a071c7f9a..89f5db6413c 100644 --- a/packages/syft-enclave/src/syft_enclaves/runner.py +++ b/packages/syft-enclave/src/syft_enclaves/runner.py @@ -16,11 +16,18 @@ import time 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.tee_token import ( - TEE_SOCKET_PATH, - build_eat_nonce, - fetch_attestation_token, +from syft_enclaves.evidence.key_bundle import write_public_bundle +from syft_enclaves.evidence import ( + AUTO, + EvidenceProvider, + probed_locations, + select_provider, ) logger = logging.getLogger(__name__) @@ -37,12 +44,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 +130,100 @@ 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 + ) + # 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) -> 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, claims: Optional[dict] + ) -> None: + """Write the provider's evidence into the peer-visible version file.""" + binding = {"claims": claims} if claims and provider.accepts_caller_nonce else {} + evidence = provider.collect(**binding) 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 _build_claims(self) -> Optional[dict]: + """The runtime facts this enclave asserts about itself. + + 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. + """ + 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( + "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 + + 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 + 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, claims=claims + ) + 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/tests/test_attestation_claims.py b/packages/syft-enclave/tests/test_attestation_claims.py new file mode 100644 index 00000000000..6b09c9cdbf8 --- /dev/null +++ b/packages/syft-enclave/tests/test_attestation_claims.py @@ -0,0 +1,308 @@ +"""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, policy=AppraisalPolicy(allow_unpinned=True), 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, policy=AppraisalPolicy(allow_unpinned=True), 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, policy=AppraisalPolicy(allow_unpinned=True), 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, policy=AppraisalPolicy(allow_unpinned=True), 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, + 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(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", allow_unpinned=True + ), + ) + + 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"], + 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)), allow_unpinned=True + ), + ) + 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 cannot commit to claims in its report, so it refuses.""" + 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()) + + +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_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_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 + + 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 + for field in ( + "expected_image_digest", + "expected_data_owners", + "expected_email", + ): + assert field in message diff --git a/packages/syft-enclave/tests/test_attestation.py b/packages/syft-enclave/tests/test_attestation_confidential_space.py similarity index 72% rename from packages/syft-enclave/tests/test_attestation.py rename to packages/syft-enclave/tests/test_attestation_confidential_space.py index a91854c48c3..e0b92e2084a 100644 --- a/packages/syft-enclave/tests/test_attestation.py +++ b/packages/syft-enclave/tests/test_attestation_confidential_space.py @@ -20,7 +20,13 @@ # 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"], + expected_email="enclave@openmined.org", +) +# For checks that are not about pinning. +UNPINNED = AppraisalPolicy(allow_unpinned=True) def _valid_claims(**overrides): @@ -49,8 +55,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 @@ -62,18 +70,21 @@ 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") 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 @@ -81,36 +92,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() @@ -118,12 +129,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) @@ -131,7 +144,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 @@ -159,7 +172,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" @@ -170,11 +183,12 @@ 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] - # 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", @@ -190,7 +204,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"} @@ -205,7 +219,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"] @@ -235,3 +249,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_attestation_dispatch.py b/packages/syft-enclave/tests/test_attestation_dispatch.py new file mode 100644 index 00000000000..24165e8b3a2 --- /dev/null +++ b/packages/syft-enclave/tests/test_attestation_dispatch.py @@ -0,0 +1,225 @@ +"""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, allow_unpinned=True), + AppraisalPolicy, + ) + 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", 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): + # 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", + 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={})) + with pytest.raises(ValueError, match="not both"): + client.attest_peer( + "enclave@openmined.org", + expected_image_digest="sha256:a", + policy=TinfoilAppraisalPolicy(allow_unpinned=True), + ) + + +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(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')" + ) + 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..4f8ca5a5ec4 --- /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..63ed9513d23 --- /dev/null +++ b/packages/syft-enclave/tests/test_attestation_https.py @@ -0,0 +1,148 @@ +"""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 + import 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..ddcd4067c13 --- /dev/null +++ b/packages/syft-enclave/tests/test_attestation_tinfoil.py @@ -0,0 +1,613 @@ +"""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.attestation.nonce import new_nonce, sign_challenge + + def _install( + *, + document=None, + bundle="real", + tls_fp=TLS_FP, + 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 + # 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, + key_bundle=served, + tls_public_key_fp=tls_fp, + host=HOST, + nonce=nonce, + nonce_signature=nonce_signature, + claims=claims, + ) + + 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) + # 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), + 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", + "claims_binding", + "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, allow_unpinned=True), + verbose=False, + ) + message = str(excinfo.value) + assert 'pip install "syft-enclave[tinfoil]"' 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 syft_enclaves.attestation.tinfoil import TinfoilAppraisalPolicy + + assert ( + TinfoilAppraisalPolicy(allow_unpinned=True).repo + == "OpenMined/syft-enclave-tinfoil" + ) + + +class TestSignedClaims: + """Tinfoil's route to attested runtime facts. + + 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. + """ + + 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"], + allow_unpinned=True, + ) + assert _check(excinfo.value.result, "data_owners").passed is False diff --git a/packages/syft-enclave/tests/test_evidence.py b/packages/syft-enclave/tests/test_evidence.py new file mode 100644 index 00000000000..c8993cb1fdf --- /dev/null +++ b/packages/syft-enclave/tests/test_evidence.py @@ -0,0 +1,244 @@ +"""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.evidence.confidential_space import ( + ConfidentialSpaceProvider, + decode_jwt_payload, + structure_claims, +) +from syft_enclaves.evidence.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.evidence.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.evidence.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.evidence.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.evidence.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.evidence.tinfoil.TINFOIL_ATTESTATION_PATH", path + ) + monkeypatch.setattr( + "syft_enclaves.evidence.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.evidence.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.evidence.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.evidence.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..f679fd20d90 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,217 @@ 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, claims=None: written.update( + bundle=bundle, keys_path=keys_path, claims=claims + ), + ) + + 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, claims=None: 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} + + +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, claims=None: 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_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 == {} diff --git a/packages/syft-enclave/tinfoil/tinfoil-config.yml b/packages/syft-enclave/tinfoil/tinfoil-config.yml new file mode 100644 index 00000000000..c8ca13e3ef7 --- /dev/null +++ b/packages/syft-enclave/tinfoil/tinfoil-config.yml @@ -0,0 +1,103 @@ +# Tinfoil workload config for the syft enclave. +# +# 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 +# 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:d0bd57f22af80b9dcd0dc151fb68d89cca65b65fcbbd1d2e4586cdfa9d7daebc + 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()))