diff --git a/CHANGELOG.md b/CHANGELOG.md index 5c332cd..234cc1c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,6 +19,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Sealed peer channel (Tier 2): `ca2a_runtime.channel` (`SealedChannel`, `generate_channel_keypair`, `open_sealed`). HPKE-style X25519 -> HKDF-SHA256 -> ChaCha20-Poly1305 sealing a payload to the peer's attested key; only the peer's private key opens it, and a wrong key or tampered ciphertext fails closed. Claim C4 (sealed-payload confidentiality) is now a validated experiment at the cryptographic layer. The enclave-binding of the private key (a hardware property) and live-path wiring remain open. - Cross-operator attestation (Claim C6) validated in software: a two-operator harness composing the SEV-SNP verifier, measurement pinning, and the sealed channel demonstrates independent keys, mutual attestation, confidential cross-operator delegation, and binary-swap detection. Synthetic report vectors (a genuine report needs SEV-SNP hardware); real hardware end to end remains open. **All six claims (C1-C6) are now validated experiments.** - cA2A-compatible conformance suite: `tests/conformance/` with a normative README (stable MUST/SHOULD test IDs across delegation, scope-policy, attestation, sealed channel, provenance, and the inbound pipeline) and runnable checks that exercise every MUST-level requirement. Wired into CI and documented at `docs/spec/conformance.md`; ties to the CHARTER trademark language. +- TPM 2.0 attestation backend: `ca2a_runtime.tee.tpm` (TPMS_ATTEST parsing, `TpmProvider`) and `ca2a_verify.tpm.verify_tpm_quote` (AK chain to a caller-supplied vendor root, AK signature over the attest blob (ECDSA or RSA), magic/type checks, and qualifying-data/PCR-digest binding), all fail-closed. Synthetic-vector validated; TPM AK roots are per-vendor so the caller supplies its trusted roots. Quote generation requires a real TPM. - Intel TDX attestation backend: `ca2a_runtime.tee.tdx` (DCAP Quote v4 parsing, `TdxProvider`) and `ca2a_verify.tdx.verify_tdx_quote` (PCK chain to a trusted Intel root, QE report signature, attestation-key binding, quote signature, and MRTD/report-data binding), all fail-closed. Chain path validated against the genuine Intel SGX Root CA; multi-level signature path validated with a synthetic self-consistent quote. Quote generation requires a real TDX guest. - Transport-agnostic inbound peer request handler: `ca2a_runtime.peer.handle_peer_request` with `PeerRequest` / `PeerResult`. Composes the full pipeline (verify chain, intersect scope and enforce, open a sealed payload with the enclave key, emit a linked provenance record) fail-closed. A transport parses its wire format into a `PeerRequest`; cA2A does not define the transport (profile, not protocol). - RFC 8785 (JSON Canonicalization Scheme) canonicalization: `ca2a_runtime.canonical.canonicalize`. Credential and provenance bodies are now signed over the JCS encoding (UTF-16 key ordering, JCS string escaping, literal non-ASCII, shortest-decimal integers), so cA2A signatures are cross-verifiable with agent-manifest. ASCII credentials are byte-identical to the previous encoding, so existing signatures still verify. @@ -28,6 +29,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Live A2A transport wiring for the peer-enforcement decision core, including binding the seal to a verified attestation report (Tier 2) - Cedar policy engine binding for the local policy (Tier 2) -- TPM attestation backend (Tier 3); end-to-end SEV-SNP and TDX validation against real hardware quotes +- End-to-end SEV-SNP, TDX, and TPM validation against real hardware quotes on a confidential VM [Unreleased]: https://github.com/agentrust-io/ca2a/commits/main diff --git a/LIMITATIONS.md b/LIMITATIONS.md index e8613e2..f9ac7c0 100644 --- a/LIMITATIONS.md +++ b/LIMITATIONS.md @@ -11,7 +11,7 @@ cA2A is a pre-release profile in active design. This document states plainly wha - **Runtime peer-delegation enforcement.** The runtime does not yet accept a delegation credential on a live inbound peer call, verify it in the request path, and intersect the delegated scope with a local Cedar policy. This is Tier 2 on the roadmap. - **Sealed peer channel.** The channel is implemented: a payload is sealed to the peer's attested X25519 key (X25519 ECDH, HKDF-SHA256, ChaCha20-Poly1305), and only the holder of the peer's private key can open it. The remaining gap is the hardware property that the private key never leaves the peer's enclave (established by attestation), and wiring the seal to a verified report on a live inbound call. Until that end-to-end binding lands on hardware, do not assume a payload is confined to a specific attested measurement. -- **Real hardware attestation.** The **SEV-SNP verifier is implemented**: report parsing, VCEK certificate chain verification, ECDSA-P384 report-signature verification, and measurement/report-data binding, all fail-closed. The chain path is validated against the genuine AMD Milan root chain; the report-signature path is validated with synthetic vectors, since a real report plus VCEK pair needs SEV-SNP hardware. Report generation (`SevSnpProvider.attest`) still requires a real SEV-SNP guest. The **Intel TDX verifier is also implemented** (DCAP Quote v4: PCK chain to the genuine Intel SGX Root CA, QE report, attestation-key binding, quote signature, MRTD binding; synthetic-quote validated, quote generation needs a TDX guest). **The TPM backend is not yet implemented (Tier 3).** Until a backend verifies a real quote end to end against a golden measurement on hardware, cA2A must not be described as fully attested across trust domains. +- **Real hardware attestation.** The **SEV-SNP verifier is implemented**: report parsing, VCEK certificate chain verification, ECDSA-P384 report-signature verification, and measurement/report-data binding, all fail-closed. The chain path is validated against the genuine AMD Milan root chain; the report-signature path is validated with synthetic vectors, since a real report plus VCEK pair needs SEV-SNP hardware. Report generation (`SevSnpProvider.attest`) still requires a real SEV-SNP guest. The **Intel TDX verifier** (DCAP Quote v4: PCK chain to the genuine Intel SGX Root CA, QE report, attestation-key binding, quote signature, MRTD binding) and the **TPM 2.0 verifier** (AK chain to a caller-supplied vendor root, AK signature, magic/type, qualifying-data and PCR-digest binding) are also implemented and synthetic-vector validated; quote generation needs the respective hardware. Until a backend verifies a real quote end to end against a golden measurement on hardware, cA2A must not be described as fully attested across trust domains. ## Out of scope diff --git a/ROADMAP.md b/ROADMAP.md index 8057586..26578df 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -32,8 +32,9 @@ Real hardware attestation verification (SEV-SNP VCEK chain, Intel TDX quote via - **SEV-SNP verifier: landed.** Report parsing, VCEK chain verification (validated against the real AMD Milan root), ECDSA-P384 report-signature verification, and measurement/report-data binding, all fail-closed. Report generation still requires a real SEV-SNP guest. See `ca2a_verify.sev_snp` and [docs/spec/attestation.md](docs/spec/attestation.md). - **TDX verifier: landed.** DCAP Quote v4 parsing, PCK chain to the genuine Intel SGX Root CA, QE report signature, attestation-key binding, quote signature, and MRTD binding, all fail-closed. Quote generation requires a real TDX guest. See `ca2a_verify.tdx`. +- **TPM 2.0 verifier: landed.** TPMS_ATTEST parsing, AK chain to a caller-supplied vendor root, AK signature (ECDSA or RSA), magic/type checks, and qualifying-data/PCR-digest binding, all fail-closed. Quote generation requires a real TPM. See `ca2a_verify.tpm`. - **Cross-operator attestation (C6): validated in software.** A two-operator harness (SEV-SNP verifier + measurement pinning + sealed channel) shows independent keys, mutual attestation, confidential cross-operator delegation, and binary-swap detection. All six claims (C1-C6) are now validated experiments. -- **Pending:** the TPM backend; end-to-end validation of the SEV-SNP and TDX signature paths against real hardware quotes on a confidential VM; and a transport that parses real A2A wire messages into a `PeerRequest`. +- **Pending:** end-to-end validation of the SEV-SNP, TDX, and TPM signature paths against real hardware quotes on a confidential VM; and a transport that parses real A2A wire messages into a `PeerRequest`. ## v1.0: Stable profile diff --git a/docs/spec/attestation.md b/docs/spec/attestation.md index ac709e5..b8bb1e1 100644 --- a/docs/spec/attestation.md +++ b/docs/spec/attestation.md @@ -18,7 +18,7 @@ An `AttestationReport` carries `platform`, `measurement`, the bound `public_key` | `software-only` | none | Available; for development and CI. Reports `platform: software-only`, never a hardware platform string. | | `sev-snp` | AMD SEV-SNP | Verifier implemented (see below). Report generation requires a real SEV-SNP guest. | | `tdx` | Intel TDX | Verifier implemented (see below). Quote generation requires a real TDX guest. | -| `tpm` | TPM 2.0 / vTPM | Tier 3, not yet implemented | +| `tpm` | TPM 2.0 / vTPM | Verifier implemented (see below). Quote generation requires a real TPM. | | `opaque` | OPAQUE Confidential Runtime | Tier 3, explicit opt-in, not auto-selected | ## SEV-SNP verification @@ -39,9 +39,15 @@ An `AttestationReport` carries `platform`, `measurement`, the bound `public_key` **What is validated.** The chain-verification path accepts the genuine self-signed Intel SGX Root CA fetched from Intel (`tests/fixtures/tdx/`) and rejects an untrusted root. The multi-level signature path (PCK to QE report to attestation key to quote) is exercised end to end with a synthetic self-consistent quote, because a genuine quote requires a TDX guest. Byte offsets follow the Intel DCAP Quote v4 layout; end-to-end validation against a real hardware quote requires a TDX guest and remains open. +## TPM verification + +`ca2a_verify.tpm.verify_tpm_quote` appraises a TPM 2.0 quote (`TPMS_ATTEST`) offline: the AK certificate chain is verified to a trusted root, the AK signature over the attest blob is verified (ECDSA-SHA256 or RSA PKCS#1 v1.5), the structure is confirmed to be a TPM-generated quote (magic and type), and the qualifying data (the verifier's nonce) and the PCR digest (the platform measurement) are checked against expected values. + +**What is validated.** Unlike SEV-SNP and TDX, TPM attestation keys chain to per-vendor EK roots, so there is no single published root to validate against; the caller supplies the vendor roots it trusts, and the verifier is exercised against synthetic self-consistent vectors. Producing a quote (`TpmProvider.attest`) fails closed off a real TPM. + ## Fail closed -Providers without a backend `detect()` to False, so they are never selected automatically, and verification fails closed when evidence is absent or invalid. This is deliberate: cA2A must not be described as attested across trust domains until a real hardware backend verifies a quote against a golden measurement on hardware. The TPM backend remains Tier 3. See [LIMITATIONS.md](../../LIMITATIONS.md). +Providers without a backend `detect()` to False, so they are never selected automatically, and verification fails closed when evidence is absent or invalid. This is deliberate: cA2A must not be described as attested across trust domains until a backend verifies a quote against a golden measurement on real hardware. See [LIMITATIONS.md](../../LIMITATIONS.md). ## Why this is the critical path diff --git a/pyproject.toml b/pyproject.toml index 2d231be..aba9592 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,95 +1,95 @@ -[build-system] -requires = ["hatchling"] -build-backend = "hatchling.build" - -[project] -name = "ca2a-runtime" -version = "0.1.0" -description = "Confidential agent-to-agent runtime: attested, attenuated delegation and sealed peer channels for A2A" -readme = "README.md" -license = { text = "MIT" } -authors = [ - { name = "AgentTrust Contributors", email = "oss@agentrust.io" }, -] -keywords = ["a2a", "agent-to-agent", "delegation", "tee", "attestation", "confidential-computing", "ai-agents"] -classifiers = [ - "Development Status :: 3 - Alpha", - "Intended Audience :: Developers", - "License :: OSI Approved :: MIT License", - "Programming Language :: Python :: 3", - "Programming Language :: Python :: 3.11", - "Programming Language :: Python :: 3.12", - "Programming Language :: Python :: 3.13", - "Topic :: Security", -] -requires-python = ">=3.11" -dependencies = [ - "cryptography>=42.0", - "pyyaml>=6.0", -] - -[project.optional-dependencies] -dev = [ - "pytest>=8.0", - "pytest-asyncio>=0.23", - "pytest-cov>=5.0", - "ruff>=0.4", - "mypy>=1.10", - "types-pyyaml", - "bandit[toml]>=1.7", - "pip-audit>=2.6", -] - -[project.scripts] -ca2a = "ca2a_runtime.cli:main" - -[project.urls] -Homepage = "https://github.com/agentrust-io/ca2a" -Repository = "https://github.com/agentrust-io/ca2a" -Documentation = "https://github.com/agentrust-io/ca2a/tree/main/docs" -"Bug Tracker" = "https://github.com/agentrust-io/ca2a/issues" - -[tool.hatch.build.targets.wheel] -packages = ["src/ca2a_runtime", "src/ca2a_verify"] - -[tool.pytest.ini_options] -testpaths = ["tests"] -asyncio_mode = "auto" -addopts = "-v --tb=short" -pythonpath = ["src"] - -[tool.ruff] -src = ["src"] -line-length = 100 -target-version = "py311" - -[tool.ruff.lint] -select = ["E", "F", "W", "I", "UP", "B", "C4", "PIE", "T20", "RET", "SIM"] -ignore = ["E501"] - -[tool.ruff.lint.per-file-ignores] -"src/ca2a_runtime/benchmarks.py" = ["T201"] -"src/ca2a_runtime/cli.py" = ["T201"] -"scripts/gen_example_chain.py" = ["T201"] -"scripts/gen_agt_evidence.py" = ["T201"] - -[tool.bandit] -skips = ["B101"] - -[tool.coverage.run] -source = ["src"] -omit = ["*/cli.py", "*/benchmarks.py"] - -[tool.coverage.report] -fail_under = 70 - -[tool.mypy] -python_version = "3.11" -strict = true -ignore_missing_imports = true -warn_return_any = false -files = ["src/ca2a_runtime", "src/ca2a_verify"] - -[[tool.mypy.overrides]] -module = ["ca2a_runtime.tee.sev_snp", "ca2a_runtime.tee.tdx", "ca2a_verify.tdx", "ca2a_runtime.channel.sealed"] -warn_unused_ignores = false +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[project] +name = "ca2a-runtime" +version = "0.1.0" +description = "Confidential agent-to-agent runtime: attested, attenuated delegation and sealed peer channels for A2A" +readme = "README.md" +license = { text = "MIT" } +authors = [ + { name = "AgentTrust Contributors", email = "oss@agentrust.io" }, +] +keywords = ["a2a", "agent-to-agent", "delegation", "tee", "attestation", "confidential-computing", "ai-agents"] +classifiers = [ + "Development Status :: 3 - Alpha", + "Intended Audience :: Developers", + "License :: OSI Approved :: MIT License", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3.13", + "Topic :: Security", +] +requires-python = ">=3.11" +dependencies = [ + "cryptography>=42.0", + "pyyaml>=6.0", +] + +[project.optional-dependencies] +dev = [ + "pytest>=8.0", + "pytest-asyncio>=0.23", + "pytest-cov>=5.0", + "ruff>=0.4", + "mypy>=1.10", + "types-pyyaml", + "bandit[toml]>=1.7", + "pip-audit>=2.6", +] + +[project.scripts] +ca2a = "ca2a_runtime.cli:main" + +[project.urls] +Homepage = "https://github.com/agentrust-io/ca2a" +Repository = "https://github.com/agentrust-io/ca2a" +Documentation = "https://github.com/agentrust-io/ca2a/tree/main/docs" +"Bug Tracker" = "https://github.com/agentrust-io/ca2a/issues" + +[tool.hatch.build.targets.wheel] +packages = ["src/ca2a_runtime", "src/ca2a_verify"] + +[tool.pytest.ini_options] +testpaths = ["tests"] +asyncio_mode = "auto" +addopts = "-v --tb=short" +pythonpath = ["src"] + +[tool.ruff] +src = ["src"] +line-length = 100 +target-version = "py311" + +[tool.ruff.lint] +select = ["E", "F", "W", "I", "UP", "B", "C4", "PIE", "T20", "RET", "SIM"] +ignore = ["E501"] + +[tool.ruff.lint.per-file-ignores] +"src/ca2a_runtime/benchmarks.py" = ["T201"] +"src/ca2a_runtime/cli.py" = ["T201"] +"scripts/gen_example_chain.py" = ["T201"] +"scripts/gen_agt_evidence.py" = ["T201"] + +[tool.bandit] +skips = ["B101"] + +[tool.coverage.run] +source = ["src"] +omit = ["*/cli.py", "*/benchmarks.py"] + +[tool.coverage.report] +fail_under = 70 + +[tool.mypy] +python_version = "3.11" +strict = true +ignore_missing_imports = true +warn_return_any = false +files = ["src/ca2a_runtime", "src/ca2a_verify"] + +[[tool.mypy.overrides]] +module = ["ca2a_runtime.tee.sev_snp", "ca2a_runtime.tee.tdx", "ca2a_runtime.tee.tpm", "ca2a_verify.tdx", "ca2a_verify.tpm", "ca2a_runtime.channel.sealed"] +warn_unused_ignores = false diff --git a/src/ca2a_runtime/tee/__init__.py b/src/ca2a_runtime/tee/__init__.py index 5b7ddcf..d033672 100644 --- a/src/ca2a_runtime/tee/__init__.py +++ b/src/ca2a_runtime/tee/__init__.py @@ -8,6 +8,7 @@ from ca2a_runtime.tee.base import AttestationReport, BaseProvider from ca2a_runtime.tee.sev_snp import SevSnpProvider, SevSnpReport from ca2a_runtime.tee.tdx import TdxProvider, TdxQuote +from ca2a_runtime.tee.tpm import TpmProvider, TpmQuote __all__ = [ "AttestationReport", @@ -16,4 +17,6 @@ "SevSnpReport", "TdxProvider", "TdxQuote", + "TpmProvider", + "TpmQuote", ] diff --git a/src/ca2a_runtime/tee/tpm.py b/src/ca2a_runtime/tee/tpm.py new file mode 100644 index 0000000..44f5ec2 --- /dev/null +++ b/src/ca2a_runtime/tee/tpm.py @@ -0,0 +1,98 @@ +"""TPM 2.0 quote (TPMS_ATTEST) parsing and the TPM provider. + +Parses the ``TPMS_ATTEST`` structure a TPM produces for a quote: the magic value +that proves the structure was generated by a TPM, the attest type, the qualifying +data (the verifier's nonce, carried in ``extraData``), and the PCR digest that +serves as the platform measurement. Verification lives in +:mod:`ca2a_verify.tpm`. + +Unlike AMD SEV-SNP and Intel TDX, TPM attestation keys chain to per-vendor EK +roots rather than one published root, so there is no single real root to +validate against. The verifier is exercised against synthetic self-consistent +vectors, and a production deployment supplies its own trusted vendor roots. +Producing a quote requires a real TPM, so :meth:`TpmProvider.attest` fails closed. +""" + +from __future__ import annotations + +from dataclasses import dataclass + +from ca2a_runtime.errors import AttestationFailed, AttestationUnsupported +from ca2a_runtime.tee.base import AttestationReport, BaseProvider + +TPM_GENERATED_VALUE = 0xFF544347 +TPM_ST_ATTEST_QUOTE = 0x8018 +CLOCK_INFO_LEN = 17 +FIRMWARE_VERSION_LEN = 8 + +TPM_DEVICES = ("/dev/tpmrm0", "/dev/tpm0") + + +def _read_u16(buf: bytes, pos: int) -> tuple[int, int]: + if pos + 2 > len(buf): + raise AttestationFailed("TPM quote truncated reading a 16-bit field") + return int.from_bytes(buf[pos : pos + 2], "big"), pos + 2 + + +def _read_2b(buf: bytes, pos: int) -> tuple[bytes, int]: + size, pos = _read_u16(buf, pos) + if pos + size > len(buf): + raise AttestationFailed("TPM quote truncated reading a sized buffer") + return buf[pos : pos + size], pos + size + + +@dataclass(frozen=True) +class TpmQuote: + """The parsed subset of a TPM 2.0 quote (TPMS_ATTEST) cA2A appraises.""" + + magic: int + attest_type: int + qualifying_data: bytes # extraData: the verifier's nonce + pcr_digest: bytes # the platform measurement + raw: bytes + + @classmethod + def parse(cls, blob: bytes) -> TpmQuote: + if len(blob) < 6: + raise AttestationFailed("TPM quote too short") + magic = int.from_bytes(blob[0:4], "big") + attest_type, pos = _read_u16(blob, 4) + _qualified_signer, pos = _read_2b(blob, pos) # TPM2B_NAME + qualifying_data, pos = _read_2b(blob, pos) # extraData / nonce + pos += CLOCK_INFO_LEN + FIRMWARE_VERSION_LEN + # TPML_PCR_SELECTION + if pos + 4 > len(blob): + raise AttestationFailed("TPM quote truncated reading PCR selection count") + count = int.from_bytes(blob[pos : pos + 4], "big") + pos += 4 + for _ in range(count): + if pos + 3 > len(blob): + raise AttestationFailed("TPM quote truncated reading a PCR selection") + size_of_select = blob[pos + 2] + pos += 3 + size_of_select + pcr_digest, pos = _read_2b(blob, pos) + return cls( + magic=magic, + attest_type=attest_type, + qualifying_data=qualifying_data, + pcr_digest=pcr_digest, + raw=bytes(blob), + ) + + +class TpmProvider(BaseProvider): + """TPM 2.0 provider. Quote generation requires a real TPM.""" + + platform = "tpm" + + @classmethod + def detect(cls) -> bool: + import os + + return any(os.path.exists(dev) for dev in TPM_DEVICES) + + def attest(self, public_key: str, nonce: str) -> AttestationReport: + raise AttestationUnsupported( + "TPM quote generation requires a real TPM", + detail="no TPM device present; run on a host with a TPM 2.0 / vTPM", + ) diff --git a/src/ca2a_verify/tpm.py b/src/ca2a_verify/tpm.py new file mode 100644 index 0000000..ddc0264 --- /dev/null +++ b/src/ca2a_verify/tpm.py @@ -0,0 +1,72 @@ +"""Offline appraisal of a TPM 2.0 quote. + +Appraisal is fail-closed: + +1. The AK certificate chain is verified up to a trusted (vendor-supplied) root. +2. The AK signature over the ``TPMS_ATTEST`` blob is verified (ECDSA or RSA). +3. The structure is confirmed to be a TPM-generated quote (magic and type). +4. The qualifying data (the verifier's nonce) and the PCR digest (the platform + measurement) are checked against expected values. + +There is no single published TPM root; the caller supplies the vendor roots it +trusts. TPM AK signature schemes vary; this verifier supports ECDSA (over +SHA-256) and RSA PKCS#1 v1.5 (SHA-256) attestation keys. +""" + +from __future__ import annotations + +from cryptography import x509 +from cryptography.exceptions import InvalidSignature +from cryptography.hazmat.primitives.asymmetric import ec, padding, rsa +from cryptography.hazmat.primitives.hashes import SHA256 + +from ca2a_runtime.errors import AttestationFailed +from ca2a_runtime.tee.tpm import TPM_GENERATED_VALUE, TPM_ST_ATTEST_QUOTE, TpmQuote +from ca2a_verify.sev_snp import verify_cert_chain + +__all__ = ["verify_tpm_quote"] + + +def _verify_ak_signature(ak: x509.Certificate, signature: bytes, message: bytes) -> None: + key = ak.public_key() + try: + if isinstance(key, ec.EllipticCurvePublicKey): + key.verify(signature, message, ec.ECDSA(SHA256())) + elif isinstance(key, rsa.RSAPublicKey): + key.verify(signature, message, padding.PKCS1v15(), SHA256()) + else: + raise AttestationFailed("unsupported AK public-key type for TPM quote") + except InvalidSignature as exc: + raise AttestationFailed("TPM quote signature failed to verify") from exc + + +def verify_tpm_quote( + attest: bytes, + signature: bytes, + ak_chain: list[x509.Certificate], + *, + trusted_roots: list[x509.Certificate], + expected_pcr_digest: bytes | None = None, + expected_qualifying_data: bytes | None = None, +) -> TpmQuote: + """Appraise a TPM 2.0 quote offline. Raises AttestationFailed on any failure.""" + quote = TpmQuote.parse(attest) + + if quote.magic != TPM_GENERATED_VALUE: + raise AttestationFailed( + "TPMS_ATTEST magic is not TPM_GENERATED", detail=f"magic={quote.magic:#x}" + ) + if quote.attest_type != TPM_ST_ATTEST_QUOTE: + raise AttestationFailed( + "attestation is not a quote", detail=f"type={quote.attest_type:#x}" + ) + + verify_cert_chain(ak_chain, trusted_roots) + _verify_ak_signature(ak_chain[0], signature, attest) + + if expected_qualifying_data is not None and quote.qualifying_data != expected_qualifying_data: + raise AttestationFailed("qualifying data (nonce) does not match the expected value") + if expected_pcr_digest is not None and quote.pcr_digest != expected_pcr_digest: + raise AttestationFailed("PCR digest does not match the expected measurement") + + return quote diff --git a/tests/unit/test_tpm.py b/tests/unit/test_tpm.py new file mode 100644 index 0000000..58ae2f6 --- /dev/null +++ b/tests/unit/test_tpm.py @@ -0,0 +1,120 @@ +"""Tests for TPM 2.0 quote parsing and offline appraisal. + +TPM attestation keys chain to per-vendor EK roots, so there is no single real +root to validate against; these tests build a synthetic self-consistent quote +and AK chain. The parsing follows the TPMS_ATTEST layout. +""" + +from __future__ import annotations + +import struct + +import pytest +from cryptography.hazmat.primitives.asymmetric import ec +from cryptography.hazmat.primitives.hashes import SHA256 + +from ca2a_runtime.errors import AttestationFailed, AttestationUnsupported +from ca2a_runtime.tee.tpm import ( + TPM_GENERATED_VALUE, + TPM_ST_ATTEST_QUOTE, + TpmProvider, + TpmQuote, +) +from ca2a_verify.tpm import verify_tpm_quote +from tests.unit.conftest import make_ec_cert + + +def build_attest(*, qualifying_data: bytes, pcr_digest: bytes, + magic: int = TPM_GENERATED_VALUE, attest_type: int = TPM_ST_ATTEST_QUOTE) -> bytes: + out = bytearray() + out += struct.pack(">I", magic) + out += struct.pack(">H", attest_type) + out += struct.pack(">H", 0) # qualifiedSigner TPM2B_NAME, empty + out += struct.pack(">H", len(qualifying_data)) + qualifying_data # extraData + out += b"\x00" * 17 # clockInfo + out += b"\x00" * 8 # firmwareVersion + # TPML_PCR_SELECTION: 1 selection, SHA-256, 3 select bytes + out += struct.pack(">I", 1) + struct.pack(">H", 0x000B) + bytes([3]) + b"\x03\x00\x00" + out += struct.pack(">H", len(pcr_digest)) + pcr_digest + return bytes(out) + + +def _ak_chain(): + root_key = ec.generate_private_key(ec.SECP256R1()) + root = make_ec_cert("vendor-root", "vendor-root", root_key, root_key) + ak_key = ec.generate_private_key(ec.SECP256R1()) + ak = make_ec_cert("AK", "vendor-root", ak_key, root_key) + return ak_key, [ak, root], root + + +def _signed(ak_key: ec.EllipticCurvePrivateKey, attest: bytes) -> bytes: + return ak_key.sign(attest, ec.ECDSA(SHA256())) + + +def test_valid_quote_verifies() -> None: + ak_key, chain, root = _ak_chain() + nonce, pcr = b"nonce-1234", b"\x11" * 32 + attest = build_attest(qualifying_data=nonce, pcr_digest=pcr) + q = verify_tpm_quote(attest, _signed(ak_key, attest), chain, trusted_roots=[root], + expected_pcr_digest=pcr, expected_qualifying_data=nonce) + assert q.pcr_digest == pcr + assert q.qualifying_data == nonce + + +def test_wrong_pcr_digest_fails() -> None: + ak_key, chain, root = _ak_chain() + attest = build_attest(qualifying_data=b"n", pcr_digest=b"\x11" * 32) + with pytest.raises(AttestationFailed): + verify_tpm_quote(attest, _signed(ak_key, attest), chain, trusted_roots=[root], + expected_pcr_digest=b"\x99" * 32) + + +def test_wrong_nonce_fails() -> None: + ak_key, chain, root = _ak_chain() + attest = build_attest(qualifying_data=b"real", pcr_digest=b"\x11" * 32) + with pytest.raises(AttestationFailed): + verify_tpm_quote(attest, _signed(ak_key, attest), chain, trusted_roots=[root], + expected_qualifying_data=b"expected") + + +def test_tampered_attest_fails() -> None: + ak_key, chain, root = _ak_chain() + attest = bytearray(build_attest(qualifying_data=b"n", pcr_digest=b"\x11" * 32)) + sig = _signed(ak_key, bytes(attest)) + attest[-1] ^= 0xFF # change PCR digest after signing + with pytest.raises(AttestationFailed): + verify_tpm_quote(bytes(attest), sig, chain, trusted_roots=[root]) + + +def test_untrusted_root_fails() -> None: + ak_key, chain, _ = _ak_chain() + attest = build_attest(qualifying_data=b"n", pcr_digest=b"\x11" * 32) + stranger = make_ec_cert("s", "s", ec.generate_private_key(ec.SECP256R1()), + ec.generate_private_key(ec.SECP256R1())) + with pytest.raises(AttestationFailed): + verify_tpm_quote(attest, _signed(ak_key, attest), chain, trusted_roots=[stranger]) + + +def test_bad_magic_rejected() -> None: + ak_key, chain, root = _ak_chain() + attest = build_attest(qualifying_data=b"n", pcr_digest=b"\x11" * 32, magic=0x00000000) + with pytest.raises(AttestationFailed): + verify_tpm_quote(attest, _signed(ak_key, attest), chain, trusted_roots=[root]) + + +def test_wrong_attest_type_rejected() -> None: + ak_key, chain, root = _ak_chain() + attest = build_attest(qualifying_data=b"n", pcr_digest=b"\x11" * 32, attest_type=0x8017) + with pytest.raises(AttestationFailed): + verify_tpm_quote(attest, _signed(ak_key, attest), chain, trusted_roots=[root]) + + +def test_short_quote_rejected() -> None: + with pytest.raises(AttestationFailed): + TpmQuote.parse(b"\x00\x00") + + +def test_provider_detect_and_attest() -> None: + assert TpmProvider.detect() is False + with pytest.raises(AttestationUnsupported): + TpmProvider().attest("deadbeef", "nonce")