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
18 changes: 9 additions & 9 deletions examples/rejection-with-proof/chain.json
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,8 @@
"chain": [
{
"credential_id": "cred-0-orchestrator",
"issuer": "0aca4cf1251cb3648cd1f8605481d8dece5cd3a106a362a11b86f43d270da087",
"subject": "41eebfb4e9ac7f0944041b6c8fad485159f3d0391721dcec1bbe478986c6879f",
"issuer": "4b6b80f73e41b5401523db167775027ee5d41a1d83954557c905b00962cd5c8e",
"subject": "f5d7ff727931d0acb4393c9012d9a4fe9c3a0837a1c191a31e5ad1c35fa2dd07",
"scope": [
"task:read",
"task:write",
Expand All @@ -12,31 +12,31 @@
],
"depth": 0,
"parent_id": null,
"signature": "2380d30571a6493f6236c3aa9d381fd28a9cd3fcd4fa0fd5316166867575b206d8eb882c39fc7362c89cd407eef77848c228410a7816a10b563888eb99789a08"
"signature": "749c4a53497b1bb6ca17f4aafe6bad30674484baa4f8f3083581cf9bbbb950d5cb9e3a2ab0453b7cb9c24b339496f4eaa6a6d989960d356633aeb53c58251007"
},
{
"credential_id": "cred-1-researcher",
"issuer": "41eebfb4e9ac7f0944041b6c8fad485159f3d0391721dcec1bbe478986c6879f",
"subject": "cb42a0b6b9ec7839846a0ddbcb67d851d9b1013fc04066687181938d9ea7dce8",
"issuer": "f5d7ff727931d0acb4393c9012d9a4fe9c3a0837a1c191a31e5ad1c35fa2dd07",
"subject": "747e897486d4d32abd45d3a9e04e5357f6a88a1bad8d0ce9dc6084cd10c7b121",
"scope": [
"task:read",
"tool:purchase",
"tool:search"
],
"depth": 1,
"parent_id": "cred-0-orchestrator",
"signature": "ad8d7b0aa00bd04e45bd9dca84f92bd7f23b4f4dde9e6eeff441ccf7c5222c5a0ccb45b5ba1b8399b6625cd3b4cfd97bc953b16bba01034a05ae01d55ba50503"
"signature": "0a5d95fded81c149bc652bfa5903c7a02e3caa9e0f5a41761ffef19d2dad9fd3e4ac5e1f2ef921bc92a6ed146aad0b3765709491447e7f87d9331df502e3af02"
},
{
"credential_id": "cred-2-retriever",
"issuer": "cb42a0b6b9ec7839846a0ddbcb67d851d9b1013fc04066687181938d9ea7dce8",
"subject": "2014e45331bb1717e9983dbba3fd4b6adc81ee48257afe5c0e027f12f61e8d7d",
"issuer": "747e897486d4d32abd45d3a9e04e5357f6a88a1bad8d0ce9dc6084cd10c7b121",
"subject": "b05e7f85927cc4a5886ec097da0200c4a02d0d944dd2d73c9a10f5662041074c",
"scope": [
"tool:search"
],
"depth": 2,
"parent_id": "cred-1-researcher",
"signature": "0a18f20de83626e8f32960f3a3a543fed07b4c9f1ccce1eec4e3a6b7798311b68d08052ec408a675cd153fbfb9594d369c6333b672d2c1ba16854c517ebebf05"
"signature": "a6ec5c6c8add5923b9bf3f9fc9cb8cd0c10cf6faf53a3e04571a418f199d8191bbf9ee4f0b7ddd06f75eaeaa4e25e0a761e62ca59b09d31d6544b4bc90ce1e0d"
}
]
}
14 changes: 7 additions & 7 deletions examples/rejection-with-proof/dag.json
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
{
"record_id": "rec-0-orchestrator",
"credential_id": "cred-0-orchestrator",
"subject": "41eebfb4e9ac7f0944041b6c8fad485159f3d0391721dcec1bbe478986c6879f",
"subject": "f5d7ff727931d0acb4393c9012d9a4fe9c3a0837a1c191a31e5ad1c35fa2dd07",
"scope": [
"task:read",
"task:write",
Expand All @@ -15,31 +15,31 @@
{
"record_id": "rec-1-researcher",
"credential_id": "cred-1-researcher",
"subject": "cb42a0b6b9ec7839846a0ddbcb67d851d9b1013fc04066687181938d9ea7dce8",
"subject": "747e897486d4d32abd45d3a9e04e5357f6a88a1bad8d0ce9dc6084cd10c7b121",
"scope": [
"task:read",
"tool:purchase",
"tool:search"
],
"parent_record_hash": "143db9ccd89d3cf4c50426e5e8dedc32e7feac0bbc2735f63b605bba45eef613"
"parent_record_hash": "f8fd5e5073d51406bdc9fe7e79b90cd3c70afbee1ac47d5763996d4af20af5a7"
},
{
"record_id": "rec-2-retriever",
"credential_id": "cred-2-retriever",
"subject": "2014e45331bb1717e9983dbba3fd4b6adc81ee48257afe5c0e027f12f61e8d7d",
"subject": "b05e7f85927cc4a5886ec097da0200c4a02d0d944dd2d73c9a10f5662041074c",
"scope": [
"tool:search"
],
"parent_record_hash": "2e0ac019c2a7949a0cb5c8eab207ddef3351405db20dd4360144b6c4e605c6a0"
"parent_record_hash": "f940a2e460960035bbe39f93b7c76ac965d914857e10bce66bffce871fbc102e"
},
{
"record_id": "rec-denied-purchase",
"credential_id": "cred-2-retriever",
"subject": "2014e45331bb1717e9983dbba3fd4b6adc81ee48257afe5c0e027f12f61e8d7d",
"subject": "b05e7f85927cc4a5886ec097da0200c4a02d0d944dd2d73c9a10f5662041074c",
"scope": [
"tool:search"
],
"parent_record_hash": "c8ce42ebbbe084bbe88571a12576d8a4c1d5d3753d071e35dd19838274605f09",
"parent_record_hash": "3867abdd0574dce6cd20cae0b1e4a22afa5ab0a22778cda449c82c12dca4f127",
"decision": "deny",
"requested_capability": "tool:purchase",
"effective_scope": [
Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ dependencies = [
"agentrust-trace>=0.5",
# Shared hardware-attestation verification (generic cert-chain verifier,
# SNP/TDX/TPM primitives) consumed via PyPI instead of duplicated per repo.
"agent-manifest>=0.5",
"agent-manifest>=0.8",
"rfc8785>=0.1",
]

Expand Down
81 changes: 19 additions & 62 deletions src/ca2a_verify/tpm.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,9 +13,11 @@
hardware-validated. Three divergent copies of one TPM verifier is the problem
being retired; see cmcp#447.

What does live here is the piece agent-manifest does not model: ``TPMT_SIGNATURE``,
the wire format ``tpm2_quote -s`` and tpm2-pytss ``signature.marshal()`` actually
emit. agent-manifest takes a bare signature, so the envelope is unwrapped here.
``TPMT_SIGNATURE`` used to be unwrapped here too, because agent-manifest did not
model it. It does as of 0.8, so :func:`parse_tpmt_signature` is now a thin
delegation that keeps cA2A's error contract (:class:`AttestationFailed`) while the
wire format itself lives in one place. cmcp carried a byte-identical copy of the
same parse; both are retired.

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
Expand All @@ -24,9 +26,8 @@

from __future__ import annotations

import struct
from dataclasses import dataclass

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.serialization import Encoding

Expand All @@ -43,70 +44,26 @@
"verify_tpm_report",
]

# TPM2_ALG_ID values for the signing schemes a quote can use.
_ALG_RSASSA = 0x0014
_ALG_RSAPSS = 0x0016
_ALG_ECDSA = 0x0018


@dataclass(frozen=True)
class ParsedSignature:
"""A parsed ``TPMT_SIGNATURE``: the algorithm ids and the bare signature."""

sig_alg: int
hash_alg: int
signature: bytes
# 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.


def parse_tpmt_signature(blob: bytes) -> ParsedSignature:
"""Unwrap a ``TPMT_SIGNATURE`` into a bare signature.

Layout: ``sigAlg`` (2), ``hashAlg`` (2), then the algorithm-specific body. For
RSA that is a size-prefixed ``TPM2B_PUBLIC_KEY_RSA``. For ECDSA it is two
size-prefixed integers, R then S, which are re-encoded as a DER sequence
because that is what ``cryptography`` verifies against.
Delegates the layout to ``agent_manifest.parse_tpmt_signature`` and translates
its error into cA2A's, so callers keep catching :class:`AttestationFailed`.
The parse handles RSASSA/RSAPSS (a size-prefixed ``TPM2B_PUBLIC_KEY_RSA``) and
ECDSA (R and S as size-prefixed integers, re-encoded as a DER sequence).

Raises :class:`AttestationFailed` on anything malformed.
The upstream reason is passed through as the message rather than collapsed
into a generic one: "unsupported algorithm" and "truncated" are different
faults and a caller that cannot tell them apart cannot report usefully.
"""
if len(blob) < 6:
raise AttestationFailed("TPMT_SIGNATURE too short")
try:
sig_alg, hash_alg = struct.unpack_from(">HH", blob, 0)
offset = 4

if sig_alg in (_ALG_RSASSA, _ALG_RSAPSS):
(size,) = struct.unpack_from(">H", blob, offset)
offset += 2
if len(blob) < offset + size:
raise AttestationFailed("TPMT_SIGNATURE truncated inside the RSA signature")
return ParsedSignature(sig_alg, hash_alg, blob[offset : offset + size])

if sig_alg == _ALG_ECDSA:
from cryptography.hazmat.primitives.asymmetric.utils import encode_dss_signature

parts: list[bytes] = []
for _ in range(2):
(size,) = struct.unpack_from(">H", blob, offset)
offset += 2
if len(blob) < offset + size:
raise AttestationFailed(
"TPMT_SIGNATURE truncated inside the ECDSA signature"
)
parts.append(blob[offset : offset + size])
offset += size
return ParsedSignature(
sig_alg,
hash_alg,
encode_dss_signature(
int.from_bytes(parts[0], "big"), int.from_bytes(parts[1], "big")
),
)
except struct.error as exc:
raise AttestationFailed("TPMT_SIGNATURE is malformed", detail=str(exc)) from exc

raise AttestationFailed(
"unsupported TPM signature algorithm", detail=f"sigAlg={sig_alg:#06x}"
)
return _am_parse_tpmt_signature(blob)
except TpmVerificationError as exc:
raise AttestationFailed(str(exc)) from exc


def _delegate(
Expand Down