Skip to content
Open
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
12 changes: 12 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,18 @@
stops reading them destroys evidence rather than tidying a codebase. Same reasoning as
keeping the `-8` Ed25519 code point acceptable indefinitely.

### Fixed

- **[SDK]** `verify_tpm_quote()` now distinguishes a legacy bare AK signature
from a marshalled `TPMT_SIGNATURE` using the attestation key's signature
shape, rather than an allowlist of the envelope's untrusted `sigAlg` prefix.
Changing a reference envelope to declare an unsupported scheme now raises the
explicit unsupported-algorithm error instead of silently falling through to
the legacy verifier and returning `False`; the mutated envelope remains
rejected, with only its diagnostic changing. Modulus-sized bare RSA
signatures whose random prefix resembles a supported scheme now remain in
the documented legacy lane and can verify normally.

## [0.11.2] — 2026-08-27

### Fixed
Expand Down
32 changes: 24 additions & 8 deletions python/src/agent_manifest/_tpm_verify.py
Original file line number Diff line number Diff line change
Expand Up @@ -391,6 +391,9 @@ def verify_tpm_quote(
PKCS#1 v1.5 over SHA-256), a parsed :class:`ParsedSignature`, or a
marshalled ``TPMT_SIGNATURE``. Envelopes select RSASSA, RSAPSS, or
ECDSA and SHA-256, SHA-384, or SHA-512 from their algorithm ids.
Legacy bare input is recognized from the AK's signature shape:
modulus-sized bytes for RSA and DER for ECDSA. Other byte input
must be a well-formed envelope.
ak_chain_pem: the AK certificate chain (PEM, leaf first).
trusted_roots_pem: the caller's trusted vendor EK/AK roots (PEM).
expected_qualifying_data: if given, the quote's ``extraData`` (nonce)
Expand All @@ -404,8 +407,9 @@ def verify_tpm_quote(
``True`` only when the structure, AK chain, AK signature, and any
supplied bindings all check out. Returns ``False`` on a well-formed but
invalid signature or a binding mismatch. Raises
:class:`TpmVerificationError` on a malformed quote / broken chain or if
``cryptography`` is unavailable.
:class:`TpmVerificationError` on a malformed quote / broken chain, a
malformed signature, an unsupported algorithm, or if ``cryptography``
is unavailable.
"""
try:
from cryptography.exceptions import InvalidSignature
Expand Down Expand Up @@ -440,12 +444,24 @@ def verify_tpm_quote(
parsed_signature: ParsedSignature | None = None
if isinstance(signature, ParsedSignature):
parsed_signature = signature
elif len(signature) >= 2 and int.from_bytes(signature[:2], "big") in (
_ALG_RSASSA,
_ALG_RSAPSS,
_ALG_ECDSA,
):
parsed_signature = parse_tpmt_signature(signature)
elif isinstance(ak_key, rsa.RSAPublicKey):
# A bare RSA signature is exactly one modulus wide. Do not use the
# untrusted sigAlg prefix as the discriminator: changing an envelope's
# scheme must not silently move it into the legacy bare-signature lane.
if len(signature) != (ak_key.key_size + 7) // 8:
parsed_signature = parse_tpmt_signature(signature)
elif isinstance(ak_key, ec.EllipticCurvePublicKey):
# cryptography's legacy ECDSA input is a complete DER SEQUENCE. Testing
# only its first 0x30 byte would let malformed sequence-prefixed input
# fall through too, so require the actual signature representation.
from cryptography.hazmat.primitives.asymmetric.utils import (
decode_dss_signature,
)

try:
decode_dss_signature(signature)
except ValueError:
parsed_signature = parse_tpmt_signature(signature)

if parsed_signature is None:
assert isinstance(signature, bytes)
Expand Down
32 changes: 32 additions & 0 deletions python/tests/fixtures/tpm-signature-framing/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
# Synthetic RSA signature-framing vector

This fixture isolates a byte-framing edge case. The 2048-bit PKCS#1 v1.5
SHA-256 signature is valid over `rsa-0014-attest.hex`, but its fixed-width
encoding happens to begin with `0x0014`, the TPM algorithm identifier for
RSASSA. It is nevertheless a documented legacy bare signature, not a
`TPMT_SIGNATURE` envelope.

The quote, AK chain, and root were generated with the synthetic helpers in
`test_tpm_verify.py`. They are test cryptography only: they did not come from a
TPM, do not establish hardware provenance, and are not independent verification
evidence. The private key is intentionally not committed. The regression pins a
2026 verification time because the synthetic certificates expire in 2029.

The fixture was generated once with a fixed, freshly generated 2048-bit RSA AK.
The generator kept the PCR digest at `bytes(range(32, 64))` and enumerated
32-byte big-endian nonce counters from 0 through 7585, signing each quote with
PKCS#1 v1.5 and SHA-256. Counter 7585 produced the first signature whose prefix
matched any supported TPM signature scheme (`0x0014`, `0x0016`, or `0x0018`),
specifically `0x0014`. The committed test performs no search and needs only the
public fixture material to verify the signature.

SHA-256:

- `rsa-0014-attest.hex` decoded bytes:
`532831280f45bb67304dcf4185ae8a7a18d104948168010f33cd69aac9daa23c`
- `rsa-0014-bare-signature.hex` decoded bytes:
`7f6831c219923c0e60d855903c99b7ee702539cc0dc496a971269eb7763be24d`
- `rsa-0014-ak-chain.pem`:
`9bf7f75d2fe564eee01ecf914f45f4b233489c140aee2f095565286e7d57142b`
- `rsa-0014-root.pem`:
`3a6601f342c700e612a2ec20133250db732a8ed8c4e68531c263a27a85233cd0`
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
-----BEGIN CERTIFICATE-----
MIICtjCCAZ6gAwIBAgIUbSxeK3viZUJeedIvYxwY62IZ5BkwDQYJKoZIhvcNAQEL
BQAwGDEWMBQGA1UEAwwNdGVzdC10cG0tcm9vdDAeFw0yMDAxMDEwMDAwMDBaFw0y
OTEyMjkwMDAwMDBaMBIxEDAOBgNVBAMMB3Rlc3QtYWswggEiMA0GCSqGSIb3DQEB
AQUAA4IBDwAwggEKAoIBAQDpRf8yxbY4FaQR3ZsHXsaj3b/hWLnHw2YgLZwEU4ty
HAjN28cOB7Gsr5JK3FasNQezhbqZ5WZGhlCh2OQGAkSsoXCOebC03+1lJe0+1JFk
UyQuWYiwQ4zvgJFViN+sXFf/Gyw9TMZM+Gzag9ZFZZDznWmo6AgdzdQOiS8OTqbS
eawacdj8N5gkKsa+a4ybabqCdIJWwCGJ4/jFOzkyP6Q4JM8Afj9WhiG3tAglzTqz
dzuUhQs6HCPU2q6H6QPKw2JzkDCDqehVIMdEBoXoW5Pz636CifWnSCrwx6soMH7u
RXtxJjg3Tleu6bLNqm+05DildXj2fDKFSIvZOOoEFTL9AgMBAAEwDQYJKoZIhvcN
AQELBQADggEBAC5m+IrfsWY3q1U/DIW3LG4iJDrwmOgwdr8/p72UPDcT+B8loSej
7Y0XlMeKPgOdP7KUTtUI9zB1Cob/BckrCJt/Z9kdX+nDcN9LCwD72cFJqk/lotaJ
fRZWg1TTOolWsWB+kpIuOJhzym8BXm8HNqeoGr+z31VXCXM1hem3smB6OvbTXPmQ
Z3juhwReJLO/kc1iaJlsNyx+IV3423HZiRj4CxOHfuLQmWHUJ6GrP/HJHWkvRwT0
/HDTy1PtTjB2lCBxqYbQQtQy/amyP1hpPHAJ7CeBV+wL0ELKE4zB1Yo7dtp0H2tu
Y+R60gF0bJRF0GseakQVx1QcYUABvQqTxzk=
-----END CERTIFICATE-----
-----BEGIN CERTIFICATE-----
MIICvDCCAaSgAwIBAgIUXBzcWQzCYVuY3EUsELTmwApxF4YwDQYJKoZIhvcNAQEL
BQAwGDEWMBQGA1UEAwwNdGVzdC10cG0tcm9vdDAeFw0yMDAxMDEwMDAwMDBaFw0y
OTEyMjkwMDAwMDBaMBgxFjAUBgNVBAMMDXRlc3QtdHBtLXJvb3QwggEiMA0GCSqG
SIb3DQEBAQUAA4IBDwAwggEKAoIBAQCijip+s23Q8V1N+HPr50Zlh2Pk1qKs/RUj
0fJcrRQlXu745h/tlxBtAsycoAmEH8nQOIx7U/GRSFjeBNn+TYJNrg7cMib8fJgB
vrWo+TX90P9GvzK+vMqWu7L4ZnPPw62fjzzOpKIiHyX7vicGpGRMg4/czagYmzyl
u52jQAUbEIBCIkfc5SuSe0yq3JBYvIdIUhqK3vvqkxs5Zhs5tcFCFOw8BAEZyYU0
++oHvJFVZJ2Fnz9FYZr3vrqSGGBFOS5fIgLcPeMKmZiZSToDa17UobI+uzDCDfDE
uOkI3f1ycRX6Gff4NXhiP4dFKuKAK/w3Or6kmLSNKPDtDAKN/fPrAgMBAAEwDQYJ
KoZIhvcNAQELBQADggEBABQHTu5Kh0fe85XFXtSlgX1Dp1sjqV78jesDKPMkgfg1
huTcb11AovTHyvr2c2awacp+kAXEt5rB0WT6hRlIl3KB6L0xZrmXmJNEOrdGaMFF
BpZY4XrOrN51eqZ03klRRjZdq1nCsOP6xh1L3VsM15cIDvUfLOnrHbs59rKPRDIs
81K9BTMx2VP/IoPrPV5ae6xik4R7iKuhaNDPDXHU2K/bi9MY+Potxhk29ElbuU0j
bWw5ZtVmi4S2OH35Bm6/yYmB5l1ko/KnVF9lJJ6fCK6zDTggBKck+4Fi+10upLj1
Skpb/i5OqBv43FI9fTlkJenECA2ahWpZW8z23Dc0hYU=
-----END CERTIFICATE-----
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
ff5443478018000000200000000000000000000000000000000000000000000000000000000000001da10000000000000000000000000000000000000000000000000000000001000b030000010020202122232425262728292a2b2c2d2e2f303132333435363738393a3b3c3d3e3f
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
00147590ea5aad4a4e18b7792a139ba4a5bf073998550f0ed291e2e1241fa95985e3bc52ee775cddec03f1a670ef4d28b9f34e4b7a008face68f956ef08e11087ccac13dac0c9d0b97c753fb4098ae18499e6424ee55d473ad0d242ce5874c1e37b2b5fe79e5912f622b67ac42201523dc6d06f39b11ce4e8ce152a8f74135aa5e4adb8d3a483a11d09abbe69280791c0710acc1f6fab7fa9c46cd14d34bf538bba1298f63b5f1914cb9ff32c92e815b245fedfdccc773a2585464f1ca785adf2bb773dc9f4f43e15f19515838004dd7716129d6bdab9393d6b050b04104164c0328e3ffd576170a3454907cde4fd0b40889972d2ea7a938836a2c75cd32891d
17 changes: 17 additions & 0 deletions python/tests/fixtures/tpm-signature-framing/rsa-0014-root.pem
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
-----BEGIN CERTIFICATE-----
MIICvDCCAaSgAwIBAgIUXBzcWQzCYVuY3EUsELTmwApxF4YwDQYJKoZIhvcNAQEL
BQAwGDEWMBQGA1UEAwwNdGVzdC10cG0tcm9vdDAeFw0yMDAxMDEwMDAwMDBaFw0y
OTEyMjkwMDAwMDBaMBgxFjAUBgNVBAMMDXRlc3QtdHBtLXJvb3QwggEiMA0GCSqG
SIb3DQEBAQUAA4IBDwAwggEKAoIBAQCijip+s23Q8V1N+HPr50Zlh2Pk1qKs/RUj
0fJcrRQlXu745h/tlxBtAsycoAmEH8nQOIx7U/GRSFjeBNn+TYJNrg7cMib8fJgB
vrWo+TX90P9GvzK+vMqWu7L4ZnPPw62fjzzOpKIiHyX7vicGpGRMg4/czagYmzyl
u52jQAUbEIBCIkfc5SuSe0yq3JBYvIdIUhqK3vvqkxs5Zhs5tcFCFOw8BAEZyYU0
++oHvJFVZJ2Fnz9FYZr3vrqSGGBFOS5fIgLcPeMKmZiZSToDa17UobI+uzDCDfDE
uOkI3f1ycRX6Gff4NXhiP4dFKuKAK/w3Or6kmLSNKPDtDAKN/fPrAgMBAAEwDQYJ
KoZIhvcNAQELBQADggEBABQHTu5Kh0fe85XFXtSlgX1Dp1sjqV78jesDKPMkgfg1
huTcb11AovTHyvr2c2awacp+kAXEt5rB0WT6hRlIl3KB6L0xZrmXmJNEOrdGaMFF
BpZY4XrOrN51eqZ03klRRjZdq1nCsOP6xh1L3VsM15cIDvUfLOnrHbs59rKPRDIs
81K9BTMx2VP/IoPrPV5ae6xik4R7iKuhaNDPDXHU2K/bi9MY+Potxhk29ElbuU0j
bWw5ZtVmi4S2OH35Bm6/yYmB5l1ko/KnVF9lJJ6fCK6zDTggBKck+4Fi+10upLj1
Skpb/i5OqBv43FI9fTlkJenECA2ahWpZW8z23Dc0hYU=
-----END CERTIFICATE-----
25 changes: 25 additions & 0 deletions python/tests/test_tpm_reference_vectors.py
Original file line number Diff line number Diff line change
Expand Up @@ -115,6 +115,31 @@ def test_reference_signature_with_wrong_declared_scheme_is_rejected() -> None:
assert _verify_vector("rsassa-sha256", misdeclared) is False


def test_unsupported_scheme_in_reference_envelope_is_explicitly_rejected() -> None:
"""Changing only sigAlg must not reclassify an envelope as a bare signature."""
tpmt = bytearray(_load_vector("rsassa-sha256")[1])
struct.pack_into(">H", tpmt, 0, 0x0015)

with pytest.raises(
TpmVerificationError, match="unsupported signature algorithm 0x0015"
):
_verify_vector("rsassa-sha256", bytes(tpmt))


@pytest.mark.parametrize(
("offset", "replacement"),
[(0, _ALG_RSAPSS), (2, _ALG_SHA384)],
ids=["sigAlg", "hashAlg"],
)
def test_supported_two_byte_envelope_mutation_never_preserves_success(
offset: int, replacement: int
) -> None:
tpmt = bytearray(_load_vector("rsassa-sha256")[1])
struct.pack_into(">H", tpmt, offset, replacement)

assert _verify_vector("rsassa-sha256", bytes(tpmt)) is False


def test_ecdsa_scheme_against_an_rsa_key_is_refused() -> None:
parsed = parse_tpmt_signature(_load_vector("rsassa-sha256")[1])
mismatched = ParsedSignature(_ALG_ECDSA, parsed.hash_alg, parsed.signature)
Expand Down
58 changes: 58 additions & 0 deletions python/tests/test_tpm_verify.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
here; validation against a real TPM quote is tracked as follow-up.)
"""
import datetime
from pathlib import Path

import pytest

Expand Down Expand Up @@ -134,6 +135,7 @@ def _tpmt_signature(ak_key, attest, *, sig_alg, hash_alg, digest):

NONCE = bytes(range(32))
PCR = bytes(range(32, 64))
SCHEME_PREFIX_FIXTURE = Path(__file__).parent / "fixtures" / "tpm-signature-framing"


# ---------------------------------------------------------------------------
Expand Down Expand Up @@ -342,6 +344,62 @@ def test_verify_rejects_wrong_ak_key():
assert verify_tpm_quote(attest, sig, chain, trusted_roots_pem=roots) is False


def test_modulus_sized_bare_rsa_with_scheme_like_prefix_stays_legacy():
"""A bare RSA signature is identified by key size, not its random prefix."""
ak_key, chain, roots = _ak_chain("rsa")
attest = _build_attest(NONCE, PCR)
signature_size = (ak_key.key_size + 7) // 8
invalid_bare_signature = b"\x00\x14" + b"\x00" * (signature_size - 2)

assert (
verify_tpm_quote(
attest,
invalid_bare_signature,
chain,
trusted_roots_pem=roots,
)
is False
)


def test_valid_bare_rsa_with_scheme_like_prefix_verifies():
"""A valid legacy signature is not reclassified from its random prefix."""
attest = bytes.fromhex(
(SCHEME_PREFIX_FIXTURE / "rsa-0014-attest.hex").read_text().strip()
)
signature = bytes.fromhex(
(SCHEME_PREFIX_FIXTURE / "rsa-0014-bare-signature.hex").read_text().strip()
)
chain = (SCHEME_PREFIX_FIXTURE / "rsa-0014-ak-chain.pem").read_bytes()
roots = (SCHEME_PREFIX_FIXTURE / "rsa-0014-root.pem").read_bytes()

assert len(signature) == 256
assert signature[:2] == b"\x00\x14"
assert verify_tpm_quote(
attest,
signature,
chain,
trusted_roots_pem=roots,
expected_qualifying_data=(7585).to_bytes(32, "big"),
expected_pcr_digest=PCR,
verification_time=datetime.datetime(
2026, 9, 1, tzinfo=datetime.timezone.utc
),
) is True


def test_sequence_prefixed_non_der_ecdsa_input_does_not_fall_back():
"""A leading DER tag is insufficient; the full legacy shape must decode."""
_ak_key, chain, roots = _ak_chain("ec")
attest = _build_attest(NONCE, PCR)
malformed = b"\x30\x00\x00\x0b\x00\x00"

with pytest.raises(
TpmVerificationError, match="unsupported signature algorithm 0x3000"
):
verify_tpm_quote(attest, malformed, chain, trusted_roots_pem=roots)


@pytest.mark.parametrize(
("kind", "sig_alg"),
[("rsa", 0x0014), ("rsa", 0x0016), ("ec", 0x0018)],
Expand Down
Loading