From f311e812c96a99dd118605922a109b2bda8d1adf Mon Sep 17 00:00:00 2001 From: Imran Siddique Date: Sat, 1 Aug 2026 20:56:55 -0700 Subject: [PATCH] feat(tee): share the SNP report union and check the declared signature algorithm Phase A2 of consolidating the TEE verification stack. cmcp and ca2a carried four copies of the SEV-SNP report layout between them, two of them inside cmcp alone. All four agree on every offset, so this is a union rather than a reconciliation, but four copies is four chances to drift. SnpReport gains guest_svn, vmpl and signature_algo, which the downstream copies parsed and this one did not. load_snp_cert_chain() becomes public; it splits a concatenated PEM into (vcek, ask, ark) by shape rather than order and came from cmcp, which had it and this package did not. verify_snp_signature() now checks the report's declared sig_algo before verifying. It assumed ECDSA-P384/SHA-384 because that is the only scheme AMD has defined, and verified under it without confirming the report agreed. Both downstream copies enforced this, so consolidating onto the shared one would have silently dropped a check that already existed. Enforcing it surfaced two synthetic fixtures here that left sig_algo at zero, which no AMD processor emits: the genuine capture in vectors/snp carries 1. Both described a report that cannot exist. Corrected rather than worked around, the same shape of defect as the cmcp TPM fixture found in 0.8.0. 648 passed, 16 skipped. Refs #204 Co-Authored-By: Claude Opus 5 (1M context) --- CHANGELOG.md | 12 +++ python/src/agent_manifest/__init__.py | 6 +- python/src/agent_manifest/_snp_verify.py | 65 ++++++++++++- python/tests/test_attestation_chain.py | 1 + python/tests/test_snp_verify.py | 115 +++++++++++++++++++++++ 5 files changed, 195 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7256c77..58c4b98 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,18 @@ All notable changes to Agent Manifest are documented here. Format follows [Keep ## [Unreleased] +### Added + +**[SDK]** **`SnpReport` now carries `guest_svn`, `vmpl` and `signature_algo`**, and `load_snp_cert_chain()` is public. Phase A2 of the TEE consolidation: cmcp and ca2a carried four copies of the SEV-SNP report layout between them (two inside cmcp alone), and all four agreed on every offset, so this is a union rather than a reconciliation. The three fields were parsed by the downstream copies and not by this one, which meant a consumer of agent-manifest could not enforce checks those copies enforced. + +`load_snp_cert_chain()` splits a concatenated PEM into `(vcek, ask, ark)` by shape rather than order: the VCEK is the only EC leaf, and of the two RSA certificates the self-signed one is the ARK. It came from cmcp, which had it and this package did not. + +### Fixed + +**[SDK]** **`verify_snp_signature()` now checks the report's declared `sig_algo` before verifying.** It assumed ECDSA-P384/SHA-384 because that is the only scheme AMD has defined, and verified under it without confirming the report said so. Both downstream copies checked this field; the shared implementation did not, so consolidating onto it would have silently dropped a check. A report declaring anything other than `SIG_ALGO_ECDSA_P384_SHA384` now raises rather than being appraised under the wrong scheme. + +This surfaced two synthetic fixtures in this repo that left `sig_algo` at zero, which no AMD processor emits — the genuine capture in `tests/vectors/snp/azure_snp_report_redacted.bin` carries 1. Both fixtures described a report that cannot exist and are corrected. Same shape of defect as the cmcp TPM fixture found in 0.8.0. + ## [0.8.0] — 2026-08-01 Shares the `TPMT_SIGNATURE` parse and teaches the quote parser both attest framings, so cmcp and ca2a can delete their copies rather than keep three implementations of the same wire formats in step by hand. Phase A1 of consolidating TEE verification into this package. No change to manifest signing or verification behaviour. diff --git a/python/src/agent_manifest/__init__.py b/python/src/agent_manifest/__init__.py index bd261f4..17f32b5 100644 --- a/python/src/agent_manifest/__init__.py +++ b/python/src/agent_manifest/__init__.py @@ -35,8 +35,9 @@ verify_attestation_chain, ChainVerificationResult, SignatureStatus, ) from ._snp_verify import ( + SIG_ALGO_ECDSA_P384_SHA384, SnpReport, SnpVerificationError, - parse_snp_report, parse_hcl_report, + parse_snp_report, parse_hcl_report, load_snp_cert_chain, verify_snp_signature, verify_vcek_chain, verify_runtime_data_binding, fetch_vcek, ) @@ -89,8 +90,9 @@ "AttestationReport", "AttestationUnavailableError", "RuntimeAttestationReport", "TPMProvider", "AzureCVMProvider", "SEVSNPProvider", "TDXProvider", "OPAQUEProvider", "verify_attestation_chain", "ChainVerificationResult", "SignatureStatus", + "SIG_ALGO_ECDSA_P384_SHA384", "SnpReport", "SnpVerificationError", - "parse_snp_report", "parse_hcl_report", + "parse_snp_report", "parse_hcl_report", "load_snp_cert_chain", "verify_snp_signature", "verify_vcek_chain", "verify_runtime_data_binding", "fetch_vcek", "verify_cert_chain", "CertChainError", diff --git a/python/src/agent_manifest/_snp_verify.py b/python/src/agent_manifest/_snp_verify.py index ac08acc..e2ab1a8 100644 --- a/python/src/agent_manifest/_snp_verify.py +++ b/python/src/agent_manifest/_snp_verify.py @@ -42,7 +42,10 @@ # Raw SNP attestation report field offsets (AMD SEV-SNP ABI, Table 22). _OFF_VERSION = 0x00 +_OFF_GUEST_SVN = 0x04 _OFF_POLICY = 0x08 +_OFF_VMPL = 0x30 +_OFF_SIG_ALGO = 0x34 _OFF_REPORT_DATA = 0x50 _OFF_MEASUREMENT = 0x90 _OFF_HOST_DATA = 0xC0 @@ -56,6 +59,11 @@ _SIG_COMPONENT_STRIDE = 72 _SIG_COMPONENT_BYTES = 48 +# sig_algo values from the AMD SEV-SNP ABI. 1 is ECDSA P-384 with SHA-384, which +# is the only scheme AMD has defined; the field exists so a future one can be +# distinguished, which is exactly why it must be checked rather than assumed. +SIG_ALGO_ECDSA_P384_SHA384 = 1 + _HCL_MAGIC = b"HCLA" _HCL_SNP_REPORT_OFFSET = 0x20 @@ -69,7 +77,10 @@ class SnpReport: """Parsed fields of a raw SEV-SNP attestation report.""" version: int + guest_svn: int policy: int + vmpl: int + signature_algo: int report_data: bytes # 64 bytes measurement: bytes # 48 bytes host_data: bytes # 32 bytes @@ -94,7 +105,10 @@ def parse_snp_report(report: bytes) -> SnpReport: ) return SnpReport( version=struct.unpack_from(" bool: return hmac.compare_digest(report.report_data[:32], digest) +def load_snp_cert_chain(pem_bundle: bytes) -> tuple[object, object, object]: + """Split a PEM bundle into ``(vcek, ask, ark)`` certificates. + + The AMD KDS and most capture tooling hand out one concatenated PEM. The three + are told apart by shape rather than by order, which varies: the VCEK is the + only EC leaf, and of the two RSA certificates the self-signed one is the ARK. + + Raises :class:`SnpVerificationError` if the bundle is not a well-formed SNP + chain, so a caller cannot proceed with two of the three. + """ + try: + from cryptography import x509 + from cryptography.hazmat.primitives.asymmetric import ec, rsa + except ImportError as e: # pragma: no cover - exercised via install extra + raise SnpVerificationError( + "loading an SNP certificate chain requires the 'cryptography' package" + ) from e + + try: + certs = x509.load_pem_x509_certificates(pem_bundle) + except Exception as exc: # noqa: BLE001 - any parse failure is a bad bundle + raise SnpVerificationError(f"could not parse the PEM bundle: {exc}") from exc + + vcek = next((c for c in certs if isinstance(c.public_key(), ec.EllipticCurvePublicKey)), None) + rsa_certs = [c for c in certs if isinstance(c.public_key(), rsa.RSAPublicKey)] + ark = next((c for c in rsa_certs if c.subject == c.issuer), None) + ask = next((c for c in rsa_certs if c is not ark), None) + + if vcek is None or ask is None or ark is None: + raise SnpVerificationError( + "bundle must contain a VCEK (EC), an ASK and a self-signed ARK (RSA)" + ) + return vcek, ask, ark + + def verify_snp_signature(report: SnpReport, vcek_cert_der: bytes) -> bool: """Verify the report's ECDSA-P384 signature against the VCEK public key. + The report's own ``sig_algo`` field is checked first. Verifying with + ECDSA-P384/SHA-384 because that is what AMD defines today, without confirming + the report says so, would silently appraise a report that declares something + else under the wrong scheme. Both downstream copies of this check enforced it; + this one did not, so it is enforced here now. + Returns True on success; raises :class:`SnpVerificationError` if the - ``cryptography`` package is unavailable. A wrong or tampered report returns - False rather than raising. + ``cryptography`` package is unavailable or the report declares an unsupported + algorithm. A wrong or tampered report returns False rather than raising. """ try: from typing import cast @@ -167,6 +222,12 @@ def verify_snp_signature(report: SnpReport, vcek_cert_der: bytes) -> bool: "SNP signature verification requires the 'cryptography' package" ) from e + if report.signature_algo != SIG_ALGO_ECDSA_P384_SHA384: + raise SnpVerificationError( + f"unsupported SNP signature algorithm {report.signature_algo} " + f"(expected {SIG_ALGO_ECDSA_P384_SHA384}, ECDSA-P384/SHA-384)" + ) + vcek = x509.load_der_x509_certificate(vcek_cert_der) r = int.from_bytes(report.signature[0:_SIG_COMPONENT_BYTES], "little") s = int.from_bytes( diff --git a/python/tests/test_attestation_chain.py b/python/tests/test_attestation_chain.py index 5af4dee..91cd7c9 100644 --- a/python/tests/test_attestation_chain.py +++ b/python/tests/test_attestation_chain.py @@ -116,6 +116,7 @@ def _synthetic_snp_with_chain(report_data_digest_hex: str, measurement_hex: str) ec_key = ec.generate_private_key(ec.SECP384R1()) # the "VCEK" signing key body = bytearray(_OFF_SIGNATURE) body[0:4] = (3).to_bytes(4, "little") + body[0x34:0x38] = (1).to_bytes(4, "little") # sig_algo, as real silicon sets body[0x50:0x50 + 32] = bytes.fromhex(report_data_digest_hex) body[0x90:0x90 + 48] = bytes.fromhex(measurement_hex) der = ec_key.sign(bytes(body), ec.ECDSA(hashes.SHA384())) diff --git a/python/tests/test_snp_verify.py b/python/tests/test_snp_verify.py index 5cf158b..4f1f8da 100644 --- a/python/tests/test_snp_verify.py +++ b/python/tests/test_snp_verify.py @@ -27,6 +27,7 @@ verify_snp_signature, verify_vcek_chain, _OFF_SIGNATURE, + SIG_ALGO_ECDSA_P384_SHA384, _SIG_COMPONENT_BYTES, _SIG_COMPONENT_STRIDE, _SNP_REPORT_LEN, @@ -125,6 +126,11 @@ def _synthetic_signed_report(): key = ec.generate_private_key(ec.SECP384R1()) body = bytearray(_OFF_SIGNATURE) body[0:4] = (3).to_bytes(4, "little") # version + # sig_algo must be set, as real silicon does: the genuine capture in + # vectors/snp/azure_snp_report_redacted.bin carries 1 here. Leaving it zero + # made the fixture describe a report no AMD processor emits, and + # verify_snp_signature now checks the declared scheme before verifying. + body[0x34:0x38] = SIG_ALGO_ECDSA_P384_SHA384.to_bytes(4, "little") body[0x50:0x50 + 32] = hashlib.sha256(b"runtime").digest() # report_data der = key.sign(bytes(body), ec.ECDSA(hashes.SHA384())) r, s = utils.decode_dss_signature(der) @@ -231,3 +237,112 @@ def test_verify_vcek_chain_requires_two_certs(): one = chain_pem.split(b"-----END CERTIFICATE-----")[0] + b"-----END CERTIFICATE-----\n" with pytest.raises(SnpVerificationError, match="ASK and ARK"): verify_vcek_chain(vcek_der, one) + + +# --------------------------------------------------------------------------- +# Declared signature algorithm and cert-bundle loading (union, agent-manifest 0.9) +# --------------------------------------------------------------------------- + + +def test_parse_exposes_the_fields_the_downstream_copies_carried(): + """guest_svn, vmpl and signature_algo came from cmcp's and ca2a's copies; + without them a consumer cannot enforce checks those copies enforced.""" + rep = parse_snp_report(SNP.read_bytes()) + + assert rep.signature_algo == SIG_ALGO_ECDSA_P384_SHA384 # real silicon sets 1 + assert rep.vmpl == 0 + assert isinstance(rep.guest_svn, int) + + +def test_verify_rejects_a_report_declaring_another_algorithm(): + """Verifying with ECDSA-P384/SHA-384 without checking the report says so + would appraise a differently-signed report under the wrong scheme.""" + report, vcek_der = _synthetic_signed_report() + other = bytearray(report) + other[0x34:0x38] = (2).to_bytes(4, "little") + + with pytest.raises(SnpVerificationError, match="unsupported SNP signature algorithm"): + verify_snp_signature(parse_snp_report(bytes(other)), vcek_der) + + +def _snp_shaped_bundle() -> bytes: + """A PEM bundle shaped like a real SNP chain: EC VCEK leaf, RSA ASK and ARK. + + ``_rsa_pss_chain`` makes an RSA leaf, which is not what AMD issues; the VCEK + is EC P-384 and that is precisely what tells it apart in a bundle. + """ + from datetime import datetime, timedelta, timezone + + from cryptography import x509 + from cryptography.hazmat.primitives import hashes + from cryptography.hazmat.primitives.asymmetric import ec, padding, rsa + from cryptography.hazmat.primitives.serialization import Encoding + from cryptography.x509.oid import NameOID + + pss = padding.PSS(mgf=padding.MGF1(hashes.SHA384()), salt_length=48) + t0 = datetime(2020, 1, 1, tzinfo=timezone.utc) + + def name(cn): + return x509.Name([x509.NameAttribute(NameOID.COMMON_NAME, cn)]) + + def cert(subj, pub, issuer_name, issuer_key): + return ( + x509.CertificateBuilder() + .subject_name(name(subj)) + .issuer_name(issuer_name) + .public_key(pub) + .serial_number(x509.random_serial_number()) + .not_valid_before(t0) + .not_valid_after(t0 + timedelta(days=3650)) + .sign(issuer_key, hashes.SHA384(), rsa_padding=pss) + ) + + ark_key = rsa.generate_private_key(public_exponent=65537, key_size=2048) + ask_key = rsa.generate_private_key(public_exponent=65537, key_size=2048) + vcek_key = ec.generate_private_key(ec.SECP384R1()) + ark = cert("ARK-test", ark_key.public_key(), name("ARK-test"), ark_key) + ask = cert("ASK-test", ask_key.public_key(), name("ARK-test"), ark_key) + vcek = cert("SEV-VCEK-test", vcek_key.public_key(), name("ASK-test"), ask_key) + # Deliberately not leaf-first: order varies by source and must not matter. + return ( + ask.public_bytes(Encoding.PEM) + + vcek.public_bytes(Encoding.PEM) + + ark.public_bytes(Encoding.PEM) + ) + + +def test_load_snp_cert_chain_sorts_by_shape_not_order(): + """The bundle order varies by source, so VCEK/ASK/ARK are told apart by key + type and self-signedness rather than position.""" + from agent_manifest import load_snp_cert_chain + + vcek, ask, ark = load_snp_cert_chain(_snp_shaped_bundle()) + + assert "VCEK" in vcek.subject.rfc4514_string() + assert ark.subject == ark.issuer # the self-signed one is the root + assert ask.subject != ask.issuer + + +def test_load_snp_cert_chain_rejects_the_kds_cert_chain_endpoint_output(): + """AMD KDS's `cert_chain` endpoint returns ASK + ARK with no VCEK. Passing it + whole is a real, recorded deployment mistake, so it must fail loudly rather + than proceed with two of the three.""" + from cryptography import x509 + from cryptography.hazmat.primitives.serialization import Encoding + + from agent_manifest import load_snp_cert_chain + + certs = x509.load_pem_x509_certificates(_snp_shaped_bundle()) + ask_and_ark = b"".join( + c.public_bytes(Encoding.PEM) for c in certs if "VCEK" not in c.subject.rfc4514_string() + ) + + with pytest.raises(SnpVerificationError, match="must contain a VCEK"): + load_snp_cert_chain(ask_and_ark) + + +def test_load_snp_cert_chain_rejects_unparseable_input(): + from agent_manifest import load_snp_cert_chain + + with pytest.raises(SnpVerificationError, match="could not parse"): + load_snp_cert_chain(b"-----BEGIN CERTIFICATE-----\nnot a cert\n-----END CERTIFICATE-----\n")