diff --git a/packages/syft-enclave/src/syft_enclaves/attestation/claims.py b/packages/syft-enclave/src/syft_enclaves/attestation/claims.py index 24058ded551..b90647e0db5 100644 --- a/packages/syft-enclave/src/syft_enclaves/attestation/claims.py +++ b/packages/syft-enclave/src/syft_enclaves/attestation/claims.py @@ -1,14 +1,19 @@ """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. +Google-signed token, via ``eat_nonce``. Only code running inside the container +can do that, and the signature is unforgeable. But *any* code running there can, +the jobs the enclave runs included, so a commitment in ``eat_nonce`` is only as +trustworthy as the separation between the enclave and those jobs. + +That makes ``eat_nonce`` the channel for facts that exist only at runtime: the +enclave's public key bundle, made inside the container at boot. Deploy-time +facts take a stronger route on Confidential Space. The operator sets the +enclave's email and data owners as ``tee-env-*`` VM metadata, and the launcher +records them in the token's ``submods.container.env_override`` before the +container starts, so the verifier reads them from there (see +``attestation.confidential_space``). The claims document still carries them, +and the verifier refuses a document that disagrees with the token. 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 @@ -95,14 +100,15 @@ def check_expected( ) -> 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 + The caller 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. + Shared by both targets: Confidential Space passes the values its token + records, Tinfoil the ones in its signed claims document, and from there + the appraisal is identical. """ return [ _compare("enclave_email", "Enclave email", expected_email, claims.get("email")), diff --git a/packages/syft-enclave/src/syft_enclaves/attestation/confidential_space.py b/packages/syft-enclave/src/syft_enclaves/attestation/confidential_space.py index 3135781ac12..084d1ba2d99 100644 --- a/packages/syft-enclave/src/syft_enclaves/attestation/confidential_space.py +++ b/packages/syft-enclave/src/syft_enclaves/attestation/confidential_space.py @@ -1,14 +1,24 @@ """Appraising Confidential Space evidence. 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``. +This module verifies that token and checks the hardware and container claims +inside it. + +The enclave's email and data owners are read out of the token as well. The +operator sets them as ``tee-env-*`` VM metadata, and the launcher records them +in ``submods.container.env_override`` before the container starts, so no code +running inside it, a job included, can change them afterwards. + +The enclave's key bundle is made inside the container at boot, so the launcher +cannot know it. The enclave commits to it through the **claims binding**: a +digest of its claims document in the token's spare nonce slot. Any code in the +container can ask the launcher for a token, so that binding is only as strong +as the separation between the enclave and the jobs it runs. See +``attestation.claims``. + +Whether the email and data owners are the ones the verifier wanted is a +separate question, answered by ``AppraisalPolicy.expected_email`` and +``expected_data_owners``. """ from __future__ import annotations @@ -36,6 +46,11 @@ ) ATTESTATION_AUDIENCE = "syft-attestation" + +# Set by the operator as tee-env-* VM metadata, and recorded by the launcher in +# submods.container.env_override before the container starts. +EMAIL_ENV = "SYFT_ENCLAVE_EMAIL" +DATA_OWNERS_ENV = "SYFT_ENCLAVE_DATA_OWNERS" CONFIDENTIAL_COMPUTING_CERTS_URL = ( "https://www.googleapis.com/service_accounts/v1/metadata/jwk/" "signer@confidentialspace-sign.iam.gserviceaccount.com" @@ -67,18 +82,44 @@ def _nonce_slots(claims: dict) -> list[str]: return [nonce] if isinstance(nonce, str) else list(nonce) +def _deployed_facts(claims: dict) -> dict: + """The email and data owners the operator deployed, read from the token. + + Only ``env_override`` counts. ``env`` also holds the image's own ``ENV`` + defaults, so a value there may come from the image rather than the + operator. A fact the token does not record is None. + """ + env = claims.get("submods", {}).get("container", {}).get("env_override") + if not isinstance(env, dict): + env = {} + email = env.get(EMAIL_ENV) + owners = env.get(DATA_OWNERS_ENV) + return { + "email": email if isinstance(email, str) else None, + # Split like EnclaveSettings, sorted like build_claims. + "data_owners": ( + sorted(e.strip() for e in owners.split(",") if e.strip()) + if isinstance(owners, str) + else None + ), + } + + def _check_claims_binding( result: AttestationResult, claims: dict, published_claims: Optional[dict], + deployed: dict, policy: AppraisalPolicy, verbose: bool, ) -> None: - """Check the published runtime facts are the ones the token commits to. + """Check the published claims document is the one the token commits to. - This is what turns the enclave's email, its data owners and its key bundle - from unsigned assertions into attested ones: only code inside the measured - container can get the launcher to sign a digest of them. + This is what turns the enclave's key bundle from an unsigned assertion + into an attested one. The launcher signs whatever digest it is asked to, + so the document must also agree with what the operator deployed: the + runner builds it from those same values, so a document that disagrees was + written by something else in the container. """ if verbose: print(" ⏳ Claims binding ...") @@ -95,37 +136,70 @@ def _check_claims_binding( except ClaimsBindingError as e: result.add("claims_binding", "Claims binding", False, str(e)) return + disagreement = _disagreement_with_deployment(published_claims, deployed) + if disagreement: + result.add("claims_binding", "Claims binding", False, disagreement) + return - owners = published_claims.get("data_owners") or [] + key_bundle = published_claims.get("key_bundle") result.add( "claims_binding", "Claims binding", True, - f"token commits to email={published_claims.get('email')!r} and " - f"{len(owners)} data owner(s)", + f"token commits to the claims of {published_claims.get('email')!r}, " + f"key bundle {'included' if key_bundle else 'absent'}", ) - if published_claims.get("key_bundle"): - result.verified_key_bundle = published_claims["key_bundle"] - _check_expected_claims(result, published_claims, policy, verbose) + if key_bundle: + result.verified_key_bundle = key_bundle + +def _disagreement_with_deployment( + published_claims: dict, deployed: dict +) -> Optional[str]: + """Why the claims document cannot be the enclave's, or None if it can be. -def _check_expected_claims( + Only a fact the token records is compared. One it does not record is + reported by ``_check_deployed_facts`` instead. + """ + for field, env_name in (("email", EMAIL_ENV), ("data_owners", DATA_OWNERS_ENV)): + expected = deployed[field] + published = published_claims.get(field) + if expected is not None and published != expected: + return ( + f"the claims document has {field}={published!r}, but the operator " + f"deployed {env_name}={expected!r}, so the enclave as deployed did " + "not write it" + ) + return None + + +def _check_deployed_facts( result: AttestationResult, - published_claims: dict, + deployed: dict, policy: AppraisalPolicy, verbose: bool, ) -> None: - """Compare the now-attested facts against what the verifier expected. + """Compare the deployed email and data owners 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. + Delegates to ``attestation.claims.check_expected``, shared with Tinfoil. + A pinned value the token records nothing for fails rather than skips, so + the check never falls back to the enclave's own word for it. """ + env_names = { + "enclave_email": ("email", EMAIL_ENV), + "data_owners": ("data_owners", DATA_OWNERS_ENV), + } for name, label, passed, detail in check_expected( - published_claims, policy.expected_email, policy.expected_data_owners + deployed, policy.expected_email, policy.expected_data_owners ): if verbose: print(f" ⏳ {label} ...") + field, env_name = env_names[name] + if passed is False and deployed[field] is None: + detail = ( + f"the token records no {env_name} override, so the deployed " + "value cannot be checked" + ) result.add(name, label, passed, detail) @@ -200,9 +274,10 @@ def verify_attestation_token( ) raise AttestationError("JWT signature verification failed", result) from e - # 2. Claims binding — the enclave's runtime facts, committed to in the + # 2. Claims binding — the enclave's key bundle, committed to in the # token's spare nonce slot. Skipped when the enclave published none. - _check_claims_binding(result, claims, published_claims, policy, verbose) + deployed = _deployed_facts(claims) + _check_claims_binding(result, claims, published_claims, deployed, policy, verbose) # 3. Secure boot if verbose: @@ -303,6 +378,9 @@ def verify_attestation_token( f"digest mismatch (got {image_digest}, expected {expected_image_digest})", ) + # 6. Email and data owners, as the operator deployed them. + _check_deployed_facts(result, deployed, policy, verbose) + # Finalize — print full checklist, then raise once if anything failed if verbose: result.print_checklist() diff --git a/packages/syft-enclave/src/syft_enclaves/evidence/confidential_space.py b/packages/syft-enclave/src/syft_enclaves/evidence/confidential_space.py index 03f3d855caa..886eb0104cf 100644 --- a/packages/syft-enclave/src/syft_enclaves/evidence/confidential_space.py +++ b/packages/syft-enclave/src/syft_enclaves/evidence/confidential_space.py @@ -104,6 +104,9 @@ def structure_claims(claims: dict[str, Any]) -> dict[str, Any]: "image_reference": container.get("image_reference"), "restart_policy": container.get("restart_policy"), "env": container.get("env"), + # The tee-env-* values the operator set, which is what the + # verifier reads the enclave's email and data owners from. + "env_override": container.get("env_override"), }, "gce": { "project_id": gce.get("project_id"), diff --git a/packages/syft-enclave/tests/test_attestation_claims.py b/packages/syft-enclave/tests/test_attestation_claims.py index 332a944bb20..144ead4e4af 100644 --- a/packages/syft-enclave/tests/test_attestation_claims.py +++ b/packages/syft-enclave/tests/test_attestation_claims.py @@ -1,9 +1,11 @@ """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. +The point of the binding: the enclave's key bundle is made inside the container +at boot, outside the measurement. Only code inside the container can get the +launcher to sign a digest of it, so the digest is what turns it from the +enclave's unsigned word into an attested fact. The email and data owners are +read from the token's env_override instead, which the fixture below records as +the operator deployed them. """ from unittest.mock import MagicMock, patch @@ -83,7 +85,15 @@ def _install(version_nonce=f"syft-{SYFT_VERSION}", claims_nonce=None): "secboot": True, "dbgstat": "disabled-since-boot", "eat_nonce": nonces, - "submods": {"container": {"image_digest": "sha256:abc"}}, + "submods": { + "container": { + "image_digest": "sha256:abc", + "env_override": { + "SYFT_ENCLAVE_EMAIL": EMAIL, + "SYFT_ENCLAVE_DATA_OWNERS": ",".join(OWNERS), + }, + } + }, } monkeypatch.setattr( "syft_enclaves.attestation.confidential_space.id_token.verify_token", diff --git a/packages/syft-enclave/tests/test_attestation_confidential_space.py b/packages/syft-enclave/tests/test_attestation_confidential_space.py index 2b11b3fdee3..627c9d51b07 100644 --- a/packages/syft-enclave/tests/test_attestation_confidential_space.py +++ b/packages/syft-enclave/tests/test_attestation_confidential_space.py @@ -44,6 +44,11 @@ def _valid_claims(**overrides): "container": { "image_digest": FAKE_IMAGE_DIGEST, "image_reference": "docker.io/openmined/syft-enclave:latest", + # What the operator set as tee-env-* metadata. + "env_override": { + "SYFT_ENCLAVE_EMAIL": "enclave@openmined.org", + "SYFT_ENCLAVE_DATA_OWNERS": "do@openmined.org", + }, } }, } @@ -200,6 +205,8 @@ def test_runs_all_checks_after_failure(self, mock_verify): "debug_disabled", "version_match", "image_digest", + "enclave_email", + "data_owners", ] def test_multiple_failures_listed(self, mock_verify): @@ -232,6 +239,133 @@ def test_jwt_failure_fails_fast(self, mock_verify): assert check_names == ["jwt_signature"] +ATTACKER = "attacker@evil.com" +BUNDLE = {"identity": "enclave@openmined.org"} + + +def _token_with_env(env, published=PUBLISHED_CLAIMS, key="env_override"): + """A token committing to *published*, recording *env* under *key*.""" + claims = _valid_claims(eat_nonce=[EXPECTED_VERSION_NONCE, claims_digest(published)]) + container = claims["submods"]["container"] + del container["env_override"] + if env is not None: + container[key] = env + return claims + + +def _check(result, name): + return next(c for c in result.checks if c.name == name) + + +class TestDeployedFacts: + """The email and data owners come from the launcher, not the enclave. + + Any code in the container, a job included, can get the launcher to sign + a digest of a claims document of its choosing. Only env_override, which + the launcher measures before the container starts, says what the operator + deployed. + """ + + def test_owners_come_from_the_token_not_the_claims_document(self, mock_verify): + # The regression: a job mints a token over a document naming the + # owners the verifier expects, while the operator deployed others. + mock_verify.return_value = _token_with_env( + { + "SYFT_ENCLAVE_EMAIL": "enclave@openmined.org", + "SYFT_ENCLAVE_DATA_OWNERS": ATTACKER, + } + ) + with pytest.raises(AttestationError) as excinfo: + verify_attestation_token( + "fake-token", + policy=DEFAULT_TEST_POLICY, + published_claims=PUBLISHED_CLAIMS, + verbose=False, + ) + result = excinfo.value.result + assert _check(result, "data_owners").passed is False + assert ATTACKER in _check(result, "data_owners").detail + + def test_a_document_that_disagrees_with_the_token_binds_no_key(self, mock_verify): + # The deployed values are right, but the document carrying the key + # names other owners, so the enclave as deployed did not write it. + forged = build_claims( + "enclave@openmined.org", [ATTACKER], SYFT_VERSION, key_bundle=BUNDLE + ) + mock_verify.return_value = _token_with_env( + _valid_claims()["submods"]["container"]["env_override"], published=forged + ) + with pytest.raises(AttestationError) as excinfo: + verify_attestation_token( + "fake-token", + policy=DEFAULT_TEST_POLICY, + published_claims=forged, + verbose=False, + ) + result = excinfo.value.result + assert _check(result, "claims_binding").passed is False + assert _check(result, "data_owners").passed is True + assert result.verified_key_bundle is None + + @pytest.mark.parametrize( + "env, key", + [ + (None, "env_override"), + # An image ENV default lands in env too, so env is never read. + (_valid_claims()["submods"]["container"]["env_override"], "env"), + ], + ids=["no_override", "only_in_env"], + ) + def test_a_pinned_value_the_token_does_not_record_fails( + self, mock_verify, env, key + ): + mock_verify.return_value = _token_with_env(env, key=key) + with pytest.raises(AttestationError) as excinfo: + verify_attestation_token( + "fake-token", + policy=DEFAULT_TEST_POLICY, + published_claims=PUBLISHED_CLAIMS, + verbose=False, + ) + result = excinfo.value.result + for name in ("enclave_email", "data_owners"): + assert _check(result, name).passed is False + assert "records no" in _check(result, name).detail + + def test_an_unpinned_policy_skips_a_value_the_token_does_not_record( + self, mock_verify + ): + mock_verify.return_value = _token_with_env(None) + result = verify_attestation_token( + "fake-token", + policy=UNPINNED, + published_claims=PUBLISHED_CLAIMS, + verbose=False, + ) + assert _check(result, "enclave_email").passed is None + assert _check(result, "data_owners").passed is None + + def test_owner_spacing_and_order_do_not_matter(self, mock_verify): + owners = ["a@openmined.org", "b@openmined.org"] + published = build_claims("enclave@openmined.org", owners, SYFT_VERSION) + mock_verify.return_value = _token_with_env( + { + "SYFT_ENCLAVE_EMAIL": "enclave@openmined.org", + "SYFT_ENCLAVE_DATA_OWNERS": " b@openmined.org , a@openmined.org,", + }, + published=published, + ) + policy = AppraisalPolicy( + expected_image_digest=FAKE_IMAGE_DIGEST, + expected_data_owners=owners, + expected_email="enclave@openmined.org", + ) + result = verify_attestation_token( + "fake-token", policy=policy, published_claims=published, verbose=False + ) + assert result.all_passed() + + class TestAttestationResult: def test_all_passed(self): result = AttestationResult() diff --git a/packages/syft-enclave/tests/test_evidence.py b/packages/syft-enclave/tests/test_evidence.py index 16018faa56a..350a69ee471 100644 --- a/packages/syft-enclave/tests/test_evidence.py +++ b/packages/syft-enclave/tests/test_evidence.py @@ -175,7 +175,10 @@ def test_structure_claims_sections(self): "dbgstat": "disabled-since-boot", "eat_nonce": ["syft-0.1.0"], "submods": { - "container": {"image_digest": "sha256:abc"}, + "container": { + "image_digest": "sha256:abc", + "env_override": {"SYFT_ENCLAVE_EMAIL": "e@openmined.org"}, + }, "confidential_space": {"support_attributes": ["X"]}, }, "nvidia_gpu": {"mode": "on"}, @@ -183,6 +186,9 @@ def test_structure_claims_sections(self): ) assert structured["hardware"]["secboot"] is True assert structured["container"]["image_digest"] == "sha256:abc" + assert structured["container"]["env_override"] == { + "SYFT_ENCLAVE_EMAIL": "e@openmined.org" + } assert structured["gpu"] == {"mode": "on"} assert structured["confidential_space"] assert structured["eat_nonce"] == ["syft-0.1.0"]