From 7292283b4e4b6ec1f2e26fcb9192ab58f76e94e8 Mon Sep 17 00:00:00 2001 From: echobt <154886644+echobt@users.noreply.github.com> Date: Wed, 8 Jul 2026 10:35:32 +0000 Subject: [PATCH 01/12] feat(worker): define ExecutionProof Phala TDX tier + attestation schema Add the foundational Phala tier to the shared ExecutionProof envelope so the image emitter (M1) and validator/master verifier (M4) share one canonical schema (architecture.md sec 6/9): - schemas/worker.py: PHALA_TDX_TIER constant ('phala-tdx'); PhalaMeasurement (mrtd, rtmr0-3, compose_hash, os_image_hash) with canonical() static subset; PhalaAttestation payload (tdx_quote, event_log, report_data, measurement, vm_config) accepting the tdx_quote_b64/report_data_hex aliases. Widen ExecutionProof.tier to int | str so it carries the Phala value; tier 0/1/2 behavior is unchanged. - worker/proof.py: phala_report_data / phala_report_data_hex (sec-6 binding, sorted task_ids, rtmr3 excluded, 64-byte left-aligned field) and build_phala_execution_proof (reuses build_execution_proof; keeps the tier-0 worker signature so verify_execution_proof still validates it). No fork of the envelope; quote verification is layered on later (M4). --- .gitignore | 4 + src/base/schemas/worker.py | 66 +++++- src/base/worker/proof.py | 132 ++++++++++- tests/unit/test_worker_proof_phala.py | 313 ++++++++++++++++++++++++++ 4 files changed, 510 insertions(+), 5 deletions(-) create mode 100644 tests/unit/test_worker_proof_phala.py diff --git a/.gitignore b/.gitignore index 706837c3..f3d5a68e 100644 --- a/.gitignore +++ b/.gitignore @@ -18,3 +18,7 @@ build/ plan/ .omo/ +*.env +secrets/ +*.pem +*.key diff --git a/src/base/schemas/worker.py b/src/base/schemas/worker.py index 689f7c15..058ebc0e 100644 --- a/src/base/schemas/worker.py +++ b/src/base/schemas/worker.py @@ -5,7 +5,7 @@ from datetime import datetime from typing import Any -from pydantic import BaseModel, Field +from pydantic import AliasChoices, BaseModel, ConfigDict, Field class WorkerFaultView(BaseModel): @@ -137,6 +137,63 @@ class WorkerSignature(BaseModel): sig: str +#: Tier value marking an ExecutionProof as a Phala Intel TDX attestation +#: (architecture.md sec 6). Distinct from the integer worker-plane tiers so the +#: Phala envelope is self-describing without disturbing tier 0/1/2. +PHALA_TDX_TIER = "phala-tdx" + + +class PhalaMeasurement(BaseModel): + """TDX measurement registers for a canonical Phala eval image (arch sec 6/7). + + The static, allowlist-pinnable ``canonical_measurement`` is the subset + ``{mrtd, rtmr0, rtmr1, rtmr2, compose_hash, os_image_hash}``. ``rtmr3`` is the + runtime event-log register (it carries the live compose-hash event); it is + kept on the record for completeness but excluded from the pinned canonical + measurement bound into ``report_data``. + """ + + mrtd: str + rtmr0: str + rtmr1: str + rtmr2: str + rtmr3: str + compose_hash: str + os_image_hash: str + + def canonical(self) -> dict[str, str]: + """The static, allowlist-pinnable subset (excludes runtime ``rtmr3``).""" + + return { + "mrtd": self.mrtd, + "rtmr0": self.rtmr0, + "rtmr1": self.rtmr1, + "rtmr2": self.rtmr2, + "compose_hash": self.compose_hash, + "os_image_hash": self.os_image_hash, + } + + +class PhalaAttestation(BaseModel): + """Phala Intel TDX attestation payload carried by a Phala-tier ExecutionProof. + + Populates the ``attestation`` block of an ExecutionProof whose ``tier`` is + :data:`PHALA_TDX_TIER` (architecture.md sec 6). The architecture's + ``tdx_quote_b64`` / ``report_data_hex`` spellings are accepted as input + aliases; serialization always uses the canonical field names. + """ + + model_config = ConfigDict(populate_by_name=True) + + tdx_quote: str = Field(validation_alias=AliasChoices("tdx_quote", "tdx_quote_b64")) + event_log: list[dict[str, Any]] = Field(default_factory=list) + report_data: str = Field( + validation_alias=AliasChoices("report_data", "report_data_hex") + ) + measurement: PhalaMeasurement + vm_config: dict[str, Any] = Field(default_factory=dict) + + class ExecutionProof(BaseModel): """Proof envelope attached to every worker result (architecture 3.4). @@ -145,11 +202,14 @@ class ExecutionProof(BaseModel): pinned message (``sha256(f"{manifest_sha256}:{unit_id}")``). Tier 1 adds ``image_digest`` + a populated ``provider`` block; tier 2 adds a non-null ``attestation``. The base worker plane emits tier 0; prism fills the higher - tiers. + tiers. The Phala TDX tier (``tier == PHALA_TDX_TIER``) carries a + :class:`PhalaAttestation` payload in ``attestation`` (architecture.md sec 6); + hence ``tier`` accepts the string Phala value in addition to the integer + worker-plane tiers. """ version: int = 1 - tier: int = 0 + tier: int | str = 0 manifest_sha256: str image_digest: str | None = None provider: ProviderInfo | None = None diff --git a/src/base/worker/proof.py b/src/base/worker/proof.py index 3dc02d3e..bdd5b0d2 100644 --- a/src/base/worker/proof.py +++ b/src/base/worker/proof.py @@ -13,9 +13,18 @@ from __future__ import annotations import hashlib +import json +from collections.abc import Iterable, Mapping from typing import Any -from base.schemas.worker import ExecutionProof, ProviderInfo, WorkerSignature +from base.schemas.worker import ( + PHALA_TDX_TIER, + ExecutionProof, + PhalaAttestation, + PhalaMeasurement, + ProviderInfo, + WorkerSignature, +) from base.security.miner_auth import SignatureVerifier, verify_substrate_signature from base.validator.agent.signing import RequestSigner @@ -26,6 +35,13 @@ #: Result-payload key carrying the deterministic prism manifest hash. MANIFEST_SHA256_PAYLOAD_KEY = "manifest_sha256" +#: Domain-separation tag bound into a Phala attestation's ``report_data`` +#: (architecture.md sec 6). Prevents a quote minted for this purpose from being +#: repurposed under a different protocol tag. +PHALA_REPORT_DATA_TAG = "base-agent-challenge-v1" +#: Byte width of the TDX ``report_data`` field a quote carries. +PHALA_REPORT_DATA_BYTES = 64 + def execution_proof_signing_payload(*, manifest_sha256: str, unit_id: str) -> bytes: """The exact bytes an ExecutionProof signature covers (pinned format). @@ -42,7 +58,7 @@ def build_execution_proof( signer: RequestSigner, manifest_sha256: str, unit_id: str, - tier: int = 0, + tier: int | str = 0, provider: ProviderInfo | None = None, image_digest: str | None = None, attestation: dict[str, Any] | None = None, @@ -89,11 +105,123 @@ def verify_execution_proof( ) +def _canonical_measurement_mapping( + canonical_measurement: PhalaMeasurement | Mapping[str, Any], +) -> dict[str, str]: + """The static, allowlist-pinnable measurement subset (excludes ``rtmr3``).""" + + if isinstance(canonical_measurement, PhalaMeasurement): + return canonical_measurement.canonical() + static_fields = ("mrtd", "rtmr0", "rtmr1", "rtmr2", "compose_hash", "os_image_hash") + return {field: str(canonical_measurement[field]) for field in static_fields} + + +def phala_report_data( + *, + canonical_measurement: PhalaMeasurement | Mapping[str, Any], + agent_hash: str, + task_ids: Iterable[str], + scores_digest: str, + validator_nonce: str, +) -> bytes: + """The 32-byte ``report_data`` digest binding a Phala run (architecture sec 6). + + ``SHA256`` over a canonical (sorted-key, compact) JSON preimage of + ``{tag, canonical_measurement, agent_hash, sorted(task_ids), scores_digest, + validator_nonce}`` with ``tag == PHALA_REPORT_DATA_TAG``. ``task_ids`` are + sorted so the binding is order-independent; the measurement contributes only + its static, pinnable subset (``rtmr3`` is runtime and excluded). Every other + component is bound, so changing any one changes the digest. + + This is the single source of truth for the derivation shared by the image + emitter (M1) and the validator/master verifier (M4): both MUST call this + function rather than re-implementing sec 6. + """ + + preimage = { + "tag": PHALA_REPORT_DATA_TAG, + "canonical_measurement": _canonical_measurement_mapping(canonical_measurement), + "agent_hash": agent_hash, + "task_ids": sorted(task_ids), + "scores_digest": scores_digest, + "validator_nonce": validator_nonce, + } + encoded = json.dumps(preimage, sort_keys=True, separators=(",", ":")).encode() + return hashlib.sha256(encoded).digest() + + +def phala_report_data_hex( + *, + canonical_measurement: PhalaMeasurement | Mapping[str, Any], + agent_hash: str, + task_ids: Iterable[str], + scores_digest: str, + validator_nonce: str, +) -> str: + """``report_data`` as a 64-byte TDX field (128 hex chars, left-aligned). + + The 32-byte :func:`phala_report_data` digest occupies the leading bytes and + the trailing bytes are zero, matching Phala's observed left-aligned zero-pad + round-trip for the fixed-width quote field. + """ + + digest = phala_report_data( + canonical_measurement=canonical_measurement, + agent_hash=agent_hash, + task_ids=task_ids, + scores_digest=scores_digest, + validator_nonce=validator_nonce, + ) + return digest.ljust(PHALA_REPORT_DATA_BYTES, b"\x00").hex() + + +def build_phala_execution_proof( + *, + signer: RequestSigner, + manifest_sha256: str, + unit_id: str, + attestation: PhalaAttestation | Mapping[str, Any], + provider: ProviderInfo | None = None, + image_digest: str | None = None, +) -> ExecutionProof: + """Build a Phala-tier ExecutionProof carrying a TDX attestation payload. + + The envelope keeps the tier-0 worker sr25519 signature over the pinned + ``sha256(f"{manifest_sha256}:{unit_id}")`` message, so + :func:`verify_execution_proof` still validates the worker-signature layer; + ``tier`` is set to :data:`PHALA_TDX_TIER` and ``attestation`` carries the + serialized :class:`PhalaAttestation`. Cryptographic quote verification is + layered on top by the validator/master (milestone M4); this helper does not + fork a parallel envelope. + """ + + payload = ( + attestation + if isinstance(attestation, PhalaAttestation) + else PhalaAttestation.model_validate(dict(attestation)) + ) + return build_execution_proof( + signer=signer, + manifest_sha256=manifest_sha256, + unit_id=unit_id, + tier=PHALA_TDX_TIER, + provider=provider, + image_digest=image_digest, + attestation=payload.model_dump(mode="json"), + ) + + __all__ = [ "EXECUTION_PROOF_VERSION", "MANIFEST_SHA256_PAYLOAD_KEY", + "PHALA_REPORT_DATA_BYTES", + "PHALA_REPORT_DATA_TAG", + "PHALA_TDX_TIER", "PROOF_PAYLOAD_KEY", "build_execution_proof", + "build_phala_execution_proof", "execution_proof_signing_payload", + "phala_report_data", + "phala_report_data_hex", "verify_execution_proof", ] diff --git a/tests/unit/test_worker_proof_phala.py b/tests/unit/test_worker_proof_phala.py new file mode 100644 index 00000000..07dcff46 --- /dev/null +++ b/tests/unit/test_worker_proof_phala.py @@ -0,0 +1,313 @@ +"""Phala TDX tier for ExecutionProof: schema, report_data binding, build helper. + +Foundational schema shared by the image emitter (M1) and the verifier (M4) +(architecture.md sec 6/9). These tests pin the Phala tier value, the attestation +payload schema, the architecture-sec-6 ``report_data`` derivation, and the +``build_phala_execution_proof`` helper -- while asserting existing tier-0/1/2 +behavior is unchanged. +""" + +from __future__ import annotations + +import pytest +from pydantic import ValidationError + +from base.schemas.worker import ( + PHALA_TDX_TIER, + ExecutionProof, + PhalaAttestation, + PhalaMeasurement, + WorkerSignature, +) +from base.validator.agent.signing import KeypairRequestSigner +from base.worker.proof import ( + PHALA_REPORT_DATA_TAG, + build_phala_execution_proof, + phala_report_data, + phala_report_data_hex, + verify_execution_proof, +) + +MANIFEST = "a" * 64 +UNIT_ID = "submission-phala-1" + + +def _signer(uri: str = "//WorkerPhala") -> KeypairRequestSigner: + import bittensor as bt + + return KeypairRequestSigner(bt.Keypair.create_from_uri(uri)) + + +def _measurement(rtmr3: str = "d" * 96) -> PhalaMeasurement: + return PhalaMeasurement( + mrtd="a" * 96, + rtmr0="b0" * 48, + rtmr1="b1" * 48, + rtmr2="b2" * 48, + rtmr3=rtmr3, + compose_hash="c" * 64, + os_image_hash="e" * 64, + ) + + +def _attestation() -> PhalaAttestation: + return PhalaAttestation( + tdx_quote="0xdeadbeef", + event_log=[{"event": "compose-hash", "digest": "c" * 64}], + report_data="ab" * 64, + measurement=_measurement(), + vm_config={"vcpu": 1, "memory_mb": 2048}, + ) + + +def _report_data_kwargs() -> dict[str, object]: + return dict( + canonical_measurement=_measurement(), + agent_hash="f" * 64, + task_ids=["task-b", "task-a", "task-c"], + scores_digest="9" * 64, + validator_nonce="nonce-123", + ) + + +# --- tier constant + schema ------------------------------------------------ + + +def test_phala_tdx_tier_constant_value() -> None: + assert PHALA_TDX_TIER == "phala-tdx" + + +def test_phala_measurement_canonical_excludes_rtmr3() -> None: + canonical = _measurement().canonical() + assert set(canonical) == { + "mrtd", + "rtmr0", + "rtmr1", + "rtmr2", + "compose_hash", + "os_image_hash", + } + assert "rtmr3" not in canonical + + +def test_phala_attestation_round_trips() -> None: + att = _attestation() + dumped = att.model_dump(mode="json") + assert set(dumped) >= { + "tdx_quote", + "event_log", + "report_data", + "measurement", + "vm_config", + } + assert set(dumped["measurement"]) == { + "mrtd", + "rtmr0", + "rtmr1", + "rtmr2", + "rtmr3", + "compose_hash", + "os_image_hash", + } + assert PhalaAttestation.model_validate(dumped) == att + + +def test_phala_attestation_accepts_architecture_aliases() -> None: + att = PhalaAttestation.model_validate( + { + "tdx_quote_b64": "0xcafe", + "report_data_hex": "ff" * 64, + "measurement": _measurement().model_dump(), + } + ) + assert att.tdx_quote == "0xcafe" + assert att.report_data == "ff" * 64 + # serialization uses the canonical field names. + assert "tdx_quote" in att.model_dump() + assert "report_data" in att.model_dump() + + +@pytest.mark.parametrize("missing", ["tdx_quote", "report_data", "measurement"]) +def test_phala_attestation_requires_core_fields(missing: str) -> None: + payload = { + "tdx_quote": "0xdead", + "report_data": "ab" * 64, + "measurement": _measurement().model_dump(), + } + payload.pop(missing) + with pytest.raises(ValidationError): + PhalaAttestation.model_validate(payload) + + +# --- report_data derivation (architecture sec 6) --------------------------- + + +def test_report_data_is_deterministic_32_bytes() -> None: + a = phala_report_data(**_report_data_kwargs()) # type: ignore[arg-type] + b = phala_report_data(**_report_data_kwargs()) # type: ignore[arg-type] + assert a == b + assert isinstance(a, bytes) + assert len(a) == 32 + + +def test_report_data_tag_is_bound() -> None: + assert PHALA_REPORT_DATA_TAG == "base-agent-challenge-v1" + + +def test_report_data_task_ids_order_independent() -> None: + kwargs = _report_data_kwargs() + kwargs["task_ids"] = ["task-a", "task-b", "task-c"] + forward = phala_report_data(**kwargs) # type: ignore[arg-type] + kwargs["task_ids"] = ["task-c", "task-a", "task-b"] + shuffled = phala_report_data(**kwargs) # type: ignore[arg-type] + assert forward == shuffled + + +def test_report_data_ignores_rtmr3_runtime_register() -> None: + base_kwargs = _report_data_kwargs() + base_kwargs["canonical_measurement"] = _measurement(rtmr3="d" * 96) + other = _report_data_kwargs() + other["canonical_measurement"] = _measurement(rtmr3="7" * 96) + assert phala_report_data(**base_kwargs) == phala_report_data(**other) # type: ignore[arg-type] + + +def test_report_data_sensitive_to_every_bound_component() -> None: + base_digest = phala_report_data(**_report_data_kwargs()) # type: ignore[arg-type] + + changed_measurement = _report_data_kwargs() + m = _measurement() + m.compose_hash = "0" * 64 + changed_measurement["canonical_measurement"] = m + + changed_agent = _report_data_kwargs() + changed_agent["agent_hash"] = "0" * 64 + + changed_tasks = _report_data_kwargs() + changed_tasks["task_ids"] = ["task-a", "task-b"] + + changed_scores = _report_data_kwargs() + changed_scores["scores_digest"] = "0" * 64 + + changed_nonce = _report_data_kwargs() + changed_nonce["validator_nonce"] = "nonce-999" + + for perturbed in ( + changed_measurement, + changed_agent, + changed_tasks, + changed_scores, + changed_nonce, + ): + assert phala_report_data(**perturbed) != base_digest # type: ignore[arg-type] + + +def test_report_data_nonce_changes_digest() -> None: + a = _report_data_kwargs() + a["validator_nonce"] = "nonce-A" + b = _report_data_kwargs() + b["validator_nonce"] = "nonce-B" + assert phala_report_data(**a) != phala_report_data(**b) # type: ignore[arg-type] + + +def test_report_data_accepts_measurement_mapping() -> None: + as_model = phala_report_data(**_report_data_kwargs()) # type: ignore[arg-type] + kwargs = _report_data_kwargs() + kwargs["canonical_measurement"] = _measurement().model_dump() + as_mapping = phala_report_data(**kwargs) # type: ignore[arg-type] + assert as_model == as_mapping + + +def test_report_data_hex_is_64_byte_zero_padded_field() -> None: + digest = phala_report_data(**_report_data_kwargs()) # type: ignore[arg-type] + hex_field = phala_report_data_hex(**_report_data_kwargs()) # type: ignore[arg-type] + assert len(hex_field) == 128 + field_bytes = bytes.fromhex(hex_field) + assert len(field_bytes) == 64 + assert field_bytes[:32] == digest + assert field_bytes[32:] == b"\x00" * 32 + + +# --- build_phala_execution_proof ------------------------------------------- + + +def test_build_phala_execution_proof_sets_tier_and_attestation() -> None: + signer = _signer() + proof = build_phala_execution_proof( + signer=signer, + manifest_sha256=MANIFEST, + unit_id=UNIT_ID, + attestation=_attestation(), + ) + assert proof.tier == PHALA_TDX_TIER + assert proof.attestation is not None + assert proof.attestation["tdx_quote"] == "0xdeadbeef" + assert proof.attestation["measurement"]["mrtd"] == "a" * 96 + assert proof.worker_signature.worker_pubkey == signer.hotkey + + +def test_build_phala_execution_proof_accepts_attestation_mapping() -> None: + signer = _signer() + proof = build_phala_execution_proof( + signer=signer, + manifest_sha256=MANIFEST, + unit_id=UNIT_ID, + attestation={ + "tdx_quote_b64": "0xfeed", + "report_data_hex": "ab" * 64, + "measurement": _measurement().model_dump(), + }, + ) + assert proof.attestation is not None + assert proof.attestation["tdx_quote"] == "0xfeed" + + +def test_phala_proof_worker_signature_still_verifies() -> None: + signer = _signer() + proof = build_phala_execution_proof( + signer=signer, + manifest_sha256=MANIFEST, + unit_id=UNIT_ID, + attestation=_attestation(), + ) + assert verify_execution_proof(proof, unit_id=UNIT_ID) is True + assert verify_execution_proof(proof, unit_id="other-unit") is False + + +def test_phala_proof_round_trips_through_serialization() -> None: + signer = _signer() + proof = build_phala_execution_proof( + signer=signer, + manifest_sha256=MANIFEST, + unit_id=UNIT_ID, + attestation=_attestation(), + ) + restored = ExecutionProof.model_validate(proof.model_dump(mode="json")) + assert restored == proof + assert restored.tier == PHALA_TDX_TIER + att = PhalaAttestation.model_validate(restored.attestation) + assert att.measurement.compose_hash == "c" * 64 + + +# --- existing tier behavior unchanged -------------------------------------- + + +def test_execution_proof_int_tier_unchanged() -> None: + proof = ExecutionProof( + version=1, + tier=2, + manifest_sha256=MANIFEST, + worker_signature=WorkerSignature(worker_pubkey="pk", sig="0x00"), + ) + assert proof.tier == 2 + assert isinstance(proof.tier, int) + + +def test_execution_proof_string_tier_supported() -> None: + proof = ExecutionProof( + version=1, + tier=PHALA_TDX_TIER, + manifest_sha256=MANIFEST, + worker_signature=WorkerSignature(worker_pubkey="pk", sig="0x00"), + ) + assert proof.tier == "phala-tdx" + assert isinstance(proof.tier, str) From 879db88c08f3e9ea2e74e0b2410d4e0fd6538ccc Mon Sep 17 00:00:00 2001 From: echobt <154886644+echobt@users.noreply.github.com> Date: Wed, 8 Jul 2026 11:37:06 +0000 Subject: [PATCH 02/12] test(worker): pin cross-repo report_data golden vector (drift guard) Assert phala_report_data/_hex against a fixed input -> expected 64-byte hex that agent-challenge's self-contained sec-6 replica asserts against too, so the two implementations cannot silently drift (VAL-IMG-012). --- tests/unit/test_worker_proof_phala.py | 27 +++++++++++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/tests/unit/test_worker_proof_phala.py b/tests/unit/test_worker_proof_phala.py index 07dcff46..bfe2cce3 100644 --- a/tests/unit/test_worker_proof_phala.py +++ b/tests/unit/test_worker_proof_phala.py @@ -227,6 +227,33 @@ def test_report_data_hex_is_64_byte_zero_padded_field() -> None: assert field_bytes[32:] == b"\x00" * 32 +# --- pinned cross-repo golden vector (drift guard) -------------------------- +# This fixed input -> expected digest/field is asserted in BOTH repos against +# their independent sec-6 implementations, using the same inputs +# (``_report_data_kwargs`` here): +# base: base.worker.proof.phala_report_data(_hex) +# agent-challenge: agent_challenge.canonical.report_data.report_data(_hex) +# (tests/test_canonical_report_data.py::GOLDEN_DIGEST_HEX) +# The agent-challenge helper is a self-contained replica because base is not +# importable inside the canonical eval image; if either implementation drifts, +# one repo's pinned-vector test fails. Do NOT change one side without the other. +GOLDEN_REPORT_DATA_DIGEST_HEX = ( + "dd2c57688b55e25df20e292b71e1cb97d8501e9280e1dd3475b3e61c30e38cc2" +) +GOLDEN_REPORT_DATA_FIELD_HEX = GOLDEN_REPORT_DATA_DIGEST_HEX + "00" * 32 + + +def test_report_data_matches_pinned_cross_repo_vector() -> None: + assert ( + phala_report_data(**_report_data_kwargs()).hex() # type: ignore[arg-type] + == GOLDEN_REPORT_DATA_DIGEST_HEX + ) + assert ( + phala_report_data_hex(**_report_data_kwargs()) # type: ignore[arg-type] + == GOLDEN_REPORT_DATA_FIELD_HEX + ) + + # --- build_phala_execution_proof ------------------------------------------- From d1b94c5d22b9e3be0f318365394bf6ab68e271e9 Mon Sep 17 00:00:00 2001 From: echobt <154886644+echobt@users.noreply.github.com> Date: Wed, 8 Jul 2026 12:02:18 +0000 Subject: [PATCH 03/12] test(worker): pin cross-repo emitted-envelope conformance (Phala tier) Validate the exact attested-result envelope the agent-challenge canonical image emits against base's real ExecutionProof/PhalaAttestation models, and assert per-field omission is rejected. Drift guard for VAL-IMG-025/026 (mirrors the report_data golden-vector pin). --- tests/unit/test_worker_proof_phala.py | 68 +++++++++++++++++++++++++++ 1 file changed, 68 insertions(+) diff --git a/tests/unit/test_worker_proof_phala.py b/tests/unit/test_worker_proof_phala.py index bfe2cce3..a3a41eaa 100644 --- a/tests/unit/test_worker_proof_phala.py +++ b/tests/unit/test_worker_proof_phala.py @@ -338,3 +338,71 @@ def test_execution_proof_string_tier_supported() -> None: ) assert proof.tier == "phala-tdx" assert isinstance(proof.tier, str) + + +# --- cross-repo emitted-envelope conformance (drift guard) ------------------ +# The agent-challenge canonical image emits the attested-result envelope below +# (fixed input vector) on the ``execution_proof`` key of its +# ``BASE_BENCHMARK_RESULT=`` line. base's ``ExecutionProof``/``PhalaAttestation`` +# are not importable inside the lean image, so the emitter builds plain dicts and +# validates them with a self-contained conformance check; this pins the EXACT +# emitted shape against base's REAL models so the two cannot drift (VAL-IMG-025 / +# VAL-IMG-026). Regenerate via agent-challenge: +# agent_challenge.canonical.attested_result.emit_attested_benchmark_result(...) +# (see agent-challenge tests/test_canonical_attested_result.py). Do NOT change +# one side without the other. +GOLDEN_EMITTED_ENVELOPE: dict[str, object] = { + "version": 1, + "tier": "phala-tdx", + "manifest_sha256": "1" * 64, + "worker_signature": {"worker_pubkey": "", "sig": ""}, + "attestation": { + "tdx_quote": "abababababababab", + "event_log": [{"imr": 3, "event": "compose-hash", "digest": "c" * 64}], + "report_data": ( + "807faf7c13ac9798f2f841ad9f05949a19d6bb1ab0833f67101a5d3ce2bbaa1d" + + "00" * 32 + ), + "measurement": { + "mrtd": "a" * 96, + "rtmr0": "b0" * 48, + "rtmr1": "b1" * 48, + "rtmr2": "b2" * 48, + "rtmr3": "d" * 96, + "compose_hash": "c" * 64, + "os_image_hash": "e" * 64, + }, + "vm_config": {"vcpu": 1, "memory_mb": 2048}, + }, +} + + +def test_emitted_envelope_validates_against_execution_proof() -> None: + proof = ExecutionProof.model_validate(GOLDEN_EMITTED_ENVELOPE) + assert proof.tier == PHALA_TDX_TIER + assert proof.attestation is not None + att = PhalaAttestation.model_validate(proof.attestation) + assert att.tdx_quote == "abababababababab" + assert att.measurement.compose_hash == "c" * 64 + assert att.measurement.rtmr3 == "d" * 96 + # report_data is the 64-byte (128-hex) TDX field, left-aligned + zero-padded. + assert len(att.report_data) == 128 + assert att.report_data.endswith("00" * 32) + + +@pytest.mark.parametrize("missing", ["manifest_sha256", "worker_signature"]) +def test_emitted_envelope_missing_required_field_rejected(missing: str) -> None: + payload = {k: v for k, v in GOLDEN_EMITTED_ENVELOPE.items() if k != missing} + with pytest.raises(ValidationError): + ExecutionProof.model_validate(payload) + + +@pytest.mark.parametrize("missing", ["tdx_quote", "report_data", "measurement"]) +def test_emitted_attestation_missing_required_field_rejected(missing: str) -> None: + attestation = { + k: v + for k, v in GOLDEN_EMITTED_ENVELOPE["attestation"].items() # type: ignore[union-attr] + if k != missing + } + with pytest.raises(ValidationError): + PhalaAttestation.model_validate(attestation) From 863d1ed29087f02c37e4640a907eff171d3a8a64 Mon Sep 17 00:00:00 2001 From: echobt <154886644+echobt@users.noreply.github.com> Date: Wed, 8 Jul 2026 12:21:22 +0000 Subject: [PATCH 04/12] test(worker): fix mypy type-ignore code on Phala envelope conformance test Correct the type: ignore code from [union-attr] to [attr-defined] on the GOLDEN_EMITTED_ENVELOPE['attestation'].items() access; mypy infers the value as object (attr-defined), so the milestone typecheck gate (uv run mypy src tests) was failing. No behavior change. --- tests/unit/test_worker_proof_phala.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/unit/test_worker_proof_phala.py b/tests/unit/test_worker_proof_phala.py index a3a41eaa..0a408df4 100644 --- a/tests/unit/test_worker_proof_phala.py +++ b/tests/unit/test_worker_proof_phala.py @@ -401,7 +401,7 @@ def test_emitted_envelope_missing_required_field_rejected(missing: str) -> None: def test_emitted_attestation_missing_required_field_rejected(missing: str) -> None: attestation = { k: v - for k, v in GOLDEN_EMITTED_ENVELOPE["attestation"].items() # type: ignore[union-attr] + for k, v in GOLDEN_EMITTED_ENVELOPE["attestation"].items() # type: ignore[attr-defined] if k != missing } with pytest.raises(ValidationError): From 5ff5ac8bafe1ca300d483d19f6a01185939c922f Mon Sep 17 00:00:00 2001 From: echobt <154886644+echobt@users.noreply.github.com> Date: Wed, 8 Jul 2026 17:31:22 +0000 Subject: [PATCH 05/12] feat(worker): Phala-tier ExecutionProof quote verifier (accept valid, reject tamper, park on outage) Extend verify_execution_proof with a Phala-tier verifier (VAL-VERIFY-001..014): DCAP sig/TCB via QuoteVerifier (dcap-qvl), measurement reconstructed from the signed quote + event-log RTMR3 replay checked against a validator-owned allowlist, report_data bound to the validator's expected (agent_hash, sorted task_ids, scores_digest, fresh single-use nonce), and tier-0 worker signature still enforced (no cross-unit replay). A transient verifier outage raises VerifierUnavailableError so the caller parks (never accepts, never fraud-rejects). The agent_challenge adapter rebinds the lean image's placeholder worker_signature to the ingesting unit; trust root remains the attestation. --- .../agent/adapters/agent_challenge.py | 42 +- src/base/worker/phala_quote.py | 493 +++++++++++++ src/base/worker/phala_verify.py | 158 ++++ src/base/worker/proof.py | 145 +++- tests/unit/test_phala_quote.py | 195 +++++ tests/unit/test_phala_verify.py | 83 +++ tests/unit/test_worker_proof_phala_verify.py | 674 ++++++++++++++++++ 7 files changed, 1783 insertions(+), 7 deletions(-) create mode 100644 src/base/worker/phala_quote.py create mode 100644 src/base/worker/phala_verify.py create mode 100644 tests/unit/test_phala_quote.py create mode 100644 tests/unit/test_phala_verify.py create mode 100644 tests/unit/test_worker_proof_phala_verify.py diff --git a/src/base/validator/agent/adapters/agent_challenge.py b/src/base/validator/agent/adapters/agent_challenge.py index 8c2bcc1f..80670971 100644 --- a/src/base/validator/agent/adapters/agent_challenge.py +++ b/src/base/validator/agent/adapters/agent_challenge.py @@ -12,12 +12,15 @@ from collections.abc import Awaitable, Callable, Mapping from typing import Any +from base.schemas.worker import PHALA_TDX_TIER, ExecutionProof, WorkerSignature from base.validator.agent.executor import ( AssignmentContext, AssignmentExecutionError, ExecutionResult, ProgressCallback, ) +from base.validator.agent.signing import RequestSigner +from base.worker.proof import execution_proof_signing_payload CHALLENGE_SLUG = "agent-challenge" @@ -58,4 +61,41 @@ def _load_dispatch() -> DispatchFn: return dispatch_assignment -__all__ = ["CHALLENGE_SLUG", "AgentChallengeCycleExecutor"] +def rebind_worker_signature( + proof: ExecutionProof, *, signer: RequestSigner, unit_id: str +) -> ExecutionProof: + """Rebind a Phala-tier envelope's tier-0 worker signature to ``unit_id``. + + The canonical eval image runs a LEAN CVM image with no bittensor/sr25519 + keypair, so its emitted Phala-tier ``ExecutionProof`` carries only a + schema-valid PLACEHOLDER ``worker_signature`` (empty pubkey/sig). When the + validator ingests the attested ``BASE_BENCHMARK_RESULT`` payload it re-signs + the tier-0 layer over the pinned ``sha256(f"{manifest_sha256}:{unit_id}")`` + message with its OWN signer, so ``verify_execution_proof`` enforces a real + signature bound to this unit (no cross-unit replay, VAL-VERIFY-013). + + The trust root remains the **attestation** (the hardware-signed TDX quote), + which the validator verifies cryptographically; this rebind only anchors the + worker-plane envelope layer to a real key -- it never substitutes for quote + verification. The attestation payload is carried through unchanged. + """ + + if proof.tier != PHALA_TDX_TIER: + raise AssignmentExecutionError( + "rebind_worker_signature only applies to Phala-tier proofs" + ) + signature = signer.sign( + execution_proof_signing_payload( + manifest_sha256=proof.manifest_sha256, unit_id=unit_id + ) + ) + return proof.model_copy( + update={ + "worker_signature": WorkerSignature( + worker_pubkey=signer.hotkey, sig=signature + ) + } + ) + + +__all__ = ["CHALLENGE_SLUG", "AgentChallengeCycleExecutor", "rebind_worker_signature"] diff --git a/src/base/worker/phala_quote.py b/src/base/worker/phala_quote.py new file mode 100644 index 00000000..67b6f4d5 --- /dev/null +++ b/src/base/worker/phala_quote.py @@ -0,0 +1,493 @@ +"""TDX quote parsing, event-log replay, and signature/TCB verification (M4). + +The validator/master Phala-tier verifier (``verify_execution_proof``) must, before +accepting an attested result, confirm that its Intel TDX quote: + +1. is cryptographically valid on genuine hardware with an acceptable TCB posture + (delegated to a :class:`QuoteVerifier` -- ``dcap-qvl`` / Phala verify); and +2. carries the expected measurement registers + ``report_data`` (parsed here + structurally from the hardware-signed TD report); and +3. has an RTMR3 that a replay of its event log reproduces, yielding the canonical + ``compose_hash`` (so the compose is bound by content, not trusted by value). + +Structural parsing (register/report_data offsets) and the dstack ``cc-eventlog`` +RTMR replay follow the same hardware/dstack facts the agent-challenge in-CVM +key-release path uses, so a live dstack event log verifies here unmodified. The +crucial base-side addition is the **park vs reject** distinction: a *cryptographic* +failure raises :class:`QuoteVerificationError` (the verifier returns False), while +a *transient* dependency outage/timeout raises :class:`VerifierUnavailableError` +so the caller PARKS the result (never accepts, never fraud-rejects) -- VAL-VERIFY-014. +""" + +from __future__ import annotations + +import hashlib +import json +import subprocess +from collections.abc import Callable, Iterable, Mapping, Sequence +from dataclasses import dataclass, field +from typing import Any, Protocol, runtime_checkable + +# --------------------------------------------------------------------------- # +# TDX quote v4 (ECDSA) layout: 48-byte header + TD report (TDREPORT10) +# --------------------------------------------------------------------------- # +#: Quote header length (bytes) preceding the TD report body. +QUOTE_HEADER_LEN = 48 +#: SHA-384 measurement register width (bytes). +REGISTER_LEN = 48 +#: TDX ``report_data`` field width (bytes). +REPORT_DATA_LEN = 64 + +# Absolute byte offsets of the fields within a v4 quote (header + TD report body). +_MRTD_OFFSET = QUOTE_HEADER_LEN + 136 +_RTMR0_OFFSET = QUOTE_HEADER_LEN + 328 +_RTMR1_OFFSET = QUOTE_HEADER_LEN + 376 +_RTMR2_OFFSET = QUOTE_HEADER_LEN + 424 +_RTMR3_OFFSET = QUOTE_HEADER_LEN + 472 +_REPORT_DATA_OFFSET = QUOTE_HEADER_LEN + 520 + +#: Minimum length (bytes) a quote must have to contain the full TD report. +MIN_QUOTE_LEN = _REPORT_DATA_OFFSET + REPORT_DATA_LEN + +# --------------------------------------------------------------------------- # +# dstack event-log constants (cc-eventlog) +# --------------------------------------------------------------------------- # +#: dstack runtime event type (RTMR3 app events). Not a TCG-defined value. +DSTACK_RUNTIME_EVENT_TYPE = 0x08000001 +#: RTMR3 event name carrying the normalized compose hash. +COMPOSE_HASH_EVENT = "compose-hash" +#: RTMR3 event name carrying the KMS key-provider identity. +KEY_PROVIDER_EVENT = "key-provider" +#: The RTMR index whose event log binds the app compose (RTMR3). +APP_IMR = 3 + + +class QuoteError(Exception): + """Base error for quote parsing / verification failures (fail closed).""" + + +class QuoteStructureError(QuoteError): + """The quote bytes are malformed / too short to contain a TD report.""" + + +class QuoteVerificationError(QuoteError): + """The quote is cryptographically invalid, or its event log is inconsistent. + + A *cryptographic* verdict: the caller should REJECT the result (verify False). + """ + + +class VerifierUnavailableError(QuoteError): + """The verification dependency is transiently unreachable / timed out. + + NOT a cryptographic verdict: the caller should PARK the result (retry later), + never accept it and never permanently fraud-reject it (VAL-VERIFY-014). + """ + + +@dataclass(frozen=True) +class TdReport: + """Measurement registers + ``report_data`` read from a TDX quote's TD report.""" + + mrtd: str + rtmr0: str + rtmr1: str + rtmr2: str + rtmr3: str + report_data: bytes + + +def _coerce_register(value: bytes | str, *, field_name: str) -> bytes: + if isinstance(value, str): + try: + raw = bytes.fromhex(value) + except ValueError as exc: + raise ValueError(f"{field_name} is not valid hex: {exc}") from exc + elif isinstance(value, bytes | bytearray): + raw = bytes(value) + else: + raise TypeError( + f"{field_name} must be hex str or bytes, not {type(value).__name__}" + ) + if len(raw) != REGISTER_LEN: + raise ValueError(f"{field_name} must be {REGISTER_LEN} bytes, got {len(raw)}") + return raw + + +def parse_quote_hex(quote_hex: str) -> bytes: + """Decode a hex quote string to bytes; fail closed on malformed hex.""" + + if not isinstance(quote_hex, str) or not quote_hex: + raise QuoteStructureError("quote must be a non-empty hex string") + text = quote_hex.strip() + if text.startswith(("0x", "0X")): + text = text[2:] + try: + return bytes.fromhex(text) + except ValueError as exc: + raise QuoteStructureError(f"quote is not valid hex: {exc}") from exc + + +def parse_td_report(quote: bytes | str) -> TdReport: + """Parse the measurement registers + ``report_data`` from a TDX quote. + + Reads MRTD/RTMR0-3 (SHA-384) and the 64-byte ``report_data`` at their fixed + v4-quote offsets. Raises :class:`QuoteStructureError` if the quote is too + short to contain a full TD report. + """ + + raw = parse_quote_hex(quote) if isinstance(quote, str) else bytes(quote) + if len(raw) < MIN_QUOTE_LEN: + raise QuoteStructureError( + f"quote is {len(raw)} bytes, need at least {MIN_QUOTE_LEN} for a TD report" + ) + + def _reg(offset: int) -> str: + return raw[offset : offset + REGISTER_LEN].hex() + + return TdReport( + mrtd=_reg(_MRTD_OFFSET), + rtmr0=_reg(_RTMR0_OFFSET), + rtmr1=_reg(_RTMR1_OFFSET), + rtmr2=_reg(_RTMR2_OFFSET), + rtmr3=_reg(_RTMR3_OFFSET), + report_data=raw[_REPORT_DATA_OFFSET : _REPORT_DATA_OFFSET + REPORT_DATA_LEN], + ) + + +def os_image_hash_from_registers(mrtd: str, rtmr1: str, rtmr2: str) -> str: + """The dstack ``mr_image`` OS identity: ``sha256(MRTD ∥ RTMR1 ∥ RTMR2)``. + + Matches the canonical eval image's ``os_image_hash`` (dstack ``mr_image``) so + a quote's registers reproduce the value a validator pins in the allowlist. + """ + + preimage = ( + _coerce_register(mrtd, field_name="mrtd") + + _coerce_register(rtmr1, field_name="rtmr1") + + _coerce_register(rtmr2, field_name="rtmr2") + ) + return hashlib.sha256(preimage).hexdigest() + + +# --------------------------------------------------------------------------- # +# Event-log replay (RTMR3) +# --------------------------------------------------------------------------- # +def runtime_event_digest(event_name: str, payload: bytes) -> bytes: + """SHA-384 digest of a dstack runtime (RTMR3) event (cc-eventlog format). + + ``SHA384( event_type(le32) ∥ b":" ∥ event_name ∥ b":" ∥ payload )`` with + ``event_type == DSTACK_RUNTIME_EVENT_TYPE``. Binding the digest to the payload + means a forged compose-hash payload cannot keep a matching RTMR3. + """ + + hasher = hashlib.sha384() + hasher.update(DSTACK_RUNTIME_EVENT_TYPE.to_bytes(4, "little")) + hasher.update(b":") + hasher.update(event_name.encode("utf-8")) + hasher.update(b":") + hasher.update(payload) + return hasher.digest() + + +def _rtmr_extend(mr: bytes, digest: bytes) -> bytes: + return hashlib.sha384(mr + digest).digest() + + +@dataclass(frozen=True) +class Rtmr3Replay: + """Result of replaying an event log into RTMR3.""" + + rtmr3: str + compose_hash: str | None + key_provider: str | None + + +def _payload_bytes(entry: Mapping[str, Any]) -> bytes: + payload = entry.get("event_payload", "") + if isinstance(payload, bytes | bytearray): + return bytes(payload) + if not isinstance(payload, str): + raise QuoteVerificationError("event_payload must be a hex string or bytes") + if payload == "": + return b"" + try: + return bytes.fromhex(payload) + except ValueError as exc: + raise QuoteVerificationError(f"event_payload is not valid hex: {exc}") from exc + + +def replay_rtmr3(event_log: Iterable[Mapping[str, Any]]) -> Rtmr3Replay: + """Replay the RTMR3 (``imr == 3``) events, binding each digest to its payload. + + For dstack runtime events the digest is recomputed from the event contents + and must equal the logged digest (else the log is inconsistent and rejected), + then folded ``RTMR = SHA384(RTMR || digest)``. The ``compose-hash`` and + ``key-provider`` event payloads are surfaced for the allowlist check. + """ + + mr = bytes(REGISTER_LEN) + compose_hash: str | None = None + key_provider: str | None = None + + for entry in event_log: + if not isinstance(entry, Mapping): + raise QuoteVerificationError("event log entries must be objects") + if entry.get("imr") != APP_IMR: + continue + event_name = entry.get("event", "") + if not isinstance(event_name, str): + raise QuoteVerificationError("event 'event' name must be a string") + payload = _payload_bytes(entry) + event_type = entry.get("event_type") + + if event_type == DSTACK_RUNTIME_EVENT_TYPE: + digest = runtime_event_digest(event_name, payload) + logged = entry.get("digest") + if isinstance(logged, str) and logged: + try: + logged_bytes = bytes.fromhex(logged) + except ValueError as exc: + raise QuoteVerificationError( + f"event digest is not valid hex: {exc}" + ) from exc + if logged_bytes != digest: + raise QuoteVerificationError( + f"event '{event_name}' digest does not match its payload" + ) + else: + logged = entry.get("digest") + if not isinstance(logged, str) or not logged: + raise QuoteVerificationError( + "non-runtime RTMR3 event is missing a digest" + ) + try: + digest = bytes.fromhex(logged) + except ValueError as exc: + raise QuoteVerificationError( + f"event digest is not valid hex: {exc}" + ) from exc + if len(digest) != REGISTER_LEN: + raise QuoteVerificationError("RTMR3 event digest must be 48 bytes") + + mr = _rtmr_extend(mr, digest) + if event_name == COMPOSE_HASH_EVENT: + compose_hash = payload.hex() + elif event_name == KEY_PROVIDER_EVENT: + key_provider = payload.hex() + + return Rtmr3Replay( + rtmr3=mr.hex(), compose_hash=compose_hash, key_provider=key_provider + ) + + +# --------------------------------------------------------------------------- # +# Cryptographic verification (signature + TCB) +# --------------------------------------------------------------------------- # +@dataclass(frozen=True) +class QuoteVerdict: + """A verified quote's TCB posture (its bytes are Intel-signed / authentic).""" + + tcb_status: str + advisory_ids: tuple[str, ...] = () + + +@runtime_checkable +class QuoteVerifier(Protocol): + """Verifies a TDX quote's DCAP signature + certificate chain. + + Returns a :class:`QuoteVerdict` (carrying the TCB status) when the quote is + cryptographically valid, raises :class:`QuoteVerificationError` when it is not + (invalid signature / broken cert chain / malformed), and raises + :class:`VerifierUnavailableError` when the verification dependency itself is + transiently unreachable (so the caller parks rather than rejects). + """ + + def verify(self, quote_hex: str) -> QuoteVerdict: # pragma: no cover - protocol + ... + + +@dataclass +class DcapQvlVerifier: + """Trustless quote verification via the ``dcap-qvl`` CLI (Intel PCS). + + ``dcap-qvl`` verifies the quote against Intel collateral and reports the TCB + status; this adapter shells out and parses that verdict. ``runner`` is + injectable for testing. A non-zero exit / unparseable output is a cryptographic + rejection (:class:`QuoteVerificationError`); a timeout / missing binary / + subprocess failure is a transient outage (:class:`VerifierUnavailableError`, + park) -- the two are deliberately distinct (VAL-VERIFY-014). + """ + + binary: str = "dcap-qvl" + timeout: float = 30.0 + runner: Callable[[list[str]], subprocess.CompletedProcess[str]] | None = None + + def _run(self, args: list[str]) -> subprocess.CompletedProcess[str]: + if self.runner is not None: + return self.runner(args) + return subprocess.run( # pragma: no cover - real CLI invoked live (M6) + args, capture_output=True, text=True, timeout=self.timeout + ) + + def verify(self, quote_hex: str) -> QuoteVerdict: + args = [self.binary, "verify", "--hex", quote_hex] + try: + proc = self._run(args) + except FileNotFoundError as exc: + raise VerifierUnavailableError(f"dcap-qvl not available: {exc}") from exc + except subprocess.TimeoutExpired as exc: + raise VerifierUnavailableError(f"dcap-qvl timed out: {exc}") from exc + except subprocess.SubprocessError as exc: + raise VerifierUnavailableError( + f"dcap-qvl invocation failed: {exc}" + ) from exc + + if proc.returncode != 0: + detail = (proc.stderr or proc.stdout or "").strip() + raise QuoteVerificationError(f"dcap-qvl rejected the quote: {detail}") + + try: + report = json.loads(proc.stdout) + except json.JSONDecodeError as exc: + raise QuoteVerificationError(f"dcap-qvl output is not JSON: {exc}") from exc + if not isinstance(report, Mapping): + raise QuoteVerificationError("dcap-qvl output was not a JSON object") + + status = ( + report.get("status") or report.get("tcbStatus") or report.get("tcb_status") + ) + if not isinstance(status, str) or not status: + raise QuoteVerificationError("dcap-qvl output is missing a TCB status") + advisories = report.get("advisory_ids") or report.get("advisoryIDs") or [] + if not isinstance(advisories, Sequence) or isinstance(advisories, str | bytes): + advisories = [] + return QuoteVerdict( + tcb_status=status, advisory_ids=tuple(str(a) for a in advisories) + ) + + +@dataclass(frozen=True) +class StaticQuoteVerifier: + """A :class:`QuoteVerifier` with a fixed verdict (tests / offline harness). + + ``tcb_status`` is the posture reported for any quote; ``valid=False`` rejects + every quote as if its signature did not verify (:class:`QuoteVerificationError`); + ``unavailable=True`` models a transient verifier outage + (:class:`VerifierUnavailableError`, park). + """ + + tcb_status: str = "UpToDate" + valid: bool = True + unavailable: bool = False + advisory_ids: tuple[str, ...] = field(default_factory=tuple) + + def verify(self, quote_hex: str) -> QuoteVerdict: + if self.unavailable: + raise VerifierUnavailableError("quote verifier is unavailable") + if not self.valid: + raise QuoteVerificationError("quote signature verification failed") + return QuoteVerdict(tcb_status=self.tcb_status, advisory_ids=self.advisory_ids) + + +# --------------------------------------------------------------------------- # +# Quote / event-log assembly (tooling + test-vector generation) +# --------------------------------------------------------------------------- # +def build_tdx_quote( + *, + mrtd: bytes | str, + rtmr0: bytes | str, + rtmr1: bytes | str, + rtmr2: bytes | str, + rtmr3: bytes | str, + report_data: bytes | str, + header: bytes = b"", + tail: bytes = b"", +) -> str: + """Assemble a minimal v4-layout TDX quote (hex) with the given fields. + + The inverse of :func:`parse_td_report`: places each register + ``report_data`` + at its fixed offset. Used to generate deterministic test vectors / fixtures + (the real quote is produced by dstack ``get_quote`` on a live CVM). + """ + + buf = bytearray(MIN_QUOTE_LEN) + if header: + buf[: min(len(header), QUOTE_HEADER_LEN)] = header[:QUOTE_HEADER_LEN] + buf[_MRTD_OFFSET : _MRTD_OFFSET + REGISTER_LEN] = _coerce_register( + mrtd, field_name="mrtd" + ) + buf[_RTMR0_OFFSET : _RTMR0_OFFSET + REGISTER_LEN] = _coerce_register( + rtmr0, field_name="rtmr0" + ) + buf[_RTMR1_OFFSET : _RTMR1_OFFSET + REGISTER_LEN] = _coerce_register( + rtmr1, field_name="rtmr1" + ) + buf[_RTMR2_OFFSET : _RTMR2_OFFSET + REGISTER_LEN] = _coerce_register( + rtmr2, field_name="rtmr2" + ) + buf[_RTMR3_OFFSET : _RTMR3_OFFSET + REGISTER_LEN] = _coerce_register( + rtmr3, field_name="rtmr3" + ) + if isinstance(report_data, str): + rd = bytes.fromhex(report_data) + else: + rd = bytes(report_data) + rd = rd[:REPORT_DATA_LEN].ljust(REPORT_DATA_LEN, b"\x00") + buf[_REPORT_DATA_OFFSET : _REPORT_DATA_OFFSET + REPORT_DATA_LEN] = rd + return (bytes(buf) + tail).hex() + + +def build_rtmr3_event_log( + events: Sequence[tuple[str, bytes]], +) -> tuple[list[dict[str, Any]], str]: + """Build a consistent RTMR3 event log + its replayed RTMR3 hex. + + ``events`` is an ordered sequence of ``(event_name, payload_bytes)``; each is + emitted as a dstack runtime event with the correctly derived digest, and the + folded RTMR3 is returned so a caller can pin it into a matching quote. + """ + + log: list[dict[str, Any]] = [] + mr = bytes(REGISTER_LEN) + for name, payload in events: + digest = runtime_event_digest(name, payload) + log.append( + { + "imr": APP_IMR, + "event_type": DSTACK_RUNTIME_EVENT_TYPE, + "digest": digest.hex(), + "event": name, + "event_payload": payload.hex(), + } + ) + mr = _rtmr_extend(mr, digest) + return log, mr.hex() + + +__all__ = [ + "APP_IMR", + "COMPOSE_HASH_EVENT", + "DSTACK_RUNTIME_EVENT_TYPE", + "KEY_PROVIDER_EVENT", + "MIN_QUOTE_LEN", + "REGISTER_LEN", + "REPORT_DATA_LEN", + "DcapQvlVerifier", + "QuoteError", + "QuoteStructureError", + "QuoteVerdict", + "QuoteVerificationError", + "QuoteVerifier", + "Rtmr3Replay", + "StaticQuoteVerifier", + "TdReport", + "VerifierUnavailableError", + "build_rtmr3_event_log", + "build_tdx_quote", + "os_image_hash_from_registers", + "parse_quote_hex", + "parse_td_report", + "replay_rtmr3", + "runtime_event_digest", +] diff --git a/src/base/worker/phala_verify.py b/src/base/worker/phala_verify.py new file mode 100644 index 00000000..2ebfbc2c --- /dev/null +++ b/src/base/worker/phala_verify.py @@ -0,0 +1,158 @@ +"""Validator-owned measurement allowlist, nonce freshness, and run binding (M4). + +Supporting types for the Phala-tier verifier in +:func:`base.worker.proof.verify_execution_proof` (architecture.md sec 4 C4 / 6 / 7): + +* :class:`MeasurementAllowlist` -- the **validator-owned** set of canonical + measurements a genuine quote must match. Membership (not mere quote validity) + governs acceptance; an EMPTY allowlist fails closed (matches nothing), never + accept-any. No requester-supplied value can widen it. +* :class:`NonceValidator` / :class:`InMemoryNonceValidator` -- validator-issued, + single-use, TTL-bounded nonces. The nonce bound into a quote's ``report_data`` + must be one the validator issued and has not consumed/expired, defeating + quote replay and cross-submission repurposing. +* :class:`PhalaBinding` -- the run identity the VALIDATOR expects for a submission + (agent_hash, task_ids, scores_digest, validator_nonce) that ``report_data`` must + bind. It is the validator's own record, never trusted from the attested payload. +""" + +from __future__ import annotations + +import secrets +import time +from collections.abc import Callable, Iterable, Mapping +from dataclasses import dataclass, field +from enum import StrEnum +from typing import Protocol, runtime_checkable + +from base.schemas.worker import PhalaMeasurement + +#: The static, allowlist-pinnable measurement fields (excludes runtime ``rtmr3``). +CANONICAL_MEASUREMENT_FIELDS: tuple[str, ...] = ( + "mrtd", + "rtmr0", + "rtmr1", + "rtmr2", + "compose_hash", + "os_image_hash", +) + + +def canonical_measurement_mapping( + measurement: PhalaMeasurement | Mapping[str, object], +) -> dict[str, str]: + """The static canonical subset of a measurement as a ``{field: str}`` mapping.""" + + if isinstance(measurement, PhalaMeasurement): + return measurement.canonical() + return {field: str(measurement[field]) for field in CANONICAL_MEASUREMENT_FIELDS} + + +@dataclass(frozen=True) +class MeasurementAllowlist: + """A validator-owned set of canonical measurements a quote must match. + + Matching is exact across ALL canonical registers. An empty allowlist matches + nothing (fail closed) -- an unconfigured validator never accepts a quote. + """ + + entries: tuple[dict[str, str], ...] = () + + @classmethod + def from_measurements( + cls, measurements: Iterable[PhalaMeasurement | Mapping[str, object]] + ) -> MeasurementAllowlist: + return cls(tuple(canonical_measurement_mapping(m) for m in measurements)) + + def __bool__(self) -> bool: + return bool(self.entries) + + def contains(self, measurement: PhalaMeasurement | Mapping[str, object]) -> bool: + """Whether ``measurement`` exactly matches a canonical allowlist entry.""" + + candidate = canonical_measurement_mapping(measurement) + return any(candidate == entry for entry in self.entries) + + +class NonceState(StrEnum): + """Outcome of consuming a validator nonce.""" + + OK = "ok" + UNKNOWN = "unknown" + EXPIRED = "expired" + CONSUMED = "consumed" + + +@runtime_checkable +class NonceValidator(Protocol): + """Consumes a validator-issued nonce, reporting its freshness state. + + ``consume`` is single-use: the first consume of a known, unexpired nonce + returns :attr:`NonceState.OK` and marks it consumed; any later consume of the + same nonce returns :attr:`NonceState.CONSUMED`. + """ + + def consume(self, nonce: str) -> NonceState: # pragma: no cover - protocol + ... + + +@dataclass +class InMemoryNonceValidator: + """A single-use, TTL-bounded :class:`NonceValidator` (reference / tests). + + Nonces are 256-bit ``secrets``-random unless a value is supplied to + :meth:`issue`. ``clock`` is injectable so expiry can be exercised + deterministically. + """ + + ttl_seconds: float = 120.0 + clock: Callable[[], float] = time.time + _issued: dict[str, float] = field(default_factory=dict) + _consumed: set[str] = field(default_factory=set) + + def issue(self, nonce: str | None = None) -> str: + value = nonce if nonce is not None else secrets.token_urlsafe(32) + self._issued[value] = self.clock() + return value + + def is_outstanding(self, nonce: str) -> bool: + if not nonce or nonce in self._consumed or nonce not in self._issued: + return False + return (self.clock() - self._issued[nonce]) <= self.ttl_seconds + + def consume(self, nonce: str) -> NonceState: + if not nonce or nonce not in self._issued: + return NonceState.UNKNOWN + if nonce in self._consumed: + return NonceState.CONSUMED + if (self.clock() - self._issued[nonce]) > self.ttl_seconds: + return NonceState.EXPIRED + self._consumed.add(nonce) + return NonceState.OK + + +@dataclass(frozen=True) +class PhalaBinding: + """The run identity a validator expects a submission's ``report_data`` to bind. + + Sourced from the validator's OWN records (the submission's agent hash, the + accepted work unit's task ids, the scores the result reports, and the nonce + the validator issued) -- never trusted from the attested payload. ``task_ids`` + are compared order-insensitively (sorted before hashing). + """ + + agent_hash: str + task_ids: tuple[str, ...] + scores_digest: str + validator_nonce: str + + +__all__ = [ + "CANONICAL_MEASUREMENT_FIELDS", + "InMemoryNonceValidator", + "MeasurementAllowlist", + "NonceState", + "NonceValidator", + "PhalaBinding", + "canonical_measurement_mapping", +] diff --git a/src/base/worker/proof.py b/src/base/worker/proof.py index bdd5b0d2..7fc85ec7 100644 --- a/src/base/worker/proof.py +++ b/src/base/worker/proof.py @@ -14,9 +14,11 @@ import hashlib import json -from collections.abc import Iterable, Mapping +from collections.abc import Collection, Iterable, Mapping from typing import Any +from pydantic import ValidationError + from base.schemas.worker import ( PHALA_TDX_TIER, ExecutionProof, @@ -27,9 +29,27 @@ ) from base.security.miner_auth import SignatureVerifier, verify_substrate_signature from base.validator.agent.signing import RequestSigner +from base.worker.phala_quote import ( + QuoteStructureError, + QuoteVerificationError, + QuoteVerifier, + os_image_hash_from_registers, + parse_td_report, + replay_rtmr3, +) +from base.worker.phala_verify import ( + MeasurementAllowlist, + NonceState, + NonceValidator, + PhalaBinding, +) EXECUTION_PROOF_VERSION = 1 +#: TCB statuses the Phala-tier verifier accepts by default (architecture sec 7). +#: A quote whose collateral reports any other posture is rejected. +ACCEPTABLE_TCB_DEFAULT: tuple[str, ...] = ("UpToDate",) + #: Result-payload key carrying the serialized :class:`ExecutionProof`. PROOF_PAYLOAD_KEY = "execution_proof" #: Result-payload key carrying the deterministic prism manifest hash. @@ -89,20 +109,132 @@ def verify_execution_proof( proof: ExecutionProof, *, unit_id: str, + expected_binding: PhalaBinding | None = None, + quote_verifier: QuoteVerifier | None = None, + allowlist: MeasurementAllowlist | None = None, + nonce_validator: NonceValidator | None = None, + acceptable_tcb: Collection[str] = ACCEPTABLE_TCB_DEFAULT, signature_verifier: SignatureVerifier = verify_substrate_signature, ) -> bool: - """Whether ``proof``'s worker signature verifies for ``unit_id`` (sr25519). - - Rejects a proof presented with a DIFFERENT ``unit_id`` than the one signed, - so a proof cannot be replayed across units. + """Whether ``proof`` verifies for ``unit_id``. + + Without ``expected_binding`` this is the tier-0 check only: the worker sr25519 + signature over ``sha256(f"{manifest_sha256}:{unit_id}")`` must verify, and a + proof signed for a DIFFERENT unit is rejected (no cross-unit replay). This is + the unchanged behavior every existing (tier 0/1/2) caller relies on. + + With ``expected_binding`` the full **Phala-tier** verification runs on top of + the tier-0 check (architecture sec 4 C4 / 6 / 7): the proof must be the Phala + tier with an attestation whose TDX quote (a) is DCAP-valid with an acceptable + TCB, (b) reconstructs a measurement in the validator ``allowlist``, (c) whose + ``report_data`` binds exactly ``expected_binding``, and (d) whose nonce is a + fresh, validator-issued, unconsumed one. The tier-0 worker signature is STILL + required, so the attested envelope's trust root is the quote while a real + (validator-rebound) signature prevents cross-unit replay -- an absent/wrong + signature is rejected even for a valid quote (VAL-VERIFY-013). + + Returns ``True`` to ACCEPT and ``False`` to REJECT. When the quote-verification + dependency is transiently unavailable it raises + :class:`base.worker.phala_quote.VerifierUnavailableError` so the caller PARKS + the result rather than accepting or fraud-rejecting it (VAL-VERIFY-014). """ payload = execution_proof_signing_payload( manifest_sha256=proof.manifest_sha256, unit_id=unit_id ) - return signature_verifier( + signature_ok = signature_verifier( proof.worker_signature.worker_pubkey, payload, proof.worker_signature.sig ) + if expected_binding is None: + return signature_ok + + if not signature_ok: + return False + if proof.tier != PHALA_TDX_TIER or proof.attestation is None: + return False + if quote_verifier is None or allowlist is None or nonce_validator is None: + return False + try: + attestation = PhalaAttestation.model_validate(proof.attestation) + except ValidationError: + return False + return _verify_phala_attestation( + attestation, + expected_binding=expected_binding, + quote_verifier=quote_verifier, + allowlist=allowlist, + nonce_validator=nonce_validator, + acceptable_tcb=acceptable_tcb, + ) + + +def _verify_phala_attestation( + attestation: PhalaAttestation, + *, + expected_binding: PhalaBinding, + quote_verifier: QuoteVerifier, + allowlist: MeasurementAllowlist, + nonce_validator: NonceValidator, + acceptable_tcb: Collection[str], +) -> bool: + """Verify a Phala attestation against the validator's expectations. + + Fail-closed and conjunctive: every check must pass. The trust root is the + hardware-signed quote -- the measurement and ``report_data`` are read from the + parsed TD report (not the untrusted attestation block), and the event log must + replay to the quote's signed RTMR3. Raises + :class:`base.worker.phala_quote.VerifierUnavailableError` (park) if the quote + verifier is transiently unavailable. + """ + + if not allowlist: + return False + if not expected_binding.validator_nonce: + return False + + try: + report = parse_td_report(attestation.tdx_quote) + except QuoteStructureError: + return False + + try: + verdict = quote_verifier.verify(attestation.tdx_quote) + except QuoteVerificationError: + return False + if verdict.tcb_status not in acceptable_tcb: + return False + + try: + replay = replay_rtmr3(attestation.event_log) + except QuoteVerificationError: + return False + if replay.rtmr3 != report.rtmr3 or replay.compose_hash is None: + return False + + measurement = { + "mrtd": report.mrtd, + "rtmr0": report.rtmr0, + "rtmr1": report.rtmr1, + "rtmr2": report.rtmr2, + "compose_hash": replay.compose_hash, + "os_image_hash": os_image_hash_from_registers( + report.mrtd, report.rtmr1, report.rtmr2 + ), + } + if not allowlist.contains(measurement): + return False + + expected_report_data = phala_report_data_hex( + canonical_measurement=measurement, + agent_hash=expected_binding.agent_hash, + task_ids=expected_binding.task_ids, + scores_digest=expected_binding.scores_digest, + validator_nonce=expected_binding.validator_nonce, + ) + if report.report_data != bytes.fromhex(expected_report_data): + return False + + return nonce_validator.consume(expected_binding.validator_nonce) is NonceState.OK def _canonical_measurement_mapping( @@ -212,6 +344,7 @@ def build_phala_execution_proof( __all__ = [ + "ACCEPTABLE_TCB_DEFAULT", "EXECUTION_PROOF_VERSION", "MANIFEST_SHA256_PAYLOAD_KEY", "PHALA_REPORT_DATA_BYTES", diff --git a/tests/unit/test_phala_quote.py b/tests/unit/test_phala_quote.py new file mode 100644 index 00000000..5a7843f2 --- /dev/null +++ b/tests/unit/test_phala_quote.py @@ -0,0 +1,195 @@ +"""Unit tests for the base-side TDX quote primitives (M4 verifier support). + +Pins the structural parser, the dstack RTMR3 event-log replay, the OS-image +identity, and the ``dcap-qvl`` adapter's accept / reject / park mapping used by +the Phala-tier verifier. +""" + +from __future__ import annotations + +import hashlib +import json +import subprocess + +import pytest + +from base.worker.phala_quote import ( + DcapQvlVerifier, + QuoteStructureError, + QuoteVerificationError, + StaticQuoteVerifier, + VerifierUnavailableError, + build_rtmr3_event_log, + build_tdx_quote, + os_image_hash_from_registers, + parse_td_report, + replay_rtmr3, + runtime_event_digest, +) + +MRTD = "a1" * 48 +RTMR0 = "b0" * 48 +RTMR1 = "b1" * 48 +RTMR2 = "b2" * 48 + + +def _quote(report_data: str = "ab" * 64) -> str: + _log, rtmr3 = build_rtmr3_event_log([("compose-hash", bytes.fromhex("c3" * 32))]) + return build_tdx_quote( + mrtd=MRTD, + rtmr0=RTMR0, + rtmr1=RTMR1, + rtmr2=RTMR2, + rtmr3=rtmr3, + report_data=report_data, + ) + + +def test_parse_td_report_round_trips_registers() -> None: + report = parse_td_report(_quote(report_data="ff" * 32)) + assert report.mrtd == MRTD + assert report.rtmr0 == RTMR0 + assert report.rtmr1 == RTMR1 + assert report.rtmr2 == RTMR2 + assert report.report_data == bytes.fromhex("ff" * 32).ljust(64, b"\x00") + + +def test_parse_td_report_accepts_0x_prefix() -> None: + report = parse_td_report("0x" + _quote()) + assert report.mrtd == MRTD + + +@pytest.mark.parametrize("bad", ["", "zz", "ab" * 4]) +def test_parse_td_report_rejects_malformed_or_short(bad: str) -> None: + with pytest.raises(QuoteStructureError): + parse_td_report(bad) + + +def test_os_image_hash_matches_sha256_of_registers() -> None: + expected = hashlib.sha256( + bytes.fromhex(MRTD) + bytes.fromhex(RTMR1) + bytes.fromhex(RTMR2) + ).hexdigest() + assert os_image_hash_from_registers(MRTD, RTMR1, RTMR2) == expected + + +def test_replay_rtmr3_surfaces_compose_and_key_provider() -> None: + compose = bytes.fromhex("c3" * 32) + provider = b"kms-root" + log, rtmr3 = build_rtmr3_event_log( + [("compose-hash", compose), ("key-provider", provider)] + ) + replay = replay_rtmr3(log) + assert replay.rtmr3 == rtmr3 + assert replay.compose_hash == compose.hex() + assert replay.key_provider == provider.hex() + + +def test_replay_rtmr3_ignores_non_app_imr_entries() -> None: + log, rtmr3 = build_rtmr3_event_log([("compose-hash", bytes.fromhex("c3" * 32))]) + noise = [{"imr": 0, "event": "boot", "digest": "aa" * 48}, *log] + assert replay_rtmr3(noise).rtmr3 == rtmr3 + + +def test_replay_rtmr3_rejects_inconsistent_digest() -> None: + log, _rtmr3 = build_rtmr3_event_log([("compose-hash", bytes.fromhex("c3" * 32))]) + log[0]["event_payload"] = "ee" * 32 # digest no longer matches payload + with pytest.raises(QuoteVerificationError): + replay_rtmr3(log) + + +def test_replay_rtmr3_rejects_non_mapping_entry() -> None: + with pytest.raises(QuoteVerificationError): + replay_rtmr3([["not", "a", "mapping"]]) # type: ignore[list-item] + + +def test_replay_rtmr3_rejects_bad_payload_hex() -> None: + from base.worker.phala_quote import APP_IMR, DSTACK_RUNTIME_EVENT_TYPE + + bad = [ + { + "imr": APP_IMR, + "event_type": DSTACK_RUNTIME_EVENT_TYPE, + "event": "compose-hash", + "event_payload": "zz", + } + ] + with pytest.raises(QuoteVerificationError): + replay_rtmr3(bad) + + +def test_replay_rtmr3_accepts_non_runtime_event_with_raw_digest() -> None: + digest = runtime_event_digest("compose-hash", bytes.fromhex("c3" * 32)) + entry = { + "imr": 3, + "event_type": 1, + "event": "some-tcg-event", + "digest": digest.hex(), + } + replay = replay_rtmr3([entry]) + assert len(bytes.fromhex(replay.rtmr3)) == 48 + + +def test_build_tdx_quote_honors_header_and_tail() -> None: + quote = build_tdx_quote( + mrtd=MRTD, + rtmr0=RTMR0, + rtmr1=RTMR1, + rtmr2=RTMR2, + rtmr3="d3" * 48, + report_data="ab" * 32, + header=b"\x01" * 48, + tail=b"\x09\x09", + ) + raw = bytes.fromhex(quote) + assert raw[:48] == b"\x01" * 48 + assert raw.endswith(b"\x09\x09") + + +def test_static_verifier_default_is_uptodate() -> None: + assert StaticQuoteVerifier().verify("00" * 8).tcb_status == "UpToDate" + + +def test_dcap_qvl_parses_advisories_and_alt_status_keys() -> None: + def runner(args: list[str]) -> subprocess.CompletedProcess[str]: + body = json.dumps({"tcbStatus": "UpToDate", "advisoryIDs": ["INTEL-SA-1"]}) + return subprocess.CompletedProcess(args, returncode=0, stdout=body, stderr="") + + verdict = DcapQvlVerifier(runner=runner).verify("00" * 8) + assert verdict.tcb_status == "UpToDate" + assert verdict.advisory_ids == ("INTEL-SA-1",) + + +def test_dcap_qvl_unparseable_json_is_reject() -> None: + def runner(args: list[str]) -> subprocess.CompletedProcess[str]: + return subprocess.CompletedProcess( + args, returncode=0, stdout="not json", stderr="" + ) + + with pytest.raises(QuoteVerificationError): + DcapQvlVerifier(runner=runner).verify("00" * 8) + + +def test_dcap_qvl_non_object_json_is_reject() -> None: + def runner(args: list[str]) -> subprocess.CompletedProcess[str]: + return subprocess.CompletedProcess( + args, returncode=0, stdout="[1,2,3]", stderr="" + ) + + with pytest.raises(QuoteVerificationError): + DcapQvlVerifier(runner=runner).verify("00" * 8) + + +def test_dcap_qvl_missing_status_is_reject() -> None: + def runner(args: list[str]) -> subprocess.CompletedProcess[str]: + return subprocess.CompletedProcess(args, returncode=0, stdout="{}", stderr="") + + with pytest.raises(QuoteVerificationError): + DcapQvlVerifier(runner=runner).verify("00" * 8) + + +def test_dcap_qvl_generic_subprocess_error_is_park() -> None: + def runner(args: list[str]) -> subprocess.CompletedProcess[str]: + raise subprocess.SubprocessError("boom") + + with pytest.raises(VerifierUnavailableError): + DcapQvlVerifier(runner=runner).verify("00" * 8) diff --git a/tests/unit/test_phala_verify.py b/tests/unit/test_phala_verify.py new file mode 100644 index 00000000..6eb1b159 --- /dev/null +++ b/tests/unit/test_phala_verify.py @@ -0,0 +1,83 @@ +"""Unit tests for the validator-owned allowlist + nonce primitives (M4). + +Pins measurement allowlist membership + fail-closed empties, and the single-use / +TTL nonce lifecycle the Phala-tier verifier relies on. +""" + +from __future__ import annotations + +from base.schemas.worker import PhalaMeasurement +from base.worker.phala_verify import ( + InMemoryNonceValidator, + MeasurementAllowlist, + NonceState, + canonical_measurement_mapping, +) + +MEASUREMENT = { + "mrtd": "a" * 96, + "rtmr0": "b0" * 48, + "rtmr1": "b1" * 48, + "rtmr2": "b2" * 48, + "compose_hash": "c" * 64, + "os_image_hash": "e" * 64, +} + + +def _phala_measurement() -> PhalaMeasurement: + return PhalaMeasurement(rtmr3="d" * 96, **MEASUREMENT) + + +def test_canonical_mapping_from_model_excludes_rtmr3() -> None: + mapping = canonical_measurement_mapping(_phala_measurement()) + assert mapping == MEASUREMENT + assert "rtmr3" not in mapping + + +def test_allowlist_contains_exact_match() -> None: + allowlist = MeasurementAllowlist.from_measurements([MEASUREMENT]) + assert allowlist.contains(MEASUREMENT) is True + assert allowlist.contains(_phala_measurement()) is True + + +def test_allowlist_rejects_single_register_mismatch() -> None: + allowlist = MeasurementAllowlist.from_measurements([MEASUREMENT]) + off = {**MEASUREMENT, "compose_hash": "0" * 64} + assert allowlist.contains(off) is False + + +def test_empty_allowlist_fails_closed() -> None: + empty = MeasurementAllowlist() + assert bool(empty) is False + assert empty.contains(MEASUREMENT) is False + + +def test_nonce_single_use_lifecycle() -> None: + nonces = InMemoryNonceValidator() + nonce = nonces.issue() + assert nonces.is_outstanding(nonce) is True + assert nonces.consume(nonce) is NonceState.OK + assert nonces.is_outstanding(nonce) is False + assert nonces.consume(nonce) is NonceState.CONSUMED + + +def test_nonce_unknown_and_empty() -> None: + nonces = InMemoryNonceValidator() + assert nonces.consume("never-issued") is NonceState.UNKNOWN + assert nonces.consume("") is NonceState.UNKNOWN + assert nonces.is_outstanding("") is False + + +def test_nonce_expiry() -> None: + clock = {"t": 0.0} + nonces = InMemoryNonceValidator(ttl_seconds=10, clock=lambda: clock["t"]) + nonce = nonces.issue() + clock["t"] = 100.0 + assert nonces.is_outstanding(nonce) is False + assert nonces.consume(nonce) is NonceState.EXPIRED + + +def test_issue_accepts_explicit_value() -> None: + nonces = InMemoryNonceValidator() + assert nonces.issue("fixed-nonce") == "fixed-nonce" + assert nonces.consume("fixed-nonce") is NonceState.OK diff --git a/tests/unit/test_worker_proof_phala_verify.py b/tests/unit/test_worker_proof_phala_verify.py new file mode 100644 index 00000000..1364be69 --- /dev/null +++ b/tests/unit/test_worker_proof_phala_verify.py @@ -0,0 +1,674 @@ +"""Phala-tier quote verifier for ExecutionProof (M4, VAL-VERIFY-001..014). + +Black-box behavioral tests for the validator/master Phala-tier verifier added to +``verify_execution_proof``: it accepts a wholly-valid attested envelope and +rejects every tamper / mismatch / stale / repurposed case, keeps the tier-0 +worker-signature + cross-unit-replay checks in force on the Phala tier, and +PARKS (raises, does not accept nor fraud-reject) when the quote-verification +dependency is transiently unavailable. + +Quotes are assembled offline with the base ``phala_quote`` helpers (the inverse +of the parser); the DCAP signature/TCB layer is modeled with an injectable +:class:`StaticQuoteVerifier`, and a fake-runner :class:`DcapQvlVerifier` test +pins the CLI accept/reject/park mapping. A real ``dcap-qvl`` run is an M6 live +assertion. +""" + +from __future__ import annotations + +import hashlib +import json +import subprocess + +import pytest + +from base.schemas.worker import ( + PHALA_TDX_TIER, + ExecutionProof, + PhalaAttestation, + PhalaMeasurement, + WorkerSignature, +) +from base.validator.agent.adapters.agent_challenge import rebind_worker_signature +from base.validator.agent.signing import KeypairRequestSigner +from base.worker.phala_quote import ( + DcapQvlVerifier, + QuoteVerificationError, + StaticQuoteVerifier, + VerifierUnavailableError, + build_rtmr3_event_log, + build_tdx_quote, +) +from base.worker.phala_verify import ( + InMemoryNonceValidator, + MeasurementAllowlist, + PhalaBinding, +) +from base.worker.proof import ( + PHALA_REPORT_DATA_TAG, + build_phala_execution_proof, + phala_report_data_hex, + verify_execution_proof, +) + +MANIFEST = "a" * 64 +UNIT_ID = "submission-verify-1" + +MRTD = "a1" * 48 +RTMR0 = "b0" * 48 +RTMR1 = "b1" * 48 +RTMR2 = "b2" * 48 +COMPOSE_PAYLOAD = bytes.fromhex("c3" * 32) + +AGENT_HASH = "f0" * 32 +TASK_IDS = ("task-b", "task-a", "task-c") +SCORES_DIGEST = "9a" * 32 + + +def _signer(uri: str = "//WorkerVerify") -> KeypairRequestSigner: + import bittensor as bt + + return KeypairRequestSigner(bt.Keypair.create_from_uri(uri)) + + +def _os_image_hash(mrtd: str, rtmr1: str, rtmr2: str) -> str: + preimage = bytes.fromhex(mrtd) + bytes.fromhex(rtmr1) + bytes.fromhex(rtmr2) + return hashlib.sha256(preimage).hexdigest() + + +def _build_attestation( + *, + agent_hash: str = AGENT_HASH, + task_ids: tuple[str, ...] = TASK_IDS, + scores_digest: str = SCORES_DIGEST, + validator_nonce: str, + compose_payload: bytes = COMPOSE_PAYLOAD, + mrtd: str = MRTD, + rtmr0: str = RTMR0, + rtmr1: str = RTMR1, + rtmr2: str = RTMR2, + report_data_hex: str | None = None, +) -> tuple[PhalaAttestation, dict[str, str]]: + """A self-consistent Phala attestation + its reconstructed canonical measurement.""" + + event_log, rtmr3 = build_rtmr3_event_log([("compose-hash", compose_payload)]) + compose_hash = compose_payload.hex() + os_image_hash = _os_image_hash(mrtd, rtmr1, rtmr2) + measurement = { + "mrtd": mrtd, + "rtmr0": rtmr0, + "rtmr1": rtmr1, + "rtmr2": rtmr2, + "compose_hash": compose_hash, + "os_image_hash": os_image_hash, + } + if report_data_hex is None: + report_data_hex = phala_report_data_hex( + canonical_measurement=measurement, + agent_hash=agent_hash, + task_ids=task_ids, + scores_digest=scores_digest, + validator_nonce=validator_nonce, + ) + quote = build_tdx_quote( + mrtd=mrtd, + rtmr0=rtmr0, + rtmr1=rtmr1, + rtmr2=rtmr2, + rtmr3=rtmr3, + report_data=report_data_hex, + ) + attestation = PhalaAttestation( + tdx_quote=quote, + event_log=event_log, + report_data=report_data_hex, + measurement=PhalaMeasurement( + mrtd=mrtd, + rtmr0=rtmr0, + rtmr1=rtmr1, + rtmr2=rtmr2, + rtmr3=rtmr3, + compose_hash=compose_hash, + os_image_hash=os_image_hash, + ), + vm_config={"vcpu": 1, "memory_mb": 2048}, + ) + return attestation, measurement + + +def _allowlist(measurement: dict[str, str]) -> MeasurementAllowlist: + return MeasurementAllowlist.from_measurements([measurement]) + + +def _fixture( + *, + unit_id: str = UNIT_ID, + agent_hash: str = AGENT_HASH, + task_ids: tuple[str, ...] = TASK_IDS, + scores_digest: str = SCORES_DIGEST, +): + """A fully-valid (proof, binding, verifier, allowlist, nonce store) tuple.""" + + nonces = InMemoryNonceValidator() + nonce = nonces.issue() + attestation, measurement = _build_attestation( + agent_hash=agent_hash, + task_ids=task_ids, + scores_digest=scores_digest, + validator_nonce=nonce, + ) + proof = build_phala_execution_proof( + signer=_signer(), + manifest_sha256=MANIFEST, + unit_id=unit_id, + attestation=attestation, + ) + binding = PhalaBinding( + agent_hash=agent_hash, + task_ids=task_ids, + scores_digest=scores_digest, + validator_nonce=nonce, + ) + return proof, binding, StaticQuoteVerifier(), _allowlist(measurement), nonces + + +def _verify(proof, binding, verifier, allowlist, nonces, *, unit_id: str = UNIT_ID): + return verify_execution_proof( + proof, + unit_id=unit_id, + expected_binding=binding, + quote_verifier=verifier, + allowlist=allowlist, + nonce_validator=nonces, + ) + + +# --- VAL-VERIFY-001: fully valid attested result is ACCEPTED ---------------- + + +def test_valid_attested_result_accepts() -> None: + proof, binding, verifier, allowlist, nonces = _fixture() + assert _verify(proof, binding, verifier, allowlist, nonces) is True + + +def test_backward_compatible_tier0_only_without_binding() -> None: + # No expected_binding => existing tier-0 signature semantics, unchanged. + proof, _binding, _verifier, _allowlist, _nonces = _fixture() + assert verify_execution_proof(proof, unit_id=UNIT_ID) is True + assert verify_execution_proof(proof, unit_id="other") is False + + +# --- VAL-VERIFY-002: invalid/forged quote signature is REJECTED ------------- + + +def test_forged_quote_signature_rejected() -> None: + proof, binding, _verifier, allowlist, nonces = _fixture() + invalid = StaticQuoteVerifier(valid=False) + assert _verify(proof, binding, invalid, allowlist, nonces) is False + + +def test_dcap_qvl_nonzero_exit_is_reject_not_park() -> None: + def runner(args: list[str]) -> subprocess.CompletedProcess[str]: + return subprocess.CompletedProcess( + args, returncode=1, stdout="", stderr="bad sig" + ) + + verifier = DcapQvlVerifier(runner=runner) + with pytest.raises(QuoteVerificationError): + verifier.verify("00" * 8) + + +# --- VAL-VERIFY-003: out-of-date / invalid TCB is REJECTED ------------------ + + +def test_out_of_date_tcb_rejected() -> None: + proof, binding, _verifier, allowlist, nonces = _fixture() + stale_tcb = StaticQuoteVerifier(tcb_status="OutOfDate") + assert _verify(proof, binding, stale_tcb, allowlist, nonces) is False + + +def test_dcap_qvl_reports_tcb_status() -> None: + def runner(args: list[str]) -> subprocess.CompletedProcess[str]: + return subprocess.CompletedProcess( + args, returncode=0, stdout=json.dumps({"status": "OutOfDate"}), stderr="" + ) + + verdict = DcapQvlVerifier(runner=runner).verify("00" * 8) + assert verdict.tcb_status == "OutOfDate" + + +# --- VAL-VERIFY-004: measurement not in the validator allowlist is REJECTED -- + + +def test_measurement_not_in_allowlist_rejected_then_accepted() -> None: + proof, binding, verifier, _allowlist, nonces = _fixture() + empty = MeasurementAllowlist() + assert _verify(proof, binding, verifier, empty, nonces) is False + + # The identical quote accepted once its measurement is added to the allowlist. + _p2, _b2, _v2, allowlist_ok, _n2 = _fixture() + assert _verify(_p2, _b2, _v2, allowlist_ok, _n2) is True + + +def test_measurement_single_register_mismatch_rejected() -> None: + proof, binding, verifier, allowlist, nonces = _fixture() + # Allowlist a measurement whose MRTD differs by one register. + other = MeasurementAllowlist.from_measurements( + [ + { + "mrtd": "ff" * 48, + "rtmr0": RTMR0, + "rtmr1": RTMR1, + "rtmr2": RTMR2, + "compose_hash": COMPOSE_PAYLOAD.hex(), + "os_image_hash": _os_image_hash(MRTD, RTMR1, RTMR2), + } + ] + ) + assert _verify(proof, binding, verifier, other, nonces) is False + + +# --- VAL-VERIFY-005: event-log / RTMR3 that does not replay is REJECTED ------ + + +def test_event_log_replay_mismatch_rejected() -> None: + proof, binding, verifier, allowlist, nonces = _fixture() + att = PhalaAttestation.model_validate(proof.attestation) + # Swap in an event log that replays to a DIFFERENT RTMR3 than the quote binds. + other_log, _other_rtmr3 = build_rtmr3_event_log( + [("compose-hash", bytes.fromhex("dd" * 32))] + ) + att.event_log = other_log + proof.attestation = att.model_dump(mode="json") + assert _verify(proof, binding, verifier, allowlist, nonces) is False + + +def test_event_log_internally_inconsistent_rejected() -> None: + proof, binding, verifier, allowlist, nonces = _fixture() + att = PhalaAttestation.model_validate(proof.attestation) + # Mutate a payload so its logged digest no longer matches (self-inconsistent). + att.event_log[0]["event_payload"] = "ee" * 32 + proof.attestation = att.model_dump(mode="json") + assert _verify(proof, binding, verifier, allowlist, nonces) is False + + +# --- VAL-VERIFY-006: wrong agent_hash is REJECTED --------------------------- + + +def test_wrong_agent_hash_rejected() -> None: + proof, binding, verifier, allowlist, nonces = _fixture() + wrong = PhalaBinding( + agent_hash="00" * 32, + task_ids=binding.task_ids, + scores_digest=binding.scores_digest, + validator_nonce=binding.validator_nonce, + ) + assert _verify(proof, wrong, verifier, allowlist, nonces) is False + + +# --- VAL-VERIFY-007: wrong task set REJECTED; reorder still ACCEPTED --------- + + +def test_wrong_task_set_rejected() -> None: + proof, binding, verifier, allowlist, nonces = _fixture() + wrong = PhalaBinding( + agent_hash=binding.agent_hash, + task_ids=("task-a", "task-b"), + scores_digest=binding.scores_digest, + validator_nonce=binding.validator_nonce, + ) + assert _verify(proof, wrong, verifier, allowlist, nonces) is False + + +def test_task_set_reordered_still_accepted() -> None: + proof, binding, verifier, allowlist, nonces = _fixture() + reordered = PhalaBinding( + agent_hash=binding.agent_hash, + task_ids=("task-c", "task-b", "task-a"), + scores_digest=binding.scores_digest, + validator_nonce=binding.validator_nonce, + ) + assert _verify(proof, reordered, verifier, allowlist, nonces) is True + + +# --- VAL-VERIFY-008: wrong scores is REJECTED ------------------------------- + + +def test_wrong_scores_digest_rejected() -> None: + proof, binding, verifier, allowlist, nonces = _fixture() + wrong = PhalaBinding( + agent_hash=binding.agent_hash, + task_ids=binding.task_ids, + scores_digest="00" * 32, + validator_nonce=binding.validator_nonce, + ) + assert _verify(proof, wrong, verifier, allowlist, nonces) is False + + +# --- VAL-VERIFY-009: wrong domain-separation tag is REJECTED ---------------- + + +def test_wrong_domain_tag_rejected() -> None: + nonces = InMemoryNonceValidator() + nonce = nonces.issue() + # Build a report_data whose preimage uses a DIFFERENT tag but is otherwise + # correctly bound; the verifier recomputes with PHALA_REPORT_DATA_TAG. + os_image_hash = _os_image_hash(MRTD, RTMR1, RTMR2) + measurement = { + "mrtd": MRTD, + "rtmr0": RTMR0, + "rtmr1": RTMR1, + "rtmr2": RTMR2, + "compose_hash": COMPOSE_PAYLOAD.hex(), + "os_image_hash": os_image_hash, + } + preimage = { + "tag": "some-other-protocol-v9", + "canonical_measurement": measurement, + "agent_hash": AGENT_HASH, + "task_ids": sorted(TASK_IDS), + "scores_digest": SCORES_DIGEST, + "validator_nonce": nonce, + } + digest = hashlib.sha256( + json.dumps(preimage, sort_keys=True, separators=(",", ":")).encode() + ).digest() + report_data_hex = digest.ljust(64, b"\x00").hex() + assert PHALA_REPORT_DATA_TAG not in preimage["tag"] + + attestation, _m = _build_attestation( + validator_nonce=nonce, report_data_hex=report_data_hex + ) + proof = build_phala_execution_proof( + signer=_signer(), + manifest_sha256=MANIFEST, + unit_id=UNIT_ID, + attestation=attestation, + ) + binding = PhalaBinding( + agent_hash=AGENT_HASH, + task_ids=TASK_IDS, + scores_digest=SCORES_DIGEST, + validator_nonce=nonce, + ) + assert ( + _verify(proof, binding, StaticQuoteVerifier(), _allowlist(measurement), nonces) + is False + ) + + +# --- VAL-VERIFY-010: stale or replayed validator nonce is REJECTED ---------- + + +def test_fresh_nonce_first_use_accepts_then_reuse_rejected() -> None: + proof, binding, verifier, allowlist, nonces = _fixture() + assert _verify(proof, binding, verifier, allowlist, nonces) is True + # Same nonce reused (consumed) => rejected. + assert _verify(proof, binding, verifier, allowlist, nonces) is False + + +def test_never_issued_nonce_rejected() -> None: + nonces = InMemoryNonceValidator() + attestation, measurement = _build_attestation(validator_nonce="never-issued-nonce") + proof = build_phala_execution_proof( + signer=_signer(), + manifest_sha256=MANIFEST, + unit_id=UNIT_ID, + attestation=attestation, + ) + binding = PhalaBinding( + agent_hash=AGENT_HASH, + task_ids=TASK_IDS, + scores_digest=SCORES_DIGEST, + validator_nonce="never-issued-nonce", + ) + assert ( + _verify(proof, binding, StaticQuoteVerifier(), _allowlist(measurement), nonces) + is False + ) + + +def test_expired_nonce_rejected() -> None: + clock = {"t": 1000.0} + nonces = InMemoryNonceValidator(ttl_seconds=120, clock=lambda: clock["t"]) + nonce = nonces.issue() + attestation, measurement = _build_attestation(validator_nonce=nonce) + proof = build_phala_execution_proof( + signer=_signer(), + manifest_sha256=MANIFEST, + unit_id=UNIT_ID, + attestation=attestation, + ) + binding = PhalaBinding( + agent_hash=AGENT_HASH, + task_ids=TASK_IDS, + scores_digest=SCORES_DIGEST, + validator_nonce=nonce, + ) + clock["t"] = 2000.0 # advance well past the TTL + assert ( + _verify(proof, binding, StaticQuoteVerifier(), _allowlist(measurement), nonces) + is False + ) + + +# --- VAL-VERIFY-011: absent nonce in report_data is REJECTED ---------------- + + +def test_absent_nonce_rejected() -> None: + nonces = InMemoryNonceValidator() + attestation, measurement = _build_attestation(validator_nonce="") + proof = build_phala_execution_proof( + signer=_signer(), + manifest_sha256=MANIFEST, + unit_id=UNIT_ID, + attestation=attestation, + ) + binding = PhalaBinding( + agent_hash=AGENT_HASH, + task_ids=TASK_IDS, + scores_digest=SCORES_DIGEST, + validator_nonce="", + ) + assert ( + _verify(proof, binding, StaticQuoteVerifier(), _allowlist(measurement), nonces) + is False + ) + + +# --- VAL-VERIFY-012: genuine quote repurposed from a different submission ---- + + +def test_repurposed_quote_rejected_for_other_submission() -> None: + # Submission B: a genuine, self-consistent, valid attested envelope. + nonces = InMemoryNonceValidator() + nonce_b = nonces.issue() + attestation_b, measurement = _build_attestation( + agent_hash="bb" * 32, + task_ids=("b-task-1", "b-task-2"), + scores_digest="cc" * 32, + validator_nonce=nonce_b, + ) + proof_b = build_phala_execution_proof( + signer=_signer(), + manifest_sha256=MANIFEST, + unit_id="submission-B", + attestation=attestation_b, + ) + # Present B's envelope as submission A's result (A's expected binding). + nonce_a = nonces.issue() + binding_a = PhalaBinding( + agent_hash="aa" * 32, + task_ids=("a-task-1", "a-task-2"), + scores_digest="dd" * 32, + validator_nonce=nonce_a, + ) + proof_a = rebind_worker_signature(proof_b, signer=_signer(), unit_id="submission-A") + assert ( + verify_execution_proof( + proof_a, + unit_id="submission-A", + expected_binding=binding_a, + quote_verifier=StaticQuoteVerifier(), + allowlist=_allowlist(measurement), + nonce_validator=nonces, + ) + is False + ) + + +# --- VAL-VERIFY-013: tier-0 worker signature + unit binding still enforced --- + + +def test_cross_unit_replay_rejected_on_phala_tier() -> None: + proof, binding, verifier, allowlist, nonces = _fixture() + # Valid for the signed unit, rejected when presented for another unit. + assert ( + _verify(proof, binding, verifier, allowlist, nonces, unit_id="other-unit") + is False + ) + + +def test_placeholder_worker_signature_rejected() -> None: + nonces = InMemoryNonceValidator() + nonce = nonces.issue() + attestation, measurement = _build_attestation(validator_nonce=nonce) + # The M1 image emits an EMPTY placeholder worker_signature. + proof = ExecutionProof( + version=1, + tier=PHALA_TDX_TIER, + manifest_sha256=MANIFEST, + worker_signature=WorkerSignature(worker_pubkey="", sig=""), + attestation=attestation.model_dump(mode="json"), + ) + binding = PhalaBinding( + agent_hash=AGENT_HASH, + task_ids=TASK_IDS, + scores_digest=SCORES_DIGEST, + validator_nonce=nonce, + ) + assert ( + _verify(proof, binding, StaticQuoteVerifier(), _allowlist(measurement), nonces) + is False + ) + + +def test_rebind_makes_placeholder_envelope_verifiable() -> None: + nonces = InMemoryNonceValidator() + nonce = nonces.issue() + attestation, measurement = _build_attestation(validator_nonce=nonce) + placeholder = ExecutionProof( + version=1, + tier=PHALA_TDX_TIER, + manifest_sha256=MANIFEST, + worker_signature=WorkerSignature(worker_pubkey="", sig=""), + attestation=attestation.model_dump(mode="json"), + ) + signer = _signer("//Validator") + bound = rebind_worker_signature(placeholder, signer=signer, unit_id=UNIT_ID) + assert bound.worker_signature.worker_pubkey == signer.hotkey + assert bound.tier == PHALA_TDX_TIER + assert bound.attestation == placeholder.attestation + + binding = PhalaBinding( + agent_hash=AGENT_HASH, + task_ids=TASK_IDS, + scores_digest=SCORES_DIGEST, + validator_nonce=nonce, + ) + assert ( + _verify(bound, binding, StaticQuoteVerifier(), _allowlist(measurement), nonces) + is True + ) + # Re-bound to UNIT_ID => cross-unit replay still rejected. + nonces2 = InMemoryNonceValidator() + nonce2 = nonces2.issue() + att2, m2 = _build_attestation(validator_nonce=nonce2) + placeholder2 = ExecutionProof( + version=1, + tier=PHALA_TDX_TIER, + manifest_sha256=MANIFEST, + worker_signature=WorkerSignature(worker_pubkey="", sig=""), + attestation=att2.model_dump(mode="json"), + ) + bound2 = rebind_worker_signature(placeholder2, signer=signer, unit_id=UNIT_ID) + binding2 = PhalaBinding( + agent_hash=AGENT_HASH, + task_ids=TASK_IDS, + scores_digest=SCORES_DIGEST, + validator_nonce=nonce2, + ) + assert ( + verify_execution_proof( + bound2, + unit_id="different-unit", + expected_binding=binding2, + quote_verifier=StaticQuoteVerifier(), + allowlist=_allowlist(m2), + nonce_validator=nonces2, + ) + is False + ) + + +def test_non_phala_tier_with_binding_rejected() -> None: + # A tier-0-only proof presented where a Phala attestation is expected. + from base.worker.proof import build_execution_proof + + proof = build_execution_proof( + signer=_signer(), manifest_sha256=MANIFEST, unit_id=UNIT_ID + ) + binding = PhalaBinding( + agent_hash=AGENT_HASH, + task_ids=TASK_IDS, + scores_digest=SCORES_DIGEST, + validator_nonce="n", + ) + assert ( + verify_execution_proof( + proof, + unit_id=UNIT_ID, + expected_binding=binding, + quote_verifier=StaticQuoteVerifier(), + allowlist=MeasurementAllowlist(), + nonce_validator=InMemoryNonceValidator(), + ) + is False + ) + + +# --- VAL-VERIFY-014: verifier transient unavailability/timeout PARKS --------- + + +def test_verifier_outage_parks_not_accepts_nor_rejects() -> None: + proof, binding, _verifier, allowlist, nonces = _fixture() + outage = StaticQuoteVerifier(unavailable=True) + with pytest.raises(VerifierUnavailableError): + _verify(proof, binding, outage, allowlist, nonces) + + +def test_parked_result_accepts_once_verifier_restored() -> None: + proof, binding, _verifier, allowlist, nonces = _fixture() + outage = StaticQuoteVerifier(unavailable=True) + with pytest.raises(VerifierUnavailableError): + _verify(proof, binding, outage, allowlist, nonces) + # The park did not consume the nonce nor fraud-reject: a later pass accepts. + assert _verify(proof, binding, StaticQuoteVerifier(), allowlist, nonces) is True + + +def test_dcap_qvl_timeout_is_park_not_reject() -> None: + def runner(args: list[str]) -> subprocess.CompletedProcess[str]: + raise subprocess.TimeoutExpired(cmd=args, timeout=1.0) + + verifier = DcapQvlVerifier(runner=runner) + with pytest.raises(VerifierUnavailableError): + verifier.verify("00" * 8) + + +def test_dcap_qvl_missing_binary_is_park_not_reject() -> None: + def runner(args: list[str]) -> subprocess.CompletedProcess[str]: + raise FileNotFoundError("dcap-qvl not found") + + verifier = DcapQvlVerifier(runner=runner) + with pytest.raises(VerifierUnavailableError): + verifier.verify("00" * 8) From 3d24495aab4d4c16ca60594ef10100b261d105b8 Mon Sep 17 00:00:00 2001 From: echobt <154886644+echobt@users.noreply.github.com> Date: Wed, 8 Jul 2026 18:13:50 +0000 Subject: [PATCH 06/12] test(master): pin R=1 + no reconciliation for attested agent-challenge units (flag ON) --- tests/unit/test_master_r1_preserved.py | 401 +++++++++++++++++++++++++ 1 file changed, 401 insertions(+) create mode 100644 tests/unit/test_master_r1_preserved.py diff --git a/tests/unit/test_master_r1_preserved.py b/tests/unit/test_master_r1_preserved.py new file mode 100644 index 00000000..ccd88eee --- /dev/null +++ b/tests/unit/test_master_r1_preserved.py @@ -0,0 +1,401 @@ +"""Master keeps agent-challenge at R=1 for attested units (flag ON). + +Guardrail for the Phala-attested agent-challenge integration: an attested +agent-challenge submission is bridged by :class:`MasterOrchestrationDriver` as +one ``cpu`` work unit per selected task and assigned to a SINGLE executor +(R=1). Even with the worker plane ON (``compute.worker_plane_enabled`` -> the +validator ``AssignmentService`` configured with ``worker_plane_capabilities= +{"gpu"}`` and a live ``WorkerAssignmentEngine`` + ``WorkerReconciliationService`` +wired into ``run_once``), attestation carries in the result payload and never +turns an agent-challenge unit into a gpu-style replicated/reconciled unit: + +* VAL-VERIFY-020: each selected task is exactly ONE cpu unit assigned to one + validator across repeated passes -- no ``worker_assignments`` replica, no + R=2. A sibling prism gpu unit in the SAME pass IS replicated to R=2, proving + the worker plane is genuinely active (the R=1 result is not vacuous). +* VAL-VERIFY-021: attested agent-challenge units never enter the worker-plane + reconciliation path -- no ``disputed`` unit, no validator AUDIT unit, no + ``worker_faults`` -- and the pre-existing retry-exhaustion fold still + finalizes a stuck unit exactly once (single ``failed``, single fold). + +Runs on in-memory SQLite (fast); the worker-plane integration parity across +Postgres is already covered by ``test_reclaim_guard_symmetry_postgres``. +""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from datetime import UTC, datetime +from typing import Any + +from sqlalchemy import func, select + +from base.bittensor.metagraph_cache import MetagraphCache +from base.db import ( + Base, + Validator, + ValidatorStatus, + WorkAssignmentStatus, + WorkerAssignment, + WorkerFault, + WorkerRegistration, + WorkerStatus, + create_engine, + create_session_factory, + session_scope, +) +from base.db.models import WorkAssignment +from base.master.assignment import ( + CAPABILITY_GPU, + EXECUTOR_KIND_PAYLOAD_KEY, + EXECUTOR_KIND_VALIDATOR, + AssignmentService, +) +from base.master.orchestration import ( + WORK_UNIT_MAX_ATTEMPTS_REASON, + ChallengePendingWork, + MasterOrchestrationDriver, +) +from base.master.validator_coordination import ValidatorCoordinationService +from base.master.worker_assignment import WorkerAssignmentService +from base.master.worker_assignment_engine import WorkerAssignmentEngine +from base.master.worker_coordination import WorkerCoordinationService +from base.master.worker_reconciliation import ( + AUDIT_WORK_UNIT_SUFFIX, + WorkerReconciliationService, +) +from base.security.worker_auth import ( + MetagraphMinerMembership, + SqlAlchemyWorkerNonceStore, +) + +NOW = datetime(2026, 6, 30, 12, 0, 0, tzinfo=UTC) +TTL = 120 + +_ACTIVE = (WorkAssignmentStatus.ASSIGNED, WorkAssignmentStatus.RUNNING) + + +@dataclass +class _FakeWorkSource: + works: list[ChallengePendingWork] = field(default_factory=list) + + async def fetch_pending_work(self) -> list[ChallengePendingWork]: + return list(self.works) + + +@dataclass +class _FakeFoldTrigger: + calls: list[tuple[str, str, str, str]] = field(default_factory=list) + + async def fold( + self, *, challenge_slug: str, job_id: str, task_id: str, reason: str + ) -> None: + self.calls.append((challenge_slug, job_id, task_id, reason)) + + +class _FakeForwarder: + def __init__(self) -> None: + self.calls: list[str] = [] + + async def forward_result( + self, + *, + challenge_slug: str, + work_unit_id: str, + submission_ref: str, + result_payload: Any, + ) -> None: + self.calls.append(work_unit_id) + + +async def _setup() -> tuple[Any, Any]: + engine = create_engine("sqlite+aiosqlite:///:memory:") + async with engine.begin() as connection: + await connection.run_sync(Base.metadata.create_all) + return engine, create_session_factory(engine) + + +def _build_flag_on_driver( + factory: Any, + works: list[ChallengePendingWork], + *, + default_max_attempts: int = 3, +) -> tuple[MasterOrchestrationDriver, _FakeForwarder, _FakeFoldTrigger]: + """Wire a full flag-ON driver (worker engine + reconciler both present).""" + + cache = MetagraphCache(netuid=1, ttl_seconds=300) + worker_service = WorkerCoordinationService( + factory, + miner_membership=MetagraphMinerMembership(cache), + binding_nonce_store=SqlAlchemyWorkerNonceStore(factory), + heartbeat_ttl_seconds=TTL, + now_fn=lambda: NOW, + ) + worker_assignment_service = WorkerAssignmentService( + factory, worker_service=worker_service, now_fn=lambda: NOW + ) + worker_engine = WorkerAssignmentEngine( + factory, + assignment_service=worker_assignment_service, + worker_service=worker_service, + replication_factor=2, + now_fn=lambda: NOW, + ) + forwarder = _FakeForwarder() + reconciler = WorkerReconciliationService( + factory, result_forwarder=forwarder, now_fn=lambda: NOW + ) + # Flag-ON semantics: gpu is owned by the worker plane (skipped by the + # validator assign/reclaim); cpu (agent-challenge) is untouched by it. + assignment_service = AssignmentService( + factory, + now_fn=lambda: NOW, + default_max_attempts=default_max_attempts, + worker_plane_capabilities=frozenset({CAPABILITY_GPU}), + ) + validator_service = ValidatorCoordinationService(factory, now_fn=lambda: NOW) + fold = _FakeFoldTrigger() + driver = MasterOrchestrationDriver( + assignment_service=assignment_service, + validator_service=validator_service, + work_source=_FakeWorkSource(works=works), + fold_trigger=fold, + worker_assignment_engine=worker_engine, + worker_reconciler=reconciler, + seed=1, + ) + return driver, forwarder, fold + + +async def _add_worker(factory: Any, *, worker_pubkey: str, miner_hotkey: str) -> None: + async with session_scope(factory) as session: + session.add( + WorkerRegistration( + worker_id=f"wid-{worker_pubkey}", + worker_pubkey=worker_pubkey, + miner_hotkey=miner_hotkey, + binding_signature="sig", + binding_nonce=f"nonce-{worker_pubkey}", + provider="local", + provider_instance_ref="local-1", + capabilities=["gpu"], + status=WorkerStatus.ACTIVE, + last_heartbeat_at=NOW, + created_at=NOW, + updated_at=NOW, + ) + ) + + +async def _add_validator(factory: Any, hotkey: str, capabilities: list[str]) -> None: + async with session_scope(factory) as session: + session.add( + Validator( + hotkey=hotkey, + uid=None, + status=ValidatorStatus.ONLINE, + capabilities=list(capabilities), + version="1.0.0", + registered_at=NOW, + last_heartbeat_at=NOW, + ) + ) + + +async def _set_validator_status( + factory: Any, hotkey: str, status: ValidatorStatus +) -> None: + async with session_scope(factory) as session: + validator = ( + await session.execute(select(Validator).where(Validator.hotkey == hotkey)) + ).scalar_one() + validator.status = status + + +async def _units(factory: Any) -> dict[str, WorkAssignment]: + async with factory() as session: + rows = (await session.execute(select(WorkAssignment))).scalars().all() + return {r.work_unit_id: r for r in rows} + + +async def _replica_count(factory: Any, work_unit_id: str) -> int: + async with factory() as session: + return ( + await session.execute( + select(func.count()) + .select_from(WorkerAssignment) + .where( + WorkerAssignment.work_unit_id == work_unit_id, + WorkerAssignment.status.in_(_ACTIVE), + ) + ) + ).scalar_one() + + +async def _worker_fault_count(factory: Any) -> int: + async with factory() as session: + return ( + await session.execute(select(func.count()).select_from(WorkerFault)) + ).scalar_one() + + +def _agent_work( + task_ids: tuple[str, ...] = ("a", "b", "c"), + *, + job_id: str | None = "job-1", +) -> ChallengePendingWork: + # Attestation rides in the result payload; the unit itself is a plain cpu + # unit as far as the master is concerned. + return ChallengePendingWork( + challenge_slug="agent-challenge", + submission_id="sub", + submission_ref="miner-C", + task_ids=task_ids, + job_id=job_id, + payload={"proof": {"tier": "phala-tdx"}}, + ) + + +def _prism_work() -> ChallengePendingWork: + return ChallengePendingWork( + challenge_slug="prism", + submission_id="psub", + submission_ref="miner-P", + ) + + +# --------------------------------------------------------------------------- # +# VAL-VERIFY-020: R=1 preserved for attested agent-challenge units (flag ON) +# --------------------------------------------------------------------------- # +async def test_flag_on_keeps_agent_challenge_at_r1_while_gpu_replicates() -> None: + engine, factory = await _setup() + try: + driver, forwarder, _fold = _build_flag_on_driver( + factory, works=[_agent_work(), _prism_work()] + ) + # Two distinct-owner gpu workers (neither owned by the prism submitter) + # so the gpu primary genuinely replicates to R=2. + await _add_worker(factory, worker_pubkey="wp-a", miner_hotkey="miner-A") + await _add_worker(factory, worker_pubkey="wp-b", miner_hotkey="miner-B") + # One cpu validator to serve the agent-challenge cpu units. + await _add_validator(factory, "v1", ["cpu"]) + + await driver.run_once() + + units = await _units(factory) + cpu_ids = ["sub:a", "sub:b", "sub:c"] + # Each selected task is exactly ONE cpu unit assigned to a single + # validator (R=1): one WorkAssignment row, one hotkey, one attempt. + for uid in cpu_ids: + unit = units[uid] + assert unit.required_capability == "cpu" + assert unit.status == WorkAssignmentStatus.ASSIGNED + assert unit.assigned_validator_hotkey == "v1" + assert unit.attempt_count == 1 + # No worker-plane replica materialized for a cpu unit. + assert await _replica_count(factory, uid) == 0 + + # Sibling gpu unit IS replicated to R=2 -> the worker plane is genuinely + # active this pass, so the cpu R=1 result above is not vacuous. + assert units["psub"].assigned_validator_hotkey is None # worker-owned + assert await _replica_count(factory, "psub") == 2 + + # Repeated passes never add a second replica or a second assignment to a + # cpu unit (still R=1, attempt_count stable, no new units created). + for _ in range(3): + result = await driver.run_once() + assert result.folded == [] + units = await _units(factory) + for uid in cpu_ids: + assert units[uid].status == WorkAssignmentStatus.ASSIGNED + assert units[uid].assigned_validator_hotkey == "v1" + assert units[uid].attempt_count == 1 + assert await _replica_count(factory, uid) == 0 + + # No audit/replica unit id was ever created for a cpu submission. + assert all(not uid.endswith(AUDIT_WORK_UNIT_SUFFIX) for uid in units) + assert set(units) == {"sub:a", "sub:b", "sub:c", "psub"} + assert forwarder.calls == [] + finally: + await engine.dispose() + + +# --------------------------------------------------------------------------- # +# VAL-VERIFY-021: no reconciliation / audit / dispute for agent-challenge units +# --------------------------------------------------------------------------- # +async def test_flag_on_no_reconciliation_or_audit_for_agent_challenge() -> None: + engine, factory = await _setup() + try: + driver, forwarder, _fold = _build_flag_on_driver(factory, works=[_agent_work()]) + await _add_validator(factory, "v1", ["cpu"]) + + result = await driver.run_once() + + # A reconciler ran this pass (flag ON) but produced NO artifacts for the + # agent-challenge units: nothing accepted/disputed/audited/faulted. + assert result.reconciliation is not None + assert result.reconciliation.accepted == [] + assert result.reconciliation.disputed == [] + assert result.reconciliation.audit_units == {} + assert result.reconciliation.faults == [] + + units = await _units(factory) + # No unit is disputed, no validator AUDIT unit exists, and no + # agent-challenge unit carries a validator executor-kind marker. + for unit in units.values(): + assert unit.status != WorkAssignmentStatus.DISPUTED + assert not unit.work_unit_id.endswith(AUDIT_WORK_UNIT_SUFFIX) + assert (unit.payload or {}).get(EXECUTOR_KIND_PAYLOAD_KEY) != ( + EXECUTOR_KIND_VALIDATOR + ) + # No worker faults and no forwards for a cpu submission. + assert await _worker_fault_count(factory) == 0 + assert forwarder.calls == [] + finally: + await engine.dispose() + + +async def test_flag_on_fold_on_exhaustion_finalizes_once_no_dispute() -> None: + engine, factory = await _setup() + try: + driver, _forwarder, fold = _build_flag_on_driver( + factory, + works=[_agent_work(task_ids=("t1",), job_id="job-x")], + default_max_attempts=1, + ) + await _add_validator(factory, "v1", ["cpu"]) + + # Pass 1: bridge + assign (attempt 1 of 1). + first = await driver.run_once() + assert first.folded == [] + assert fold.calls == [] + + # The only cpu validator crashes; the unit exhausts its single attempt. + await _set_validator_status(factory, "v1", ValidatorStatus.OFFLINE) + + # Pass 2: retry-exhausted -> failed -> folded EXACTLY once, via the + # pre-existing fold path (never a dispute/audit). + second = await driver.run_once() + assert second.reassignment.failed == ["sub:t1"] + assert second.folded == ["sub:t1"] + assert fold.calls == [ + ("agent-challenge", "job-x", "t1", WORK_UNIT_MAX_ATTEMPTS_REASON) + ] + assert second.reconciliation is not None + assert second.reconciliation.disputed == [] + assert second.reconciliation.audit_units == {} + + units = await _units(factory) + assert units["sub:t1"].status == WorkAssignmentStatus.FAILED + assert set(units) == {"sub:t1"} # no audit/replica sibling unit created + assert await _replica_count(factory, "sub:t1") == 0 + assert await _worker_fault_count(factory) == 0 + + # Pass 3: an already-folded terminal unit is not re-folded or audited. + third = await driver.run_once() + assert third.folded == [] + assert len(fold.calls) == 1 + assert third.reconciliation is not None + assert third.reconciliation.disputed == [] + finally: + await engine.dispose() From 601b64e79ec61e98cb88785edd3d385627fdf2ca Mon Sep 17 00:00:00 2001 From: echobt <154886644+echobt@users.noreply.github.com> Date: Wed, 8 Jul 2026 18:31:42 +0000 Subject: [PATCH 07/12] test(verify): base-side flag-OFF invariant (R=1 legacy path, Phala verifier never invoked) --- tests/unit/test_flag_off_invariant.py | 340 ++++++++++++++++++++++++++ 1 file changed, 340 insertions(+) create mode 100644 tests/unit/test_flag_off_invariant.py diff --git a/tests/unit/test_flag_off_invariant.py b/tests/unit/test_flag_off_invariant.py new file mode 100644 index 00000000..ba8bbd17 --- /dev/null +++ b/tests/unit/test_flag_off_invariant.py @@ -0,0 +1,340 @@ +"""Base-side flag-OFF invariant for the Phala-attested agent-challenge integration. + +When the Phala attestation flag is OFF an agent-challenge submission is legacy / +unattested: no Phala-tier ``proof`` rides in the bridged work-unit payload. This +module pins the base-side manifestation of that invariant (architecture.md sec 8; +AGENTS.md "Feature-flagged, byte-identical legacy behavior when OFF"): + +* VAL-VERIFY-022 -- the master bridges an unattested agent-challenge submission at + R=1 exactly like the legacy validator-run path: one cpu work unit per selected + task assigned to a single validator, no worker-plane replica / reconciliation / + audit unit. The bridged unit set is byte-identical whether or not an attestation + payload rides along, so attestation presence never perturbs the flag-off path. +* VAL-VERIFY-023 -- no base code path invokes the Phala quote verifier + (:func:`base.worker.proof.verify_execution_proof`), the validator-side signature + rebind (``base.validator.agent.adapters.agent_challenge.rebind_worker_signature``), + or the external dcap-qvl dependency (``DcapQvlVerifier.verify``) while the flag is + off -- even when a result carries an attestation payload it is ignored and + dispatched via the legacy own_runner path. + +The verifier surfaces are genuinely wired functions (see +``test_worker_proof_phala_verify``); a positive-control test proves the spy detects +a real invocation, so the zero-invocation assertions are non-vacuous. +""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from datetime import UTC, datetime +from typing import Any + +import pytest +from sqlalchemy import func, select + +from base.db import ( + Base, + Validator, + ValidatorStatus, + WorkAssignmentStatus, + WorkerAssignment, + create_engine, + create_session_factory, + session_scope, +) +from base.db.models import WorkAssignment +from base.master.assignment import ( + EXECUTOR_KIND_PAYLOAD_KEY, + EXECUTOR_KIND_VALIDATOR, + AssignmentService, +) +from base.master.orchestration import ( + ChallengePendingWork, + MasterOrchestrationDriver, +) +from base.master.validator_coordination import ValidatorCoordinationService +from base.master.worker_reconciliation import AUDIT_WORK_UNIT_SUFFIX +from base.schemas.assignment import AssignmentView +from base.validator.agent import AssignmentContext, BrokerConfig +from base.validator.agent.adapters import AgentChallengeCycleExecutor + +NOW = datetime(2026, 6, 30, 12, 0, 0, tzinfo=UTC) + +#: The three base "Phala verifier" surfaces that MUST stay untouched with the +#: flag off (patched to recorders in the guardrail tests). +_VERIFY_EXECUTION_PROOF = "base.worker.proof.verify_execution_proof" +_REBIND_WORKER_SIGNATURE = ( + "base.validator.agent.adapters.agent_challenge.rebind_worker_signature" +) +_DCAP_QVL_VERIFY = "base.worker.phala_quote.DcapQvlVerifier.verify" + +_ACTIVE = (WorkAssignmentStatus.ASSIGNED, WorkAssignmentStatus.RUNNING) + + +@dataclass +class _FakeWorkSource: + works: list[ChallengePendingWork] = field(default_factory=list) + + async def fetch_pending_work(self) -> list[ChallengePendingWork]: + return list(self.works) + + +@dataclass +class _FakeFoldTrigger: + calls: list[tuple[str, str, str, str]] = field(default_factory=list) + + async def fold( + self, *, challenge_slug: str, job_id: str, task_id: str, reason: str + ) -> None: + self.calls.append((challenge_slug, job_id, task_id, reason)) + + +async def _setup() -> tuple[Any, Any]: + engine = create_engine("sqlite+aiosqlite:///:memory:") + async with engine.begin() as connection: + await connection.run_sync(Base.metadata.create_all) + return engine, create_session_factory(engine) + + +def _build_flag_off_driver( + factory: Any, works: list[ChallengePendingWork] +) -> MasterOrchestrationDriver: + """A flag-OFF driver: no worker engine, no reconciler (legacy routing). + + ``worker_plane_capabilities`` defaults empty so every capability (incl. cpu) + is validator-assigned, and neither the worker assignment engine nor the + reconciler is constructed -- ``run_once`` leaves ``worker``/``reconciliation`` + ``None`` (byte-identical legacy path). + """ + + assignment_service = AssignmentService( + factory, now_fn=lambda: NOW, default_max_attempts=3 + ) + validator_service = ValidatorCoordinationService(factory, now_fn=lambda: NOW) + return MasterOrchestrationDriver( + assignment_service=assignment_service, + validator_service=validator_service, + work_source=_FakeWorkSource(works=works), + fold_trigger=_FakeFoldTrigger(), + worker_assignment_engine=None, + worker_reconciler=None, + seed=1, + ) + + +async def _add_validator(factory: Any, hotkey: str, capabilities: list[str]) -> None: + async with session_scope(factory) as session: + session.add( + Validator( + hotkey=hotkey, + uid=None, + status=ValidatorStatus.ONLINE, + capabilities=list(capabilities), + version="1.0.0", + registered_at=NOW, + last_heartbeat_at=NOW, + ) + ) + + +async def _units(factory: Any) -> dict[str, WorkAssignment]: + async with factory() as session: + rows = (await session.execute(select(WorkAssignment))).scalars().all() + return {r.work_unit_id: r for r in rows} + + +async def _replica_count(factory: Any, work_unit_id: str) -> int: + async with factory() as session: + return ( + await session.execute( + select(func.count()) + .select_from(WorkerAssignment) + .where( + WorkerAssignment.work_unit_id == work_unit_id, + WorkerAssignment.status.in_(_ACTIVE), + ) + ) + ).scalar_one() + + +def _agent_work( + *, + task_ids: tuple[str, ...] = ("a", "b", "c"), + job_id: str | None = "job-1", + attested: bool = False, +) -> ChallengePendingWork: + # Flag OFF => unattested (empty payload). ``attested=True`` rides a Phala-tier + # proof block in the payload to prove attestation presence is INERT on the + # flag-off base path (the master never reads/verifies it). + payload: dict[str, Any] = {} + if attested: + payload["proof"] = {"tier": "phala-tdx"} + return ChallengePendingWork( + challenge_slug="agent-challenge", + submission_id="sub", + submission_ref="miner-C", + task_ids=task_ids, + job_id=job_id, + payload=payload, + ) + + +def _install_verifier_spies(monkeypatch: pytest.MonkeyPatch, calls: list[str]) -> None: + """Patch every base Phala-verifier surface to record (and refuse) invocation.""" + + def _spy(name: str) -> Any: + def _recorder(*_a: Any, **_k: Any) -> Any: + calls.append(name) + raise AssertionError(f"{name} invoked while the Phala flag is OFF") + + return _recorder + + monkeypatch.setattr(_VERIFY_EXECUTION_PROOF, _spy("verify_execution_proof")) + monkeypatch.setattr(_REBIND_WORKER_SIGNATURE, _spy("rebind_worker_signature")) + monkeypatch.setattr(_DCAP_QVL_VERIFY, _spy("dcap_qvl_verify")) + + +# --------------------------------------------------------------------------- # +# VAL-VERIFY-022: flag OFF -> legacy validator-run path, R=1 (byte-identical). +# --------------------------------------------------------------------------- # +async def test_val_verify_022_flag_off_bridges_agent_challenge_at_r1_legacy() -> None: + engine, factory = await _setup() + try: + driver = _build_flag_off_driver(factory, works=[_agent_work()]) + await _add_validator(factory, "v1", ["cpu"]) + + result = await driver.run_once() + + # Worker plane is OFF -> no engine/reconciler ran this pass (legacy). + assert result.worker is None + assert result.reconciliation is None + + units = await _units(factory) + cpu_ids = ["sub:a", "sub:b", "sub:c"] + for uid in cpu_ids: + unit = units[uid] + assert unit.required_capability == "cpu" + assert unit.status == WorkAssignmentStatus.ASSIGNED + assert unit.assigned_validator_hotkey == "v1" # single executor (R=1) + assert unit.attempt_count == 1 + assert await _replica_count(factory, uid) == 0 # no worker replica + # A cpu unit never carries a validator worker-plane executor marker. + assert (unit.payload or {}).get(EXECUTOR_KIND_PAYLOAD_KEY) != ( + EXECUTOR_KIND_VALIDATOR + ) + + # Exactly the fanned cpu units exist; no audit / replica sibling unit. + assert set(units) == set(cpu_ids) + assert all(not uid.endswith(AUDIT_WORK_UNIT_SUFFIX) for uid in units) + finally: + await engine.dispose() + + +async def test_val_verify_022_attestation_payload_is_inert_on_flag_off_path() -> None: + # The bridged unit shape must be byte-identical whether or not an attestation + # payload rides along: attestation presence never perturbs the flag-off path. + async def _bridge(attested: bool) -> dict[str, tuple[str, str | None, int]]: + engine, factory = await _setup() + try: + driver = _build_flag_off_driver( + factory, works=[_agent_work(attested=attested)] + ) + await _add_validator(factory, "v1", ["cpu"]) + await driver.run_once() + units = await _units(factory) + return { + uid: ( + u.required_capability, + u.assigned_validator_hotkey, + u.attempt_count, + ) + for uid, u in units.items() + } + finally: + await engine.dispose() + + assert await _bridge(attested=False) == await _bridge(attested=True) + + +# --------------------------------------------------------------------------- # +# VAL-VERIFY-023: flag OFF -> the Phala quote verifier is NEVER invoked. +# --------------------------------------------------------------------------- # +async def test_val_verify_023_master_never_invokes_phala_verifier( + monkeypatch: pytest.MonkeyPatch, +) -> None: + calls: list[str] = [] + _install_verifier_spies(monkeypatch, calls) + + engine, factory = await _setup() + try: + # Even with an attestation-bearing payload, the flag-off master path must + # neither read nor verify it. + driver = _build_flag_off_driver(factory, works=[_agent_work(attested=True)]) + await _add_validator(factory, "v1", ["cpu"]) + + result = await driver.run_once() + + assert calls == [] # zero verifier / rebind / dcap-qvl invocations + units = await _units(factory) + for uid in ("sub:a", "sub:b", "sub:c"): + assert units[uid].assigned_validator_hotkey == "v1" + assert await _replica_count(factory, uid) == 0 + assert result.reconciliation is None + finally: + await engine.dispose() + + +async def test_val_verify_023_adapter_dispatches_legacy_without_verifier( + monkeypatch: pytest.MonkeyPatch, +) -> None: + calls: list[str] = [] + _install_verifier_spies(monkeypatch, calls) + dispatched: list[dict[str, Any]] = [] + + async def _fake_dispatch(**kwargs: Any) -> dict[str, Any]: + dispatched.append(kwargs) + return {"pulled": 1, "executed": 1, "posted": 1, "skipped": 0} + + adapter = AgentChallengeCycleExecutor(dispatch=_fake_dispatch) + assignment = AssignmentView( + id="11111111-1111-1111-1111-111111111111", + challenge_slug="agent-challenge", + work_unit_id="sub:agent-challenge", + submission_ref="sub", + # An attestation-bearing payload the legacy path must ignore (not verify). + payload={"task_id": "task-1", "proof": {"tier": "phala-tdx"}}, + required_capability="cpu", + status="running", + attempt_count=1, + max_attempts=3, + ) + context = AssignmentContext( + assignment=assignment, + gateway_env={"BASE_GATEWAY_TOKEN": "scoped-token"}, + broker=BrokerConfig( + broker_url="http://broker-val:8082", + broker_token="bt", + broker_token_file="/run/bt", + allowed_images=("img:1",), + ), + ) + + async def _noop_progress(**_: Any) -> None: + return None + + result = await adapter.execute(context, progress=_noop_progress) + + assert result.success is True # dispatched via the legacy own_runner path + assert len(dispatched) == 1 + assert calls == [] # the adapter never invoked any Phala verifier surface + + +def test_verifier_spy_is_not_vacuous(monkeypatch: pytest.MonkeyPatch) -> None: + # Positive control: the spy DOES detect a real invocation, so the + # zero-invocation assertions above are meaningful, not vacuous. + import base.worker.proof as proof_module + + calls: list[str] = [] + _install_verifier_spies(monkeypatch, calls) + with pytest.raises(AssertionError): + proof_module.verify_execution_proof(object(), unit_id="u") # type: ignore[arg-type] + assert calls == ["verify_execution_proof"] From 5885c2bef31c0a3b787b68e1528b9c3666a58add Mon Sep 17 00:00:00 2001 From: echobt <154886644+echobt@users.noreply.github.com> Date: Wed, 8 Jul 2026 18:41:50 +0000 Subject: [PATCH 08/12] feat(verify): allowlist fail-closed loading + multi-entry image rotation [VAL-VERIFY-025/027] --- src/base/worker/phala_verify.py | 75 ++++++- tests/unit/test_phala_verify.py | 86 ++++++++ tests/unit/test_worker_proof_phala_verify.py | 195 +++++++++++++++++++ 3 files changed, 354 insertions(+), 2 deletions(-) diff --git a/src/base/worker/phala_verify.py b/src/base/worker/phala_verify.py index 2ebfbc2c..fe682824 100644 --- a/src/base/worker/phala_verify.py +++ b/src/base/worker/phala_verify.py @@ -18,11 +18,14 @@ from __future__ import annotations +import json +import os import secrets import time from collections.abc import Callable, Iterable, Mapping from dataclasses import dataclass, field from enum import StrEnum +from pathlib import Path from typing import Protocol, runtime_checkable from base.schemas.worker import PhalaMeasurement @@ -37,6 +40,10 @@ "os_image_hash", ) +#: Env var naming a JSON file with the validator's canonical measurement +#: allowlist. Unset/empty ⇒ an unconfigured validator ⇒ empty (fail-closed). +MEASUREMENT_ALLOWLIST_FILE_ENV = "BASE_PHALA_MEASUREMENT_ALLOWLIST_FILE" + def canonical_measurement_mapping( measurement: PhalaMeasurement | Mapping[str, object], @@ -52,8 +59,13 @@ def canonical_measurement_mapping( class MeasurementAllowlist: """A validator-owned set of canonical measurements a quote must match. - Matching is exact across ALL canonical registers. An empty allowlist matches - nothing (fail closed) -- an unconfigured validator never accepts a quote. + Matching is exact across ALL canonical registers. The allowlist can hold + MORE THAN ONE entry so that during a canonical-image rotation both the + outgoing and incoming measurements are trusted simultaneously -- a quote + matching ANY entry passes, one matching none is rejected. An empty allowlist + matches nothing (fail closed) -- an unconfigured validator never accepts a + quote, and every load path below fails closed (to empty) on a missing, + unreadable, or unparseable source rather than defaulting to accept-any. """ entries: tuple[dict[str, str], ...] = () @@ -64,6 +76,64 @@ def from_measurements( ) -> MeasurementAllowlist: return cls(tuple(canonical_measurement_mapping(m) for m in measurements)) + @classmethod + def from_json(cls, text: str) -> MeasurementAllowlist: + """Parse a JSON allowlist, FAILING CLOSED (empty) on any malformed input. + + Accepts either a bare ``[entry, ...]`` list or a ``{"entries": [...]}`` + object. Invalid JSON, an unexpected top-level shape, a non-mapping entry, + or an entry missing a canonical register yields an EMPTY allowlist (which + rejects everything) -- never an accept-any allowlist and never an + exception a caller might mistake for success (VAL-VERIFY-025). + """ + + try: + data = json.loads(text) + except (json.JSONDecodeError, TypeError, ValueError): + return cls() + if isinstance(data, Mapping): + data = data.get("entries", []) + if not isinstance(data, list): + return cls() + entries: list[dict[str, str]] = [] + for item in data: + if not isinstance(item, Mapping): + return cls() + try: + entries.append(canonical_measurement_mapping(item)) + except (KeyError, TypeError): + return cls() + return cls(tuple(entries)) + + @classmethod + def from_file(cls, path: str | Path) -> MeasurementAllowlist: + """Load an allowlist from a JSON file, FAILING CLOSED on any I/O error. + + A missing or unreadable file yields an EMPTY allowlist (fail closed) + rather than raising or accepting anything. + """ + + file_path = Path(path) + try: + text = file_path.read_text(encoding="utf-8") + except OSError: + return cls() + return cls.from_json(text) + + @classmethod + def from_env(cls, env: Mapping[str, str] | None = None) -> MeasurementAllowlist: + """Load the allowlist named by :data:`MEASUREMENT_ALLOWLIST_FILE_ENV`. + + When the env var is unset/empty the validator is UNCONFIGURED, which + fails closed to an empty allowlist (accepts nothing). + """ + + environ = os.environ if env is None else env + path = environ.get(MEASUREMENT_ALLOWLIST_FILE_ENV) + if not path: + return cls() + return cls.from_file(path) + def __bool__(self) -> bool: return bool(self.entries) @@ -149,6 +219,7 @@ class PhalaBinding: __all__ = [ "CANONICAL_MEASUREMENT_FIELDS", + "MEASUREMENT_ALLOWLIST_FILE_ENV", "InMemoryNonceValidator", "MeasurementAllowlist", "NonceState", diff --git a/tests/unit/test_phala_verify.py b/tests/unit/test_phala_verify.py index 6eb1b159..825d9989 100644 --- a/tests/unit/test_phala_verify.py +++ b/tests/unit/test_phala_verify.py @@ -6,8 +6,11 @@ from __future__ import annotations +import json + from base.schemas.worker import PhalaMeasurement from base.worker.phala_verify import ( + MEASUREMENT_ALLOWLIST_FILE_ENV, InMemoryNonceValidator, MeasurementAllowlist, NonceState, @@ -23,6 +26,16 @@ "os_image_hash": "e" * 64, } +#: A second, distinct canonical measurement (image rotation: old + new). +OTHER_MEASUREMENT = { + "mrtd": "f" * 96, + "rtmr0": "a0" * 48, + "rtmr1": "a1" * 48, + "rtmr2": "a2" * 48, + "compose_hash": "d" * 64, + "os_image_hash": "9" * 64, +} + def _phala_measurement() -> PhalaMeasurement: return PhalaMeasurement(rtmr3="d" * 96, **MEASUREMENT) @@ -52,6 +65,79 @@ def test_empty_allowlist_fails_closed() -> None: assert empty.contains(MEASUREMENT) is False +# --- VAL-VERIFY-027: multiple canonical entries (image rotation) ------------- + + +def test_allowlist_holds_multiple_entries_matches_any() -> None: + rotation = MeasurementAllowlist.from_measurements([MEASUREMENT, OTHER_MEASUREMENT]) + assert len(rotation.entries) == 2 + # Either allowlisted entry (outgoing + incoming image) matches. + assert rotation.contains(MEASUREMENT) is True + assert rotation.contains(OTHER_MEASUREMENT) is True + # A measurement listed under neither entry is rejected. + third = {**MEASUREMENT, "compose_hash": "1" * 64} + assert rotation.contains(third) is False + + +# --- VAL-VERIFY-025: fail-closed loading (empty / missing / unparseable) ----- + + +def test_from_json_parses_entries_object_and_bare_list() -> None: + obj = MeasurementAllowlist.from_json(json.dumps({"entries": [MEASUREMENT]})) + assert obj.contains(MEASUREMENT) is True + bare = MeasurementAllowlist.from_json(json.dumps([MEASUREMENT, OTHER_MEASUREMENT])) + assert bare.contains(MEASUREMENT) is True + assert bare.contains(OTHER_MEASUREMENT) is True + + +def test_from_json_unparseable_fails_closed() -> None: + broken = MeasurementAllowlist.from_json("{ this is : not json ]") + assert bool(broken) is False + assert broken.contains(MEASUREMENT) is False + + +def test_from_json_wrong_json_shape_fails_closed() -> None: + assert bool(MeasurementAllowlist.from_json(json.dumps(42))) is False + assert bool(MeasurementAllowlist.from_json(json.dumps("nope"))) is False + assert bool(MeasurementAllowlist.from_json(json.dumps({"other": []}))) is False + + +def test_from_json_malformed_entry_fails_closed() -> None: + missing_register = {k: v for k, v in MEASUREMENT.items() if k != "mrtd"} + assert bool(MeasurementAllowlist.from_json(json.dumps([missing_register]))) is False + assert bool(MeasurementAllowlist.from_json(json.dumps(["not-a-mapping"]))) is False + + +def test_from_file_missing_fails_closed(tmp_path) -> None: + absent = MeasurementAllowlist.from_file(tmp_path / "does-not-exist.json") + assert bool(absent) is False + + +def test_from_file_reads_entries(tmp_path) -> None: + path = tmp_path / "allowlist.json" + path.write_text(json.dumps({"entries": [MEASUREMENT]}), encoding="utf-8") + loaded = MeasurementAllowlist.from_file(path) + assert loaded.contains(MEASUREMENT) is True + + +def test_from_env_unconfigured_fails_closed() -> None: + assert bool(MeasurementAllowlist.from_env(env={})) is False + assert ( + bool(MeasurementAllowlist.from_env(env={MEASUREMENT_ALLOWLIST_FILE_ENV: ""})) + is False + ) + + +def test_from_env_reads_configured_file(tmp_path) -> None: + path = tmp_path / "allowlist.json" + path.write_text(json.dumps([MEASUREMENT, OTHER_MEASUREMENT]), encoding="utf-8") + loaded = MeasurementAllowlist.from_env( + env={MEASUREMENT_ALLOWLIST_FILE_ENV: str(path)} + ) + assert loaded.contains(MEASUREMENT) is True + assert loaded.contains(OTHER_MEASUREMENT) is True + + def test_nonce_single_use_lifecycle() -> None: nonces = InMemoryNonceValidator() nonce = nonces.issue() diff --git a/tests/unit/test_worker_proof_phala_verify.py b/tests/unit/test_worker_proof_phala_verify.py index 1364be69..b0513607 100644 --- a/tests/unit/test_worker_proof_phala_verify.py +++ b/tests/unit/test_worker_proof_phala_verify.py @@ -672,3 +672,198 @@ def runner(args: list[str]) -> subprocess.CompletedProcess[str]: verifier = DcapQvlVerifier(runner=runner) with pytest.raises(VerifierUnavailableError): verifier.verify("00" * 8) + + +# --- VAL-VERIFY-025: empty/missing/unparseable allowlist fails closed -------- + + +def test_empty_or_missing_allowlist_fails_closed_then_populated_accepts() -> None: + # A genuine, fully-valid attested envelope that WOULD verify against a + # populated allowlist. An empty/missing allowlist must reject it (never + # accept-any) WITHOUT consuming the nonce, so the same valid result still + # accepts once the allowlist is populated. + proof, binding, verifier, populated, nonces = _fixture() + + assert ( + verify_execution_proof( + proof, + unit_id=UNIT_ID, + expected_binding=binding, + quote_verifier=verifier, + allowlist=MeasurementAllowlist(), + nonce_validator=nonces, + ) + is False + ) + # Absent/unconfigured allowlist (None) also fails closed. + assert ( + verify_execution_proof( + proof, + unit_id=UNIT_ID, + expected_binding=binding, + quote_verifier=verifier, + allowlist=None, + nonce_validator=nonces, + ) + is False + ) + # The SAME valid result now accepts against the populated allowlist. + assert _verify(proof, binding, verifier, populated, nonces) is True + + +def test_unparseable_or_missing_allowlist_config_fails_closed(tmp_path) -> None: + nonces = InMemoryNonceValidator() + nonce = nonces.issue() + attestation, measurement = _build_attestation(validator_nonce=nonce) + proof = build_phala_execution_proof( + signer=_signer(), + manifest_sha256=MANIFEST, + unit_id=UNIT_ID, + attestation=attestation, + ) + binding = PhalaBinding( + agent_hash=AGENT_HASH, + task_ids=TASK_IDS, + scores_digest=SCORES_DIGEST, + validator_nonce=nonce, + ) + verifier = StaticQuoteVerifier() + + # Unparseable config => empty allowlist => reject (nonce not consumed). + unparseable = MeasurementAllowlist.from_json("{ not valid json ]") + assert bool(unparseable) is False + assert ( + verify_execution_proof( + proof, + unit_id=UNIT_ID, + expected_binding=binding, + quote_verifier=verifier, + allowlist=unparseable, + nonce_validator=nonces, + ) + is False + ) + # Missing config file => empty allowlist => reject (nonce not consumed). + missing = MeasurementAllowlist.from_file(tmp_path / "absent.json") + assert bool(missing) is False + assert ( + verify_execution_proof( + proof, + unit_id=UNIT_ID, + expected_binding=binding, + quote_verifier=verifier, + allowlist=missing, + nonce_validator=nonces, + ) + is False + ) + # A valid config carrying the measurement accepts the SAME valid result. + config = tmp_path / "allowlist.json" + config.write_text(json.dumps({"entries": [measurement]}), encoding="utf-8") + populated = MeasurementAllowlist.from_file(config) + assert bool(populated) is True + assert ( + verify_execution_proof( + proof, + unit_id=UNIT_ID, + expected_binding=binding, + quote_verifier=verifier, + allowlist=populated, + nonce_validator=nonces, + ) + is True + ) + + +# --- VAL-VERIFY-027: multi-entry allowlist (image rotation) ------------------ + + +def _rotation_case( + nonces: InMemoryNonceValidator, + *, + unit_id: str, + mrtd: str, + compose_payload: bytes, +): + """A self-consistent (proof, binding, measurement) for a given image.""" + + nonce = nonces.issue() + attestation, measurement = _build_attestation( + validator_nonce=nonce, mrtd=mrtd, compose_payload=compose_payload + ) + proof = build_phala_execution_proof( + signer=_signer(), + manifest_sha256=MANIFEST, + unit_id=unit_id, + attestation=attestation, + ) + binding = PhalaBinding( + agent_hash=AGENT_HASH, + task_ids=TASK_IDS, + scores_digest=SCORES_DIGEST, + validator_nonce=nonce, + ) + return proof, binding, measurement + + +def test_two_entry_allowlist_accepts_either_rejects_third() -> None: + nonces = InMemoryNonceValidator() + old_proof, old_binding, old_m = _rotation_case( + nonces, + unit_id="unit-old", + mrtd="a1" * 48, + compose_payload=bytes.fromhex("c3" * 32), + ) + new_proof, new_binding, new_m = _rotation_case( + nonces, + unit_id="unit-new", + mrtd="b2" * 48, + compose_payload=bytes.fromhex("d4" * 32), + ) + third_proof, third_binding, _third_m = _rotation_case( + nonces, + unit_id="unit-third", + mrtd="c3" * 48, + compose_payload=bytes.fromhex("e5" * 32), + ) + assert old_m != new_m + + rotation = MeasurementAllowlist.from_measurements([old_m, new_m]) + verifier = StaticQuoteVerifier() + + # Both the outgoing (old) and incoming (new) images verify against the + # two-entry allowlist during a rotation window. + assert ( + verify_execution_proof( + old_proof, + unit_id="unit-old", + expected_binding=old_binding, + quote_verifier=verifier, + allowlist=rotation, + nonce_validator=nonces, + ) + is True + ) + assert ( + verify_execution_proof( + new_proof, + unit_id="unit-new", + expected_binding=new_binding, + quote_verifier=verifier, + allowlist=rotation, + nonce_validator=nonces, + ) + is True + ) + # A third image, listed under neither entry, is rejected. + assert ( + verify_execution_proof( + third_proof, + unit_id="unit-third", + expected_binding=third_binding, + quote_verifier=verifier, + allowlist=rotation, + nonce_validator=nonces, + ) + is False + ) From 9dd8af4b4fcbca1c330aa501c7d75ab0705b39b8 Mon Sep 17 00:00:00 2001 From: echobt <154886644+echobt@users.noreply.github.com> Date: Wed, 8 Jul 2026 21:34:44 +0000 Subject: [PATCH 09/12] fix(worker): park dcap-qvl exit-0-unparseable output; pin cross-repo TDX quote golden vector M4 verify-weights scrutiny robustness follow-ups (phala_quote.py): 1. DcapQvlVerifier.verify: a returncode==0 invocation whose stdout is unparseable / non-JSON / missing a TCB status is a dcap-qvl *tooling* regression, not a fraud verdict. Map it to VerifierUnavailableError (PARK, retryable) instead of QuoteVerificationError (permanent reject), so a tooling regression cannot permanently fraud-reject a legitimate result. Genuine invalid-quote/bad-TCB (nonzero exit) still permanently rejects; timeout / missing-binary / subprocess error still parks. Never accept-any. 2. Cross-repo anti-drift: pin a shared golden TDX quote/measurement/RTMR3 vector (fixed quote + event-log bytes -> expected MRTD/RTMR0-3/os_image_hash/RTMR3 compose-hash) as a byte-identical fixture asserted here and in agent-challenge (tests/test_quote_golden_vector.py). A one-sided offset/hash tweak to either parser diverges from the pinned values and fails a test on that side. --- src/base/worker/phala_quote.py | 32 ++++-- tests/unit/phala_quote_golden_vector.json | 31 ++++++ tests/unit/test_phala_quote.py | 53 ++++++++- tests/unit/test_phala_quote_golden_vector.py | 108 +++++++++++++++++++ 4 files changed, 212 insertions(+), 12 deletions(-) create mode 100644 tests/unit/phala_quote_golden_vector.json create mode 100644 tests/unit/test_phala_quote_golden_vector.py diff --git a/src/base/worker/phala_quote.py b/src/base/worker/phala_quote.py index 67b6f4d5..11a53345 100644 --- a/src/base/worker/phala_quote.py +++ b/src/base/worker/phala_quote.py @@ -313,10 +313,19 @@ class DcapQvlVerifier: ``dcap-qvl`` verifies the quote against Intel collateral and reports the TCB status; this adapter shells out and parses that verdict. ``runner`` is - injectable for testing. A non-zero exit / unparseable output is a cryptographic - rejection (:class:`QuoteVerificationError`); a timeout / missing binary / - subprocess failure is a transient outage (:class:`VerifierUnavailableError`, - park) -- the two are deliberately distinct (VAL-VERIFY-014). + injectable for testing. The accept / reject / park mapping is deliberate + (VAL-VERIFY-014): + + * a **non-zero exit** is a cryptographic verdict -- the tool judged the quote + invalid / its TCB unacceptable -- so it PERMANENTLY rejects + (:class:`QuoteVerificationError`); and + * a **timeout / missing binary / subprocess error** is a transient outage, so + it PARKS (:class:`VerifierUnavailableError`, retryable); and + * an **exit-0-but-unparseable / non-object / missing-TCB-status** output is a + *tooling* regression (dcap-qvl accepted the quote's cryptography but changed + its stdout format), NOT a fraud verdict -- so it PARKS + (:class:`VerifierUnavailableError`) rather than permanently fraud-rejecting a + legitimate result. It is never accepted (no verdict is returned). """ binary: str = "dcap-qvl" @@ -350,15 +359,24 @@ def verify(self, quote_hex: str) -> QuoteVerdict: try: report = json.loads(proc.stdout) except json.JSONDecodeError as exc: - raise QuoteVerificationError(f"dcap-qvl output is not JSON: {exc}") from exc + raise VerifierUnavailableError( + f"dcap-qvl exited 0 but its output is not JSON " + f"(tooling regression -- park, do not reject): {exc}" + ) from exc if not isinstance(report, Mapping): - raise QuoteVerificationError("dcap-qvl output was not a JSON object") + raise VerifierUnavailableError( + "dcap-qvl exited 0 but its output was not a JSON object " + "(tooling regression -- park, do not reject)" + ) status = ( report.get("status") or report.get("tcbStatus") or report.get("tcb_status") ) if not isinstance(status, str) or not status: - raise QuoteVerificationError("dcap-qvl output is missing a TCB status") + raise VerifierUnavailableError( + "dcap-qvl exited 0 but its output is missing a TCB status " + "(tooling regression -- park, do not reject)" + ) advisories = report.get("advisory_ids") or report.get("advisoryIDs") or [] if not isinstance(advisories, Sequence) or isinstance(advisories, str | bytes): advisories = [] diff --git a/tests/unit/phala_quote_golden_vector.json b/tests/unit/phala_quote_golden_vector.json new file mode 100644 index 00000000..5def8341 --- /dev/null +++ b/tests/unit/phala_quote_golden_vector.json @@ -0,0 +1,31 @@ +{ + "description": "Shared cross-repo golden TDX quote/measurement/RTMR3 anti-drift vector. A fixed v4 TDX quote + dstack RTMR3 event log with the exact registers, os_image_hash (sha256(MRTD||RTMR1||RTMR2)) and RTMR3/compose-hash a correct parser must reproduce. Asserted BYTE-IDENTICALLY in both base (tests/unit/test_phala_quote_golden_vector.py) and agent-challenge (tests/test_quote_golden_vector.py). A one-sided offset/hash tweak to either base.worker.phala_quote or agent_challenge.keyrelease.quote makes that repo's parse of this fixed quote diverge from these pinned values, failing a test on that side. Do NOT edit one repo's copy without the other (see AGENTS.md 'TDX-quote-parse / measurement / RTMR3 anti-drift').", + "event_log": [ + { + "digest": "2851989bdb17f204310f1c0ef0ed4bf8ffbd3ed455af43ce0f438e94356c858b8746a15516c7c0102a58bb71d67f51e2", + "event": "compose-hash", + "event_payload": "abababababababababababababababababababababababababababababababab", + "event_type": 134217729, + "imr": 3 + }, + { + "digest": "0c365c23d8f1ad06b7bb3c843b1d3d7b0b8347a0f175e71c118241d8048005fe3538cc05eb89c52250bad3244646b739", + "event": "key-provider", + "event_payload": "cdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcd", + "event_type": 134217729, + "imr": 3 + } + ], + "expected": { + "compose_hash": "abababababababababababababababababababababababababababababababab", + "key_provider": "cdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcd", + "mrtd": "111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111", + "os_image_hash": "90ac6e18901b222f3a58a45231a39545d14a9cd59854cc3eafa683083f0e11d0", + "report_data_hex": "55555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555", + "rtmr0": "222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222", + "rtmr1": "333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333", + "rtmr2": "444444444444444444444444444444444444444444444444444444444444444444444444444444444444444444444444", + "rtmr3": "7934b424e98de2a6fba5c468ee804fd575de5d6cb6201f78416a976b23e616e1c74aa9e28a1d4e0c55a65b849007ae68" + }, + "quote_hex": "000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000001111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111110000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000002222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222223333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333334444444444444444444444444444444444444444444444444444444444444444444444444444444444444444444444447934b424e98de2a6fba5c468ee804fd575de5d6cb6201f78416a976b23e616e1c74aa9e28a1d4e0c55a65b849007ae6855555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555" +} diff --git a/tests/unit/test_phala_quote.py b/tests/unit/test_phala_quote.py index 5a7843f2..2e51b091 100644 --- a/tests/unit/test_phala_quote.py +++ b/tests/unit/test_phala_quote.py @@ -15,6 +15,7 @@ from base.worker.phala_quote import ( DcapQvlVerifier, + QuoteError, QuoteStructureError, QuoteVerificationError, StaticQuoteVerifier, @@ -159,34 +160,76 @@ def runner(args: list[str]) -> subprocess.CompletedProcess[str]: assert verdict.advisory_ids == ("INTEL-SA-1",) -def test_dcap_qvl_unparseable_json_is_reject() -> None: +# --- exit-0-but-unparseable / missing-TCB-status output PARKS (retryable) ---- +# A dcap-qvl exit code of 0 means the tool accepted the quote's cryptography; if +# its stdout is then unparseable / missing a TCB status that is a *tooling* +# regression (output-format change), NOT a fraud verdict -- park (retry), never +# permanently fraud-reject a legitimate result. Genuine invalid-quote/bad-TCB +# verdicts (nonzero exit) still reject; the distinction is asserted below. + + +def test_dcap_qvl_exit0_unparseable_json_is_park() -> None: def runner(args: list[str]) -> subprocess.CompletedProcess[str]: return subprocess.CompletedProcess( args, returncode=0, stdout="not json", stderr="" ) - with pytest.raises(QuoteVerificationError): + with pytest.raises(VerifierUnavailableError): DcapQvlVerifier(runner=runner).verify("00" * 8) -def test_dcap_qvl_non_object_json_is_reject() -> None: +def test_dcap_qvl_exit0_non_object_json_is_park() -> None: def runner(args: list[str]) -> subprocess.CompletedProcess[str]: return subprocess.CompletedProcess( args, returncode=0, stdout="[1,2,3]", stderr="" ) - with pytest.raises(QuoteVerificationError): + with pytest.raises(VerifierUnavailableError): DcapQvlVerifier(runner=runner).verify("00" * 8) -def test_dcap_qvl_missing_status_is_reject() -> None: +def test_dcap_qvl_exit0_missing_status_is_park() -> None: def runner(args: list[str]) -> subprocess.CompletedProcess[str]: return subprocess.CompletedProcess(args, returncode=0, stdout="{}", stderr="") + with pytest.raises(VerifierUnavailableError): + DcapQvlVerifier(runner=runner).verify("00" * 8) + + +def test_dcap_qvl_exit0_empty_stdout_is_park() -> None: + def runner(args: list[str]) -> subprocess.CompletedProcess[str]: + return subprocess.CompletedProcess(args, returncode=0, stdout="", stderr="") + + with pytest.raises(VerifierUnavailableError): + DcapQvlVerifier(runner=runner).verify("00" * 8) + + +# --- discriminator: a genuine rejection (nonzero exit) must NOT park ---------- +# Proves the park mapping above is not "everything parks": a real invalid-quote / +# bad-TCB verdict from dcap-qvl (nonzero exit) is still a PERMANENT reject. + + +def test_dcap_qvl_nonzero_exit_is_reject_not_park() -> None: + def runner(args: list[str]) -> subprocess.CompletedProcess[str]: + return subprocess.CompletedProcess( + args, returncode=1, stdout="", stderr="quote rejected: bad signature" + ) + with pytest.raises(QuoteVerificationError): DcapQvlVerifier(runner=runner).verify("00" * 8) +def test_dcap_qvl_exit0_unparseable_is_not_accepted() -> None: + # Never accept-any: an exit-0-but-unparseable result yields no QuoteVerdict. + def runner(args: list[str]) -> subprocess.CompletedProcess[str]: + return subprocess.CompletedProcess( + args, returncode=0, stdout="not json", stderr="" + ) + + with pytest.raises(QuoteError): + DcapQvlVerifier(runner=runner).verify("00" * 8) + + def test_dcap_qvl_generic_subprocess_error_is_park() -> None: def runner(args: list[str]) -> subprocess.CompletedProcess[str]: raise subprocess.SubprocessError("boom") diff --git a/tests/unit/test_phala_quote_golden_vector.py b/tests/unit/test_phala_quote_golden_vector.py new file mode 100644 index 00000000..807ca5b8 --- /dev/null +++ b/tests/unit/test_phala_quote_golden_vector.py @@ -0,0 +1,108 @@ +"""Shared cross-repo golden TDX quote/measurement/RTMR3 anti-drift vector (base). + +base ``src/base/worker/phala_quote.py`` re-implements agent-challenge +``src/agent_challenge/keyrelease/quote.py`` almost verbatim (TDX register +byte-offsets, ``os_image_hash = sha256(MRTD||RTMR1||RTMR2)``, dstack RTMR3 +event-log replay) because base cannot import the lean in-CVM module. That +duplication is drift-prone: a one-sided offset/hash tweak could let a real dstack +quote verify in one repo and silently fail in the other. + +This test pins a FIXED quote + event log (``tests/unit/phala_quote_golden_vector.json``) +to the exact registers / os_image_hash / RTMR3 / compose-hash a correct parser +must reproduce, and asserts base's parser reproduces them. The SAME fixture bytes +and the SAME :data:`GOLDEN_VECTOR_SHA256` are asserted in agent-challenge +(``tests/test_quote_golden_vector.py``); because the pinned expected values come +from the frozen fixture (not recomputed with the same offsets under test), a +one-sided offset/hash change diverges from these values and fails here or there. +Do NOT edit one repo's copy without the other (AGENTS.md +'TDX-quote-parse / measurement / RTMR3 anti-drift'). +""" + +from __future__ import annotations + +import hashlib +import json +from pathlib import Path + +from base.worker.phala_quote import ( + _MRTD_OFFSET, + REGISTER_LEN, + build_rtmr3_event_log, + os_image_hash_from_registers, + parse_td_report, + replay_rtmr3, +) + +# Same literal in BOTH repos: the SHA-256 of the byte-identical golden fixture. +# If either repo's fixture is edited, that side's pin fails; a reviewer can grep +# this constant across both repos to confirm they still match. +GOLDEN_VECTOR_SHA256 = ( + "053979e8445c147798ec6f9165f9849a38ff5797e4dccd2ee38aa329b7f673bf" +) + +_VECTOR_PATH = Path(__file__).parent / "phala_quote_golden_vector.json" + + +def _vector() -> dict: + return json.loads(_VECTOR_PATH.read_text(encoding="utf-8")) + + +def test_golden_fixture_is_byte_identical_across_repos() -> None: + digest = hashlib.sha256(_VECTOR_PATH.read_bytes()).hexdigest() + assert digest == GOLDEN_VECTOR_SHA256 + + +def test_golden_quote_parses_to_expected_registers() -> None: + vector = _vector() + expected = vector["expected"] + report = parse_td_report(vector["quote_hex"]) + assert report.mrtd == expected["mrtd"] + assert report.rtmr0 == expected["rtmr0"] + assert report.rtmr1 == expected["rtmr1"] + assert report.rtmr2 == expected["rtmr2"] + assert report.rtmr3 == expected["rtmr3"] + assert report.report_data.hex() == expected["report_data_hex"] + + +def test_golden_os_image_hash_matches() -> None: + vector = _vector() + expected = vector["expected"] + assert ( + os_image_hash_from_registers( + expected["mrtd"], expected["rtmr1"], expected["rtmr2"] + ) + == expected["os_image_hash"] + ) + + +def test_golden_event_log_replays_to_expected_rtmr3() -> None: + vector = _vector() + expected = vector["expected"] + replay = replay_rtmr3(vector["event_log"]) + assert replay.rtmr3 == expected["rtmr3"] + assert replay.compose_hash == expected["compose_hash"] + assert replay.key_provider == expected["key_provider"] + # The event-log replay reproduces the RTMR3 the fixed quote carries. + assert replay.rtmr3 == parse_td_report(vector["quote_hex"]).rtmr3 + + +def test_golden_vector_offset_sensitivity_discriminator() -> None: + # Non-vacuity: the pinned MRTD is not read from a constant. Reading one byte + # off the register offset (a simulated one-sided off-by-one) yields a value + # that differs from the golden -- exactly the divergence the pin above catches. + vector = _vector() + raw = bytes.fromhex(vector["quote_hex"]) + correct = raw[_MRTD_OFFSET : _MRTD_OFFSET + REGISTER_LEN].hex() + tweaked = raw[_MRTD_OFFSET + 1 : _MRTD_OFFSET + 1 + REGISTER_LEN].hex() + assert correct == vector["expected"]["mrtd"] + assert tweaked != vector["expected"]["mrtd"] + + +def test_golden_vector_replay_sensitivity_discriminator() -> None: + # Non-vacuity: a different compose payload replays to a different RTMR3, so the + # pinned RTMR3 genuinely binds the event-log digest/extend formula. + vector = _vector() + _log, other_rtmr3 = build_rtmr3_event_log( + [("compose-hash", bytes.fromhex("00" * 32))] + ) + assert other_rtmr3 != vector["expected"]["rtmr3"] From e1c0af2808d54a615c3cc40c2316525e39134a9b Mon Sep 17 00:00:00 2001 From: echobt <154886644+echobt@users.noreply.github.com> Date: Thu, 9 Jul 2026 00:16:14 +0000 Subject: [PATCH 10/12] test(cross-integration): base-leg R=1 + attestation carry-chain integrity Offline complements to the agent-challenge cross-integration suite: the master keeps attested agent-challenge units at replication factor 1 with no worker-plane reconcile (a sibling prism gpu unit still replicates to R=2, so the result is non-vacuous), and the TDX quote/report_data/measurement survive the challenge<->BASE JSON boundary and validator tier-0 rebind byte-for-byte (a single flipped byte / re-encoded report_data breaks it). Uses a deterministic in-test signer to keep bittensor's import-time logging reconfiguration out of the module. Covers VAL-CROSS-003 and VAL-CROSS-007 (base leg). --- .../test_cross_integration_carry_chain.py | 549 ++++++++++++++++++ 1 file changed, 549 insertions(+) create mode 100644 tests/unit/test_cross_integration_carry_chain.py diff --git a/tests/unit/test_cross_integration_carry_chain.py b/tests/unit/test_cross_integration_carry_chain.py new file mode 100644 index 00000000..b46a08ba --- /dev/null +++ b/tests/unit/test_cross_integration_carry_chain.py @@ -0,0 +1,549 @@ +"""Base-side cross-integration: R=1 at the master + carry-chain integrity. + +Offline complements to the agent-challenge cross-integration suite +(``agent-challenge/tests/test_cross_integration_e2e_offline.py``). These cover +the two legs that live in the base repo: + +* **VAL-CROSS-003** -- for an attested agent-challenge unit the master + coordination plane (``MasterOrchestrationDriver.run_once``) assigns it as a + SINGLE cpu work unit at replication factor 1 and never enters the worker-plane + replicate+reconcile path: exactly one owner, no second replica, no + reconciliation/audit/dispute row, across repeated passes. A sibling prism gpu + unit in the SAME pass IS replicated to R=2, proving the worker plane is + genuinely active (the R=1 result is not vacuous). (This is the VAL-CROSS + framing of the behaviour ``test_master_r1_preserved`` asserts under + VAL-VERIFY-020/021.) + +* **VAL-CROSS-007 (base leg)** -- the TDX quote / ``report_data`` / measurement + emitted alongside the ``BASE_BENCHMARK_RESULT=`` line by the in-CVM backend is + mapped onto the BASE ``ExecutionProof`` Phala tier and carried to the master + BYTE-FOR-BYTE across the JSON serialization boundary and the validator + tier-0 signature rebind. The carried envelope still verifies, and a single + flipped quote byte / re-encoded ``report_data`` breaks it (the carry is a real + discriminator, not a constant pass). The challenge leg (envelope emitted + alongside the result line and parsed by the host normalizer) is asserted in the + agent-challenge repo. +""" + +from __future__ import annotations + +import hashlib +from dataclasses import dataclass, field +from datetime import UTC, datetime +from typing import Any + +from sqlalchemy import func, select + +from base.bittensor.metagraph_cache import MetagraphCache +from base.db import ( + Base, + Validator, + ValidatorStatus, + WorkAssignmentStatus, + WorkerAssignment, + WorkerFault, + WorkerRegistration, + WorkerStatus, + create_engine, + create_session_factory, + session_scope, +) +from base.db.models import WorkAssignment +from base.master.assignment import CAPABILITY_GPU, AssignmentService +from base.master.orchestration import ( + ChallengePendingWork, + MasterOrchestrationDriver, +) +from base.master.validator_coordination import ValidatorCoordinationService +from base.master.worker_assignment import WorkerAssignmentService +from base.master.worker_assignment_engine import WorkerAssignmentEngine +from base.master.worker_coordination import WorkerCoordinationService +from base.master.worker_reconciliation import ( + AUDIT_WORK_UNIT_SUFFIX, + WorkerReconciliationService, +) +from base.schemas.worker import ( + PHALA_TDX_TIER, + ExecutionProof, + PhalaAttestation, + PhalaMeasurement, + WorkerSignature, +) +from base.security.worker_auth import ( + MetagraphMinerMembership, + SqlAlchemyWorkerNonceStore, +) +from base.validator.agent.adapters.agent_challenge import rebind_worker_signature +from base.worker.phala_quote import ( + StaticQuoteVerifier, + build_rtmr3_event_log, + build_tdx_quote, +) +from base.worker.phala_verify import ( + InMemoryNonceValidator, + MeasurementAllowlist, + PhalaBinding, +) +from base.worker.proof import ( + build_phala_execution_proof, + phala_report_data_hex, + verify_execution_proof, +) + +NOW = datetime(2026, 6, 30, 12, 0, 0, tzinfo=UTC) +TTL = 120 +_ACTIVE = (WorkAssignmentStatus.ASSIGNED, WorkAssignmentStatus.RUNNING) + + +# =========================================================================== # +# VAL-CROSS-003: master keeps attested agent-challenge units at R=1 +# =========================================================================== # +@dataclass +class _FakeWorkSource: + works: list[ChallengePendingWork] = field(default_factory=list) + + async def fetch_pending_work(self) -> list[ChallengePendingWork]: + return list(self.works) + + +@dataclass +class _FakeFoldTrigger: + calls: list[tuple[str, str, str, str]] = field(default_factory=list) + + async def fold( + self, *, challenge_slug: str, job_id: str, task_id: str, reason: str + ) -> None: + self.calls.append((challenge_slug, job_id, task_id, reason)) + + +class _FakeForwarder: + def __init__(self) -> None: + self.calls: list[str] = [] + + async def forward_result( + self, + *, + challenge_slug: str, + work_unit_id: str, + submission_ref: str, + result_payload: Any, + ) -> None: + self.calls.append(work_unit_id) + + +async def _setup() -> tuple[Any, Any]: + engine = create_engine("sqlite+aiosqlite:///:memory:") + async with engine.begin() as connection: + await connection.run_sync(Base.metadata.create_all) + return engine, create_session_factory(engine) + + +def _build_flag_on_driver( + factory: Any, works: list[ChallengePendingWork] +) -> MasterOrchestrationDriver: + """A full flag-ON driver: worker assignment engine + reconciler both live.""" + + cache = MetagraphCache(netuid=1, ttl_seconds=300) + worker_service = WorkerCoordinationService( + factory, + miner_membership=MetagraphMinerMembership(cache), + binding_nonce_store=SqlAlchemyWorkerNonceStore(factory), + heartbeat_ttl_seconds=TTL, + now_fn=lambda: NOW, + ) + worker_assignment_service = WorkerAssignmentService( + factory, worker_service=worker_service, now_fn=lambda: NOW + ) + worker_engine = WorkerAssignmentEngine( + factory, + assignment_service=worker_assignment_service, + worker_service=worker_service, + replication_factor=2, + now_fn=lambda: NOW, + ) + reconciler = WorkerReconciliationService( + factory, result_forwarder=_FakeForwarder(), now_fn=lambda: NOW + ) + assignment_service = AssignmentService( + factory, + now_fn=lambda: NOW, + default_max_attempts=3, + worker_plane_capabilities=frozenset({CAPABILITY_GPU}), + ) + return MasterOrchestrationDriver( + assignment_service=assignment_service, + validator_service=ValidatorCoordinationService(factory, now_fn=lambda: NOW), + work_source=_FakeWorkSource(works=works), + fold_trigger=_FakeFoldTrigger(), + worker_assignment_engine=worker_engine, + worker_reconciler=reconciler, + seed=1, + ) + + +async def _add_worker(factory: Any, *, worker_pubkey: str, miner_hotkey: str) -> None: + async with session_scope(factory) as session: + session.add( + WorkerRegistration( + worker_id=f"wid-{worker_pubkey}", + worker_pubkey=worker_pubkey, + miner_hotkey=miner_hotkey, + binding_signature="sig", + binding_nonce=f"nonce-{worker_pubkey}", + provider="local", + provider_instance_ref="local-1", + capabilities=["gpu"], + status=WorkerStatus.ACTIVE, + last_heartbeat_at=NOW, + created_at=NOW, + updated_at=NOW, + ) + ) + + +async def _add_validator(factory: Any, hotkey: str, capabilities: list[str]) -> None: + async with session_scope(factory) as session: + session.add( + Validator( + hotkey=hotkey, + uid=None, + status=ValidatorStatus.ONLINE, + capabilities=list(capabilities), + version="1.0.0", + registered_at=NOW, + last_heartbeat_at=NOW, + ) + ) + + +async def _units(factory: Any) -> dict[str, WorkAssignment]: + async with factory() as session: + rows = (await session.execute(select(WorkAssignment))).scalars().all() + return {r.work_unit_id: r for r in rows} + + +async def _replica_count(factory: Any, work_unit_id: str) -> int: + async with factory() as session: + return ( + await session.execute( + select(func.count()) + .select_from(WorkerAssignment) + .where( + WorkerAssignment.work_unit_id == work_unit_id, + WorkerAssignment.status.in_(_ACTIVE), + ) + ) + ).scalar_one() + + +async def _worker_fault_count(factory: Any) -> int: + async with factory() as session: + return ( + await session.execute(select(func.count()).select_from(WorkerFault)) + ).scalar_one() + + +def _attested_agent_work() -> ChallengePendingWork: + # Attestation rides in the result payload; the unit itself is a plain cpu + # unit as far as the master's assignment/reconciliation plane is concerned. + return ChallengePendingWork( + challenge_slug="agent-challenge", + submission_id="sub", + submission_ref="miner-C", + task_ids=("a", "b", "c"), + job_id="job-1", + payload={"proof": {"tier": PHALA_TDX_TIER}}, + ) + + +def _prism_work() -> ChallengePendingWork: + return ChallengePendingWork( + challenge_slug="prism", submission_id="psub", submission_ref="miner-P" + ) + + +async def test_val_cross_003_master_keeps_attested_units_at_r1_no_reconcile() -> None: + engine, factory = await _setup() + try: + driver = _build_flag_on_driver( + factory, works=[_attested_agent_work(), _prism_work()] + ) + # Two distinct-owner gpu workers so the prism gpu primary genuinely + # replicates to R=2 this pass (non-vacuous worker plane). + await _add_worker(factory, worker_pubkey="wp-a", miner_hotkey="miner-A") + await _add_worker(factory, worker_pubkey="wp-b", miner_hotkey="miner-B") + await _add_validator(factory, "v1", ["cpu"]) + + result = await driver.run_once() + + units = await _units(factory) + cpu_ids = ["sub:a", "sub:b", "sub:c"] + # Each selected task is exactly ONE cpu unit, one owner, one attempt, and + # NO worker-plane replica (replication factor 1). + for uid in cpu_ids: + unit = units[uid] + assert unit.required_capability == "cpu" + assert unit.status == WorkAssignmentStatus.ASSIGNED + assert unit.assigned_validator_hotkey == "v1" + assert unit.attempt_count == 1 + assert await _replica_count(factory, uid) == 0 + + # Sibling gpu unit replicated to R=2 -> the worker plane is genuinely on, + # so the cpu R=1 result above is not vacuous. + assert units["psub"].assigned_validator_hotkey is None + assert await _replica_count(factory, "psub") == 2 + + # The reconciler ran (flag ON) but produced NO artifacts for the attested + # agent-challenge units: nothing disputed/audited/faulted. + assert result.reconciliation is not None + assert result.reconciliation.disputed == [] + assert result.reconciliation.audit_units == {} + assert result.reconciliation.faults == [] + assert await _worker_fault_count(factory) == 0 + assert all(not uid.endswith(AUDIT_WORK_UNIT_SUFFIX) for uid in units) + + # Repeated passes never add a second replica/assignment nor a new unit + # for a cpu submission (still R=1; no reconciliation/replica rows). + for _ in range(3): + again = await driver.run_once() + assert again.folded == [] + units = await _units(factory) + for uid in cpu_ids: + assert units[uid].assigned_validator_hotkey == "v1" + assert units[uid].attempt_count == 1 + assert await _replica_count(factory, uid) == 0 + assert set(units) == {"sub:a", "sub:b", "sub:c", "psub"} + finally: + await engine.dispose() + + +# =========================================================================== # +# VAL-CROSS-007 (base leg): the attestation envelope survives the challenge<-> +# BASE boundary + the validator tier-0 rebind byte-for-byte. +# =========================================================================== # +MANIFEST = "a" * 64 +UNIT_ID = "submission-carry-1" +MRTD = "a1" * 48 +RTMR0 = "b0" * 48 +RTMR1 = "b1" * 48 +RTMR2 = "b2" * 48 +COMPOSE_PAYLOAD = bytes.fromhex("c3" * 32) +AGENT_HASH = "f0" * 32 +TASK_IDS = ("task-b", "task-a", "task-c") +SCORES_DIGEST = "9a" * 32 + + +def _signer(hotkey: str = "carry-chain-worker") -> _FakeSigner: + return _FakeSigner(hotkey=hotkey) + + +@dataclass(frozen=True) +class _FakeSigner: + """A ``RequestSigner`` that signs deterministically without bittensor. + + The sr25519 primitive itself is covered by ``test_worker_proof_phala_verify``; + this carry-chain test targets attestation-envelope integrity. Importing + bittensor here would reconfigure process-wide logging at import time (it + disables previously-imported loggers), which breaks a fragile logging test + that sorts after this module. The signature is still a real function of + ``(hotkey, message)`` so the tier-0 no-cross-unit-replay property is exercised. + """ + + hotkey: str = "carry-chain-worker" + + def sign(self, message: bytes) -> str: + return "0x" + hashlib.sha256(self.hotkey.encode() + message).hexdigest() + + +def _fake_verify(pubkey: str, message: bytes, signature: str) -> bool: + return signature == "0x" + hashlib.sha256(pubkey.encode() + message).hexdigest() + + +def _os_image_hash(mrtd: str, rtmr1: str, rtmr2: str) -> str: + return hashlib.sha256( + bytes.fromhex(mrtd) + bytes.fromhex(rtmr1) + bytes.fromhex(rtmr2) + ).hexdigest() + + +def _cvm_emitted_attestation( + *, validator_nonce: str +) -> tuple[PhalaAttestation, dict[str, str]]: + """What the in-CVM backend emits: a self-consistent Phala attestation.""" + + event_log, rtmr3 = build_rtmr3_event_log([("compose-hash", COMPOSE_PAYLOAD)]) + measurement = { + "mrtd": MRTD, + "rtmr0": RTMR0, + "rtmr1": RTMR1, + "rtmr2": RTMR2, + "compose_hash": COMPOSE_PAYLOAD.hex(), + "os_image_hash": _os_image_hash(MRTD, RTMR1, RTMR2), + } + report_data_hex = phala_report_data_hex( + canonical_measurement=measurement, + agent_hash=AGENT_HASH, + task_ids=TASK_IDS, + scores_digest=SCORES_DIGEST, + validator_nonce=validator_nonce, + ) + quote = build_tdx_quote( + mrtd=MRTD, + rtmr0=RTMR0, + rtmr1=RTMR1, + rtmr2=RTMR2, + rtmr3=rtmr3, + report_data=report_data_hex, + ) + attestation = PhalaAttestation( + tdx_quote=quote, + event_log=event_log, + report_data=report_data_hex, + measurement=PhalaMeasurement( + mrtd=MRTD, + rtmr0=RTMR0, + rtmr1=RTMR1, + rtmr2=RTMR2, + rtmr3=rtmr3, + compose_hash=COMPOSE_PAYLOAD.hex(), + os_image_hash=measurement["os_image_hash"], + ), + vm_config={"vcpu": 1, "memory_mb": 2048}, + ) + return attestation, measurement + + +def _binding(nonce: str) -> PhalaBinding: + return PhalaBinding( + agent_hash=AGENT_HASH, + task_ids=TASK_IDS, + scores_digest=SCORES_DIGEST, + validator_nonce=nonce, + ) + + +def test_val_cross_007_base_leg_envelope_carried_byte_for_byte_and_verifies() -> None: + nonces = InMemoryNonceValidator() + nonce = nonces.issue() + emitted, measurement = _cvm_emitted_attestation(validator_nonce=nonce) + emitted_quote = emitted.tdx_quote + emitted_report_data = emitted.report_data + + # 1. The lean CVM image maps the attestation onto the BASE ExecutionProof + # Phala tier with a PLACEHOLDER (empty) worker signature, and it rides the + # challenge<->BASE JSON boundary (model_dump -> model_validate). + placeholder = ExecutionProof( + version=1, + tier=PHALA_TDX_TIER, + manifest_sha256=MANIFEST, + worker_signature=WorkerSignature(worker_pubkey="", sig=""), + attestation=emitted.model_dump(mode="json"), + ) + round_tripped = ExecutionProof.model_validate(placeholder.model_dump(mode="json")) + carried = PhalaAttestation.model_validate(round_tripped.attestation) + # Quote + report_data survive the boundary byte-for-byte (no truncation / + # re-encode / field loss). + assert carried.tdx_quote == emitted_quote + assert carried.report_data == emitted_report_data + assert carried.measurement.model_dump() == emitted.measurement.model_dump() + + # 2. The validator ingests the payload and rebinds ONLY the tier-0 worker + # signature to this unit; the attestation payload is carried through + # UNCHANGED (the quote is the trust root, never re-minted). + bound = rebind_worker_signature(round_tripped, signer=_signer(), unit_id=UNIT_ID) + assert bound.attestation == round_tripped.attestation + assert bound.tier == PHALA_TDX_TIER + + # 3. What reaches the master (serialize the bound proof) still carries the + # exact bytes the CVM emitted. + at_master = PhalaAttestation.model_validate( + ExecutionProof.model_validate(bound.model_dump(mode="json")).attestation + ) + assert at_master.tdx_quote == emitted_quote + assert at_master.report_data == emitted_report_data + + # 4. The carried envelope still verifies against the validator's expectations. + assert ( + verify_execution_proof( + bound, + unit_id=UNIT_ID, + expected_binding=_binding(nonce), + quote_verifier=StaticQuoteVerifier(), + allowlist=MeasurementAllowlist.from_measurements([measurement]), + nonce_validator=nonces, + signature_verifier=_fake_verify, + ) + is True + ) + + +def test_val_cross_007_base_leg_single_carried_byte_flip_breaks_verification() -> None: + # Discriminator: the carry is NOT a constant pass. Flipping ONE quote byte + # (last nibble -> perturbs the trailing report_data byte) breaks the chain. + nonces = InMemoryNonceValidator() + nonce = nonces.issue() + emitted, measurement = _cvm_emitted_attestation(validator_nonce=nonce) + + last = emitted.tdx_quote[-1] + tampered_quote = emitted.tdx_quote[:-1] + ("0" if last != "0" else "1") + assert tampered_quote != emitted.tdx_quote + + tampered = emitted.model_copy(update={"tdx_quote": tampered_quote}) + proof = build_phala_execution_proof( + signer=_signer(), + manifest_sha256=MANIFEST, + unit_id=UNIT_ID, + attestation=tampered, + ) + assert ( + verify_execution_proof( + proof, + unit_id=UNIT_ID, + expected_binding=_binding(nonce), + quote_verifier=StaticQuoteVerifier(), + allowlist=MeasurementAllowlist.from_measurements([measurement]), + nonce_validator=nonces, + signature_verifier=_fake_verify, + ) + is False + ) + + +def test_val_cross_007_base_leg_report_data_reencode_breaks_verification() -> None: + # A re-encoded (zero-truncated) report_data no longer matches the quote-bound + # field, so the carried envelope is rejected. + nonces = InMemoryNonceValidator() + nonce = nonces.issue() + emitted, measurement = _cvm_emitted_attestation(validator_nonce=nonce) + + # Rebuild the quote with a DIFFERENT report_data than the binding expects. + event_log, rtmr3 = build_rtmr3_event_log([("compose-hash", COMPOSE_PAYLOAD)]) + wrong_report_data = ("00" * 32).ljust(128, "0") + reencoded_quote = build_tdx_quote( + mrtd=MRTD, + rtmr0=RTMR0, + rtmr1=RTMR1, + rtmr2=RTMR2, + rtmr3=rtmr3, + report_data=wrong_report_data, + ) + tampered = emitted.model_copy( + update={"tdx_quote": reencoded_quote, "report_data": wrong_report_data} + ) + proof = build_phala_execution_proof( + signer=_signer(), + manifest_sha256=MANIFEST, + unit_id=UNIT_ID, + attestation=tampered, + ) + assert ( + verify_execution_proof( + proof, + unit_id=UNIT_ID, + expected_binding=_binding(nonce), + quote_verifier=StaticQuoteVerifier(), + allowlist=MeasurementAllowlist.from_measurements([measurement]), + nonce_validator=nonces, + signature_verifier=_fake_verify, + ) + is False + ) From 8815fd322dd465730f5458dc8e934917a0f72fd4 Mon Sep 17 00:00:00 2001 From: echobt <154886644+echobt@users.noreply.github.com> Date: Thu, 9 Jul 2026 02:03:43 +0000 Subject: [PATCH 11/12] test(supervisor): make weights compute-raise log test order-independent The scheduler error-log test only restored the logger's .disabled flag, so a bittensor import (forces existing loggers to CRITICAL) or alembic fileConfig (logging.disable) run earlier in the process silenced the ERROR record and failed the test only in the full alphabetical suite. Snapshot and neutralize logging.disable, the scheduler logger's .disabled/.level/.propagate in a fixture, capture on its own logger, then restore. Assertions unchanged (a compute raise is logged and the schedule keeps ticking). --- tests/unit/test_supervisor_weights.py | 76 +++++++++++++++++++-------- 1 file changed, 53 insertions(+), 23 deletions(-) diff --git a/tests/unit/test_supervisor_weights.py b/tests/unit/test_supervisor_weights.py index a4fb181d..87581913 100644 --- a/tests/unit/test_supervisor_weights.py +++ b/tests/unit/test_supervisor_weights.py @@ -12,6 +12,7 @@ import logging import threading import time +from collections.abc import Iterator from types import SimpleNamespace from typing import Any @@ -109,8 +110,54 @@ def test_compute_uses_cli_path_with_zero_chain_calls( assert recorder["set_weights_calls"] == [] +@pytest.fixture +def scheduler_error_messages() -> Iterator[list[str]]: + """Capture ERROR records from ``base.supervisor.scheduler`` order-independently. + + The full suite runs, in alphabetical order, files that import bittensor + (which forces every already-created logger's level to CRITICAL) and files + that run alembic's ``fileConfig`` (which can set ``logging.disable`` and a + logger's ``.disabled`` flag). Any of those left set would swallow the + scheduler's error log and fail this test only in the full run. Snapshot and + neutralize every one of those process-wide knobs for the duration of the + test, capture via a handler on the scheduler's own logger (propagation off), + then restore the prior state so this test perturbs no sibling. + """ + logger = logging.getLogger("base.supervisor.scheduler") + + class _RecordingHandler(logging.Handler): + def __init__(self) -> None: + super().__init__(level=logging.ERROR) + self.messages: list[str] = [] + self._lock = threading.Lock() + + def emit(self, record: logging.LogRecord) -> None: + with self._lock: + self.messages.append(record.getMessage()) + + handler = _RecordingHandler() + prev_disable = logging.root.manager.disable + prev_disabled = logger.disabled + prev_level = logger.level + prev_propagate = logger.propagate + logging.disable(logging.NOTSET) + logger.disabled = False + logger.setLevel(logging.DEBUG) + logger.propagate = False + logger.addHandler(handler) + try: + yield handler.messages + finally: + logger.removeHandler(handler) + logger.propagate = prev_propagate + logger.setLevel(prev_level) + logger.disabled = prev_disabled + logging.disable(prev_disable) + + def test_compute_raise_is_logged_and_schedule_continues( monkeypatch: pytest.MonkeyPatch, + scheduler_error_messages: list[str], ) -> None: counts = {"n": 0} lock = threading.Lock() @@ -126,27 +173,8 @@ def boom(settings: Settings) -> FinalWeights: shutdown = threading.Event() worker = TaskWorker(task=fast, shutdown=shutdown) - class _RecordingHandler(logging.Handler): - def __init__(self) -> None: - super().__init__(level=logging.ERROR) - self.messages: list[str] = [] - self._lock = threading.Lock() - - def emit(self, record: logging.LogRecord) -> None: - with self._lock: - self.messages.append(record.getMessage()) - - # Attach directly to the scheduler logger: immune to other tests - # reconfiguring root logging (which breaks caplog in the full run). - # Re-enable it too: alembic's env.py fileConfig (run by any migration - # test) disables all previously-imported loggers suite-wide. - scheduler_logger = logging.getLogger("base.supervisor.scheduler") - handler = _RecordingHandler() - was_disabled = scheduler_logger.disabled - scheduler_logger.disabled = False - scheduler_logger.addHandler(handler) + worker.start() try: - worker.start() deadline = time.monotonic() + 5.0 while time.monotonic() < deadline: with lock: @@ -156,7 +184,9 @@ def emit(self, record: logging.LogRecord) -> None: shutdown.set() assert worker.join(timeout=5.0) finally: - scheduler_logger.removeHandler(handler) - scheduler_logger.disabled = was_disabled + shutdown.set() + worker.join(timeout=5.0) assert counts["n"] >= 3 # schedule kept ticking after each raise - assert any("raised; continuing schedule" in message for message in handler.messages) + assert any( + "raised; continuing schedule" in message for message in scheduler_error_messages + ) From 12f81a53b46b6aef51d3dbcbdccf9931c5a9370c Mon Sep 17 00:00:00 2001 From: echobt <154886644+echobt@users.noreply.github.com> Date: Thu, 9 Jul 2026 02:40:42 +0000 Subject: [PATCH 12/12] test(cross-integration): base leg flag-OFF legacy handling (VAL-CROSS-021) With the flag OFF the master bridges an attested agent-challenge submission exactly like legacy: cpu units at R=1, no worker plane / reconciliation, and the legacy reassign-on-failure (never replicate) path (owner crash -> revert -> reassign to a different validator, +1 attempt, zero worker replicas). The sibling gpu R=2 replica in VAL-CROSS-003 keeps this non-vacuous. bittensor-free (reuses the file's fixtures) to avoid the supervisor-weights log-test ordering hazard. --- .../test_cross_integration_carry_chain.py | 89 +++++++++++++++++++ 1 file changed, 89 insertions(+) diff --git a/tests/unit/test_cross_integration_carry_chain.py b/tests/unit/test_cross_integration_carry_chain.py index b46a08ba..8fd35ac6 100644 --- a/tests/unit/test_cross_integration_carry_chain.py +++ b/tests/unit/test_cross_integration_carry_chain.py @@ -23,6 +23,13 @@ discriminator, not a constant pass). The challenge leg (envelope emitted alongside the result line and parsed by the host normalizer) is asserted in the agent-challenge repo. + +* **VAL-CROSS-021 (base leg)** -- with the flag OFF the BASE validator adapter + + master handle an agent-challenge unit exactly as legacy: no Phala tier is + required on results, no attestation gate is applied, and master orchestration + keeps the legacy reassign-on-failure (NEVER replicate) path for the cpu units. + A sibling gpu unit replicating to R=2 (VAL-CROSS-003 above) proves the R=1 + reassign-only result here is not vacuous. """ from __future__ import annotations @@ -50,6 +57,7 @@ ) from base.db.models import WorkAssignment from base.master.assignment import CAPABILITY_GPU, AssignmentService +from base.master.assignment_coordination import AssignmentCoordinationService from base.master.orchestration import ( ChallengePendingWork, MasterOrchestrationDriver, @@ -547,3 +555,84 @@ def test_val_cross_007_base_leg_report_data_reencode_breaks_verification() -> No ) is False ) + + +# =========================================================================== # +# VAL-CROSS-021 (base leg): flag OFF => the BASE side treats agent-challenge +# units as legacy (no Phala tier, no attestation gate, reassign-never-replicate). +# =========================================================================== # +def _build_flag_off_driver( + factory: Any, works: list[ChallengePendingWork], *, service: AssignmentService +) -> MasterOrchestrationDriver: + """A flag-OFF driver: no worker engine, no reconciler (legacy routing).""" + + return MasterOrchestrationDriver( + assignment_service=service, + validator_service=ValidatorCoordinationService(factory, now_fn=lambda: NOW), + work_source=_FakeWorkSource(works=works), + fold_trigger=_FakeFoldTrigger(), + worker_assignment_engine=None, + worker_reconciler=None, + seed=1, + ) + + +async def _set_validator_status( + factory: Any, hotkey: str, status: ValidatorStatus +) -> None: + async with session_scope(factory) as session: + validator = ( + await session.execute(select(Validator).where(Validator.hotkey == hotkey)) + ).scalar_one() + validator.status = status + + +async def test_val_cross_021_flag_off_base_treats_agent_challenge_units_as_legacy() -> ( + None +): + engine, factory = await _setup() + try: + # Flag OFF: even an attestation-bearing agent-challenge submission is + # bridged exactly like legacy -- no Phala tier required, no worker plane. + service = AssignmentService(factory, now_fn=lambda: NOW, default_max_attempts=3) + driver = _build_flag_off_driver( + factory, works=[_attested_agent_work()], service=service + ) + await _add_validator(factory, "v1", ["cpu"]) + + result = await driver.run_once() + assert result.worker is None # no worker-plane engine ran (legacy) + assert result.reconciliation is None # no attestation/audit reconcile + + units = await _units(factory) + cpu_ids = ["sub:a", "sub:b", "sub:c"] + assert set(units) == set(cpu_ids) # only the fanned cpu units + for uid in cpu_ids: + unit = units[uid] + assert unit.required_capability == "cpu" + assert unit.assigned_validator_hotkey == "v1" # single executor (R=1) + assert unit.attempt_count == 1 + assert await _replica_count(factory, uid) == 0 # never replicated + assert all(not uid.endswith(AUDIT_WORK_UNIT_SUFFIX) for uid in units) + + # Legacy reassign-on-failure (NEVER replicate): the owner crashes, the cpu + # units revert to pending and reassign to a DIFFERENT validator (exactly + # one more attempt) -- still zero worker replicas. The gpu R=2 replica in + # VAL-CROSS-003 above proves this R=1 reassign-only path is not vacuous. + coordination = AssignmentCoordinationService(factory, now_fn=lambda: NOW) + await coordination.pull(hotkey="v1") # assigned -> running + await _set_validator_status(factory, "v1", ValidatorStatus.OFFLINE) + await _add_validator(factory, "v2", ["cpu"]) + + outcome = await service.reclaim_stale_assignments() + assert set(outcome.reverted) == set(cpu_ids) + await service.assign_pending(seed=1) + + units = await _units(factory) + for uid in cpu_ids: + unit = units[uid] + assert unit.assigned_validator_hotkey == "v2" # reassigned, not replicated + assert unit.attempt_count == 2 # exactly one more attempt + assert await _replica_count(factory, uid) == 0 # NEVER a worker replica + finally: + await engine.dispose()