diff --git a/src/cmcp_runtime/tee/tpm.py b/src/cmcp_runtime/tee/tpm.py index f95cabcb..23dcf831 100644 --- a/src/cmcp_runtime/tee/tpm.py +++ b/src/cmcp_runtime/tee/tpm.py @@ -302,14 +302,19 @@ def _chain_from_leaf(leaf_der: bytes) -> bytes: The chain is assembled at collection time on purpose: shipping it with the evidence is what keeps verification offline later. A self-signed certificate - or a missing AIA ends the walk, and whatever was gathered is returned so a - partial chain still travels rather than being discarded. + ends the walk. Where AIA is absent or yields nothing -- as on the Azure + Trusted Launch hosts whose AK certificate carries no AIA at all -- the + issuer is resolved from :mod:`cmcp_runtime.tee.vtpm_ca_bundle` instead. + If neither produces an issuer the walk stops, and whatever was gathered is + returned so a partial chain still travels rather than being discarded. """ import urllib.request from cryptography import x509 from cryptography.hazmat.primitives.serialization import pkcs7 + from cmcp_runtime.tee.vtpm_ca_bundle import vendored_issuer_for + def load_any(data: bytes) -> list[x509.Certificate]: for loader in (x509.load_der_x509_certificate, x509.load_pem_x509_certificate): try: @@ -348,12 +353,16 @@ def load_any(data: bytes) -> list[x509.Certificate]: x509.AuthorityInformationAccess ).value except x509.ExtensionNotFound: - break - urls = [ - d.access_location.value - for d in aia - if d.access_method.dotted_string == "1.3.6.1.5.5.7.48.2" - ] + aia = None + urls = ( + [ + d.access_location.value + for d in aia + if d.access_method.dotted_string == "1.3.6.1.5.5.7.48.2" + ] + if aia is not None + else [] + ) issuer = None for url in urls: if not url.startswith(("http://", "https://")): @@ -369,6 +378,19 @@ def load_any(data: bytes) -> list[x509.Certificate]: except Exception as exc: # noqa: BLE001 logger.debug("AIA fetch failed for %s: %s", url, exc) if issuer is None: + # Some Azure Trusted Launch hosts present an AK certificate with + # no AIA extension, so there is nothing to walk and the chain + # would stop at the leaf and never reach a pinned root. The + # issuer is resolved from a vendored bundle instead, and only + # when it verifiably signed this certificate. + issuer = vendored_issuer_for(current) + if issuer is None: + logger.debug( + "no issuer for %s: AIA gave nothing and no vendored certificate " + "signed it; shipping a %d-certificate chain", + current.subject.rfc4514_string(), + len(chain), + ) break chain.append(issuer) diff --git a/src/cmcp_runtime/tee/vtpm_ca_bundle.py b/src/cmcp_runtime/tee/vtpm_ca_bundle.py new file mode 100644 index 00000000..9b6e2d9b --- /dev/null +++ b/src/cmcp_runtime/tee/vtpm_ca_bundle.py @@ -0,0 +1,169 @@ +""" +Vendored Azure vTPM CA certificates for hosts that publish no AIA. + +Azure Trusted Launch presents more than one vTPM attestation-key certificate +hierarchy at NV index 0x01C101D0. One is issued by ``Azure Cloud Virtual TPM +CA - 11`` and carries an AIA extension, so :func:`TPMProvider._chain_from_leaf` +can walk to the root over the network. The other is issued by ``Global Virtual +TPM CA - 03`` and carries no AIA at all, so there is nothing to walk and the +chain stops at the leaf. Verification then fails closed, correctly but +uselessly, because the chain never reaches a pinned root. + +Both hierarchies terminate at the same self-signed root, the one already pinned +in ``cmcp_verify.tpm_roots``. What is missing on a CA-03 host is therefore not a +trust anchor but the intermediate needed to reach it, and Microsoft does not +publish that intermediate at a fetchable URL -- only inline in the Trusted +Launch FAQ. It has to be vendored, which is what this module does. + +Source, both certificates: + https://learn.microsoft.com/en-us/azure/virtual-machines/trusted-launch-faq + ("vTPM AK certificate" section, the ``.p7b`` for Root + ICA-03) + +The root is additionally downloadable, and was verified byte-identical to the +copy published inline on that page and to the constant pinned in +``cmcp_verify.tpm_roots``: + https://www.microsoft.com/pkiops/certs/Azure%20Virtual%20TPM%20Root%20Certificate%20Authority%202023.crt + +Adding a certificate here does not widen what is trusted. Nothing in this module +is a trust anchor: these are chain-building material, supplied in place of an +AIA fetch, and :func:`vendored_issuer_for` returns a certificate only after +checking that it actually signed the one being resolved. A wrong or colliding +certificate cannot produce a chain that verifies -- it fails closed exactly as +it does today. +""" + +from __future__ import annotations + +import logging + +from cryptography import x509 + +logger = logging.getLogger(__name__) + +# CN=Global Virtual TPM CA - 03 +# issuer CN=Azure Virtual TPM Root Certificate Authority 2023 +# serial 33000000092740E5AC727B0EA6000000000009 +# validity 2025-04-24 -> 2027-04-24 +# sha256 FD:7C:92:DA:BC:E4:DC:EC:9F:EA:A3:0F:8B:08:7A:DA: +# 05:98:41:31:89:18:21:52:B8:81:F5:56:40:64:A2:9C +# +# NOTE: intermediates expire on a far shorter cycle than roots, and there is no +# URL to poll for the replacement. test_vtpm_ca_bundle.py fails ahead of the +# notAfter above so this is refreshed deliberately rather than discovered as an +# outage on a CA-03 host. +GLOBAL_VIRTUAL_TPM_CA_03_PEM = b"""\ +-----BEGIN CERTIFICATE----- +MIIFnDCCA4SgAwIBAgITMwAAAAknQOWscnsOpgAAAAAACTANBgkqhkiG9w0BAQwF +ADBpMQswCQYDVQQGEwJVUzEeMBwGA1UEChMVTWljcm9zb2Z0IENvcnBvcmF0aW9u +MTowOAYDVQQDEzFBenVyZSBWaXJ0dWFsIFRQTSBSb290IENlcnRpZmljYXRlIEF1 +dGhvcml0eSAyMDIzMB4XDTI1MDQyNDE4MDExN1oXDTI3MDQyNDE4MDExN1owJTEj +MCEGA1UEAxMaR2xvYmFsIFZpcnR1YWwgVFBNIENBIC0gMDMwggEiMA0GCSqGSIb3 +DQEBAQUAA4IBDwAwggEKAoIBAQDYGYtis5ka0cxQkhU11jslgX6wzjR/UXQIFdUn +8juTUMJl91VokwUPX3WfXeog7mtbWyYWD8SI0BSnchRGlV8u3AhcW61/HetHqmIL +tD0c75UATi+gsTQnpwKPA/m38MGGyXFETr3xHXjilUPfIhmxO4ImuNJ0R95bZYhx +bLYmOZpVUcj8oz980An8HlIqSzrskQR6NiuEmikHkHc1/CpoNunrr8kQNPF6gxex +IrvXsKLUAuUqnNtcQWc/8Er5EN9+TdX6AOjUmKriVGbCInP1m/aC+DWH/+aJ/8aD +pKze6fe7OHh2BL9hxqIsmJAStIh4siRdLYTt8hKGmkdzOWnRAgMBAAGjggF/MIIB +ezASBgNVHRMBAf8ECDAGAQH/AgEAMA4GA1UdDwEB/wQEAwICBDAXBgNVHSUEEDAO +BgVngQUIAQYFZ4EFCAMwHQYDVR0OBBYEFGcJhvj5gV6TrfnJZOcUCtqZywotMB8G +A1UdIwQYMBaAFEv+JlqUwfYzw4NIJt3z5bBksqqVMHYGA1UdHwRvMG0wa6BpoGeG +ZWh0dHA6Ly93d3cubWljcm9zb2Z0LmNvbS9wa2lvcHMvY3JsL0F6dXJlJTIwVmly +dHVhbCUyMFRQTSUyMFJvb3QlMjBDZXJ0aWZpY2F0ZSUyMEF1dGhvcml0eSUyMDIw +MjMuY3JsMIGDBggrBgEFBQcBAQR3MHUwcwYIKwYBBQUHMAKGZ2h0dHA6Ly93d3cu +bWljcm9zb2Z0LmNvbS9wa2lvcHMvY2VydHMvQXp1cmUlMjBWaXJ0dWFsJTIwVFBN +JTIwUm9vdCUyMENlcnRpZmljYXRlJTIwQXV0aG9yaXR5JTIwMjAyMy5jcnQwDQYJ +KoZIhvcNAQEMBQADggIBAJPP3Z2z1zhzUS3qSRVgyoUVnaxCGuMHzPQAZuoPBVpz +wKnv4HqyjMgT8pBtQqxkqAsg7KiqbPfO97bMCHcuqkkfHjw8yg6IYt01RjUjVPKq +lrsY2iw7hFWNWr8SGMa10JdNYNyf5dxob5+mKAwEOhLzKNwq9rM/uIvZky77pNly +RLt55XEPfBMYdI9I8uQ5Uqmrw7mVJfERMfTBhSQF9BrcajAsaLcs7qEUyj0yUdJf +cgZkfCoUEUSPr3OwLHaYeV1J6VidhIYsYo53sXXal91d60NspYgei2nJFei/+R3E +SWnGbPBW+EQ4FbvZXxu57zUMX9mM7lC+GoXLvA6/vtKShEi9ZXl2PSnBQ/R2A7b3 +AXyg4fmMLFausEk6OiuU8E/bvp+gPLOJ8YrX7SAJVuEn+koJaK5G7os5DMIh7/KM +l9cI9WxPwqoWjp4VBfrF4hDOCmKWrqtFUDQCML8qD8RTxlQKQtgeGAcNDfoAuL9K +VtSG5/iIhuyBEFYEHa3vRWbSaHCUzaHJsTmLcz4cp1VDdepzqZRVuErBzJKFnBXb +zRNW32EFmcAUKZImIsE5dgB7y7eiijf33VWNfWmK05fxzQziWFWRYlET4SVc3jMn +PBiY3N8BfK8EBOYbLvzo0qn2n3SAmPhYX3Ag6vbbIHd4Qc8DQKHRV0PB8D3jPGmD +-----END CERTIFICATE----- +""" + +# CN=Azure Virtual TPM Root Certificate Authority 2023 +# self-signed, validity 2023-06-01 -> 2048-06-01 +# sha256 E6:C5:96:B1:7F:8F:FE:FB:A5:C3:00:F7:14:CF:B1:26: +# 0C:60:28:70:4E:CF:7B:EC:C4:AB:50:18:EF:B0:0E:95 +# +# Present so an assembled chain terminates at the root, which is what the +# verifier compares against its pinned set. Byte-identical to +# cmcp_verify.tpm_roots.AZURE_VTPM_ROOT_2023_PEM; a test asserts they stay so. +AZURE_VTPM_ROOT_2023_PEM = b"""\ +-----BEGIN CERTIFICATE----- +MIIFsDCCA5igAwIBAgIQUfQx2iySCIpOKeDZKd5KpzANBgkqhkiG9w0BAQwFADBp +MQswCQYDVQQGEwJVUzEeMBwGA1UEChMVTWljcm9zb2Z0IENvcnBvcmF0aW9uMTow +OAYDVQQDEzFBenVyZSBWaXJ0dWFsIFRQTSBSb290IENlcnRpZmljYXRlIEF1dGhv +cml0eSAyMDIzMB4XDTIzMDYwMTE4MDg1M1oXDTQ4MDYwMTE4MTU0MVowaTELMAkG +A1UEBhMCVVMxHjAcBgNVBAoTFU1pY3Jvc29mdCBDb3Jwb3JhdGlvbjE6MDgGA1UE +AxMxQXp1cmUgVmlydHVhbCBUUE0gUm9vdCBDZXJ0aWZpY2F0ZSBBdXRob3JpdHkg +MjAyMzCCAiIwDQYJKoZIhvcNAQEBBQADggIPADCCAgoCggIBALoMMwvdRJ7+bW00 +adKE1VemNqJS+268Ure8QcfZXVOsVO22+PL9WRoPnWo0r5dVoomYGbobh4HC72s9 +sGY6BGRe+Ui2LMwuWnirBtOjaJ34r1ZieNMcVNJT/dXW5HN/HLlm/gSKlWzqCEx6 +gFFAQTvyYl/5jYI4Oe05zJ7ojgjK/6ZHXpFysXnyUITJ9qgjn546IJh/G5OMC3mD +fFU7A/GAi+LYaOHSzXj69Lk1vCftNq9DcQHtB7otO0VxFkRLaULcfu/AYHM7FC/S +q6cJb9Au8K/IUhw/5lJSXZawLJwHpcEYzETm2blad0VHsACaLNucZL5wBi8GEusQ +9Wo8W1p1rUCMp89pufxa3Ar9sYZvWeJlvKggWcQVUlhvvIZEnT+fteEvwTdoajl5 +qSvZbDPGCPjb91rSznoiLq8XqgQBBFjnEiTL+ViaZmyZPYUsBvBY3lKXB1l2hgga +hfBIag4j0wcgqlL82SL7pAdGjq0Fou6SKgHnkkrV5CNxUBBVMNCwUoj5mvEjd5mF +7XPgfM98qNABb2Aqtfl+VuCkU/G1XvFoTqS9AkwbLTGFMS9+jCEU2rw6wnKuGv1T +x9iuSdNvsXt8stx4fkVeJvnFpJeAIwBZVgKRSTa3w3099k0mW8qGiMnwCI5SfdZ2 +SJyD4uEmszsnieE6wAWd1tLLg1jvAgMBAAGjVDBSMA4GA1UdDwEB/wQEAwIBhjAP +BgNVHRMBAf8EBTADAQH/MB0GA1UdDgQWBBRL/iZalMH2M8ODSCbd8+WwZLKqlTAQ +BgkrBgEEAYI3FQEEAwIBADANBgkqhkiG9w0BAQwFAAOCAgEALgNAyg8I0ANNO/8I +2BhpTOsbywN2YSmShAmig5h4sCtaJSM1dRXwA+keY6PCXQEt/PRAQAiHNcOF5zbu +OU1Bw/Z5Z7k9okt04eu8CsS2Bpc+POg9js6lBtmigM5LWJCH1goMD0kJYpzkaCzx +1TdD3yjo0xSxgGhabk5Iu1soD3OxhUyIFcxaluhwkiVINt3Jhy7G7VJTlEwkk21A +oOrQxUsJH0f2GXjYShS1r9qLPzLf7ykcOm62jHGmLZVZujBzLIdNk1bljP9VuGW+ +cISBwzkNeEMMFufcL2xh6s/oiUnXicFWvG7E6ioPnayYXrHy3Rh68XLnhfpzeCzv +bz/I4yMV38qGo/cAY2OJpXUuuD/ZbI5rT+lRBEkDW1kxHP8cpwkRwGopV8+gX2KS +UucIIN4l8/rrNDEX8T0b5U+BUqiO7Z5YnxCya/H0ZIwmQnTlLRTU2fW+OGG+xyIr +jMi/0l6/yWPUkIAkNtvS/yO7USRVLPbtGVk3Qre6HcqacCXzEjINcJhGEVg83Y8n +M+Y+a9J0lUnHytMSFZE85h88OseRS2QwqjozUo2j1DowmhSSUv9Na5Ae22ycciBk +EZSq8a4rSlwqthaELNpeoTLUk6iVoUkK/iLvaMvrkdj9yJY1O/gvlfN2aiNTST/2 +bd+PA4RBToG9rXn6vNkUWdbLibU= +-----END CERTIFICATE----- +""" + +_BUNDLE_PEMS = (GLOBAL_VIRTUAL_TPM_CA_03_PEM, AZURE_VTPM_ROOT_2023_PEM) + + +def _bundle() -> list[x509.Certificate]: + return [x509.load_pem_x509_certificate(pem) for pem in _BUNDLE_PEMS] + + +def vendored_issuer_for(cert: x509.Certificate) -> x509.Certificate | None: + """ + Return the vendored certificate that issued ``cert``, or None. + + Subject/issuer names are not identities: a name match alone would let a + same-named certificate with a different key into the chain. The candidate is + therefore accepted only if it actually signed ``cert``, so the worst case for + an unrecognised or re-keyed CA is the present behaviour, a short chain that + fails to reach a pinned root. + """ + if cert.subject == cert.issuer: + # A self-signed certificate is its own issuer and would resolve to + # itself, appending forever. The caller already stops at the root; this + # keeps that from depending on the order of two checks. + return None + for candidate in _bundle(): + if candidate.subject != cert.issuer: + continue + try: + cert.verify_directly_issued_by(candidate) + except Exception as exc: # noqa: BLE001 + logger.debug( + "vendored candidate %s did not sign %s: %s", + candidate.subject.rfc4514_string(), + cert.subject.rfc4514_string(), + exc, + ) + continue + return candidate + return None diff --git a/tests/unit/test_vtpm_ca_bundle.py b/tests/unit/test_vtpm_ca_bundle.py new file mode 100644 index 00000000..f1d89d07 --- /dev/null +++ b/tests/unit/test_vtpm_ca_bundle.py @@ -0,0 +1,158 @@ +"""Unit tests for vendored vTPM CA resolution on hosts that publish no AIA. + +None of these need a TPM or a network. The chain-assembly tests run against a +synthetic PKI so a leaf can actually be signed; the real Microsoft certificates +are exercised for the links that do not need a private key, plus the properties +that would silently rot -- expiry, and drift against the pinned root. +""" + +from __future__ import annotations + +import datetime + +import pytest +from cryptography import x509 +from cryptography.hazmat.primitives import hashes, serialization +from cryptography.hazmat.primitives.asymmetric import rsa +from cryptography.x509.oid import NameOID + +from cmcp_runtime.tee import vtpm_ca_bundle +from cmcp_runtime.tee.tpm import TPMProvider +from cmcp_runtime.tee.vtpm_ca_bundle import ( + AZURE_VTPM_ROOT_2023_PEM, + GLOBAL_VIRTUAL_TPM_CA_03_PEM, + vendored_issuer_for, +) + +_DAY = datetime.timedelta(days=1) + + +def _issue( + subject: str, + *, + issuer_cert: x509.Certificate | None = None, + issuer_key: rsa.RSAPrivateKey | None = None, + ca: bool = False, +) -> tuple[x509.Certificate, rsa.RSAPrivateKey]: + """Issue a certificate, self-signed unless an issuer is supplied.""" + key = rsa.generate_private_key(public_exponent=65537, key_size=2048) + name = x509.Name([x509.NameAttribute(NameOID.COMMON_NAME, subject)]) + now = datetime.datetime.now(datetime.UTC) + signing_key = issuer_key or key + builder = ( + x509.CertificateBuilder() + .subject_name(name) + .issuer_name(issuer_cert.subject if issuer_cert else name) + .public_key(key.public_key()) + .serial_number(x509.random_serial_number()) + .not_valid_before(now - _DAY) + .not_valid_after(now + 365 * _DAY) + .add_extension(x509.BasicConstraints(ca=ca, path_length=None), critical=True) + ) + return builder.sign(signing_key, hashes.SHA256()), key + + +@pytest.fixture +def synthetic_pki(monkeypatch: pytest.MonkeyPatch) -> tuple[x509.Certificate, ...]: + """A root -> intermediate -> leaf PKI installed as the vendored bundle.""" + root, root_key = _issue("Test Root", ca=True) + inter, inter_key = _issue("Test Intermediate", issuer_cert=root, issuer_key=root_key, ca=True) + leaf, _ = _issue("test-vm.example", issuer_cert=inter, issuer_key=inter_key) + monkeypatch.setattr( + vtpm_ca_bundle, + "_BUNDLE_PEMS", + ( + inter.public_bytes(serialization.Encoding.PEM), + root.public_bytes(serialization.Encoding.PEM), + ), + ) + return leaf, inter, root + + +def test_leaf_without_aia_reaches_the_root_via_the_vendored_bundle( + synthetic_pki: tuple[x509.Certificate, ...], +) -> None: + """The CA-03 case: no AIA to walk, so the chain must come from the bundle.""" + leaf, inter, root = synthetic_pki + + chain_pem = TPMProvider._chain_from_leaf(leaf.public_bytes(serialization.Encoding.DER)) + chain = x509.load_pem_x509_certificates(chain_pem) + + assert [c.subject for c in chain] == [leaf.subject, inter.subject, root.subject] + assert chain[-1].subject == chain[-1].issuer, "chain must terminate at a self-signed root" + + +def test_unresolvable_leaf_still_ships_a_partial_chain( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """An unknown issuer is the present behaviour: short chain, not a crash.""" + monkeypatch.setattr(vtpm_ca_bundle, "_BUNDLE_PEMS", ()) + root, root_key = _issue("Unknown CA", ca=True) + leaf, _ = _issue("test-vm.example", issuer_cert=root, issuer_key=root_key) + + chain_pem = TPMProvider._chain_from_leaf(leaf.public_bytes(serialization.Encoding.DER)) + + assert chain_pem.count(b"BEGIN CERTIFICATE") == 1 + + +def test_same_name_different_key_is_refused(monkeypatch: pytest.MonkeyPatch) -> None: + """A subject name is not an identity, so a name match alone must not resolve. + + This is the property that makes vendoring safe: were Microsoft to re-key a CA + while keeping its name, the impostor cannot enter the chain. + """ + real_ca, real_key = _issue("Global Virtual TPM CA - 03", ca=True) + impostor, _ = _issue("Global Virtual TPM CA - 03", ca=True) + leaf, _ = _issue("test-vm.example", issuer_cert=real_ca, issuer_key=real_key) + + monkeypatch.setattr( + vtpm_ca_bundle, "_BUNDLE_PEMS", (impostor.public_bytes(serialization.Encoding.PEM),) + ) + + assert impostor.subject == leaf.issuer, "precondition: the names do collide" + assert vendored_issuer_for(leaf) is None + + +def test_self_signed_certificate_does_not_resolve_to_itself() -> None: + """Otherwise the walk would append the root forever.""" + root = x509.load_pem_x509_certificate(AZURE_VTPM_ROOT_2023_PEM) + + assert vendored_issuer_for(root) is None + + +def test_real_ca_03_resolves_to_the_real_root() -> None: + """The link that can be checked without hardware, against the shipped bytes.""" + ica = x509.load_pem_x509_certificate(GLOBAL_VIRTUAL_TPM_CA_03_PEM) + + issuer = vendored_issuer_for(ica) + + assert issuer is not None + assert issuer.subject.rfc4514_string().startswith("CN=Azure Virtual TPM Root Certificate") + + +def test_bundled_root_matches_the_pinned_root() -> None: + """Two copies of a trust anchor must not drift apart.""" + from cmcp_verify.tpm_roots import AZURE_VTPM_ROOT_2023_PEM as pinned + + bundled = x509.load_pem_x509_certificate(AZURE_VTPM_ROOT_2023_PEM) + pinned_cert = x509.load_pem_x509_certificate(pinned) + + assert bundled.public_bytes(serialization.Encoding.DER) == pinned_cert.public_bytes( + serialization.Encoding.DER + ) + + +def test_bundled_intermediate_is_not_close_to_expiry() -> None: + """Fail deliberately, ahead of time, rather than as an outage on a CA-03 host. + + Intermediates rotate on a far shorter cycle than roots and Microsoft publishes + no URL to poll, so nothing will notice this for us. + """ + ica = x509.load_pem_x509_certificate(GLOBAL_VIRTUAL_TPM_CA_03_PEM) + remaining = ica.not_valid_after_utc - datetime.datetime.now(datetime.UTC) + + assert remaining > datetime.timedelta(days=90), ( + f"Global Virtual TPM CA - 03 expires {ica.not_valid_after_utc:%Y-%m-%d} " + f"({remaining.days} days). Refresh it from the Trusted Launch FAQ " + "(vTPM AK certificate section) and update this bundle." + )