diff --git a/CHANGELOG.md b/CHANGELOG.md index 0ffa4f7..aea6045 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -29,6 +29,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 Not addressed here, per the issue: binding the audit-chain root, which is session activity rather than gateway identity and which AUDIT-006 already owns in the per-session report's `report_data[32:64]`, and validation on real SEV-SNP and TDX silicon. The contract lands with software-only proof: the round trip through `SoftwareOnlyProvider` composes the nonce, takes the report, and has an independent recompute match both halves, with a stale pre-reload measurement correctly rejected. +- **The public chained TPM quote verifier discarded the signature scheme and digest after parsing them (agent-manifest#320).** Passing only the bare signature made the shared verifier apply its legacy RSASSA/SHA-256 defaults: a valid RSAPSS/SHA-384 quote failed, while false scheme or digest metadata was not authoritative. `verify_tpm_quote_chained` now forwards the complete `ParsedSignature`; the existing Azure RSASSA/SHA-256 vector remains unchanged. This helper currently has no runtime caller, so the fix corrects the public API and its future reuse rather than changing today's TRACE claim-verification path. + - **An approval record stopped verifying once its approvals expired (#533, follow-up to #531).** `verify_catalog_change` judged `approved_at` and `expires_at` against `time.time()`, so a record that was valid when the catalog was approved became permanently unverifiable, and the chain #517 exists to let an auditor replay could not be replayed. That made the record an authorization token with a lifetime rather than a provenance record. The interval is now what it says it is: an assertion about when the signature could have been produced. The caller passes `validity_instant`, a pinned checkpoint or transparency-receipt timestamp where it has one, and where it passes nothing each approval is judged at its own `approved_at`. Requiring a pin instead would mean an auditor cannot verify a record without also holding the pin, which is a worse default than the one it replaces. The `now` parameter is gone with the wall clock, and `time` is no longer imported. diff --git a/pyproject.toml b/pyproject.toml index d6eeea5..e2b5ea6 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -27,11 +27,9 @@ classifiers = [ requires-python = ">=3.11" dependencies = [ "agentrust-trace>=0.5", - # 0.6.1 is the floor, not merely a compatible version: verify_manifest() - # is called on a peer-supplied manifest below, and before 0.6.1 a manifest - # declaring ML-DSA-65 or hybrid crashed the verifier with an uncaught - # RuntimeError on any install without the optional [pq] extra. - "agent-manifest>=0.11", + # 0.11.1 is the floor: the chained TPM verifier forwards ParsedSignature so + # the envelope's declared signature scheme and digest remain authoritative. + "agent-manifest>=0.11.1", # AGT's remaining gateway/scanner integrations currently constrain this; # the Cedar enforcement path itself no longer depends on AGT (#472). "cryptography>=42.0", diff --git a/src/cmcp_verify/tpm.py b/src/cmcp_verify/tpm.py index 9d99b24..1b9b6d5 100644 --- a/src/cmcp_verify/tpm.py +++ b/src/cmcp_verify/tpm.py @@ -291,10 +291,9 @@ def verify_quote_signature( # --------------------------------------------------------------------------- # Chained verification (issues #431, #447) # -# The signature, chain, and root pinning all live in agent-manifest, which cMCP -# already depends on and which is hardware-validated. cMCP keeps only the piece -# agent-manifest does not model: the TPMT_SIGNATURE wire format written by -# tpm2_quote and by tpm2-pytss `signature.marshal()`. +# TPMT_SIGNATURE parsing, scheme and digest enforcement, chain verification, and +# root pinning all live in agent-manifest. cMCP keeps its stable public entry point +# plus the ``(verified, details)`` result shaping used by existing callers. # --------------------------------------------------------------------------- @@ -450,8 +449,9 @@ def verify_tpm_quote_chained( Fully verify a TPM quote: signature, certificate chain, and pinned root. Delegates the cryptography to ``agent_manifest.verify_tpm_quote`` rather than - reimplementing it. ``signature_blob`` is a marshalled TPMT_SIGNATURE; the raw - signature is extracted here because agent-manifest takes the bare signature. + reimplementing it. ``signature_blob`` is a marshalled TPMT_SIGNATURE. Its + parsed signature, scheme, and digest are forwarded together so the envelope's + declared algorithms remain authoritative during verification. Returns (verified, details) and never raises. A malformed quote, a broken chain, or a root outside ``trusted_roots_pem`` all report verified=False with a @@ -470,7 +470,7 @@ def verify_tpm_quote_chained( try: ok = verify_tpm_quote( attest, - parsed.signature, + parsed, ak_chain_pem, trusted_roots_pem=trusted_roots_pem, expected_qualifying_data=expected_qualifying_data, diff --git a/tests/unit/test_tpm_chained_signature_metadata.py b/tests/unit/test_tpm_chained_signature_metadata.py new file mode 100644 index 0000000..6b1b10e --- /dev/null +++ b/tests/unit/test_tpm_chained_signature_metadata.py @@ -0,0 +1,177 @@ +"""TPMT_SIGNATURE metadata must reach the shared chained TPM verifier.""" + +from __future__ import annotations + +import datetime +import struct + +import pytest +from cryptography import x509 +from cryptography.hazmat.primitives import hashes, serialization +from cryptography.hazmat.primitives.asymmetric import padding, rsa +from cryptography.x509.oid import NameOID + +from cmcp_verify.tpm import verify_tpm_quote_chained + +_ALG_RSASSA = 0x0014 +_ALG_RSAPSS = 0x0016 +_ALG_SHA256 = 0x000B +_ALG_SHA384 = 0x000C +_TPM_GENERATED_VALUE = 0xFF544347 +_TPM_ST_ATTEST_QUOTE = 0x8018 + +_NONCE = bytes(range(32)) +_PCR_DIGEST = bytes(range(32, 64)) +_NOW = datetime.datetime.now(datetime.UTC) +_NOT_BEFORE = _NOW - datetime.timedelta(days=1) +_NOT_AFTER = _NOW + datetime.timedelta(days=3650) + + +def _name(common_name: str) -> x509.Name: + return x509.Name([x509.NameAttribute(NameOID.COMMON_NAME, common_name)]) + + +def _certificate( + *, + subject: str, + subject_key: rsa.RSAPublicKey, + issuer: str, + issuer_key: rsa.RSAPrivateKey, + is_ca: bool, +) -> x509.Certificate: + return ( + x509.CertificateBuilder() + .subject_name(_name(subject)) + .issuer_name(_name(issuer)) + .public_key(subject_key) + .serial_number(x509.random_serial_number()) + .not_valid_before(_NOT_BEFORE) + .not_valid_after(_NOT_AFTER) + .add_extension(x509.BasicConstraints(ca=is_ca, path_length=None), critical=True) + .sign(issuer_key, hashes.SHA256()) + ) + + +@pytest.fixture(scope="module") +def rsa_quote_material() -> tuple[rsa.RSAPrivateKey, bytes, bytes, bytes]: + root_key = rsa.generate_private_key(public_exponent=65537, key_size=2048) + ak_key = rsa.generate_private_key(public_exponent=65537, key_size=2048) + root = _certificate( + subject="test-tpm-root", + subject_key=root_key.public_key(), + issuer="test-tpm-root", + issuer_key=root_key, + is_ca=True, + ) + ak = _certificate( + subject="test-ak", + subject_key=ak_key.public_key(), + issuer="test-tpm-root", + issuer_key=root_key, + is_ca=False, + ) + chain = ak.public_bytes(serialization.Encoding.PEM) + root.public_bytes( + serialization.Encoding.PEM + ) + return ak_key, _quote(), chain, root.public_bytes(serialization.Encoding.PEM) + + +def _quote() -> bytes: + return ( + struct.pack(">IH", _TPM_GENERATED_VALUE, _TPM_ST_ATTEST_QUOTE) + + struct.pack(">H", 0) # qualifiedSigner + + struct.pack(">H", len(_NONCE)) + + _NONCE + + b"\x00" * 17 # clockInfo + + b"\x00" * 8 # firmwareVersion + + struct.pack(">IHB", 1, _ALG_SHA256, 3) # one PCR selection + + b"\x00\x00\x01" + + struct.pack(">H", len(_PCR_DIGEST)) + + _PCR_DIGEST + ) + + +def _rsa_tpmt_signature( + key: rsa.RSAPrivateKey, + attest: bytes, + *, + actual_padding: padding.AsymmetricPadding, + actual_digest: hashes.HashAlgorithm, + declared_scheme: int, + declared_hash: int, +) -> bytes: + signature = key.sign(attest, actual_padding, actual_digest) + return struct.pack(">HHH", declared_scheme, declared_hash, len(signature)) + signature + + +def _verify( + signature: bytes, material: tuple[rsa.RSAPrivateKey, bytes, bytes, bytes] +) -> tuple[bool, dict[str, str]]: + _key, attest, chain, root = material + return verify_tpm_quote_chained( + attest, + signature, + chain, + trusted_roots_pem=root, + expected_qualifying_data=_NONCE, + expected_pcr_digest=_PCR_DIGEST, + ) + + +def test_chained_verifier_accepts_rsapss_sha384( + rsa_quote_material: tuple[rsa.RSAPrivateKey, bytes, bytes, bytes], +) -> None: + key, attest, _chain, _root = rsa_quote_material + digest = hashes.SHA384() + signature = _rsa_tpmt_signature( + key, + attest, + actual_padding=padding.PSS(mgf=padding.MGF1(digest), salt_length=digest.digest_size), + actual_digest=digest, + declared_scheme=_ALG_RSAPSS, + declared_hash=_ALG_SHA384, + ) + + verified, details = _verify(signature, rsa_quote_material) + + assert verified is True + assert details["signature_algorithm"] == "rsapss" + assert details["signature_digest"] == "0x000c" + + +def test_chained_verifier_rejects_false_hash_metadata( + rsa_quote_material: tuple[rsa.RSAPrivateKey, bytes, bytes, bytes], +) -> None: + key, attest, _chain, _root = rsa_quote_material + signature = _rsa_tpmt_signature( + key, + attest, + actual_padding=padding.PKCS1v15(), + actual_digest=hashes.SHA256(), + declared_scheme=_ALG_RSASSA, + declared_hash=_ALG_SHA384, + ) + + verified, details = _verify(signature, rsa_quote_material) + + assert verified is False + assert details == {"verification": "signature or binding mismatch"} + + +def test_chained_verifier_rejects_false_scheme_metadata( + rsa_quote_material: tuple[rsa.RSAPrivateKey, bytes, bytes, bytes], +) -> None: + key, attest, _chain, _root = rsa_quote_material + signature = _rsa_tpmt_signature( + key, + attest, + actual_padding=padding.PKCS1v15(), + actual_digest=hashes.SHA256(), + declared_scheme=_ALG_RSAPSS, + declared_hash=_ALG_SHA256, + ) + + verified, details = _verify(signature, rsa_quote_material) + + assert verified is False + assert details == {"verification": "signature or binding mismatch"} diff --git a/tests/unit/test_tpm_chained_verify.py b/tests/unit/test_tpm_chained_verify.py index 2491b27..81e8dab 100644 --- a/tests/unit/test_tpm_chained_verify.py +++ b/tests/unit/test_tpm_chained_verify.py @@ -205,6 +205,7 @@ def test_real_quote_verifies_against_the_pinned_azure_root() -> None: assert verified is True assert details["chain"] == "verified to a pinned root" assert details["signature_algorithm"] == "rsassa" + assert details["signature_digest"] == "0x000b" def test_a_wrong_nonce_fails_the_binding() -> None: