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/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..11a53345 --- /dev/null +++ b/src/base/worker/phala_quote.py @@ -0,0 +1,511 @@ +"""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. 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" + 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 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 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 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 = [] + 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..fe682824 --- /dev/null +++ b/src/base/worker/phala_verify.py @@ -0,0 +1,229 @@ +"""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 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 + +#: The static, allowlist-pinnable measurement fields (excludes runtime ``rtmr3``). +CANONICAL_MEASUREMENT_FIELDS: tuple[str, ...] = ( + "mrtd", + "rtmr0", + "rtmr1", + "rtmr2", + "compose_hash", + "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], +) -> 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. 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], ...] = () + + @classmethod + def from_measurements( + cls, measurements: Iterable[PhalaMeasurement | Mapping[str, object]] + ) -> 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) + + 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", + "MEASUREMENT_ALLOWLIST_FILE_ENV", + "InMemoryNonceValidator", + "MeasurementAllowlist", + "NonceState", + "NonceValidator", + "PhalaBinding", + "canonical_measurement_mapping", +] diff --git a/src/base/worker/proof.py b/src/base/worker/proof.py index 3dc02d3e..7fc85ec7 100644 --- a/src/base/worker/proof.py +++ b/src/base/worker/proof.py @@ -13,19 +13,55 @@ from __future__ import annotations import hashlib +import json +from collections.abc import Collection, Iterable, Mapping from typing import Any -from base.schemas.worker import ExecutionProof, ProviderInfo, WorkerSignature +from pydantic import ValidationError + +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 +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. 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 +78,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, @@ -73,27 +109,252 @@ 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). + """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. - Rejects a proof presented with a DIFFERENT ``unit_id`` than the one signed, - so a proof cannot be replayed across units. + 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( + 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__ = [ + "ACCEPTABLE_TCB_DEFAULT", "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/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_cross_integration_carry_chain.py b/tests/unit/test_cross_integration_carry_chain.py new file mode 100644 index 00000000..8fd35ac6 --- /dev/null +++ b/tests/unit/test_cross_integration_carry_chain.py @@ -0,0 +1,638 @@ +"""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. + +* **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 + +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.assignment_coordination import AssignmentCoordinationService +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 + ) + + +# =========================================================================== # +# 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() 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"] 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() diff --git a/tests/unit/test_phala_quote.py b/tests/unit/test_phala_quote.py new file mode 100644 index 00000000..2e51b091 --- /dev/null +++ b/tests/unit/test_phala_quote.py @@ -0,0 +1,238 @@ +"""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, + QuoteError, + 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",) + + +# --- 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(VerifierUnavailableError): + DcapQvlVerifier(runner=runner).verify("00" * 8) + + +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(VerifierUnavailableError): + DcapQvlVerifier(runner=runner).verify("00" * 8) + + +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") + + with pytest.raises(VerifierUnavailableError): + DcapQvlVerifier(runner=runner).verify("00" * 8) 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"] diff --git a/tests/unit/test_phala_verify.py b/tests/unit/test_phala_verify.py new file mode 100644 index 00000000..825d9989 --- /dev/null +++ b/tests/unit/test_phala_verify.py @@ -0,0 +1,169 @@ +"""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 + +import json + +from base.schemas.worker import PhalaMeasurement +from base.worker.phala_verify import ( + MEASUREMENT_ALLOWLIST_FILE_ENV, + 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, +} + +#: 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) + + +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 + + +# --- 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() + 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_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 + ) diff --git a/tests/unit/test_worker_proof_phala.py b/tests/unit/test_worker_proof_phala.py new file mode 100644 index 00000000..0a408df4 --- /dev/null +++ b/tests/unit/test_worker_proof_phala.py @@ -0,0 +1,408 @@ +"""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 + + +# --- 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 ------------------------------------------- + + +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) + + +# --- 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[attr-defined] + if k != missing + } + with pytest.raises(ValidationError): + PhalaAttestation.model_validate(attestation) 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..b0513607 --- /dev/null +++ b/tests/unit/test_worker_proof_phala_verify.py @@ -0,0 +1,869 @@ +"""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) + + +# --- 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 + )