Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
8 changes: 3 additions & 5 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
14 changes: 7 additions & 7 deletions src/cmcp_verify/tpm.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
# ---------------------------------------------------------------------------


Expand Down Expand Up @@ -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
Expand All @@ -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,
Expand Down
177 changes: 177 additions & 0 deletions tests/unit/test_tpm_chained_signature_metadata.py
Original file line number Diff line number Diff line change
@@ -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"}
1 change: 1 addition & 0 deletions tests/unit/test_tpm_chained_verify.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
Loading