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

## [Unreleased]

### Changed

- Attestation crypto now delegates to agent-manifest's shared verification library (`agent-manifest>=0.5`) instead of cMCP's own copies: the SEV-SNP report signature (`verify_snp_signature`) and the VCEK/PCK certificate-chain verification (the generic `verify_cert_chain`) are shared across the org rather than duplicated. cMCP keeps its own DCAP quote parser, `*VerificationResult` shapes, TR-field semantics, and report_data/qualifying-data bindings; behavior is unchanged (all tests pass unchanged). cMCP's TPM verifier stays local (Phase-1 parse-only: no AK signature/chain to share).

### Added

- Azure confidential-VM attestation (`cmcp_runtime.tee.azure_cvm.AzureCVMProvider` + `cmcp_verify.azure_cvm`), **hardware-validated on live Azure SEV-SNP silicon**. Azure runs SNP behind a Hyper-V paravisor with no `/dev/sev-guest`; the SNP report is read from the vTPM NV index `0x01400001` and the guest cannot control `REPORT_DATA` (the paravisor binds the vTPM AK there). cMCP's nonce (`jwk_thumbprint || audit-root`) is therefore committed into an AK-signed TPM quote's qualifying data, with the AK rooted in silicon via the SNP report (`REPORT_DATA == sha256(runtime_data)`) and the VCEK→ASK→ARK chain (reusing `cmcp_verify.sev_snp`). Auto-detected first (before TPM/SEV-SNP) since Azure exposes no `/dev/sev-guest`. Carries its own `runtime.platform` value `azure-cvm-sev-snp` (requires `agentrust-trace>=0.4`) so a consumer keying on `runtime.platform` knows the root of trust is vTPM-rooted, not a guest-controlled SNP `report_data`.
Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ classifiers = [
requires-python = ">=3.11"
dependencies = [
"agentrust-trace>=0.4",
"agent-manifest>=0.1.1",
"agent-manifest>=0.5",
"cryptography>=42.0",
"pyyaml>=6.0",
"httpx>=0.27",
Expand Down
82 changes: 22 additions & 60 deletions src/cmcp_verify/sev_snp.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,8 +20,7 @@

from cryptography import x509
from cryptography.hazmat.primitives.asymmetric import ec, rsa
from cryptography.hazmat.primitives.asymmetric.utils import encode_dss_signature
from cryptography.hazmat.primitives.hashes import SHA384
from cryptography.hazmat.primitives.serialization import Encoding

# The SNP report is signed over its leading bytes; the 512-byte signature field
# occupies the tail. sizeof(report) == 0x4A0, signature == 0x200, so the signed
Expand Down Expand Up @@ -92,27 +91,18 @@ class SNPVerificationResult:
details: dict[str, str] = field(default_factory=dict)


def _snp_report_signature_der(raw_report: bytes) -> bytes:
"""Extract the ECDSA-P384 signature from an SNP report and DER-encode it.

R and S are stored as little-endian 72-byte fields; P-384 uses 48 bytes each.
"""
sig_field = raw_report[_SNP_SIG_OFFSET : _SNP_SIG_OFFSET + 2 * _SNP_SIG_COMPONENT_LEN]
r_le = sig_field[:_SNP_SIG_COMPONENT_LEN]
s_le = sig_field[_SNP_SIG_COMPONENT_LEN : 2 * _SNP_SIG_COMPONENT_LEN]
r = int.from_bytes(r_le[:_P384_COMPONENT_LEN], "little")
s = int.from_bytes(s_le[:_P384_COMPONENT_LEN], "little")
return encode_dss_signature(r, s)


def verify_snp_report_signature(
raw_report: bytes, vcek_cert: x509.Certificate
) -> tuple[bool, str | None]:
"""Verify the SNP report is signed by the VCEK (ECDSA P-384 / SHA-384).

Returns (True, None) on a valid signature, (False, reason) otherwise. Fails
closed on any parse or verification error.
The cryptographic check is delegated to agent-manifest's shared verifier
(`agent_manifest.verify_snp_signature`) so the org shares one implementation;
the format pre-checks below keep cmcp's specific failure reasons. Returns
(True, None) on a valid signature, (False, reason) otherwise. Fails closed.
"""
from agent_manifest import parse_snp_report, verify_snp_signature

if len(raw_report) < _SNP_SIG_OFFSET + 2 * _SNP_SIG_COMPONENT_LEN:
return False, "report too short to contain a signature"
try:
Expand All @@ -126,44 +116,15 @@ def verify_snp_report_signature(
if not isinstance(pub, ec.EllipticCurvePublicKey) or pub.curve.name != "secp384r1":
return False, "VCEK public key is not EC P-384"

signed_region = raw_report[:_SNP_SIGNED_LEN]
try:
der_sig = _snp_report_signature_der(raw_report)
pub.verify(der_sig, signed_region, ec.ECDSA(SHA384()))
except Exception: # noqa: BLE001 (InvalidSignature and malformed r/s both fail closed)
ok = verify_snp_signature(parse_snp_report(raw_report), vcek_cert.public_bytes(Encoding.DER))
except Exception: # noqa: BLE001 (fail closed on any parse/verify error)
return False, "SNP report signature does not verify against the VCEK"
if not ok:
return False, "SNP report signature does not verify against the VCEK"
return True, None


def _cert_signed_by(child: x509.Certificate, issuer: x509.Certificate) -> bool:
"""True iff `child` carries a valid signature from `issuer`'s public key.

Handles both AMD's RSA-PSS cert signatures (ARK/ASK) and EC signatures by
reading the signature parameters off the certificate, so we do not hand-roll
fragile PSS parameters.
"""
issuer_pub = issuer.public_key()
try:
if isinstance(issuer_pub, rsa.RSAPublicKey):
issuer_pub.verify(
child.signature,
child.tbs_certificate_bytes,
child.signature_algorithm_parameters,
child.signature_hash_algorithm,
)
elif isinstance(issuer_pub, ec.EllipticCurvePublicKey):
issuer_pub.verify(
child.signature,
child.tbs_certificate_bytes,
ec.ECDSA(child.signature_hash_algorithm),
)
else:
return False
return True
except Exception: # noqa: BLE001
return False


def load_snp_cert_chain(
pem_bundle: bytes,
) -> tuple[x509.Certificate, x509.Certificate, x509.Certificate]:
Expand Down Expand Up @@ -193,17 +154,18 @@ def verify_vcek_chain(
) -> tuple[bool, str | None]:
"""Verify VCEK -> ASK -> ARK, with ARK pinned to a caller-trusted AMD root.

Returns (True, None) or (False, reason). Fails closed.
Returns (True, None) or (False, reason). Fails closed. Delegates to
agent-manifest's generic, algorithm-agnostic chain verifier (shared across
the org) which honors each certificate's own signature algorithm and pins
the root by fingerprint.
"""
if ark.fingerprint(SHA384()) != trusted_ark.fingerprint(SHA384()):
return False, "chain ARK does not match the pinned trusted AMD ARK"
if not _cert_signed_by(ark, ark):
return False, "ARK is not validly self-signed"
if not _cert_signed_by(ask, ark):
return False, "ASK is not signed by the ARK"
if not _cert_signed_by(vcek, ask):
return False, "VCEK is not signed by the ASK"
return True, None
from agent_manifest import verify_cert_chain

try:
verify_cert_chain([vcek, ask, ark], [trusted_ark])
return True, None
except Exception as exc: # noqa: BLE001 (CertChainError + any parse error → fail closed)
return False, str(exc)


def verify_sev_snp_measurement(
Expand Down
28 changes: 8 additions & 20 deletions src/cmcp_verify/tdx.py
Original file line number Diff line number Diff line change
Expand Up @@ -234,19 +234,6 @@ def _verify_p256(pub: ec.EllipticCurvePublicKey, sig64: bytes, data: bytes) -> b
return False


def _cert_signed_by(child: x509.Certificate, issuer: x509.Certificate) -> bool:
# PCK chain certs are ECDSA P-256.
pub = issuer.public_key()
if not isinstance(pub, ec.EllipticCurvePublicKey):
return False
try:
pub.verify(child.signature, child.tbs_certificate_bytes,
ec.ECDSA(child.signature_hash_algorithm))
return True
except (InvalidSignature, ValueError):
return False


@dataclass
class _ParsedQuote:
signed_region: bytes
Expand Down Expand Up @@ -350,13 +337,14 @@ def fail(reason: str) -> TDXVerificationResult:
root = x509.load_pem_x509_certificate(trusted_intel_root_pem)
except ValueError as exc:
return fail(f"intel_root_parse_error: {exc}")
if not _cert_signed_by(root, root):
return fail("intel_root_not_self_signed")
# Verify each cert is signed by the next in the chain, with the last PCK cert
# signed by the pinned root (which was checked self-signed above).
for child, issuer in zip(certs, [*certs[1:], root], strict=True):
if not _cert_signed_by(child, issuer):
return fail("pck_chain_invalid")
# PCK chain (leaf..intermediates) up to the pinned Intel root, via the shared
# generic cert-chain verifier (agent-manifest) instead of a local copy.
from agent_manifest import verify_cert_chain

try:
verify_cert_chain([*certs, root], [root])
except Exception: # noqa: BLE001 (CertChainError etc. -> fail closed)
return fail("pck_chain_invalid")

result = TDXVerificationResult(verified=True)
result.verified_fields.extend(["dcap_quote_signature", "pck_chain"])
Expand Down