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
12 changes: 12 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
6 changes: 4 additions & 2 deletions python/src/agent_manifest/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
)
Expand Down Expand Up @@ -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",
Expand Down
65 changes: 63 additions & 2 deletions python/src/agent_manifest/_snp_verify.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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

Expand All @@ -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
Expand All @@ -94,7 +105,10 @@ def parse_snp_report(report: bytes) -> SnpReport:
)
return SnpReport(
version=struct.unpack_from("<I", report, _OFF_VERSION)[0],
guest_svn=struct.unpack_from("<I", report, _OFF_GUEST_SVN)[0],
policy=struct.unpack_from("<Q", report, _OFF_POLICY)[0],
vmpl=struct.unpack_from("<I", report, _OFF_VMPL)[0],
signature_algo=struct.unpack_from("<I", report, _OFF_SIG_ALGO)[0],
report_data=report[_OFF_REPORT_DATA:_OFF_REPORT_DATA + 64],
measurement=report[_OFF_MEASUREMENT:_OFF_MEASUREMENT + 48],
host_data=report[_OFF_HOST_DATA:_OFF_HOST_DATA + 32],
Expand Down Expand Up @@ -148,12 +162,53 @@ def verify_runtime_data_binding(report: SnpReport, runtime_data: bytes) -> 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
Expand All @@ -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(
Expand Down
1 change: 1 addition & 0 deletions python/tests/test_attestation_chain.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()))
Expand Down
115 changes: 115 additions & 0 deletions python/tests/test_snp_verify.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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")
Loading