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
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,10 @@ All notable changes to Agent Manifest are documented here. Format follows [Keep

## [Unreleased]

### Security

- Generic hardware-attestation certificate-chain verification now enforces every certificate's validity period and requires every issuing certificate to carry `BasicConstraints(ca=True)`. If an issuer declares `KeyUsage`, it must permit certificate signing.

### Fixed

- The Python test harness now pins imports to the checkout's `src` tree and asserts that location, preventing a stale installed `agent-manifest` wheel from producing misleading release-validation results.
Expand Down Expand Up @@ -336,3 +340,5 @@ Initial developer preview. Launching at Confidential Computing Summit, June 23 2
- Python 3.11, 3.12, 3.13 support

- Python 3.11, 3.12, 3.13 support

- Python 3.11, 3.12, 3.13 support
40 changes: 38 additions & 2 deletions python/src/agent_manifest/_cert_chain.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@
"""
from __future__ import annotations

from datetime import datetime, timezone
from typing import TYPE_CHECKING, Optional, Sequence

if TYPE_CHECKING:
Expand All @@ -35,6 +36,7 @@ def verify_cert_chain(
trusted_roots: "Sequence[x509.Certificate]",
*,
root_fingerprint_hash: "Optional[HashAlgorithm]" = None,
verification_time: Optional[datetime] = None,
) -> bool:
"""Verify a leaf-first certificate chain up to a fingerprint-pinned root.

Expand All @@ -47,6 +49,8 @@ def verify_cert_chain(
root_fingerprint_hash: hash used to compare root fingerprints
(default SHA-256). The pin is on identity, so any collision-
resistant hash works as long as it is used consistently.
verification_time: UTC-aware time used for certificate validity checks
(default: current UTC time). Primarily useful for deterministic tests.

Returns:
``True`` when every link verifies (honoring each child's own signature
Expand All @@ -55,11 +59,13 @@ def verify_cert_chain(

Raises:
CertChainError: on an empty chain, no trusted roots, a link that is not
validly issued by the next, an unpinned root, or missing
``cryptography``.
validly issued by the next, an expired or not-yet-valid certificate,
an issuer that is not a CA, an issuer whose key usage forbids
certificate signing, an unpinned root, or missing ``cryptography``.
"""
try:
from cryptography.exceptions import InvalidSignature
from cryptography import x509
from cryptography.hazmat.primitives.hashes import SHA256
except ImportError as e: # pragma: no cover - exercised via install extra
raise CertChainError(
Expand All @@ -71,6 +77,36 @@ def verify_cert_chain(
if not trusted_roots:
raise CertChainError("no trusted roots supplied")

now = verification_time or datetime.now(timezone.utc)
if now.tzinfo is None or now.utcoffset() is None:
raise CertChainError("verification_time must be timezone-aware")
now = now.astimezone(timezone.utc)

for i, cert in enumerate(chain):
if not (cert.not_valid_before_utc <= now < cert.not_valid_after_utc):
raise CertChainError(f"certificate at position {i} is outside its validity period")

for i, issuer in enumerate(chain[1:], start=1):
try:
constraints = issuer.extensions.get_extension_for_class(
x509.BasicConstraints
).value
except x509.ExtensionNotFound as exc:
raise CertChainError(
f"issuer certificate at position {i} has no BasicConstraints"
) from exc
if not constraints.ca:
raise CertChainError(f"issuer certificate at position {i} is not a CA")
try:
key_usage = issuer.extensions.get_extension_for_class(x509.KeyUsage).value
except x509.ExtensionNotFound:
pass
else:
if not key_usage.key_cert_sign:
raise CertChainError(
f"issuer certificate at position {i} cannot sign certificates"
)

for i in range(len(chain) - 1):
try:
# Honors the child's own signature algorithm (ECDSA / RSA-PSS /
Expand Down
87 changes: 77 additions & 10 deletions python/tests/test_cert_chain.py
Original file line number Diff line number Diff line change
Expand Up @@ -37,16 +37,43 @@ def _sign(builder, issuer_key, *, pss=False):
return builder.sign(issuer_key, hashes.SHA384())


def _cert(subject_cn, issuer_cn, subject_pub, issuer_key, *, pss=False):
def _cert(
subject_cn,
issuer_cn,
subject_pub,
issuer_key,
*,
pss=False,
ca=False,
key_cert_sign=None,
not_before=_T0,
not_after=_T0 + timedelta(days=3650),
):
b = (
x509.CertificateBuilder()
.subject_name(_name(subject_cn))
.issuer_name(_name(issuer_cn))
.public_key(subject_pub)
.serial_number(x509.random_serial_number())
.not_valid_before(_T0)
.not_valid_after(_T0 + timedelta(days=3650))
.not_valid_before(not_before)
.not_valid_after(not_after)
.add_extension(x509.BasicConstraints(ca=ca, path_length=None), critical=True)
)
if key_cert_sign is not None:
b = b.add_extension(
x509.KeyUsage(
digital_signature=True,
content_commitment=False,
key_encipherment=False,
data_encipherment=False,
key_agreement=False,
key_cert_sign=key_cert_sign,
crl_sign=key_cert_sign,
encipher_only=None,
decipher_only=None,
),
critical=True,
)
return _sign(b, issuer_key, pss=pss)


Expand All @@ -61,26 +88,26 @@ def _rsa():
def _ec_chain():
"""All-ECDSA chain (Intel PCK / ca2a shape): leaf <- inter <- root."""
rk, ik, lk = _ec(), _ec(), _ec()
root = _cert("root", "root", rk.public_key(), rk)
inter = _cert("inter", "root", ik.public_key(), rk)
root = _cert("root", "root", rk.public_key(), rk, ca=True)
inter = _cert("inter", "root", ik.public_key(), rk, ca=True)
leaf = _cert("leaf", "inter", lk.public_key(), ik)
return [leaf, inter, root], root


def _amd_pss_chain():
"""Real-AMD shape: EC VCEK leaf, RSA-PSS ASK/ARK."""
ark_k, ask_k, vcek_k = _rsa(), _rsa(), _ec()
ark = _cert("ARK", "ARK", ark_k.public_key(), ark_k, pss=True)
ask = _cert("ASK", "ARK", ask_k.public_key(), ark_k, pss=True)
ark = _cert("ARK", "ARK", ark_k.public_key(), ark_k, pss=True, ca=True)
ask = _cert("ASK", "ARK", ask_k.public_key(), ark_k, pss=True, ca=True)
vcek = _cert("VCEK", "ASK", vcek_k.public_key(), ask_k, pss=True)
return [vcek, ask, ark], ark


def _pkcs1v15_chain():
"""cmcp synthetic shape: EC VCEK leaf, RSA PKCS#1 v1.5 ASK/ARK."""
ark_k, ask_k, vcek_k = _rsa(), _rsa(), _ec()
ark = _cert("ARK", "ARK", ark_k.public_key(), ark_k) # default = PKCS1v15
ask = _cert("ASK", "ARK", ask_k.public_key(), ark_k)
ark = _cert("ARK", "ARK", ark_k.public_key(), ark_k, ca=True) # default = PKCS1v15
ask = _cert("ASK", "ARK", ask_k.public_key(), ark_k, ca=True)
vcek = _cert("VCEK", "ASK", vcek_k.public_key(), ask_k)
return [vcek, ask, ark], ark

Expand Down Expand Up @@ -127,11 +154,51 @@ def test_no_trusted_roots_rejected():
def test_two_cert_chain_leaf_and_root():
# A minimal [leaf, root] chain (self-signed root) also verifies + pins.
rk, lk = _ec(), _ec()
root = _cert("root", "root", rk.public_key(), rk)
root = _cert("root", "root", rk.public_key(), rk, ca=True)
leaf = _cert("leaf", "root", lk.public_key(), rk)
assert verify_cert_chain([leaf, root], [root]) is True


def test_expired_leaf_rejected():
rk, lk = _ec(), _ec()
root = _cert("root", "root", rk.public_key(), rk, ca=True)
leaf = _cert(
"leaf",
"root",
lk.public_key(),
rk,
not_before=_T0,
not_after=_T0 + timedelta(days=1),
)
with pytest.raises(CertChainError, match="outside its validity period"):
verify_cert_chain([leaf, root], [root])


def test_non_ca_issuer_rejected():
rk, ik, lk = _ec(), _ec(), _ec()
root = _cert("root", "root", rk.public_key(), rk, ca=True)
inter = _cert("inter", "root", ik.public_key(), rk, ca=False)
leaf = _cert("leaf", "inter", lk.public_key(), ik)
with pytest.raises(CertChainError, match="is not a CA"):
verify_cert_chain([leaf, inter, root], [root])


def test_naive_verification_time_rejected():
chain, root = _ec_chain()
with pytest.raises(CertChainError, match="timezone-aware"):
verify_cert_chain(chain, [root], verification_time=datetime(2026, 1, 1))


def test_issuer_key_usage_must_allow_certificate_signing():
rk, lk = _ec(), _ec()
root = _cert(
"root", "root", rk.public_key(), rk, ca=True, key_cert_sign=False
)
leaf = _cert("leaf", "root", lk.public_key(), rk)
with pytest.raises(CertChainError, match="cannot sign certificates"):
verify_cert_chain([leaf, root], [root])


# --- parse_tdx_quote strict vs lenient -------------------------------------


Expand Down
Loading