From f49204114bf84758be95d78cfa27c04f8b0c1dfa Mon Sep 17 00:00:00 2001 From: Khoa Duy Nguyen <88959106+khoaguin@users.noreply.github.com> Date: Sun, 19 Jul 2026 11:46:10 +0700 Subject: [PATCH 1/6] Pin enclave image digest in attestation, fail closed MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The image_digest check previously skipped when EXPECTED_IMAGE_DIGEST was empty (a TODO), so the verifier accepted any container running in a genuine Confidential Space — whoever controls the deployment could swap in a tampered image and attestation still passed. - Pin EXPECTED_IMAGE_DIGEST to the released openminedreleasebot/syft-client-enclave:latest digest - Fail closed: reject on empty pinned digest, missing image_digest claim, or mismatch — no skip path - Tests for all three reject paths + guard that the constant stays a real sha256 digest - docs/security.md: how data owners independently confirm the digest, and the maintainer re-pin step on each image release Co-Authored-By: Claude Fable 5 --- packages/syft-enclave/docs/security.md | 49 +++++++++++++++ .../src/syft_enclaves/attestation.py | 32 +++++++--- .../syft-enclave/tests/test_attestation.py | 62 +++++++++++++------ 3 files changed, 118 insertions(+), 25 deletions(-) diff --git a/packages/syft-enclave/docs/security.md b/packages/syft-enclave/docs/security.md index 508e9f97880..33d32bfc2e1 100644 --- a/packages/syft-enclave/docs/security.md +++ b/packages/syft-enclave/docs/security.md @@ -102,3 +102,52 @@ 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. Verifying the enclave image digest + +Attestation proves the hardware is genuine, but that alone is not enough: a +genuine Confidential Space VM could be running **any** container. The verifier +therefore also pins the exact container image. The attestation JWT carries the +sha256 digest of the running image (`submods.container.image_digest`), and the +client compares it against `EXPECTED_IMAGE_DIGEST` in +`syft_enclaves/attestation.py`. The check **fails closed**: verification is +rejected if the pinned digest is empty, if the token carries no digest, or if +the two don't match. This means whoever operates the deployment (or controls +its CI or service account) cannot swap in a tampered image — attestation would +fail even though the hardware is real. + +### Confirming the digest independently + +The pinned value in the source is the anchor, but a data owner should not have +to take the repo's word for it. To confirm independently: + +1. **Fetch the published image's digest straight from Docker Hub** (no pull + needed): + + ```bash + docker buildx imagetools inspect \ + docker.io/openminedreleasebot/syft-client-enclave:latest + ``` + + The top-level `Digest:` line is the value the attestation token reports. + +2. **Compare it** against `EXPECTED_IMAGE_DIGEST` in + [`attestation.py`](../src/syft_enclaves/attestation.py) *at the release tag + of the `syft-client` version you installed* — not at an arbitrary branch. + +3. **Optionally rebuild from source.** The image is built from the + [`docker/Dockerfile`](../docker/Dockerfile) in this repo; a reproducible + local build lets you confirm the published image corresponds to the audited + source rather than trusting the registry. + +Known gap: releases do not yet ship a signed manifest binding +`syft-client version → image digest`, so step 2 currently trusts the git tag. +A signed release manifest (e.g. sigstore/cosign) is the planned hardening. + +### Maintainer note: re-pinning on release + +Every push of a new enclave image changes the digest, so +`EXPECTED_IMAGE_DIGEST` must be updated in the same release that publishes the +image — otherwise clients on the new version will (correctly) reject the old +enclave, and vice versa. Fetch the new value with the `imagetools inspect` +command above. diff --git a/packages/syft-enclave/src/syft_enclaves/attestation.py b/packages/syft-enclave/src/syft_enclaves/attestation.py index 238b50cddd4..78eed832442 100644 --- a/packages/syft-enclave/src/syft_enclaves/attestation.py +++ b/packages/syft-enclave/src/syft_enclaves/attestation.py @@ -25,7 +25,17 @@ # this client. Tracks releases automatically — no manual bump needed when # syft-client's version changes. EXPECTED_SYFT_VERSION = SYFT_CLIENT_VERSION -EXPECTED_IMAGE_DIGEST = "" # TODO: set after enclave image is published +# Digest of the released enclave image +# (docker.io/openminedreleasebot/syft-client-enclave). Must be re-pinned on +# every image release — fetch the current value with: +# docker buildx imagetools inspect \ +# docker.io/openminedreleasebot/syft-client-enclave:latest +# See docs/security.md ("Verifying the enclave image digest") for how data +# owners independently confirm this value. The verifier fails closed: an +# empty digest here rejects every attestation token. +EXPECTED_IMAGE_DIGEST = ( + "sha256:23a06dd0e2173e734eea9f0492ea019babfa6240e00702a6e76aee30a2cb1ec8" +) class AttestationError(Exception): @@ -182,7 +192,10 @@ def verify_attestation_token(token: str, verbose: bool = True) -> AttestationRes f"version mismatch (enclave={actual_version_nonce!r}, expected={expected_version_nonce!r})", ) - # 5. Image digest + # 5. Image digest — fail closed. An unset expected digest or a missing + # claim is a hard failure, not a skip: without this check the verifier + # accepts ANY image running in a genuine Confidential Space, not the + # audited enclave image. if verbose: print(" ⏳ Image digest ...") container = claims.get("submods", {}).get("container", {}) @@ -191,10 +204,15 @@ def verify_attestation_token(token: str, verbose: bool = True) -> AttestationRes result.add( "image_digest", "Image digest", - None, - f"digest {image_digest[:20]}... (expected digest not configured, skipped)" - if image_digest - else "no digest in token (expected digest not configured, skipped)", + False, + "expected image digest not configured — refusing to trust unpinned image", + ) + elif not image_digest: + result.add( + "image_digest", + "Image digest", + False, + "no image digest in token — cannot verify enclave is running the released image", ) elif image_digest == EXPECTED_IMAGE_DIGEST: result.add( @@ -208,7 +226,7 @@ def verify_attestation_token(token: str, verbose: bool = True) -> AttestationRes "image_digest", "Image digest", False, - f"digest mismatch (got {image_digest or 'none'}, expected {EXPECTED_IMAGE_DIGEST[:20]}...)", + f"digest mismatch (got {image_digest}, expected {EXPECTED_IMAGE_DIGEST[:20]}...)", ) # Finalize — print full checklist, then raise once if anything failed diff --git a/packages/syft-enclave/tests/test_attestation.py b/packages/syft-enclave/tests/test_attestation.py index 54d0c2b9039..76166509045 100644 --- a/packages/syft-enclave/tests/test_attestation.py +++ b/packages/syft-enclave/tests/test_attestation.py @@ -34,10 +34,19 @@ def _valid_claims(**overrides): @pytest.fixture def mock_verify(): - """Patch google id_token.verify_token to return valid claims.""" + """Patch google id_token.verify_token to return valid claims. + + Also pins EXPECTED_IMAGE_DIGEST to the fake digest in the claims so + tests exercising other checks aren't tripped by the (fail-closed) + image_digest check. Tests targeting image_digest override the patch. + """ with ( patch("syft_enclaves.attestation.id_token.verify_token") as mock_vt, patch("syft_enclaves.attestation.google_requests.Request"), + patch( + "syft_enclaves.attestation.EXPECTED_IMAGE_DIGEST", + FAKE_IMAGE_DIGEST, + ), ): mock_vt.return_value = _valid_claims() yield mock_vt @@ -45,16 +54,10 @@ def mock_verify(): class TestVerifyAttestationToken: def test_all_checks_pass(self, mock_verify): - # Configure EXPECTED_IMAGE_DIGEST so the image_digest check resolves - # to True (not None/skipped) and all five checks pass. - with patch( - "syft_enclaves.attestation.EXPECTED_IMAGE_DIGEST", - FAKE_IMAGE_DIGEST, - ): - result = verify_attestation_token("fake-token", verbose=False) - assert result.all_passed() - assert len(result.checks) == 5 - assert all(c.passed for c in result.checks) + result = verify_attestation_token("fake-token", verbose=False) + assert result.all_passed() + assert len(result.checks) == 5 + assert all(c.passed for c in result.checks) def test_jwt_signature_failure(self, mock_verify): mock_verify.side_effect = ValueError("bad signature") @@ -114,13 +117,36 @@ def test_image_digest_mismatch(self, mock_verify): with pytest.raises(AttestationError, match="image_digest"): verify_attestation_token("fake-token", verbose=False) - def test_image_digest_skipped_when_not_configured(self, mock_verify): - """When EXPECTED_IMAGE_DIGEST is empty, image check is skipped (passed=None).""" - result = verify_attestation_token("fake-token", verbose=False) - image_check = next(c for c in result.checks if c.name == "image_digest") - # Skipped checks use passed=None — distinguishes "not run" from "failed". - assert image_check.passed is None - assert "skipped" in image_check.detail + def test_image_digest_fails_closed_when_not_configured(self, mock_verify): + """Empty EXPECTED_IMAGE_DIGEST must REJECT, not skip — an unpinned + verifier would accept any image running in a genuine Confidential + Space, defeating the point of attestation.""" + with patch("syft_enclaves.attestation.EXPECTED_IMAGE_DIGEST", ""): + with pytest.raises(AttestationError, match="image_digest") as exc_info: + verify_attestation_token("fake-token", verbose=False) + image_check = next( + c for c in exc_info.value.result.checks if c.name == "image_digest" + ) + assert image_check.passed is False + assert "not configured" in image_check.detail + + def test_image_digest_fails_closed_when_missing_from_token(self, mock_verify): + """A token with no image_digest claim must be rejected — the verifier + can't confirm which image is running, so it must not trust it.""" + claims = _valid_claims() + del claims["submods"]["container"]["image_digest"] + mock_verify.return_value = claims + with pytest.raises(AttestationError, match="image_digest"): + verify_attestation_token("fake-token", verbose=False) + + def test_pinned_digest_is_set(self): + """The shipped constant must be a real pinned sha256 digest — an empty + or malformed value silently disables image verification for every + client. Guards against the digest being blanked in a future edit.""" + from syft_enclaves.attestation import EXPECTED_IMAGE_DIGEST + + assert EXPECTED_IMAGE_DIGEST.startswith("sha256:") + assert len(EXPECTED_IMAGE_DIGEST) == len("sha256:") + 64 def test_image_digest_matches(self, mock_verify): with patch( From 8f81dd97473ccf06d8d70ba27497dfb4acd06034 Mon Sep 17 00:00:00 2001 From: Khoa Duy Nguyen <88959106+khoaguin@users.noreply.github.com> Date: Sun, 19 Jul 2026 11:55:40 +0700 Subject: [PATCH 2/6] fix: apply prettier formatting to security.md Co-Authored-By: Claude Fable 5 --- packages/syft-enclave/docs/security.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/syft-enclave/docs/security.md b/packages/syft-enclave/docs/security.md index 33d32bfc2e1..7285c90c359 100644 --- a/packages/syft-enclave/docs/security.md +++ b/packages/syft-enclave/docs/security.md @@ -132,8 +132,8 @@ to take the repo's word for it. To confirm independently: The top-level `Digest:` line is the value the attestation token reports. 2. **Compare it** against `EXPECTED_IMAGE_DIGEST` in - [`attestation.py`](../src/syft_enclaves/attestation.py) *at the release tag - of the `syft-client` version you installed* — not at an arbitrary branch. + [`attestation.py`](../src/syft_enclaves/attestation.py) _at the release tag + of the `syft-client` version you installed_ — not at an arbitrary branch. 3. **Optionally rebuild from source.** The image is built from the [`docker/Dockerfile`](../docker/Dockerfile) in this repo; a reproducible From 6ffd147cd1a6eaeb2f7ca2c771bb22f448ac4381 Mon Sep 17 00:00:00 2001 From: rasswanth-s <43314053+rasswanth-s@users.noreply.github.com> Date: Wed, 22 Jul 2026 18:16:07 +0530 Subject: [PATCH 3/6] add appraisal policy to attestation --- .../src/syft_enclaves/attestation.py | 76 ++++++++++------ .../syft-enclave/src/syft_enclaves/client.py | 31 ++++++- .../syft-enclave/tests/test_attestation.py | 91 ++++++++----------- 3 files changed, 116 insertions(+), 82 deletions(-) diff --git a/packages/syft-enclave/src/syft_enclaves/attestation.py b/packages/syft-enclave/src/syft_enclaves/attestation.py index 78eed832442..17d55787598 100644 --- a/packages/syft-enclave/src/syft_enclaves/attestation.py +++ b/packages/syft-enclave/src/syft_enclaves/attestation.py @@ -9,9 +9,11 @@ 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_client.version import SYFT_CLIENT_VERSION @@ -21,21 +23,25 @@ "signer@confidentialspace-sign.iam.gserviceaccount.com" ) -# The verifier expects the enclave to run the same syft-client version as -# this client. Tracks releases automatically — no manual bump needed when -# syft-client's version changes. -EXPECTED_SYFT_VERSION = SYFT_CLIENT_VERSION -# Digest of the released enclave image -# (docker.io/openminedreleasebot/syft-client-enclave). Must be re-pinned on -# every image release — fetch the current value with: -# docker buildx imagetools inspect \ -# docker.io/openminedreleasebot/syft-client-enclave:latest -# See docs/security.md ("Verifying the enclave image digest") for how data -# owners independently confirm this value. The verifier fails closed: an -# empty digest here rejects every attestation token. -EXPECTED_IMAGE_DIGEST = ( - "sha256:23a06dd0e2173e734eea9f0492ea019babfa6240e00702a6e76aee30a2cb1ec8" -) + +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. + + 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. + """ + + 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-client as the verifier. + expected_syft_version: Optional[str] = SYFT_CLIENT_VERSION class AttestationError(Exception): @@ -80,7 +86,11 @@ def print_checklist(self) -> None: print(f"{icon} {check.label:<20s} — {check.detail}") -def verify_attestation_token(token: str, verbose: bool = True) -> AttestationResult: +def verify_attestation_token( + token: str, + policy: AppraisalPolicy | None = None, + verbose: bool = True, +) -> AttestationResult: """Verify an attestation JWT and return the result checklist. Runs every check before raising — so a failure in one (e.g. ``dbgstat``) @@ -93,7 +103,19 @@ def verify_attestation_token(token: str, verbose: bool = True) -> AttestationRes to inspect. ``passed=None`` ("skipped") does not count as a failure. + + Args: + token: the attestation JWT to verify. + policy: reference values to appraise the evidence against. Defaults to + the shipped ``AppraisalPolicy()`` (module-level pinned digest and + version). Pass a custom policy to appraise against your own + independently-verified image digest. + verbose: print the check progress and final checklist. """ + policy = policy or AppraisalPolicy() + expected_image_digest = policy.expected_image_digest + expected_syft_version = policy.expected_syft_version + result = AttestationResult() if verbose: @@ -169,7 +191,7 @@ def verify_attestation_token(token: str, verbose: bool = True) -> AttestationRes 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. - expected_version_nonce = f"syft-client-{EXPECTED_SYFT_VERSION}" + expected_version_nonce = f"syft-client-{expected_syft_version}" if not actual_version_nonce: result.add( "version_match", @@ -182,7 +204,7 @@ def verify_attestation_token(token: str, verbose: bool = True) -> AttestationRes "version_match", "Version match", True, - f"enclave runs expected syft-client {EXPECTED_SYFT_VERSION}", + f"enclave runs expected syft-client {expected_syft_version}", ) else: result.add( @@ -192,20 +214,20 @@ def verify_attestation_token(token: str, verbose: bool = True) -> AttestationRes f"version mismatch (enclave={actual_version_nonce!r}, expected={expected_version_nonce!r})", ) - # 5. Image digest — fail closed. An unset expected digest or a missing - # claim is a hard failure, not a skip: without this check the verifier - # accepts ANY image running in a genuine Confidential Space, not the - # audited enclave image. + # 5. Image digest. The expected digest is supplied by the data owner via the + # AppraisalPolicy . When none is supplied the check is + # SKIPPED (passed=None), not failed. if verbose: print(" ⏳ Image digest ...") container = claims.get("submods", {}).get("container", {}) image_digest = container.get("image_digest") - if not EXPECTED_IMAGE_DIGEST: + if not expected_image_digest: result.add( "image_digest", "Image digest", - False, - "expected image digest not configured — refusing to trust unpinned image", + None, + "no expected image digest supplied — pass one via AppraisalPolicy " + "(attest_peer(..., expected_image_digest=...)) to pin the image (skipped)", ) elif not image_digest: result.add( @@ -214,7 +236,7 @@ def verify_attestation_token(token: str, verbose: bool = True) -> AttestationRes False, "no image digest in token — cannot verify enclave is running the released image", ) - elif image_digest == EXPECTED_IMAGE_DIGEST: + elif image_digest == expected_image_digest: result.add( "image_digest", "Image digest", @@ -226,7 +248,7 @@ def verify_attestation_token(token: str, verbose: bool = True) -> AttestationRes "image_digest", "Image digest", False, - f"digest mismatch (got {image_digest}, expected {EXPECTED_IMAGE_DIGEST[:20]}...)", + f"digest mismatch (got {image_digest}, expected {expected_image_digest[:20]}...)", ) # Finalize — print full checklist, then raise once if anything failed diff --git a/packages/syft-enclave/src/syft_enclaves/client.py b/packages/syft-enclave/src/syft_enclaves/client.py index 7437ff460ff..6bdac8af9af 100644 --- a/packages/syft-enclave/src/syft_enclaves/client.py +++ b/packages/syft-enclave/src/syft_enclaves/client.py @@ -16,6 +16,10 @@ PartyApprovalStatus, enclave_approval_file_name, ) +from syft_enclaves.attestation import ( + AppraisalPolicy, + verify_attestation_token, +) from syft_perms.syftperm_context import SyftPermContext from syft_enclaves.enclave_job_client import EnclaveJobClient @@ -74,11 +78,30 @@ def approve_peer_request( def reject_peer_request(self, email_or_peer: str | Peer): self._manager.reject_peer_request(email_or_peer) - def attest_peer(self, peer_email: str): + def attest_peer( + self, + peer_email: str, + expected_image_digest: str | None = None, + policy: "AppraisalPolicy | 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 syft_enclaves.attestation import verify_attestation_token + raises AttestationError only when verification of an existing token fails. + + 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-client version). Mutually exclusive with + ``expected_image_digest``. + """ + + if expected_image_digest is not None and policy is not None: + raise ValueError("Pass either expected_image_digest or policy, not both.") + if expected_image_digest is not None: + policy = AppraisalPolicy(expected_image_digest=expected_image_digest) version_info = ( self._manager.peer_manager.connection_router.read_peer_version_file( @@ -96,7 +119,7 @@ def attest_peer(self, peer_email: str): "(not running in a Confidential Space); skipping attestation." ) return None - return verify_attestation_token(version_info.attestation_token) + return verify_attestation_token(version_info.attestation_token, policy=policy) def sync(self): self._manager.sync() diff --git a/packages/syft-enclave/tests/test_attestation.py b/packages/syft-enclave/tests/test_attestation.py index 76166509045..8cdfa7f7b0e 100644 --- a/packages/syft-enclave/tests/test_attestation.py +++ b/packages/syft-enclave/tests/test_attestation.py @@ -4,15 +4,22 @@ import pytest +from syft_client.version import SYFT_CLIENT_VERSION + from syft_enclaves.attestation import ( - EXPECTED_SYFT_VERSION, + AppraisalPolicy, AttestationError, AttestationResult, verify_attestation_token, ) FAKE_IMAGE_DIGEST = "sha256:abc123" -EXPECTED_VERSION_NONCE = f"syft-client-{EXPECTED_SYFT_VERSION}" +EXPECTED_VERSION_NONCE = f"syft-client-{SYFT_CLIENT_VERSION}" + +# 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) def _valid_claims(**overrides): @@ -36,17 +43,13 @@ def _valid_claims(**overrides): def mock_verify(): """Patch google id_token.verify_token to return valid claims. - Also pins EXPECTED_IMAGE_DIGEST to the fake digest in the claims so - tests exercising other checks aren't tripped by the (fail-closed) - image_digest check. Tests targeting image_digest override the patch. + Tests use ``_verify`` (or pass ``DEFAULT_TEST_POLICY``) so the fake token's + digest matches the policy and the image_digest check passes. Tests + 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.EXPECTED_IMAGE_DIGEST", - FAKE_IMAGE_DIGEST, - ), ): mock_vt.return_value = _valid_claims() yield mock_vt @@ -54,7 +57,9 @@ def mock_verify(): class TestVerifyAttestationToken: def test_all_checks_pass(self, mock_verify): - result = verify_attestation_token("fake-token", verbose=False) + result = verify_attestation_token( + "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) @@ -89,7 +94,7 @@ def test_version_mismatch(self, mock_verify): 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=[EXPECTED_SYFT_VERSION]) + mock_verify.return_value = _valid_claims(eat_nonce=[SYFT_CLIENT_VERSION]) with pytest.raises(AttestationError, match="version_match"): verify_attestation_token("fake-token", verbose=False) @@ -109,54 +114,38 @@ def test_version_as_string(self, mock_verify): assert version_check.passed is True def test_image_digest_mismatch(self, mock_verify): - with patch( - "syft_enclaves.attestation.EXPECTED_IMAGE_DIGEST", - "sha256:expected", - ): - mock_verify.return_value = _valid_claims() - with pytest.raises(AttestationError, match="image_digest"): - verify_attestation_token("fake-token", verbose=False) - - def test_image_digest_fails_closed_when_not_configured(self, mock_verify): - """Empty EXPECTED_IMAGE_DIGEST must REJECT, not skip — an unpinned - verifier would accept any image running in a genuine Confidential - Space, defeating the point of attestation.""" - with patch("syft_enclaves.attestation.EXPECTED_IMAGE_DIGEST", ""): - with pytest.raises(AttestationError, match="image_digest") as exc_info: - verify_attestation_token("fake-token", verbose=False) - image_check = next( - c for c in exc_info.value.result.checks if c.name == "image_digest" + policy = AppraisalPolicy(expected_image_digest="sha256:expected") + with pytest.raises(AttestationError, match="image_digest"): + verify_attestation_token("fake-token", policy=policy, verbose=False) + + 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 ) - assert image_check.passed is False - assert "not configured" in image_check.detail + image_check = next(c for c in result.checks if c.name == "image_digest") + assert image_check.passed is None + assert "no expected image digest supplied" in image_check.detail - def test_image_digest_fails_closed_when_missing_from_token(self, mock_verify): - """A token with no image_digest claim must be rejected — the verifier - can't confirm which image is running, so it must not trust it.""" + def test_image_digest_fails_when_supplied_but_missing_from_token(self, mock_verify): + """A pinned policy plus a token with no image_digest claim must be + rejected — the verifier can't confirm which image is running.""" claims = _valid_claims() del claims["submods"]["container"]["image_digest"] mock_verify.return_value = claims with pytest.raises(AttestationError, match="image_digest"): - verify_attestation_token("fake-token", verbose=False) - - def test_pinned_digest_is_set(self): - """The shipped constant must be a real pinned sha256 digest — an empty - or malformed value silently disables image verification for every - client. Guards against the digest being blanked in a future edit.""" - from syft_enclaves.attestation import EXPECTED_IMAGE_DIGEST - - assert EXPECTED_IMAGE_DIGEST.startswith("sha256:") - assert len(EXPECTED_IMAGE_DIGEST) == len("sha256:") + 64 + verify_attestation_token( + "fake-token", policy=DEFAULT_TEST_POLICY, verbose=False + ) def test_image_digest_matches(self, mock_verify): - with patch( - "syft_enclaves.attestation.EXPECTED_IMAGE_DIGEST", - FAKE_IMAGE_DIGEST, - ): - result = verify_attestation_token("fake-token", verbose=False) - image_check = next(c for c in result.checks if c.name == "image_digest") - assert image_check.passed - assert "matches" in image_check.detail + result = verify_attestation_token( + "fake-token", policy=DEFAULT_TEST_POLICY, verbose=False + ) + image_check = next(c for c in result.checks if c.name == "image_digest") + assert image_check.passed + assert "matches" in image_check.detail def test_error_carries_result(self, mock_verify): mock_verify.return_value = _valid_claims(secboot=False) From 972aa4601c3811f50df1467509d647886b459396 Mon Sep 17 00:00:00 2001 From: rasswanth-s <43314053+rasswanth-s@users.noreply.github.com> Date: Wed, 22 Jul 2026 18:20:11 +0530 Subject: [PATCH 4/6] revert security.md changes --- packages/syft-enclave/docs/security.md | 49 -------------------------- 1 file changed, 49 deletions(-) diff --git a/packages/syft-enclave/docs/security.md b/packages/syft-enclave/docs/security.md index 7285c90c359..508e9f97880 100644 --- a/packages/syft-enclave/docs/security.md +++ b/packages/syft-enclave/docs/security.md @@ -102,52 +102,3 @@ 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. Verifying the enclave image digest - -Attestation proves the hardware is genuine, but that alone is not enough: a -genuine Confidential Space VM could be running **any** container. The verifier -therefore also pins the exact container image. The attestation JWT carries the -sha256 digest of the running image (`submods.container.image_digest`), and the -client compares it against `EXPECTED_IMAGE_DIGEST` in -`syft_enclaves/attestation.py`. The check **fails closed**: verification is -rejected if the pinned digest is empty, if the token carries no digest, or if -the two don't match. This means whoever operates the deployment (or controls -its CI or service account) cannot swap in a tampered image — attestation would -fail even though the hardware is real. - -### Confirming the digest independently - -The pinned value in the source is the anchor, but a data owner should not have -to take the repo's word for it. To confirm independently: - -1. **Fetch the published image's digest straight from Docker Hub** (no pull - needed): - - ```bash - docker buildx imagetools inspect \ - docker.io/openminedreleasebot/syft-client-enclave:latest - ``` - - The top-level `Digest:` line is the value the attestation token reports. - -2. **Compare it** against `EXPECTED_IMAGE_DIGEST` in - [`attestation.py`](../src/syft_enclaves/attestation.py) _at the release tag - of the `syft-client` version you installed_ — not at an arbitrary branch. - -3. **Optionally rebuild from source.** The image is built from the - [`docker/Dockerfile`](../docker/Dockerfile) in this repo; a reproducible - local build lets you confirm the published image corresponds to the audited - source rather than trusting the registry. - -Known gap: releases do not yet ship a signed manifest binding -`syft-client version → image digest`, so step 2 currently trusts the git tag. -A signed release manifest (e.g. sigstore/cosign) is the planned hardening. - -### Maintainer note: re-pinning on release - -Every push of a new enclave image changes the digest, so -`EXPECTED_IMAGE_DIGEST` must be updated in the same release that publishes the -image — otherwise clients on the new version will (correctly) reject the old -enclave, and vice versa. Fetch the new value with the `imagetools inspect` -command above. From c7e2edbd07a07bd7381502aaa0f2c38887de8c33 Mon Sep 17 00:00:00 2001 From: rasswanth-s <43314053+rasswanth-s@users.noreply.github.com> Date: Wed, 22 Jul 2026 18:32:33 +0530 Subject: [PATCH 5/6] fix: accept enclave attestation JWT within a ~1 month grace window Google mints Confidential Space attestation tokens with a short (~30 min) lifetime, but the enclave writes its token to SYFT_version.json once at boot and does not yet refresh it, so peers would reject a still-genuine enclave after ~30 minutes. Widen the accepted expiry window on the verifier side via jwt.decode's clock_skew_in_seconds (JWT_EXPIRY_GRACE_SECONDS, ~1 month) as a stopgap until the enclave refreshes its token. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../syft-enclave/src/syft_enclaves/attestation.py | 15 ++++++++++++++- packages/syft-enclave/tests/test_attestation.py | 9 +++++++++ 2 files changed, 23 insertions(+), 1 deletion(-) diff --git a/packages/syft-enclave/src/syft_enclaves/attestation.py b/packages/syft-enclave/src/syft_enclaves/attestation.py index 17d55787598..091eb8600aa 100644 --- a/packages/syft-enclave/src/syft_enclaves/attestation.py +++ b/packages/syft-enclave/src/syft_enclaves/attestation.py @@ -23,6 +23,15 @@ "signer@confidentialspace-sign.iam.gserviceaccount.com" ) +# Google mints Confidential Space attestation tokens with a short lifetime +# (~30 minutes), but the enclave only writes its token to SYFT_version.json once +# at boot and does not yet refresh it. So after ~30 minutes every peer would +# reject the enclave. +# +# TODO: remove this once the enclave periodically refreshes its attestation +# token in SYFT_version.json — then the real (short) expiry can be honoured. +JWT_EXPIRY_GRACE_SECONDS = 30 * 24 * 60 * 60 # ~1 month + class AppraisalPolicy(BaseModel): """Reference values the verifier appraises attestation evidence against. @@ -121,7 +130,10 @@ def verify_attestation_token( if verbose: print("🔒 Verifying enclave attestation...") - # 1. JWT signature + expiry — fail-fast (no claims → no point continuing) + # 1. JWT signature + expiry — fail-fast (no claims → no point continuing). + # clock_skew_in_seconds widens the accepted expiry window (token valid until + # exp + grace) as a stopgap for the enclave not yet refreshing its token — + # see JWT_EXPIRY_GRACE_SECONDS. if verbose: print(" ⏳ JWT signature ...") try: @@ -131,6 +143,7 @@ def verify_attestation_token( request, audience=ATTESTATION_AUDIENCE, certs_url=CONFIDENTIAL_COMPUTING_CERTS_URL, + clock_skew_in_seconds=JWT_EXPIRY_GRACE_SECONDS, ) result.add( "jwt_signature", diff --git a/packages/syft-enclave/tests/test_attestation.py b/packages/syft-enclave/tests/test_attestation.py index 8cdfa7f7b0e..1682b28e0a8 100644 --- a/packages/syft-enclave/tests/test_attestation.py +++ b/packages/syft-enclave/tests/test_attestation.py @@ -7,6 +7,7 @@ from syft_client.version import SYFT_CLIENT_VERSION from syft_enclaves.attestation import ( + JWT_EXPIRY_GRACE_SECONDS, AppraisalPolicy, AttestationError, AttestationResult, @@ -69,6 +70,14 @@ def test_jwt_signature_failure(self, mock_verify): with pytest.raises(AttestationError, match="JWT signature"): verify_attestation_token("fake-token", 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) + _, kwargs = mock_verify.call_args + assert kwargs["clock_skew_in_seconds"] == JWT_EXPIRY_GRACE_SECONDS + assert JWT_EXPIRY_GRACE_SECONDS == 30 * 24 * 60 * 60 + def test_secure_boot_disabled(self, mock_verify): mock_verify.return_value = _valid_claims(secboot=False) with pytest.raises(AttestationError, match="secure_boot"): From e8ce21b6d772a72974b5690f0ca43e0b5dc0c39a Mon Sep 17 00:00:00 2001 From: rasswanth-s <43314053+rasswanth-s@users.noreply.github.com> Date: Wed, 22 Jul 2026 18:34:00 +0530 Subject: [PATCH 6/6] shift to print pull image digest --- packages/syft-enclave/src/syft_enclaves/attestation.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/syft-enclave/src/syft_enclaves/attestation.py b/packages/syft-enclave/src/syft_enclaves/attestation.py index 091eb8600aa..665ef596a83 100644 --- a/packages/syft-enclave/src/syft_enclaves/attestation.py +++ b/packages/syft-enclave/src/syft_enclaves/attestation.py @@ -261,7 +261,7 @@ def verify_attestation_token( "image_digest", "Image digest", False, - f"digest mismatch (got {image_digest}, expected {expected_image_digest[:20]}...)", + f"digest mismatch (got {image_digest}, expected {expected_image_digest})", ) # Finalize — print full checklist, then raise once if anything failed