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
16 changes: 16 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,22 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

### Security

- TPM report verification now preserves the parsed `TPMT_SIGNATURE` algorithm
metadata alongside the signature when it delegates to Agent Manifest. Previously
cA2A parsed `sig_alg` and `hash_alg`, then passed only the bare signature to
the shared verifier's legacy defaults (RSASSA/SHA-256 for these RSA cases).
A valid RSAPSS/SHA-384 report therefore failed, while changing an
RSASSA/SHA-256 envelope to falsely
declare RSAPSS or SHA-384 did not change the verification decision. The
declared scheme and digest now govern verification, so their consistency
with the signature operation is checked, with regression tests for both
two-byte fields. The `TPMT_SIGNATURE` wrapper is not itself part of the
signed `TPMS_ATTEST` bytes. The lower-level bare-signature API now applies its
ECDSA/SHA-256 or RSASSA/SHA-256 compatibility default explicitly according to
the AK key type. It no longer lets a valid bare RSA signature whose first two
bytes resemble a TPM algorithm id be misparsed as an envelope. This change
does not add an algorithm-strength policy.

- Offline delegation verification now requires an explicit trusted root issuer.
The core `verify_chain` API also fails closed when callers omit the trust set,
so a self-consistent chain minted by an attacker cannot be mistaken for an
Expand Down
8 changes: 4 additions & 4 deletions docs/spec/attestation.md
Original file line number Diff line number Diff line change
Expand Up @@ -92,9 +92,9 @@ The collector has **not** been run on real TDX silicon. It is exercised against

## TPM verification

`ca2a_verify.tpm.verify_tpm_report` appraises a peer's TPM report 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 key-and-nonce binding below is checked. `verify_tpm_quote` is the lower-level form taking an attest blob and a bare signature directly.
The cryptography is not implemented in cA2A. Steps 1, 2 and 4 delegate to `agent_manifest.verify_tpm_quote`, the canonical hardware-validated implementation cA2A already depends on; three divergent copies of one TPM verifier is the problem being retired (cmcp#447). What cA2A keeps is the piece agent-manifest does not model: `TPMT_SIGNATURE`, the envelope `tpm2_quote -s` and tpm2-pytss `signature.marshal()` actually emit, which is unwrapped to the bare signature agent-manifest takes.
`ca2a_verify.tpm.verify_tpm_report` appraises a peer's TPM report offline: the AK certificate chain is verified to a trusted root, the AK signature over the `TPMS_ATTEST` blob is verified using the signature scheme and digest declared by the marshalled `TPMT_SIGNATURE`, the structure is confirmed to be a TPM-generated quote (magic and type), and the key-and-nonce binding below is checked. The released shared verifier supports ECDSA, RSASSA, and RSAPSS with SHA-256, SHA-384, or SHA-512. `verify_tpm_quote` is the lower-level compatibility form taking an attest blob plus either parsed signature metadata or a legacy bare signature. At this cA2A boundary a `bytes` value means only the legacy form: cA2A explicitly tags it as ECDSA/SHA-256 or RSASSA/SHA-256 according to the AK public-key type before delegation. This avoids guessing whether arbitrary signature-prefix bytes are a `TPMT_SIGNATURE` header. Callers holding a marshalled `TPMT_SIGNATURE` must use `parse_tpmt_signature()` first.

The cryptography is not implemented in cA2A. Steps 1, 2 and 4 delegate to `agent_manifest.verify_tpm_quote`, the canonical hardware-validated implementation cA2A already depends on; three divergent copies of one TPM verifier is the problem being retired (cmcp#447). cA2A delegates parsing of the `TPMT_SIGNATURE` emitted by `tpm2_quote -s` and `tpm2-pytss`'s `signature.marshal()` to Agent Manifest too, then passes the complete parsed result to quote verification. The scheme and digest declarations are outside the signed `TPMS_ATTEST` bytes, but they select the verification operation: relabelling either one without producing a matching signature fails. This establishes algorithm consistency, not a deployment policy about which supported algorithms are sufficiently strong.

### What the quote measures

Expand All @@ -112,7 +112,7 @@ TPM attestation keys chain to per-vendor roots, not to one published root the wa

cmcp falls back to the SHA-1 PCR bank and downgrades the report to `software-only`; cA2A requires the SHA-256 bank and raises instead, because a report labelled `sha256:` that measured SHA-1 banks is a mislabel waiting to happen. cmcp can also emit a report whose only evidence is an unsigned PCR read, marked software-only; in cA2A, failing to produce a signed quote raises, because the platform string on a cA2A report is the provider's identity and a `tpm` report that can never verify is worse than an honest error.

What is validated. The collector's checks, the TPM interaction shapes, and report verification end to end are exercised against synthetic self-consistent vectors in `tests/unit/test_tpm_attest.py`. On a real Azure Trusted Launch vTPM (2026-08-01) the collector produced a genuine platform-AK quote, `parse_tpmt_signature` unwrapped the real `TPMT_SIGNATURE` (RSASSA/SHA-256) and the bare signature verified against the shipped key, a tampered attest blob was rejected, and the quote's `extraData` equalled the derived key-and-nonce binding. Collector and verifier ran in one process, which retires the earlier tooling caveat.
What is validated. The collector's checks, the TPM interaction shapes, and report verification end to end are exercised against synthetic self-consistent vectors in `tests/unit/test_tpm_attest.py`. Those vectors cover a valid RSAPSS/SHA-384 report and reject RSASSA/SHA-256 signatures falsely declared as either RSAPSS or SHA-384, both directly and through the reference wire and offer-verification path. On a real Azure Trusted Launch vTPM (2026-08-01) the collector produced a genuine platform-AK quote, `parse_tpmt_signature` parsed the real `TPMT_SIGNATURE` (RSASSA/SHA-256), the signature verified against the shipped key, a tampered attest blob was rejected, and the quote's `extraData` equalled the derived key-and-nonce binding. Collector and verifier ran in one process, which retires the earlier tooling caveat. The new non-default algorithm cases are synthetic; they do not claim a live hardware RSAPSS/SHA-384 run.

Chained verification did **not** pass on that host, because its AK certificate carries no AIA extension and so no chain can be assembled. That is a property of the host, not a defect in the verifier, and it is recorded in [LIMITATIONS.md](../../LIMITATIONS.md).

Expand Down
61 changes: 53 additions & 8 deletions src/ca2a_verify/tpm.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,17 @@
wire format itself lives in one place. cmcp carried a byte-identical copy of the
same parse; both are retired.

The complete parsed signature is passed to the shared verifier. Its ``sig_alg``
and ``hash_alg`` declarations therefore select verification; cA2A does not
discard them and silently fall back to the legacy bare-signature defaults.
Those declarations are not themselves inside the signed ``TPMS_ATTEST`` bytes.
Applying them to the verification operation is what checks that they describe
the signature. At the lower-level compatibility API, cA2A explicitly tags a
bare signature with the historical algorithm for the AK key type instead of
asking the shared verifier to guess from attacker-controlled signature-prefix
bytes. This path enforces algorithm consistency, not a deployment strength
policy.

There is no single published TPM root, so the caller supplies the vendor roots it
trusts. :mod:`ca2a_verify.tpm_roots` carries the one root validated on hardware as
an opt-in constant. Verifying against no root at all is refused.
Expand All @@ -29,6 +40,7 @@
from agent_manifest import ParsedSignature, TpmVerificationError
from agent_manifest import parse_tpmt_signature as _am_parse_tpmt_signature
from cryptography import x509
from cryptography.hazmat.primitives.asymmetric import ec, rsa
from cryptography.hazmat.primitives.serialization import Encoding

from ca2a_runtime.attestation import Verifier
Expand All @@ -47,9 +59,13 @@
# ParsedSignature is re-exported from agent-manifest, which owns the layout. Its
# fields (sig_alg, hash_alg, signature) are unchanged from cA2A's former copy.

_ALG_RSASSA = 0x0014
_ALG_ECDSA = 0x0018
_ALG_SHA256 = 0x000B


def parse_tpmt_signature(blob: bytes) -> ParsedSignature:
"""Unwrap a ``TPMT_SIGNATURE`` into a bare signature.
"""Parse a ``TPMT_SIGNATURE`` into its algorithm ids and signature bytes.

Delegates the layout to ``agent_manifest.parse_tpmt_signature`` and translates
its error into cA2A's, so callers keep catching :class:`AttestationFailed`.
Expand All @@ -66,9 +82,33 @@ def parse_tpmt_signature(blob: bytes) -> ParsedSignature:
raise AttestationFailed(str(exc)) from exc


def _tag_legacy_bare_signature(
signature: bytes | ParsedSignature,
ak: x509.Certificate,
) -> bytes | ParsedSignature:
"""Make the legacy bare-signature interpretation explicit.

A bare RSA signature is arbitrary fixed-width data. Its first two bytes can
therefore equal a valid TPM algorithm id by chance. Passing such bytes to a
verifier that also accepts marshalled ``TPMT_SIGNATURE`` values makes the two
formats ambiguous. cA2A's public contract distinguishes them by Python type:
``bytes`` means the historical SHA-256 default, while a parsed envelope is a
:class:`ParsedSignature`.
"""
if isinstance(signature, ParsedSignature):
return signature

key = ak.public_key()
if isinstance(key, rsa.RSAPublicKey):
return ParsedSignature(_ALG_RSASSA, _ALG_SHA256, signature)
if isinstance(key, ec.EllipticCurvePublicKey):
return ParsedSignature(_ALG_ECDSA, _ALG_SHA256, signature)
return signature


def _delegate(
attest: bytes,
signature: bytes,
signature: bytes | ParsedSignature,
ak_chain_pem: bytes,
trusted_roots_pem: bytes,
expected_qualifying_data: bytes | None,
Expand Down Expand Up @@ -104,7 +144,7 @@ def _delegate(

def verify_tpm_quote(
attest: bytes,
signature: bytes,
signature: bytes | ParsedSignature,
ak_chain: list[x509.Certificate],
*,
trusted_roots: list[x509.Certificate],
Expand All @@ -113,9 +153,14 @@ def verify_tpm_quote(
) -> TpmQuote:
"""Appraise a TPM 2.0 quote offline. Raises AttestationFailed on any failure.

``signature`` is the bare AK signature. For a marshalled ``TPMT_SIGNATURE``
(what real tooling emits), unwrap it with :func:`parse_tpmt_signature` first,
or use :func:`verify_tpm_report`, which does that for you.
``signature`` may be the legacy bare AK signature or the result of
:func:`parse_tpmt_signature`. A ``bytes`` value is unconditionally the bare
compatibility form: cA2A explicitly applies ECDSA/SHA-256 or
RSASSA/SHA-256 according to the AK key type, so signature-prefix bytes are
never treated as an algorithm header. For a marshalled ``TPMT_SIGNATURE``
(what real tooling emits), parse it first so the declared scheme and digest
reach the verifier, or use :func:`verify_tpm_report`, which does that for
you.
"""
if not ak_chain:
raise AttestationFailed("no AK certificate chain was supplied")
Expand All @@ -128,7 +173,7 @@ def verify_tpm_quote(
quote = TpmQuote.parse(attest)
_delegate(
attest,
signature,
_tag_legacy_bare_signature(signature, ak_chain[0]),
b"".join(c.public_bytes(Encoding.PEM) for c in ak_chain),
b"".join(c.public_bytes(Encoding.PEM) for c in trusted_roots),
expected_qualifying_data,
Expand Down Expand Up @@ -185,7 +230,7 @@ def verify_tpm_report(
quote = TpmQuote.parse(report.raw_evidence)
_delegate(
report.raw_evidence,
parsed.signature,
parsed,
report.attestation_key_chain_pem,
trusted_roots_pem,
expected_qualifying_data,
Expand Down
19 changes: 19 additions & 0 deletions tests/fixtures/tpm/bare-rsa-prefix-0016/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
# Bare RSA signature prefix-collision vector

This frozen synthetic vector exercises cA2A's legacy bare-signature API. The
256-byte RSASSA/SHA-256 signature is valid for `attest.hex` under the leaf in
`ak-chain.pem`, but begins with `00 16`. Interpreting those already-bare bytes
as a marshalled `TPMT_SIGNATURE` therefore mistakes `0x0016` for
`TPM_ALG_RSAPSS` and fails while trying to parse a structure that is not there.

The vector was generated offline by varying the quote's qualifying data and
signing each otherwise fixed synthetic quote until counter `88540` produced the
required prefix. No private key is included. SHA-256:

- `attest.hex` decoded bytes:
`55f4158f2ee2d5bbb5f3e3f2e9e55b9f86ba83337c1a666ce16f227bd4cea82d`
- `signature.hex` decoded bytes:
`983fe06c34b2d9eabc98cded3aeac2f4ab9a6774c93d14f0ec11ea6bcf2521e2`

This is a compatibility/availability regression vector. It does not
demonstrate forged-evidence acceptance.
24 changes: 24 additions & 0 deletions tests/fixtures/tpm/bare-rsa-prefix-0016/ak-chain.pem
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
-----BEGIN CERTIFICATE-----
MIICHzCCAcagAwIBAgIDFJACMAoGCCqGSM49BAMCMCIxIDAeBgNVBAMMF2NBMkEg
c3ludGhldGljIFRQTSByb290MCAXDTIwMDEwMTAwMDAwMFoYDzIxMjAwMTAxMDAw
MDAwWjAgMR4wHAYDVQQDDBVjQTJBIHN5bnRoZXRpYyBSU0EgQUswggEiMA0GCSqG
SIb3DQEBAQUAA4IBDwAwggEKAoIBAQCzNx9EDwvcys0CbsJ1RXI2kiaKI+7dPAJ3
5V4PJunEpWpxbxym0AYE6C2T/GMyH6r7/7UGl+yMI7+WbNHKYzZ7Kp8LkNqLdxrY
A2PYKV3XdyoRPQ1EIlPKqvNA3VutiVx1VTmNCrMiai3QzkpldejWFMgpdp1Yqspz
Nb3miJnL9wwC56AdzXHzXZaPd3ub7wIvYMBgmGkMSqwtRP6cZP4kMthPLE6Di4if
WYEXoboI5k7F2EuAvoeV74QeNDnSclSb9YH+3VFfB/mQBiCtBsAAQ3VUVMurcx7x
BihOeq0fBRSjXW1YhDhtjQrC773HxFtwk9yYbFd2rxFVbDfvhLHLAgMBAAGjIDAe
MAwGA1UdEwEB/wQCMAAwDgYDVR0PAQH/BAQDAgeAMAoGCCqGSM49BAMCA0cAMEQC
IGyGBVT05OfWm4CrseYunGF+11H6tSREENqpMsW7NligAiAYf4h44VuGcfJtR4lb
L075BKskQzuClshAipKWRXUW1g==
-----END CERTIFICATE-----
-----BEGIN CERTIFICATE-----
MIIBXDCCAQOgAwIBAgIDFJABMAoGCCqGSM49BAMCMCIxIDAeBgNVBAMMF2NBMkEg
c3ludGhldGljIFRQTSByb290MCAXDTIwMDEwMTAwMDAwMFoYDzIxMjAwMTAxMDAw
MDAwWjAiMSAwHgYDVQQDDBdjQTJBIHN5bnRoZXRpYyBUUE0gcm9vdDBZMBMGByqG
SM49AgEGCCqGSM49AwEHA0IABKiKOqGTVL5Gy/YneS9/rVpNJEmdlwd+PDQR2Eha
OPgvVYxrQDpkesx7QoKW0KLpUdwQfRD5/HGMC1jgBMbklBajJjAkMBIGA1UdEwEB
/wQIMAYBAf8CAQAwDgYDVR0PAQH/BAQDAgGGMAoGCCqGSM49BAMCA0cAMEQCIAV3
VfzQCaGSsRqKjHpot73geNymFWN5udKVdz5idckzAiBO6qmlxOmXVownuYHfP1oZ
6vTZNNPVPtUwZN6EaXqfVg==
-----END CERTIFICATE-----
1 change: 1 addition & 0 deletions tests/fixtures/tpm/bare-rsa-prefix-0016/attest.hex
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
ff544347801800000020e0d13e64d0b36e10ac4e950464fa397e1787ff892845794c8d0a24806220e5590000000000000000000000000000000000000000000000000000000001000b0303000000201111111111111111111111111111111111111111111111111111111111111111
1 change: 1 addition & 0 deletions tests/fixtures/tpm/bare-rsa-prefix-0016/signature.hex
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
001693823823ebe443099070eb4bf661a96ad8aca88c5bfad789f095467b2025500c6e6b286f4f4620718d4520127e42237baba7e80f16bf49ad1490bb2c1dcb525079548c7edb035e6ad84f623e016945070141f38f7bd4fd8bafb5f894e2c20d3900e7f01a353cb8bbf58c7e78780519a1d30ac988d2e939d7b1fa9c3d99ea4df8aba1f19140bef62935044018a76327462f8e7cad25ea74f791b02b2e989401e87af6ac86b834065001589d6eae3317767eaf8839843c954d4a3afec7c7125a1f5a1762d41f9872833261b4340d99e65252aedd5493b43a0abeb944a9951b4d862749aa28b50505ee9665d4dcc658ac6b60934d90f3a5941ffb1f1c3161c5
10 changes: 10 additions & 0 deletions tests/fixtures/tpm/bare-rsa-prefix-0016/trusted-root.pem
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
-----BEGIN CERTIFICATE-----
MIIBXDCCAQOgAwIBAgIDFJABMAoGCCqGSM49BAMCMCIxIDAeBgNVBAMMF2NBMkEg
c3ludGhldGljIFRQTSByb290MCAXDTIwMDEwMTAwMDAwMFoYDzIxMjAwMTAxMDAw
MDAwWjAiMSAwHgYDVQQDDBdjQTJBIHN5bnRoZXRpYyBUUE0gcm9vdDBZMBMGByqG
SM49AgEGCCqGSM49AwEHA0IABKiKOqGTVL5Gy/YneS9/rVpNJEmdlwd+PDQR2Eha
OPgvVYxrQDpkesx7QoKW0KLpUdwQfRD5/HGMC1jgBMbklBajJjAkMBIGA1UdEwEB
/wQIMAYBAf8CAQAwDgYDVR0PAQH/BAQDAgGGMAoGCCqGSM49BAMCA0cAMEQCIAV3
VfzQCaGSsRqKjHpot73geNymFWN5udKVdz5idckzAiBO6qmlxOmXVownuYHfP1oZ
6vTZNNPVPtUwZN6EaXqfVg==
-----END CERTIFICATE-----
Loading