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
23 changes: 23 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,29 @@

## Unreleased

### Fixed

- **[SECURITY][SDK]** `verify_attestation_chain` no longer treats the
`azure-cvm-sev-snp` platform label as proof that the manifest binding was
verified. REPORT_DATA on Azure is `sha256(runtime_data)`, never the
manifest hash, and this function cannot itself establish Azure's real
binding (a vTPM AK-signed quote over a PCR derived from the manifest
hash). It now takes an explicit `azure_manifest_binding_verified`
parameter, and `report_data_matched` is genuinely three-state for Azure
(the same discipline already used for `measurement_matched`): `True` only
from an authenticated result the caller obtained via
`AzureCVMProvider.verify_manifest_in_report()`, `False` for an explicit
failure (e.g. a wrong PCR), and `None` - "not established", never assumed
fine - when nothing is supplied. `None` and `False` are never treated as
a pass. A `platform` value selects which verification procedure applies;
it is never itself evidence that the procedure ran.

- **[SECURITY][SDK]** `verify_attestation_chain` platform dispatch is now an
explicit allow-list (`amd-sev-snp`, `azure-cvm-sev-snp`, `intel-tdx`,
`tpm`, `aws-nitro`) instead of a catch-all `else` that routed any
unrecognized platform label through the SNP verifier. Unsupported labels
now report `NOT_IMPLEMENTED` and cannot pass. Closes #363.

### Deprecated

- **[SPEC] Issuing v0.1 manifests ends 2026-11-30** (issue #315, phase 5). From that
Expand Down
129 changes: 110 additions & 19 deletions python/src/agent_manifest/_attestation.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,11 @@
model where the guest controls ``REPORT_DATA``. On Azure confidential VMs
the guest does not control ``REPORT_DATA`` (the paravisor binds the vTPM AK
there); manifest binding on Azure is via the vTPM quote produced by
``AzureCVMProvider``, not this field.
``AzureCVMProvider``, not this field. A ``platform`` value only selects
which of the above applies as it never itself evidence that any of them
ran. For Azure, this function takes the caller's already-authenticated
result as the ``azure_manifest_binding_verified`` argument rather than
inferring anything from the platform string.

:func:`verify_attestation_chain` **fails closed**: ``passed`` is ``True`` only
when the hardware signature is ``VERIFIED``, the manifest-hash binding matches,
Expand Down Expand Up @@ -52,11 +56,25 @@ class ChainVerificationResult:
accepted (or not requested), and the manifest-hash binding matched. Until
the signature backends land (#204), ``passed`` is always ``False`` and
``reasons`` explains why.

For ``"azure-cvm-sev-snp"`` reports, ``report_data_matched`` reflects the
caller-supplied ``azure_manifest_binding_verified`` argument to
:func:`verify_attestation_chain`, not the ``report_data`` field itself
(which does not carry the manifest hash on Azure). It is genuinely
three-state here, the same discipline as ``measurement_matched``:
``True`` only from an authenticated caller-supplied ``True``; ``False``
for an explicit caller-supplied ``False`` (checked, and failed --
e.g. a wrong PCR); and ``None`` when nothing was supplied at all (never
checked, so never established). ``None`` and ``False`` both gate
``passed`` identically -- neither is ever treated as a pass -- but they
are represented as distinct values so a caller can tell "we checked and
it was wrong" apart from "we never checked at all". Every other platform
always reports a definite ``True``/``False`` here, never ``None``.
"""

passed: bool
signature: SignatureStatus
report_data_matched: bool
report_data_matched: Optional[bool] # None only for azure-cvm-sev-snp "not established"
measurement_matched: Optional[bool] # None = no allow-list supplied
reasons: list[str] = field(default_factory=list)

Expand Down Expand Up @@ -204,6 +222,7 @@ def verify_attestation_chain(
tpm_trusted_roots_pem: Optional[bytes] = None,
expected_qualifying_data: Optional[bytes] = None,
expected_pcr_digest: Optional[bytes] = None,
azure_manifest_binding_verified: Optional[bool] = None,
) -> ChainVerificationResult:
"""Verify a boot-time ``AttestationReport`` against expected values.

Expand All @@ -224,27 +243,94 @@ def verify_attestation_chain(
cert_chain_pem: The AMD KDS ``cert_chain`` blob (ASK then ARK, PEM).
trusted_ark_der: Optional pinned AMD root (ARK) certificate. When given,
the chain's ARK public key must match it.
azure_manifest_binding_verified: For ``"azure-cvm-sev-snp"`` reports
only. This function cannot itself check Azure's manifest binding
(REPORT_DATA there is ``sha256(runtime_data)``, not the manifest
hash — the actual binding is a vTPM AK-signed quote over a PCR
derived from the manifest hash). Pass the caller's own
authenticated result from
``AzureCVMProvider.verify_manifest_in_report(report, manifest)``
here: ``True`` if that call verified the PCR/AK-quote binding for
*this* manifest, ``False`` if it did not (wrong PCR, bad AK
signature, etc). Leaving this ``None`` means the binding was
never established -- reported as ``report_data_matched=None``,
never treated as a pass, but distinct from an explicit ``False``.
Ignored for every other platform.

Returns:
A :class:`ChainVerificationResult`. ``passed`` is ``True`` only when the
hardware signature is ``VERIFIED``, the manifest-hash binding matches,
and the measurement is accepted (or no allow-list was requested).
Without VCEK material the signature step is not performed and the result
cannot pass, because an unverified report proves nothing.
cannot pass, because an unverified report proves nothing. An
unrecognized ``report.platform`` value also cannot pass: the signature
step is reported as ``NOT_IMPLEMENTED`` rather than falling through to
a verifier for a different profile. For ``"azure-cvm-sev-snp"``
reports, the manifest-hash binding step has three distinct outcomes:
``True`` only when ``azure_manifest_binding_verified=True`` was
supplied (an authenticated result the caller obtained elsewhere);
``False`` for an explicit ``azure_manifest_binding_verified=False``
(e.g. a wrong PCR); and ``None`` -- meaning "not established", never
"assumed fine" -- when the caller supplied nothing at all. Both
``False`` and ``None`` are treated identically for gating ``passed``
(neither ever passes); they are kept distinct only so a caller can
tell "checked and failed" apart from "never checked". A
``platform`` value only selects which verification procedure applies;
it is never itself evidence that the procedure ran.
"""
reasons: list[str] = []
platform = getattr(report, "platform", "") or ""

# Step 3: manifest-hash binding (software-checkable).
expected_digest = expected_manifest_hash.split(":", 1)[-1].lower()
actual_hex = _report_data_hex(report)
if actual_hex is None:
report_data_matched = False
reasons.append("report has no 'report_data' field to check the manifest binding against")
#
# Does not apply on Azure via REPORT_DATA: the guest never controls that
# field there (the paravisor sets it to sha256(runtime_data) to bind the
# vTPM AK, not the manifest hash). Azure's real binding is a vTPM
# AK-signed quote over a PCR derived from the manifest hash, established
# by AzureCVMProvider.verify_manifest_in_report() -- outside this
# function's own crypto boundary. So this step must never be set True
# from the platform label alone: a `platform` value says which procedure
# applies, it is not evidence that the procedure ran. It can only become
# True from an authenticated result the caller actually obtained and
# passed in via azure_manifest_binding_verified. No such result means
# "not established" -- treated the same as a failed check, never as a
# pass by default.
azure_paravisor = platform == "azure-cvm-sev-snp"
if azure_paravisor:
if azure_manifest_binding_verified is True:
report_data_matched = True
reasons.append(
"Azure manifest binding confirmed by an authenticated result "
"supplied by the caller (azure_manifest_binding_verified=True)"
)
elif azure_manifest_binding_verified is False:
report_data_matched = False
reasons.append(
"Azure manifest binding check failed: caller supplied "
"azure_manifest_binding_verified=False (e.g. wrong PCR "
"or invalid AK-quote signature)"
)
else:
report_data_matched = None
reasons.append(
"Azure manifest binding not established: no authenticated "
"result was supplied via azure_manifest_binding_verified; "
"report_data itself does not carry the manifest hash on "
"Azure, so it cannot be checked directly -- run "
"AzureCVMProvider.verify_manifest_in_report() and pass its "
"result in"
)
else:
# The first 32 bytes (64 hex chars) of REPORT_DATA carry the digest.
report_data_matched = hmac.compare_digest(actual_hex[:64].lower(), expected_digest)
if not report_data_matched:
reasons.append("manifest hash does not match the report_data binding")
expected_digest = expected_manifest_hash.split(":", 1)[-1].lower()
actual_hex = _report_data_hex(report)
if actual_hex is None:
report_data_matched = False
reasons.append("report has no 'report_data' field to check the manifest binding against")
else:
# The first 32 bytes (64 hex chars) of REPORT_DATA carry the digest.
report_data_matched = hmac.compare_digest(actual_hex[:64].lower(), expected_digest)
if not report_data_matched:
reasons.append("manifest hash does not match the report_data binding")

# Step 2: launch-measurement allow-list (software-checkable, optional).
measurement_matched: Optional[bool]
Expand All @@ -262,11 +348,13 @@ def verify_attestation_chain(
reasons.append("launch measurement is not in the supplied allow-list")

# Step 1: hardware signature / quote chain, dispatched by platform.
# AMD SEV-SNP verifies the report signature + VCEK<-ASK<-ARK chain (needs the
# VCEK material). Intel TDX verifies the self-contained DCAP quote + PCK chain
# to the pinned Intel SGX Root CA. Either way, without a verifiable signature
# the result cannot pass.
platform = getattr(report, "platform", "") or ""
# AMD SEV-SNP (bare-metal and Azure's paravisor variant, which carries a
# real SNP report too) verifies the report signature + VCEK<-ASK<-ARK
# chain (needs the VCEK material). Intel TDX verifies the self-contained
# DCAP quote + PCK chain to the pinned Intel SGX Root CA. TPM/AWS Nitro
# verify an AK-signed quote. Dispatch is an explicit allow-list, not a
# catch-all: an unrecognized platform label must fail closed rather than
# silently inherit a verifier meant for a different profile.
if platform == "intel-tdx":
signature = _verify_tdx_signature_step(report, reasons, trusted_tdx_root_pem)
elif platform in ("tpm", "aws-nitro"):
Expand All @@ -279,7 +367,7 @@ def verify_attestation_chain(
expected_pcr_digest,
reasons,
)
else:
elif platform in ("amd-sev-snp", "azure-cvm-sev-snp"):
signature = _verify_snp_signature_step(
report,
snp_report_bytes,
Expand All @@ -288,8 +376,11 @@ def verify_attestation_chain(
trusted_ark_der,
reasons,
)
else:
signature = SignatureStatus.NOT_IMPLEMENTED
reasons.append(f"platform {platform!r} is not a supported attestation profile")

passed = (
passed = bool(
signature == SignatureStatus.VERIFIED
and report_data_matched
and measurement_matched is not False
Expand Down
2 changes: 1 addition & 1 deletion python/src/agent_manifest/_providers.py
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@ class AttestationUnavailableError(RuntimeError):
class AttestationReport:
"""Portable attestation report returned by all providers."""

platform: str # "tpm" | "sev-snp" | "tdx" | "opaque"
platform: str # "amd-sev-snp" | "azure-cvm-sev-snp" | "intel-tdx" | "tpm" | "aws-nitro" | "opaque"
manifest_hash: str # "sha256:<64-hex>" — hash of the signed manifest
pcr_values: dict[str, str] = field(default_factory=dict) # {"PCR15": "sha256:..."}
quote: Optional[bytes] = None # raw platform quote/report blob
Expand Down
126 changes: 126 additions & 0 deletions python/tests/test_attestation_chain.py
Original file line number Diff line number Diff line change
Expand Up @@ -239,3 +239,129 @@ def test_full_chain_reads_snp_bytes_from_report_quote():
)
assert result.signature is SignatureStatus.VERIFIED
assert result.passed is True


# ---------------------------------------------------------------------------
# Azure paravisor SNP + unsupported-platform dispatch.
#
# REPORT_DATA on Azure is sha256(runtime_data), never the manifest hash (the
# guest does not control it). This function cannot itself establish Azure's
# real manifest binding (a vTPM AK-signed quote over a PCR derived from the
# manifest hash - see AzureCVMProvider.verify_manifest_in_report()), so a
# platform label alone must never be treated as evidence that binding was
# checked. The binding step is three-state: verified (only from an
# authenticated azure_manifest_binding_verified=True the caller supplies),
# mismatched (explicit False - e.g. a wrong PCR), or not established (nothing
# supplied) - and "not established" must be indistinguishable from "failed"
# as far as `passed` is concerned. Platform dispatch must also be an explicit
# allow-list, not a catch-all, so an unrecognized platform label can't
# silently borrow the SNP verifier.
# ---------------------------------------------------------------------------


def _azure_report(*, report_data_hex: str, measurement: str = MEASUREMENT) -> AttestationReport:
return AttestationReport(
platform="azure-cvm-sev-snp",
manifest_hash=MANIFEST_HASH,
raw={"report_data": report_data_hex, "measurement": measurement},
)


def test_azure_report_with_valid_snp_signature_and_wrong_pcr_does_not_pass():
# The maintainer's exact reproduction shape: a correctly signed SNP
# report, but the caller's own PCR/AK-quote check (done outside this
# function, e.g. via AzureCVMProvider.verify_manifest_in_report) came
# back False. A valid hardware signature must not be enough on its own.
snp, vcek_der, chain = _synthetic_snp_with_chain(DIGEST, MEASUREMENT)
report = AttestationReport(
platform="azure-cvm-sev-snp",
manifest_hash=MANIFEST_HASH,
quote=snp,
raw={"report_data": DIGEST + "00" * 32, "measurement": MEASUREMENT},
)
result = verify_attestation_chain(
report,
expected_manifest_hash=MANIFEST_HASH,
vcek_cert_der=vcek_der,
cert_chain_pem=chain,
azure_manifest_binding_verified=False, # wrong PCR
)
assert result.signature is SignatureStatus.VERIFIED
assert result.report_data_matched is False
assert result.passed is False


def test_azure_report_with_authenticated_binding_and_valid_signature_passes():
# The positive counterpart: this is not a permanent False. When the
# caller supplies a genuine authenticated result (their own PCR/AK-quote
# check succeeded) and the SNP signature verifies, passed can be True.
snp, vcek_der, chain = _synthetic_snp_with_chain(DIGEST, MEASUREMENT)
report = AttestationReport(
platform="azure-cvm-sev-snp",
manifest_hash=MANIFEST_HASH,
quote=snp,
raw={"report_data": DIGEST + "00" * 32, "measurement": MEASUREMENT},
)
result = verify_attestation_chain(
report,
expected_manifest_hash=MANIFEST_HASH,
vcek_cert_der=vcek_der,
cert_chain_pem=chain,
azure_manifest_binding_verified=True,
)
assert result.signature is SignatureStatus.VERIFIED
assert result.report_data_matched is True
assert result.passed is True


def test_azure_report_with_no_binding_result_supplied_is_not_established_not_passed():
# Nothing supplied at all (the common/default case): must not be quietly
# assumed fine, and must be distinguishable from an explicit failure.
report = _azure_report(report_data_hex=DIGEST + "00" * 32)
result = verify_attestation_chain(report, expected_manifest_hash=MANIFEST_HASH)
assert result.report_data_matched is None # not established, distinct from False
assert result.passed is False
assert isinstance(result.passed, bool) # never leaks a bare None
assert any("not established" in r for r in result.reasons)


@pytest.mark.parametrize("arbitrary_hash", ["sha256:" + "11" * 32, "sha256:" + "22" * 32, "sha256:" + "ff" * 32])
def test_azure_report_fails_closed_for_any_expected_manifest_hash(arbitrary_hash):
# The platform label and expected_manifest_hash alone must never combine
# into a pass, regardless of which hash is expected.
report = _azure_report(report_data_hex=DIGEST + "00" * 32)
result = verify_attestation_chain(report, expected_manifest_hash=arbitrary_hash)
assert result.passed is False


def test_azure_report_with_no_report_data_still_fails_closed():
report = AttestationReport(platform="azure-cvm-sev-snp", manifest_hash=MANIFEST_HASH, raw={})
result = verify_attestation_chain(report, expected_manifest_hash=MANIFEST_HASH)
assert result.report_data_matched is None
assert result.passed is False


@pytest.mark.parametrize("unsupported_platform", ["opaque", "", "quantum-tee-v9"])
def test_unsupported_platform_label_does_not_borrow_the_snp_verifier(unsupported_platform):
# #363 regression matrix: same exact SNP evidence, only the platform
# label changes to an unrecognized/empty/future value. Dispatch must not
# fall through to SNP verification for any of them.
report = _report(report_data_hex=DIGEST + "00" * 32)
report.platform = unsupported_platform
result = verify_attestation_chain(report, expected_manifest_hash=MANIFEST_HASH)
assert result.signature is SignatureStatus.NOT_IMPLEMENTED
assert result.passed is False
assert any("not a supported attestation profile" in r for r in result.reasons)


def test_non_azure_snp_still_requires_report_data_to_match():
# Confirms the fix is scoped to azure-cvm-sev-snp: direct-silicon SNP
# (amd-sev-snp) must still bind REPORT_DATA to the manifest hash, and
# azure_manifest_binding_verified has no effect on it.
wrong = hashlib.sha256(b"different").hexdigest()
report = _report(report_data_hex=wrong + "00" * 32)
result = verify_attestation_chain(
report, expected_manifest_hash=MANIFEST_HASH, azure_manifest_binding_verified=True
)
assert result.report_data_matched is False
assert result.passed is False
Loading