diff --git a/solstone/think/services/spp_attest/__init__.py b/solstone/think/services/spp_attest/__init__.py new file mode 100644 index 000000000..74ab1d682 --- /dev/null +++ b/solstone/think/services/spp_attest/__init__.py @@ -0,0 +1,39 @@ +# SPDX-License-Identifier: AGPL-3.0-only +# Copyright (c) 2026 sol pbc + +from __future__ import annotations + +from solstone.think.services.spp_attest.binding import ( + BINDING_DOMAIN, + check_envelope_nonce, + composite_binding_hash, +) +from solstone.think.services.spp_attest.errors import VerificationError +from solstone.think.services.spp_attest.snp import ( + AppraisalStep, + CpuAppraisal, + Policy, + appraise_cpu_leg, +) +from solstone.think.services.spp_attest.tlv import ( + GpuEnvelope, + decode_gpu_envelope, + extract_spdm_nonce, +) +from solstone.think.services.spp_attest.tpm_quote import TpmQuoteVerifier, verify_quote + +__all__ = [ + "BINDING_DOMAIN", + "AppraisalStep", + "CpuAppraisal", + "GpuEnvelope", + "Policy", + "TpmQuoteVerifier", + "VerificationError", + "appraise_cpu_leg", + "check_envelope_nonce", + "composite_binding_hash", + "decode_gpu_envelope", + "extract_spdm_nonce", + "verify_quote", +] diff --git a/solstone/think/services/spp_attest/binding.py b/solstone/think/services/spp_attest/binding.py new file mode 100644 index 000000000..e77f6c7fe --- /dev/null +++ b/solstone/think/services/spp_attest/binding.py @@ -0,0 +1,59 @@ +# SPDX-License-Identifier: AGPL-3.0-only +# Copyright (c) 2026 sol pbc + +"""Composite binding checks for SPP CPU/GPU attestation.""" + +from __future__ import annotations + +import hashlib + +from solstone.think.services.spp_attest.errors import VerificationError +from solstone.think.services.spp_attest.tlv import ( + SPDM_NONCE_SIZE, + GpuEnvelope, + extract_spdm_nonce, +) + +BINDING_DOMAIN = "sol-spp-option-a-bind-v1" + + +def composite_binding_hash( + *, + nonce: bytes, + channel_binding: bytes, + envelope_tlv: bytes, + domain: str = BINDING_DOMAIN, +) -> bytes: + """Hash the verifier nonce, channel binding, and GPU envelope fingerprint.""" + + if len(nonce) != SPDM_NONCE_SIZE: + raise VerificationError(f"binding nonce is {len(nonce)} bytes, expected 32") + if not channel_binding: + raise VerificationError("channel binding is empty") + if not envelope_tlv: + raise VerificationError("GPU envelope TLV is empty") + if not domain: + raise VerificationError("binding domain is empty") + + envelope_digest = hashlib.sha256(envelope_tlv).digest() + digest = hashlib.sha256() + digest.update(domain.encode("utf-8")) + digest.update(nonce) + digest.update(channel_binding) + digest.update(envelope_digest) + return digest.digest() + + +def check_envelope_nonce(envelope: GpuEnvelope, owner_nonce: bytes) -> None: + """Validate the owner nonce is spliced into both GPU envelope nonce locations.""" + + if len(owner_nonce) != SPDM_NONCE_SIZE: + raise VerificationError(f"owner nonce is {len(owner_nonce)} bytes, expected 32") + if envelope.nonce != owner_nonce: + raise VerificationError("GPU envelope field-1 nonce does not match owner nonce") + + spdm_nonce = extract_spdm_nonce(envelope.spdm_report) + if spdm_nonce != envelope.nonce: + raise VerificationError( + "SPDM report nonce does not match GPU envelope field-1 nonce" + ) diff --git a/solstone/think/services/spp_attest/errors.py b/solstone/think/services/spp_attest/errors.py new file mode 100644 index 000000000..7c9bb2192 --- /dev/null +++ b/solstone/think/services/spp_attest/errors.py @@ -0,0 +1,8 @@ +# SPDX-License-Identifier: AGPL-3.0-only +# Copyright (c) 2026 sol pbc + +from __future__ import annotations + + +class VerificationError(RuntimeError): + """Raised when SPP attestation evidence fails appraisal.""" diff --git a/solstone/think/services/spp_attest/roots/amd/Genoa/ark.pem b/solstone/think/services/spp_attest/roots/amd/Genoa/ark.pem new file mode 100644 index 000000000..a68860826 --- /dev/null +++ b/solstone/think/services/spp_attest/roots/amd/Genoa/ark.pem @@ -0,0 +1,37 @@ +-----BEGIN CERTIFICATE----- +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 +/wMz1R1BHg== +-----END CERTIFICATE----- diff --git a/solstone/think/services/spp_attest/roots/amd/Genoa/ask.pem b/solstone/think/services/spp_attest/roots/amd/Genoa/ask.pem new file mode 100644 index 000000000..215b78409 --- /dev/null +++ b/solstone/think/services/spp_attest/roots/amd/Genoa/ask.pem @@ -0,0 +1,37 @@ +-----BEGIN CERTIFICATE----- +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 +-----END CERTIFICATE----- diff --git a/solstone/think/services/spp_attest/roots/amd/Milan/ark.pem b/solstone/think/services/spp_attest/roots/amd/Milan/ark.pem new file mode 100644 index 000000000..838631734 --- /dev/null +++ b/solstone/think/services/spp_attest/roots/amd/Milan/ark.pem @@ -0,0 +1,37 @@ +-----BEGIN CERTIFICATE----- +MIIGYzCCBBKgAwIBAgIDAQAAMEYGCSqGSIb3DQEBCjA5oA8wDQYJYIZIAWUDBAIC +BQChHDAaBgkqhkiG9w0BAQgwDQYJYIZIAWUDBAICBQCiAwIBMKMDAgEBMHsxFDAS +BgNVBAsMC0VuZ2luZWVyaW5nMQswCQYDVQQGEwJVUzEUMBIGA1UEBwwLU2FudGEg +Q2xhcmExCzAJBgNVBAgMAkNBMR8wHQYDVQQKDBZBZHZhbmNlZCBNaWNybyBEZXZp +Y2VzMRIwEAYDVQQDDAlBUkstTWlsYW4wHhcNMjAxMDIyMTcyMzA1WhcNNDUxMDIy +MTcyMzA1WjB7MRQwEgYDVQQLDAtFbmdpbmVlcmluZzELMAkGA1UEBhMCVVMxFDAS +BgNVBAcMC1NhbnRhIENsYXJhMQswCQYDVQQIDAJDQTEfMB0GA1UECgwWQWR2YW5j +ZWQgTWljcm8gRGV2aWNlczESMBAGA1UEAwwJQVJLLU1pbGFuMIICIjANBgkqhkiG +9w0BAQEFAAOCAg8AMIICCgKCAgEA0Ld52RJOdeiJlqK2JdsVmD7FktuotWwX1fNg +W41XY9Xz1HEhSUmhLz9Cu9DHRlvgJSNxbeYYsnJfvyjx1MfU0V5tkKiU1EesNFta +1kTA0szNisdYc9isqk7mXT5+KfGRbfc4V/9zRIcE8jlHN61S1ju8X93+6dxDUrG2 +SzxqJ4BhqyYmUDruPXJSX4vUc01P7j98MpqOS95rORdGHeI52Naz5m2B+O+vjsC0 +60d37jY9LFeuOP4Meri8qgfi2S5kKqg/aF6aPtuAZQVR7u3KFYXP59XmJgtcog05 +gmI0T/OitLhuzVvpZcLph0odh/1IPXqx3+MnjD97A7fXpqGd/y8KxX7jksTEzAOg +bKAeam3lm+3yKIcTYMlsRMXPcjNbIvmsBykD//xSniusuHBkgnlENEWx1UcbQQrs ++gVDkuVPhsnzIRNgYvM48Y+7LGiJYnrmE8xcrexekBxrva2V9TJQqnN3Q53kt5vi +Qi3+gCfmkwC0F0tirIZbLkXPrPwzZ0M9eNxhIySb2npJfgnqz55I0u33wh4r0ZNQ +eTGfw03MBUtyuzGesGkcw+loqMaq1qR4tjGbPYxCvpCq7+OgpCCoMNit2uLo9M18 +fHz10lOMT8nWAUvRZFzteXCm+7PHdYPlmQwUw3LvenJ/ILXoQPHfbkH0CyPfhl1j +WhJFZasCAwEAAaN+MHwwDgYDVR0PAQH/BAQDAgEGMB0GA1UdDgQWBBSFrBrRQ/fI +rFXUxR1BSKvVeErUUzAPBgNVHRMBAf8EBTADAQH/MDoGA1UdHwQzMDEwL6AtoCuG +KWh0dHBzOi8va2RzaW50Zi5hbWQuY29tL3ZjZWsvdjEvTWlsYW4vY3JsMEYGCSqG +SIb3DQEBCjA5oA8wDQYJYIZIAWUDBAICBQChHDAaBgkqhkiG9w0BAQgwDQYJYIZI +AWUDBAICBQCiAwIBMKMDAgEBA4ICAQC6m0kDp6zv4Ojfgy+zleehsx6ol0ocgVel +ETobpx+EuCsqVFRPK1jZ1sp/lyd9+0fQ0r66n7kagRk4Ca39g66WGTJMeJdqYriw +STjjDCKVPSesWXYPVAyDhmP5n2v+BYipZWhpvqpaiO+EGK5IBP+578QeW/sSokrK +dHaLAxG2LhZxj9aF73fqC7OAJZ5aPonw4RE299FVarh1Tx2eT3wSgkDgutCTB1Yq +zT5DuwvAe+co2CIVIzMDamYuSFjPN0BCgojl7V+bTou7dMsqIu/TW/rPCX9/EUcp +KGKqPQ3P+N9r1hjEFY1plBg93t53OOo49GNI+V1zvXPLI6xIFVsh+mto2RtgEX/e +pmMKTNN6psW88qg7c1hTWtN6MbRuQ0vm+O+/2tKBF2h8THb94OvvHHoFDpbCELlq +HnIYhxy0YKXGyaW1NjfULxrrmxVW4wcn5E8GddmvNa6yYm8scJagEi13mhGu4Jqh +3QU3sf8iUSUr09xQDwHtOQUVIqx4maBZPBtSMf+qUDtjXSSq8lfWcd8bLr9mdsUn +JZJ0+tuPMKmBnSH860llKk+VpVQsgqbzDIvOLvD6W1Umq25boxCYJ+TuBoa4s+HH +CViAvgT9kf/rBq1d+ivj6skkHxuzcxbk1xv6ZGxrteJxVH7KlX7YRdZ6eARKwLe4 +AFZEAwoKCQ== +-----END CERTIFICATE----- diff --git a/solstone/think/services/spp_attest/roots/amd/Milan/ask.pem b/solstone/think/services/spp_attest/roots/amd/Milan/ask.pem new file mode 100644 index 000000000..26c059c70 --- /dev/null +++ b/solstone/think/services/spp_attest/roots/amd/Milan/ask.pem @@ -0,0 +1,37 @@ +-----BEGIN CERTIFICATE----- +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 +-----END CERTIFICATE----- diff --git a/solstone/think/services/spp_attest/roots/amd/Turin/ark.pem b/solstone/think/services/spp_attest/roots/amd/Turin/ark.pem new file mode 100644 index 000000000..dc8949180 --- /dev/null +++ b/solstone/think/services/spp_attest/roots/amd/Turin/ark.pem @@ -0,0 +1,37 @@ +-----BEGIN CERTIFICATE----- +MIIGYzCCBBKgAwIBAgIDAwAAMEYGCSqGSIb3DQEBCjA5oA8wDQYJYIZIAWUDBAIC +BQChHDAaBgkqhkiG9w0BAQgwDQYJYIZIAWUDBAICBQCiAwIBMKMDAgEBMHsxFDAS +BgNVBAsMC0VuZ2luZWVyaW5nMQswCQYDVQQGEwJVUzEUMBIGA1UEBwwLU2FudGEg +Q2xhcmExCzAJBgNVBAgMAkNBMR8wHQYDVQQKDBZBZHZhbmNlZCBNaWNybyBEZXZp +Y2VzMRIwEAYDVQQDDAlBUkstVHVyaW4wHhcNMjMwNTE1MjAwMzEyWhcNNDgwNTE1 +MjAwMzEyWjB7MRQwEgYDVQQLDAtFbmdpbmVlcmluZzELMAkGA1UEBhMCVVMxFDAS +BgNVBAcMC1NhbnRhIENsYXJhMQswCQYDVQQIDAJDQTEfMB0GA1UECgwWQWR2YW5j +ZWQgTWljcm8gRGV2aWNlczESMBAGA1UEAwwJQVJLLVR1cmluMIICIjANBgkqhkiG +9w0BAQEFAAOCAg8AMIICCgKCAgEAwaAriB7EIuVc4ZB1wD3YfDxL+9eyS7+izm0J +j3W772NINCWl8Bj3w/JD2ZjmbRxWdIq/4d9iarCKorXloJUB1jRdgxqccTx1aOoi +g4+2w1XhVVJT7K457wT5ZLNJgQaxqa9Etkwjd6+9sOhlCDE9l43kQ0R2BikVJa/u +yyVOSwEk5w5tXKOuG9jvq6QtAMJasW38wlqRDaKEGtZ9VUgGon27ZuL4sTJuC/az +z9/iQBw8kEilzOl95AiTkeY5jSEBDWbAqnZk5qlM7kISKG20kgQm14mhNKDI2p2o +ua+zuAG7i52epoRF2GfU0TYk/yf+vCNB2tnechFQuP2e8bLk95ZdqPi9/UWw4JXj +tdEA4u2JYplSSUPQVAXKt6LVqujtJcM59JKr2u0XQ75KwxcMp15gSXhBfInvPAwu +AY4dEwwGqT8oIg4esPHwEsmChhYeDIxPG9R4fx9O0q6p8Gb+HXlTiS47P9YNeOpi +dOUKzDl/S1OvyhDtSL8LJc24QATFydo/iD/KUdvFTRlD0crkAMkZLoWQ8hLDGc6B +ZJXsdd7Zf2e4UW3tI/1oh/2t23Ot3zyhTcv5gDbABu0LjVe98uRnS15SMwK//lJt +9e5BqKvgABkSoABf+B4VFtPVEX0ygrYaFaI9i5ABrxnVBmzXpRb21iI1NlNCfOGU +PIhVpWECAwEAAaN+MHwwDgYDVR0PAQH/BAQDAgEGMB0GA1UdDgQWBBRkoF9x4wwK +ZNg7deUBWZ4r7gYDRDAPBgNVHRMBAf8EBTADAQH/MDoGA1UdHwQzMDEwL6AtoCuG +KWh0dHBzOi8va2RzaW50Zi5hbWQuY29tL3ZjZWsvdjEvVHVyaW4vY3JsMEYGCSqG +SIb3DQEBCjA5oA8wDQYJYIZIAWUDBAICBQChHDAaBgkqhkiG9w0BAQgwDQYJYIZI +AWUDBAICBQCiAwIBMKMDAgEBA4ICAQA/i6Mz4IETMK8YU/HxP7Bfej5i4aXhenJo +TuiDX0nqx5CDJm9ELhskxAkJ/oLA1O92UoLybfFk4gEpKFtyfiUYex9LogZj5ix0 +sb2qfSSy9CRnOktGqfpel4e3KAhLgF5n2qZrqyq/8EPPldtSjEXn78sZMlIlUcQK +SnnNCQZVFpktDfDiEiGNuitux3ghHUrcVuxSbZcrXDbsbMF7NDdfLUUS9TijrL33 +lrCXJs7m8kggGyCusiRQKHli1AEswiA4xU+8xsZrByYTopiGYtbJK8s0UCCXylyO +uKSubvdAnMDJ5GDD0+DX46LSfv7fgGNSG+LOBWdif7KoQf9cIhKJtxGxZCn/tvHm +wMzu4Jnx8N2vRnT+8DpBqhxtNvdXmrZUelSeQakx4djMKvmTR8Gd25EnC4RppCkj +bmPxY3zPd1X7raalTn34EOF9DeLsC9JfzkDuojxpHWMm30wKnDo20mlDQk/zKCDa +2Zc+YjtsTZCrTbvdgCukTKNZOUUVlWRu+sO/OwrmS2p16seHTIqHEbE1LntPv3gk +CcHGDSUAKx9c0Aol+Dj9xpb2nmGqoDeJ59Ja6REkHCdw5TduXyqqMqfD1AX0/QDN +devCMKlWBRCQ7DFlog3H1a+r/kuMUZ/Ij9yyKlSgYZMJ4VgNKDgTQdcsAL0MCEMr +zpacMwFusA== +-----END CERTIFICATE----- diff --git a/solstone/think/services/spp_attest/roots/amd/Turin/ask.pem b/solstone/think/services/spp_attest/roots/amd/Turin/ask.pem new file mode 100644 index 000000000..ba57582ae --- /dev/null +++ b/solstone/think/services/spp_attest/roots/amd/Turin/ask.pem @@ -0,0 +1,37 @@ +-----BEGIN CERTIFICATE----- +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 +-----END CERTIFICATE----- diff --git a/solstone/think/services/spp_attest/snp.py b/solstone/think/services/spp_attest/snp.py new file mode 100644 index 000000000..c904e7ecd --- /dev/null +++ b/solstone/think/services/spp_attest/snp.py @@ -0,0 +1,725 @@ +# SPDX-License-Identifier: AGPL-3.0-only +# Copyright (c) 2026 sol pbc + +"""SEV-SNP CPU-leg appraisal for SPP attestation.""" + +from __future__ import annotations + +import base64 +import binascii +import datetime as dt +import hashlib +import json +from dataclasses import dataclass, field +from pathlib import Path +from typing import Any + +from cryptography import x509 +from cryptography.exceptions import InvalidSignature +from cryptography.hazmat.primitives import hashes, serialization +from cryptography.hazmat.primitives.asymmetric import ec, rsa, utils +from cryptography.hazmat.primitives.asymmetric import padding as asym_padding +from cryptography.x509.oid import ExtensionOID, NameOID + +from solstone.think.services.spp_attest.binding import ( + check_envelope_nonce, + composite_binding_hash, +) +from solstone.think.services.spp_attest.errors import VerificationError +from solstone.think.services.spp_attest.tlv import decode_gpu_envelope +from solstone.think.services.spp_attest.tpm_quote import TpmQuoteVerifier + +HCL_SIG = b"HCLA" +HCL_REPORT_OFFSET = 32 +HCL_REPORT_SIZE = 1184 +HCL_RUNTIME_OFFSET = HCL_REPORT_OFFSET + HCL_REPORT_SIZE + +SNP_OFF_VERSION = 0x000 +SNP_OFF_GUEST_SVN = 0x004 +SNP_OFF_POLICY = 0x008 +SNP_OFF_VMPL = 0x030 +SNP_OFF_SIG_ALGO = 0x034 +SNP_OFF_CURRENT_TCB = 0x038 +SNP_OFF_PLATFORM_INFO = 0x040 +SNP_OFF_KEY_INFO = 0x048 +SNP_OFF_REPORT_DATA = 0x050 +SNP_OFF_MEASUREMENT = 0x090 +SNP_OFF_HOST_DATA = 0x0C0 +SNP_OFF_REPORTED_TCB = 0x180 +SNP_OFF_CPUID_FAMILY = 0x188 +SNP_OFF_CPUID_MODEL = 0x189 +SNP_OFF_CPUID_STEP = 0x18A +SNP_OFF_CHIP_ID = 0x1A0 +SNP_OFF_COMMITTED_TCB = 0x1E0 +SNP_OFF_CURRENT_VERSION = 0x1E8 +SNP_OFF_COMMITTED_VERSION = 0x1EC +SNP_OFF_LAUNCH_TCB = 0x1F0 +SNP_OFF_SIGNATURE = 0x2A0 +SNP_SIGNED_PREFIX_LEN = 0x2A0 +SNP_POLICY_DEBUG_BIT = 19 + +DEFAULT_ROOTS_DIR = Path(__file__).parent / "roots" / "amd" + + +@dataclass(frozen=True, slots=True) +class TcbVersion: + boot_loader: int | None + tee: int | None + snp: int | None + microcode: int | None + fmc: int | None = None + + @classmethod + def from_raw(cls, raw: bytes, generation: str) -> TcbVersion: + if len(raw) != 8: + raise VerificationError(f"TCB field is {len(raw)} bytes, expected 8") + if generation == "turin": + return cls( + fmc=raw[0], + boot_loader=raw[1], + tee=raw[2], + snp=raw[3], + microcode=raw[7], + ) + return cls( + boot_loader=raw[0], + tee=raw[1], + snp=raw[6], + microcode=raw[7], + ) + + def as_dict(self) -> dict[str, int | None]: + return { + "boot_loader": self.boot_loader, + "tee": self.tee, + "snp": self.snp, + "microcode": self.microcode, + "fmc": self.fmc, + } + + +@dataclass(frozen=True, slots=True) +class TcbFloor: + boot_loader: int | None = None + tee: int | None = None + snp: int | None = None + microcode: int | None = None + fmc: int | None = None + + def check(self, observed: TcbVersion, label: str) -> None: + observed_values = observed.as_dict() + for field_name in ("boot_loader", "tee", "snp", "microcode", "fmc"): + floor_value = getattr(self, field_name) + if floor_value is None: + continue + observed_value = observed_values[field_name] + if observed_value is None: + raise VerificationError(f"{label} TCB has no {field_name} field") + if observed_value < floor_value: + raise VerificationError( + f"{label} TCB {field_name}={observed_value} " + f"is below policy floor {floor_value}" + ) + + +@dataclass(frozen=True, slots=True) +class Policy: + allowed_report_versions: set[int] = field(default_factory=lambda: {3, 5}) + allowed_hcla_versions: set[int] = field(default_factory=lambda: {1, 2}) + allowed_vmpl: set[int] = field(default_factory=lambda: {0}) + require_debug_disabled: bool = True + min_tcb: dict[str, TcbFloor] = field(default_factory=dict) + pcr_mode: str = "record" + pcr_pins: set[str] = field(default_factory=set) + + +@dataclass(frozen=True, slots=True) +class SnpReport: + raw: bytes + version: int + guest_svn: int + policy: int + vmpl: int + sig_algo: int + platform_info: int + key_info: int + report_data: bytes + measurement: bytes + host_data: bytes + chip_id: bytes + cpuid_family: int | None + cpuid_model: int | None + cpuid_step: int | None + generation: str + current_tcb: TcbVersion + reported_tcb: TcbVersion + committed_tcb: TcbVersion + launch_tcb: TcbVersion + current_version: str + committed_version: str + + @classmethod + def parse(cls, raw: bytes) -> SnpReport: + if len(raw) != HCL_REPORT_SIZE: + raise VerificationError(f"AMD report is {len(raw)} bytes, expected 1184") + version = _u32(raw, SNP_OFF_VERSION) + family = raw[SNP_OFF_CPUID_FAMILY] if version >= 3 else None + model = raw[SNP_OFF_CPUID_MODEL] if version >= 3 else None + step = raw[SNP_OFF_CPUID_STEP] if version >= 3 else None + generation = _generation_for_cpuid(family, model) + return cls( + raw=raw, + version=version, + guest_svn=_u32(raw, SNP_OFF_GUEST_SVN), + policy=_u64(raw, SNP_OFF_POLICY), + vmpl=_u32(raw, SNP_OFF_VMPL), + sig_algo=_u32(raw, SNP_OFF_SIG_ALGO), + platform_info=_u64(raw, SNP_OFF_PLATFORM_INFO), + key_info=_u32(raw, SNP_OFF_KEY_INFO), + report_data=raw[SNP_OFF_REPORT_DATA : SNP_OFF_REPORT_DATA + 64], + measurement=raw[SNP_OFF_MEASUREMENT : SNP_OFF_MEASUREMENT + 48], + host_data=raw[SNP_OFF_HOST_DATA : SNP_OFF_HOST_DATA + 32], + chip_id=raw[SNP_OFF_CHIP_ID : SNP_OFF_CHIP_ID + 64], + cpuid_family=family, + cpuid_model=model, + cpuid_step=step, + generation=generation, + current_tcb=TcbVersion.from_raw( + raw[SNP_OFF_CURRENT_TCB : SNP_OFF_CURRENT_TCB + 8], + generation, + ), + reported_tcb=TcbVersion.from_raw( + raw[SNP_OFF_REPORTED_TCB : SNP_OFF_REPORTED_TCB + 8], + generation, + ), + committed_tcb=TcbVersion.from_raw( + raw[SNP_OFF_COMMITTED_TCB : SNP_OFF_COMMITTED_TCB + 8], + generation, + ), + launch_tcb=TcbVersion.from_raw( + raw[SNP_OFF_LAUNCH_TCB : SNP_OFF_LAUNCH_TCB + 8], + generation, + ), + current_version=_version( + raw[SNP_OFF_CURRENT_VERSION : SNP_OFF_CURRENT_VERSION + 3] + ), + committed_version=_version( + raw[SNP_OFF_COMMITTED_VERSION : SNP_OFF_COMMITTED_VERSION + 3] + ), + ) + + @property + def debug_allowed(self) -> bool: + return ((self.policy >> SNP_POLICY_DEBUG_BIT) & 1) == 1 + + +@dataclass(frozen=True, slots=True) +class AppraisalStep: + name: str + status: str + detail: str + + +@dataclass(frozen=True, slots=True) +class CpuAppraisal: + steps: list[AppraisalStep] + hcla_version: int + report_version: int + cpuid: dict[str, int | None] + tcb: dict[str, dict[str, int | None]] + pcr_sha256: str + host_data: str + measurement: str + chip_id: str + + +@dataclass(frozen=True, slots=True) +class _HclaBlob: + version: int + request_type: int + report: bytes + runtime_json: bytes + runtime: dict[str, Any] + + +@dataclass(frozen=True, slots=True) +class _AmdRootSet: + product: str + ark: x509.Certificate + ask: x509.Certificate + ark_path: Path + ask_path: Path + + +def appraise_cpu_leg( + bundle_dir: Path, + *, + envelope_tlv: bytes, + channel_binding: bytes, + roots_dir: Path | None = None, + policy: Policy | None = None, + quote_verifier: TpmQuoteVerifier | None = None, +) -> CpuAppraisal: + """Appraise the CPU side of an SPP composite attestation bundle.""" + + policy = policy or Policy() + quote_verifier = quote_verifier or TpmQuoteVerifier() + roots_dir = roots_dir or DEFAULT_ROOTS_DIR + paths = _bundle_paths(bundle_dir) + _require( + paths, "hcl", "certs", "ak_pub", "nonce", "quote_msg", "quote_sig", "quote_pcrs" + ) + + nonce = _read_nonce(paths["nonce"]) + envelope = decode_gpu_envelope(envelope_tlv) + check_envelope_nonce(envelope, nonce) + + steps: list[AppraisalStep] = [] + hcla = _parse_hcla(paths["hcl"].read_bytes()) + if hcla.version not in policy.allowed_hcla_versions: + raise VerificationError(f"HCLA version {hcla.version} is not allowed") + steps.append( + _ok("hcla", f"sig=HCLA version={hcla.version} request_type={hcla.request_type}") + ) + + if paths["report"].exists() and paths["report"].read_bytes() != hcla.report: + steps.append( + _ok("standalone-report", "report.bin differs; using HCLA-embedded report") + ) + + report = SnpReport.parse(hcla.report) + cpuid = { + "family": report.cpuid_family, + "model": report.cpuid_model, + "step": report.cpuid_step, + } + tcb = { + "current": report.current_tcb.as_dict(), + "reported": report.reported_tcb.as_dict(), + "committed": report.committed_tcb.as_dict(), + "launch": report.launch_tcb.as_dict(), + } + + _check_runtime_binding(report, hcla.runtime_json) + steps.append(_ok("runtime-binding", "report_data == SHA-256(runtime JSON)")) + + vcek = _verify_amd_chain_and_report(report, paths["certs"], roots_dir) + steps.append( + _ok("amd-chain", f"VCEK chains to pinned {name_cn(vcek.issuer)} roots") + ) + steps.append(_ok("amd-report-signature", "VCEK signed report bytes 0..0x29f")) + + _check_policy(report, policy) + steps.append( + _ok( + "snp-policy", + f"version={report.version} vmpl={report.vmpl} " + f"debug_allowed={report.debug_allowed}", + ) + ) + + _verify_ak_binding(hcla.runtime, paths["ak_pub"]) + steps.append(_ok("ak-binding", "bundle AK public key matches AMD-bound HCLAkPub")) + + binding = composite_binding_hash( + nonce=nonce, + channel_binding=channel_binding, + envelope_tlv=envelope_tlv, + ) + quote_verifier.verify( + paths["ak_pub"], + paths["quote_msg"], + paths["quote_sig"], + paths["quote_pcrs"], + binding.hex(), + ) + steps.append( + _ok( + "quote", + "AK quote signature valid and extraData matches verifier nonce + guest key", + ) + ) + + pcr_sha256 = hashlib.sha256(paths["quote_pcrs"].read_bytes()).hexdigest() + _check_pcr_policy(pcr_sha256, policy) + if policy.pcr_mode == "record": + steps.append(_ok("pcr-policy", f"record-then-pin v1 fingerprint={pcr_sha256}")) + else: + steps.append(_ok("pcr-policy", f"pinned PCR fingerprint matched {pcr_sha256}")) + + return CpuAppraisal( + steps=steps, + hcla_version=hcla.version, + report_version=report.version, + cpuid=cpuid, + tcb=tcb, + pcr_sha256=pcr_sha256, + host_data=report.host_data.hex(), + measurement=report.measurement.hex(), + chip_id=report.chip_id.hex(), + ) + + +def name_cn(name: x509.Name) -> str: + attrs = name.get_attributes_for_oid(NameOID.COMMON_NAME) + return attrs[0].value if attrs else name.rfc4514_string() + + +def _ok(name: str, detail: str) -> AppraisalStep: + return AppraisalStep(name=name, status="ok", detail=detail) + + +def _parse_hcla(blob: bytes) -> _HclaBlob: + if len(blob) < HCL_RUNTIME_OFFSET: + raise VerificationError( + f"HCLA blob is {len(blob)} bytes; expected at least {HCL_RUNTIME_OFFSET}" + ) + sig = blob[:4] + version = _u32(blob, 4) + request_type = _u32(blob, 12) + if sig != HCL_SIG: + raise VerificationError(f"HCLA signature mismatch: {sig!r}") + if request_type != 2: + raise VerificationError( + f"HCLA request_type={request_type}, expected AMD-SNP request_type 2" + ) + report = blob[HCL_REPORT_OFFSET : HCL_REPORT_OFFSET + HCL_REPORT_SIZE] + runtime_json = _extract_runtime_json(blob) + try: + runtime = json.loads(runtime_json) + except json.JSONDecodeError as exc: + raise VerificationError(f"HCL runtime JSON did not parse: {exc}") from exc + if not isinstance(runtime, dict): + raise VerificationError("HCL runtime JSON is not an object") + return _HclaBlob( + version=version, + request_type=request_type, + report=report, + runtime_json=runtime_json, + runtime=runtime, + ) + + +def _extract_runtime_json(blob: bytes) -> bytes: + start = blob.find(b'{"', HCL_RUNTIME_OFFSET) + if start < 0: + raise VerificationError( + f"no JSON object found at/after HCLA offset {HCL_RUNTIME_OFFSET}" + ) + end = blob.find(b"\x00", start) + if end < 0: + end = len(blob) + return blob[start:end] + + +def _check_runtime_binding(report: SnpReport, runtime_json: bytes) -> None: + digest = hashlib.sha256(runtime_json).digest() + if report.report_data[:32] != digest: + raise VerificationError( + "runtime-data binding failed: " + f"SHA-256(runtime)={digest.hex()} " + f"report_data={report.report_data[:32].hex()}" + ) + if report.report_data[32:] != b"\x00" * 32: + raise VerificationError( + "report_data[32..64] is nonzero; expected SHA-256 runtime binding" + ) + + +def _verify_amd_chain_and_report( + report: SnpReport, + certs_dir: Path, + roots_dir: Path, +) -> x509.Certificate: + certs = _load_certs_from_dir(certs_dir) + vcek = _select_vcek(certs) + root = _select_root_set(vcek, _load_root_sets(roots_dir)) + _verify_cert_signature(root.ask, root.ark) + _verify_cert_signature(root.ark, root.ark) + _verify_cert_signature(vcek, root.ask) + for cert in [root.ark, root.ask, vcek]: + _check_cert_time(cert) + _reject_mismatched_bundle_cas(certs, root) + _verify_report_signature(report.raw, vcek) + return vcek + + +def _load_certs_from_dir(certs_dir: Path) -> list[x509.Certificate]: + if not certs_dir.is_dir(): + raise VerificationError(f"missing certs directory: {certs_dir}") + certs: list[x509.Certificate] = [] + for path in sorted(certs_dir.glob("*.pem")): + try: + certs.append(x509.load_pem_x509_certificate(path.read_bytes())) + except ValueError as exc: + raise VerificationError(f"certificate did not parse: {path}") from exc + if not certs: + raise VerificationError(f"no PEM certificates in {certs_dir}") + return certs + + +def _load_root_sets(roots_dir: Path) -> list[_AmdRootSet]: + root_sets: list[_AmdRootSet] = [] + for product_dir in sorted(roots_dir.glob("*")): + if not product_dir.is_dir(): + continue + ark_path = product_dir / "ark.pem" + ask_path = product_dir / "ask.pem" + if not ark_path.exists() or not ask_path.exists(): + continue + try: + ark = x509.load_pem_x509_certificate(ark_path.read_bytes()) + ask = x509.load_pem_x509_certificate(ask_path.read_bytes()) + except ValueError as exc: + raise VerificationError( + f"AMD root set did not parse: {product_dir}" + ) from exc + root_sets.append( + _AmdRootSet( + product=product_dir.name, + ark=ark, + ask=ask, + ark_path=ark_path, + ask_path=ask_path, + ) + ) + if not root_sets: + raise VerificationError(f"no AMD root sets under {roots_dir}") + return root_sets + + +def _select_vcek(certs: list[x509.Certificate]) -> x509.Certificate: + candidates = [ + cert + for cert in certs + if not _is_ca(cert) and isinstance(cert.public_key(), ec.EllipticCurvePublicKey) + ] + if len(candidates) != 1: + raise VerificationError( + f"expected exactly one VCEK/VLEK cert, found {len(candidates)}" + ) + return candidates[0] + + +def _select_root_set( + vcek: x509.Certificate, root_sets: list[_AmdRootSet] +) -> _AmdRootSet: + issuer = name_cn(vcek.issuer) + for root in root_sets: + if name_cn(root.ask.subject) == issuer: + return root + products = ", ".join( + f"{root.product}:{name_cn(root.ask.subject)}" for root in root_sets + ) + raise VerificationError( + f"no pinned AMD ASK for VCEK issuer {issuer}; available {products}" + ) + + +def _reject_mismatched_bundle_cas( + certs: list[x509.Certificate], + root: _AmdRootSet, +) -> None: + pinned = { + name_cn(root.ark.subject): root.ark.fingerprint(hashes.SHA256()), + name_cn(root.ask.subject): root.ask.fingerprint(hashes.SHA256()), + } + for cert in certs: + subject = name_cn(cert.subject) + if subject in pinned and cert.fingerprint(hashes.SHA256()) != pinned[subject]: + raise VerificationError( + f"bundle CA {subject} does not match pinned root material" + ) + + +def _verify_cert_signature(cert: x509.Certificate, issuer: x509.Certificate) -> None: + public_key = issuer.public_key() + try: + if isinstance(public_key, rsa.RSAPublicKey): + params = cert.signature_algorithm_parameters + if params is None: + params = asym_padding.PKCS1v15() + public_key.verify( + cert.signature, + cert.tbs_certificate_bytes, + params, + cert.signature_hash_algorithm, + ) + elif isinstance(public_key, ec.EllipticCurvePublicKey): + public_key.verify( + cert.signature, + cert.tbs_certificate_bytes, + ec.ECDSA(cert.signature_hash_algorithm), + ) + else: + raise VerificationError( + f"unsupported issuer key type: {type(public_key).__name__}" + ) + except InvalidSignature as exc: + raise VerificationError( + f"certificate signature invalid: {name_cn(cert.subject)} <- {name_cn(issuer.subject)}" + ) from exc + + +def _verify_report_signature(report: bytes, vcek: x509.Certificate) -> None: + if len(report) != HCL_REPORT_SIZE: + raise VerificationError(f"report length {len(report)} != {HCL_REPORT_SIZE}") + raw_sig = report[SNP_OFF_SIGNATURE : SNP_OFF_SIGNATURE + 512] + if raw_sig[144:] != b"\x00" * (512 - 144): + raise VerificationError("AMD report signature reserved bytes are nonzero") + r = int.from_bytes(raw_sig[:72], "little") + s = int.from_bytes(raw_sig[72:144], "little") + der_sig = utils.encode_dss_signature(r, s) + public_key = vcek.public_key() + if not isinstance(public_key, ec.EllipticCurvePublicKey): + raise VerificationError("VCEK public key is not an EC key") + try: + public_key.verify( + der_sig, report[:SNP_SIGNED_PREFIX_LEN], ec.ECDSA(hashes.SHA384()) + ) + except InvalidSignature as exc: + raise VerificationError("VCEK did not sign the AMD report") from exc + + +def _check_policy(report: SnpReport, policy: Policy) -> None: + if report.version not in policy.allowed_report_versions: + raise VerificationError(f"SNP report version {report.version} not allowed") + if policy.allowed_vmpl and report.vmpl not in policy.allowed_vmpl: + raise VerificationError(f"VMPL {report.vmpl} not allowed") + if policy.require_debug_disabled and report.debug_allowed: + raise VerificationError("SNP guest policy allows DEBUG") + tcb_fields = { + "current": report.current_tcb, + "reported": report.reported_tcb, + "committed": report.committed_tcb, + "launch": report.launch_tcb, + } + for label, floor in policy.min_tcb.items(): + if label not in tcb_fields: + raise VerificationError(f"unknown TCB policy label: {label}") + floor.check(tcb_fields[label], label) + + +def _verify_ak_binding(runtime: dict[str, Any], ak_pub_path: Path) -> None: + keys = runtime.get("keys", []) + if not isinstance(keys, list): + raise VerificationError("runtime claims field 'keys' is not a list") + jwk = next( + (key for key in keys if isinstance(key, dict) and key.get("kid") == "HCLAkPub"), + None, + ) + if jwk is None: + raise VerificationError("HCLAkPub not found in HCL runtime claims") + if "n" not in jwk or "e" not in jwk: + raise VerificationError("HCLAkPub JWK is missing RSA modulus or exponent") + runtime_n = int.from_bytes(_b64url_decode(jwk["n"]), "big") + runtime_e = int.from_bytes(_b64url_decode(jwk["e"]), "big") + try: + ak_pub = serialization.load_pem_public_key(ak_pub_path.read_bytes()) + except ValueError as exc: + raise VerificationError("bundle AK public key did not parse") from exc + if not isinstance(ak_pub, rsa.RSAPublicKey): + raise VerificationError("bundle AK public key is not RSA") + ak_numbers = ak_pub.public_numbers() + if ak_numbers.n != runtime_n or ak_numbers.e != runtime_e: + raise VerificationError( + "bundle AK public key does not match AMD-bound HCLAkPub" + ) + + +def _check_pcr_policy(pcr_sha256: str, policy: Policy) -> None: + if policy.pcr_mode == "record": + return + if policy.pcr_mode != "pin": + raise VerificationError(f"unknown PCR policy mode {policy.pcr_mode!r}") + pins = {pin.lower() for pin in policy.pcr_pins} + if pcr_sha256.lower() not in pins: + raise VerificationError(f"PCR fingerprint {pcr_sha256} not in pinned policy") + + +def _is_ca(cert: x509.Certificate) -> bool: + try: + return cert.extensions.get_extension_for_oid( + ExtensionOID.BASIC_CONSTRAINTS + ).value.ca + except x509.ExtensionNotFound: + return False + + +def _check_cert_time(cert: x509.Certificate) -> None: + now = dt.datetime.now(dt.UTC) + if now < cert.not_valid_before_utc or now > cert.not_valid_after_utc: + raise VerificationError( + f"certificate outside validity window: {name_cn(cert.subject)}" + ) + + +def _bundle_paths(bundle: Path) -> dict[str, Path]: + return { + "hcl": bundle / "hcl_report.bin", + "report": bundle / "report.bin", + "certs": bundle / "certs", + "ak_pub": bundle / "akpub.pem", + "nonce": bundle / "nonce.hex", + "quote_msg": bundle / "quote.msg", + "quote_sig": bundle / "quote.sig", + "quote_pcrs": bundle / "quote.pcrs", + } + + +def _require(paths: dict[str, Path], *names: str) -> None: + missing = [str(paths[name]) for name in names if not paths[name].exists()] + if missing: + raise VerificationError("missing bundle files: " + ", ".join(missing)) + + +def _read_nonce(path: Path) -> bytes: + nonce_hex = "".join(path.read_text(encoding="utf-8").split()) + if len(nonce_hex) != 64: + raise VerificationError(f"nonce is {len(nonce_hex)} hex chars, expected 64") + try: + return bytes.fromhex(nonce_hex) + except ValueError as exc: + raise VerificationError("nonce is not valid hex") from exc + + +def _u32(data: bytes, offset: int) -> int: + if offset + 4 > len(data): + raise VerificationError(f"u32 read at offset {offset} overruns buffer") + return int.from_bytes(data[offset : offset + 4], "little") + + +def _u64(data: bytes, offset: int) -> int: + if offset + 8 > len(data): + raise VerificationError(f"u64 read at offset {offset} overruns buffer") + return int.from_bytes(data[offset : offset + 8], "little") + + +def _version(raw: bytes) -> str: + if len(raw) != 3: + return "unknown" + return f"{raw[2]}.{raw[1]}.{raw[0]}" + + +def _generation_for_cpuid(family: int | None, model: int | None) -> str: + if ( + family == 0x1A + and model is not None + and (0x90 <= model <= 0xAF or 0xC0 <= model <= 0xCF) + ): + return "turin" + return "pre_turin" + + +def _b64url_decode(value: Any) -> bytes: + if not isinstance(value, str): + raise VerificationError("HCLAkPub JWK field is not a string") + padding = "=" * (-len(value) % 4) + try: + return base64.b64decode( + (value + padding).encode("ascii"), + altchars=b"-_", + validate=True, + ) + except (binascii.Error, UnicodeEncodeError, ValueError) as exc: + raise VerificationError("HCLAkPub JWK field is not valid base64url") from exc diff --git a/solstone/think/services/spp_attest/tlv.py b/solstone/think/services/spp_attest/tlv.py new file mode 100644 index 000000000..337528cae --- /dev/null +++ b/solstone/think/services/spp_attest/tlv.py @@ -0,0 +1,136 @@ +# SPDX-License-Identifier: AGPL-3.0-only +# Copyright (c) 2026 sol pbc + +"""Decode SPP GPU TLV envelopes.""" + +from __future__ import annotations + +from dataclasses import dataclass + +from solstone.think.services.spp_attest.errors import VerificationError + +GPU_ENVELOPE_MAGIC = b"SPPGPU1\x00" +GPU_ENVELOPE_FIELD_COUNT = 7 +GPU_ENVELOPE_FIELD_IDS = tuple(range(1, GPU_ENVELOPE_FIELD_COUNT + 1)) +SPDM_GET_MEASUREMENTS_HEADER = bytes.fromhex("11e001ff") +SPDM_NONCE_OFFSET = 4 +SPDM_NONCE_SIZE = 32 + + +@dataclass(frozen=True, slots=True) +class GpuEnvelopeField: + field_id: int + value: bytes + + +@dataclass(frozen=True, slots=True) +class GpuEnvelope: + fields: tuple[GpuEnvelopeField, ...] + nonce: bytes + spdm_report: bytes + + def field(self, field_id: int) -> bytes: + for item in self.fields: + if item.field_id == field_id: + return item.value + raise KeyError(field_id) + + +def decode_gpu_envelope(data: bytes) -> GpuEnvelope: + """Decode a SPPGPU1 TLV envelope and validate its fixed field set.""" + + if len(data) < len(GPU_ENVELOPE_MAGIC) + 2: + raise VerificationError("GPU envelope is too short for SPPGPU1 header") + if data[: len(GPU_ENVELOPE_MAGIC)] != GPU_ENVELOPE_MAGIC: + raise VerificationError("GPU envelope magic mismatch") + + field_count_offset = len(GPU_ENVELOPE_MAGIC) + field_count = int.from_bytes( + data[field_count_offset : field_count_offset + 2], "big" + ) + if field_count != GPU_ENVELOPE_FIELD_COUNT: + raise VerificationError( + f"GPU envelope field_count={field_count}, expected {GPU_ENVELOPE_FIELD_COUNT}" + ) + + fields: list[GpuEnvelopeField] = [] + seen: set[int] = set() + field_ids: list[int] = [] + offset = field_count_offset + 2 + last_field_id = 0 + for index in range(field_count): + if offset + 6 > len(data): + raise VerificationError( + f"GPU envelope field {index + 1} header is truncated" + ) + field_id = int.from_bytes(data[offset : offset + 2], "big") + length = int.from_bytes(data[offset + 2 : offset + 6], "big") + offset += 6 + + if field_id < last_field_id: + raise VerificationError( + f"GPU envelope field id {field_id} is out of order after {last_field_id}" + ) + + end = offset + length + if end > len(data): + raise VerificationError( + f"GPU envelope field {field_id} length overruns buffer" + ) + fields.append(GpuEnvelopeField(field_id=field_id, value=data[offset:end])) + seen.add(field_id) + field_ids.append(field_id) + last_field_id = field_id + offset = end + + if offset != len(data): + raise VerificationError("GPU envelope has trailing bytes") + + unknown = sorted( + field_id for field_id in seen if field_id not in GPU_ENVELOPE_FIELD_IDS + ) + if unknown: + raise VerificationError( + "GPU envelope unknown field id(s): " + + ", ".join(str(item) for item in unknown) + ) + + duplicates = sorted(field_id for field_id in seen if field_ids.count(field_id) > 1) + missing = sorted(set(GPU_ENVELOPE_FIELD_IDS) - seen) + if duplicates or missing: + parts: list[str] = [] + if duplicates: + parts.append( + "duplicate field id(s): " + ", ".join(str(item) for item in duplicates) + ) + if missing: + parts.append( + "missing field id(s): " + ", ".join(str(item) for item in missing) + ) + raise VerificationError("GPU envelope " + "; ".join(parts)) + + by_id = {field.field_id: field.value for field in fields} + nonce = by_id[1] + if len(nonce) != SPDM_NONCE_SIZE: + raise VerificationError( + f"GPU envelope field 1 nonce is {len(nonce)} bytes, expected 32" + ) + + spdm_report = by_id[2] + extract_spdm_nonce(spdm_report) + return GpuEnvelope(fields=tuple(fields), nonce=nonce, spdm_report=spdm_report) + + +def extract_spdm_nonce(spdm_report: bytes) -> bytes: + """Return the SPDM GET_MEASUREMENTS nonce at its structural offset.""" + + nonce_end = SPDM_NONCE_OFFSET + SPDM_NONCE_SIZE + if len(spdm_report) < nonce_end: + raise VerificationError( + "SPDM report is too short to carry a GET_MEASUREMENTS nonce" + ) + if spdm_report[: len(SPDM_GET_MEASUREMENTS_HEADER)] != SPDM_GET_MEASUREMENTS_HEADER: + raise VerificationError( + "SPDM report does not start with GET_MEASUREMENTS header" + ) + return spdm_report[SPDM_NONCE_OFFSET:nonce_end] diff --git a/solstone/think/services/spp_attest/tpm_quote.py b/solstone/think/services/spp_attest/tpm_quote.py new file mode 100644 index 000000000..29fd15f22 --- /dev/null +++ b/solstone/think/services/spp_attest/tpm_quote.py @@ -0,0 +1,366 @@ +# SPDX-License-Identifier: AGPL-3.0-only +# Copyright (c) 2026 sol pbc + +"""Pure-Python TPM2 quote verification for SPP attestation.""" + +from __future__ import annotations + +import hashlib +from dataclasses import dataclass +from pathlib import Path + +from cryptography.exceptions import InvalidSignature +from cryptography.hazmat.primitives import hashes, serialization +from cryptography.hazmat.primitives.asymmetric import padding, rsa + +from solstone.think.services.spp_attest.errors import VerificationError + +TPM_GENERATED_VALUE = 0xFF544347 +TPM_ST_ATTEST_QUOTE = 0x8018 +TPM_ALG_SHA256 = 0x000B +TPM_ALG_RSASSA = 0x0014 +TPM_ALG_RSAPSS = 0x0016 +SHA256_DIGEST_SIZE = 32 +PCR_SELECTION_SLOT_COUNT = 8 +PCR_SELECTION_SLOT_SIZE = 16 +PCR_DIGEST_SLOT_COUNT = 8 +PCR_DIGEST_SLOT_SIZE = 66 +PCR_DIGEST_BUFFER_SIZE = 64 + + +@dataclass(frozen=True, slots=True) +class _PcrSelection: + hash_alg: int + sizeof_select: int + pcr_select: bytes + + def selected_pcrs(self) -> tuple[int, ...]: + selected: list[int] = [] + for byte_index, value in enumerate(self.pcr_select): + for bit in range(8): + if value & (1 << bit): + selected.append(byte_index * 8 + bit) + return tuple(selected) + + +@dataclass(frozen=True, slots=True) +class _QuoteInfo: + extra_data: bytes + selections: tuple[_PcrSelection, ...] + pcr_digest: bytes + + +@dataclass(frozen=True, slots=True) +class _SignatureInfo: + sig_alg: int + hash_alg: int + signature: bytes + + +class _Reader: + def __init__(self, data: bytes, label: str) -> None: + self._data = data + self._label = label + self.offset = 0 + + def read(self, size: int, field: str) -> bytes: + if size < 0: + raise VerificationError(f"{self._label} field {field} has negative size") + end = self.offset + size + if end > len(self._data): + raise VerificationError(f"{self._label} field {field} overruns buffer") + value = self._data[self.offset : end] + self.offset = end + return value + + def u8(self, field: str) -> int: + return self.read(1, field)[0] + + def u16be(self, field: str) -> int: + return int.from_bytes(self.read(2, field), "big") + + def u32be(self, field: str) -> int: + return int.from_bytes(self.read(4, field), "big") + + def u64be(self, field: str) -> int: + return int.from_bytes(self.read(8, field), "big") + + def u16le(self, field: str) -> int: + return int.from_bytes(self.read(2, field), "little") + + def u32le(self, field: str) -> int: + return int.from_bytes(self.read(4, field), "little") + + def require_consumed(self) -> None: + if self.offset != len(self._data): + raise VerificationError(f"{self._label} has trailing bytes") + + +def verify_quote( + *, + ak_pub_pem: bytes, + quote_msg: bytes, + quote_sig: bytes, + quote_pcrs: bytes, + expected_binding: bytes, +) -> None: + """Verify a TPM2 quote, its extraData binding, signature, and PCR digest.""" + + public_key = _load_ak_public_key(ak_pub_pem) + quote = _parse_quote_msg(quote_msg, expected_binding) + _check_pcrs(quote_pcrs, quote) + signature = _parse_quote_sig(quote_sig, public_key.key_size // 8) + _verify_signature(public_key, quote_msg, signature) + + +class TpmQuoteVerifier: + def verify( + self, + ak_pub: Path, + quote_msg: Path, + quote_sig: Path, + quote_pcrs: Path, + binding_hex: str, + ) -> None: + try: + binding = bytes.fromhex(binding_hex) + except ValueError as exc: + raise VerificationError("TPM quote binding_hex is not valid hex") from exc + verify_quote( + ak_pub_pem=ak_pub.read_bytes(), + quote_msg=quote_msg.read_bytes(), + quote_sig=quote_sig.read_bytes(), + quote_pcrs=quote_pcrs.read_bytes(), + expected_binding=binding, + ) + + +def _load_ak_public_key(ak_pub_pem: bytes) -> rsa.RSAPublicKey: + try: + public_key = serialization.load_pem_public_key(ak_pub_pem) + except ValueError as exc: + raise VerificationError("AK public key PEM did not parse") from exc + if not isinstance(public_key, rsa.RSAPublicKey): + raise VerificationError("AK public key is not RSA") + return public_key + + +def _parse_quote_msg(quote_msg: bytes, expected_binding: bytes) -> _QuoteInfo: + if len(expected_binding) != SHA256_DIGEST_SIZE: + raise VerificationError( + f"expected TPM quote binding is {len(expected_binding)} bytes, expected 32" + ) + + reader = _Reader(quote_msg, "TPMS_ATTEST") + magic = reader.u32be("magic") + if magic != TPM_GENERATED_VALUE: + raise VerificationError(f"TPMS_ATTEST magic 0x{magic:08x} != 0xff544347") + attest_type = reader.u16be("type") + if attest_type != TPM_ST_ATTEST_QUOTE: + raise VerificationError(f"TPMS_ATTEST type 0x{attest_type:04x} is not quote") + + qualified_signer_size = reader.u16be("qualifiedSigner.size") + reader.read(qualified_signer_size, "qualifiedSigner.name") + + extra_data_size = reader.u16be("extraData.size") + extra_data = reader.read(extra_data_size, "extraData.buffer") + if extra_data != expected_binding: + raise VerificationError( + "TPM quote extraData mismatch: " + f"quote={extra_data.hex()} expected={expected_binding.hex()}" + ) + + reader.u64be("clockInfo.clock") + reader.u32be("clockInfo.resetCount") + reader.u32be("clockInfo.restartCount") + reader.u8("clockInfo.safe") + reader.u64be("firmwareVersion") + + selection_count = reader.u32be("attested.quote.pcrSelect.count") + if selection_count != 1: + raise VerificationError( + f"TPM quote selection count {selection_count} unsupported; expected 1" + ) + + selections: list[_PcrSelection] = [] + for index in range(selection_count): + hash_alg = reader.u16be(f"attested.quote.pcrSelect[{index}].hash") + if hash_alg != TPM_ALG_SHA256: + raise VerificationError( + f"TPM quote PCR hashAlg 0x{hash_alg:04x} unsupported" + ) + sizeof_select = reader.u8(f"attested.quote.pcrSelect[{index}].sizeofSelect") + if sizeof_select > PCR_SELECTION_SLOT_COUNT: + raise VerificationError( + f"TPM quote PCR sizeofSelect {sizeof_select} exceeds 8" + ) + pcr_select = reader.read( + sizeof_select, + f"attested.quote.pcrSelect[{index}].pcrSelect", + ) + selections.append( + _PcrSelection( + hash_alg=hash_alg, + sizeof_select=sizeof_select, + pcr_select=pcr_select, + ) + ) + + pcr_digest_size = reader.u16be("attested.quote.pcrDigest.size") + if pcr_digest_size != SHA256_DIGEST_SIZE: + raise VerificationError( + f"TPM quote pcrDigest is {pcr_digest_size} bytes, expected 32" + ) + pcr_digest = reader.read(pcr_digest_size, "attested.quote.pcrDigest.buffer") + reader.require_consumed() + return _QuoteInfo( + extra_data=extra_data, + selections=tuple(selections), + pcr_digest=pcr_digest, + ) + + +def _parse_quote_sig(quote_sig: bytes, key_size_bytes: int) -> _SignatureInfo: + reader = _Reader(quote_sig, "TPMT_SIGNATURE") + sig_alg = reader.u16be("sigAlg") + if sig_alg not in {TPM_ALG_RSASSA, TPM_ALG_RSAPSS}: + raise VerificationError(f"TPM signature alg 0x{sig_alg:04x} unsupported") + hash_alg = reader.u16be("hashAlg") + if hash_alg != TPM_ALG_SHA256: + raise VerificationError(f"TPM signature hashAlg 0x{hash_alg:04x} unsupported") + signature_size = reader.u16be("signature.size") + if signature_size != key_size_bytes: + raise VerificationError( + f"TPM signature is {signature_size} bytes, expected RSA key size {key_size_bytes}" + ) + signature = reader.read(signature_size, "signature.buffer") + reader.require_consumed() + return _SignatureInfo(sig_alg=sig_alg, hash_alg=hash_alg, signature=signature) + + +def _verify_signature( + public_key: rsa.RSAPublicKey, + quote_msg: bytes, + signature: _SignatureInfo, +) -> None: + try: + if signature.sig_alg == TPM_ALG_RSASSA: + public_key.verify( + signature.signature, + quote_msg, + padding.PKCS1v15(), + hashes.SHA256(), + ) + else: + public_key.verify( + signature.signature, + quote_msg, + padding.PSS( + mgf=padding.MGF1(hashes.SHA256()), + salt_length=SHA256_DIGEST_SIZE, + ), + hashes.SHA256(), + ) + except InvalidSignature as exc: + raise VerificationError("TPM quote signature invalid") from exc + + +def _check_pcrs(quote_pcrs: bytes, quote: _QuoteInfo) -> None: + pcrs_selections, digest_buffers = _parse_pcrs(quote_pcrs) + if pcrs_selections != quote.selections: + raise VerificationError("TPM PCR selection file does not match quote selection") + + selected_count = sum( + len(selection.selected_pcrs()) for selection in pcrs_selections + ) + if selected_count != len(digest_buffers): + raise VerificationError( + f"TPM PCR digest count {len(digest_buffers)} does not match " + f"selected PCR count {selected_count}" + ) + + pcr_digest = hashlib.sha256(b"".join(digest_buffers)).digest() + if pcr_digest != quote.pcr_digest: + raise VerificationError( + "TPM PCR digest mismatch: " + f"computed={pcr_digest.hex()} quote={quote.pcr_digest.hex()}" + ) + + +def _parse_pcrs( + quote_pcrs: bytes, +) -> tuple[tuple[_PcrSelection, ...], tuple[bytes, ...]]: + reader = _Reader(quote_pcrs, "quote.pcrs") + selection_count = reader.u32le("selection_count") + if selection_count > PCR_SELECTION_SLOT_COUNT: + raise VerificationError( + f"quote.pcrs selection_count {selection_count} exceeds 8" + ) + + selections: list[_PcrSelection] = [] + for index in range(PCR_SELECTION_SLOT_COUNT): + hash_alg = reader.u16le(f"selection[{index}].hashAlg") + sizeof_select = reader.u8(f"selection[{index}].sizeofSelect") + pcr_select_slot = reader.read(8, f"selection[{index}].pcrSelect") + pad = reader.read(5, f"selection[{index}].pad") + if pad != b"\x00" * 5: + raise VerificationError( + f"quote.pcrs selection[{index}] pad bytes are nonzero" + ) + if index >= selection_count: + continue + if hash_alg != TPM_ALG_SHA256: + raise VerificationError(f"quote.pcrs hashAlg 0x{hash_alg:04x} unsupported") + if sizeof_select > PCR_SELECTION_SLOT_COUNT: + raise VerificationError( + f"quote.pcrs selection[{index}] sizeofSelect {sizeof_select} exceeds 8" + ) + if any(pcr_select_slot[sizeof_select:]): + raise VerificationError( + f"quote.pcrs selection[{index}] has nonzero bytes after sizeofSelect" + ) + selections.append( + _PcrSelection( + hash_alg=hash_alg, + sizeof_select=sizeof_select, + pcr_select=pcr_select_slot[:sizeof_select], + ) + ) + + digest_list_count = reader.u32le("digest_list_count") + digest_buffers: list[bytes] = [] + for list_index in range(digest_list_count): + count = reader.u32le(f"digest_list[{list_index}].count") + if count > PCR_DIGEST_SLOT_COUNT: + raise VerificationError( + f"quote.pcrs digest_list[{list_index}] count {count} exceeds 8" + ) + for digest_index in range(PCR_DIGEST_SLOT_COUNT): + size = reader.u16le( + f"digest_list[{list_index}].digest[{digest_index}].size" + ) + buffer = reader.read( + PCR_DIGEST_BUFFER_SIZE, + f"digest_list[{list_index}].digest[{digest_index}].buffer", + ) + if size > PCR_DIGEST_BUFFER_SIZE: + raise VerificationError( + f"quote.pcrs digest_list[{list_index}].digest[{digest_index}] " + f"size {size} exceeds 64" + ) + if digest_index >= count: + if size != 0 or any(buffer): + raise VerificationError( + f"quote.pcrs digest_list[{list_index}].digest[{digest_index}] " + "inactive slot is nonzero" + ) + continue + if size != SHA256_DIGEST_SIZE: + raise VerificationError( + f"quote.pcrs digest_list[{list_index}].digest[{digest_index}] " + f"size {size} != 32" + ) + digest_buffers.append(buffer[:size]) + + reader.require_consumed() + return tuple(selections), tuple(digest_buffers) diff --git a/tests/fixtures/spp_attest/akpub.pem b/tests/fixtures/spp_attest/akpub.pem new file mode 100644 index 000000000..9834aad15 --- /dev/null +++ b/tests/fixtures/spp_attest/akpub.pem @@ -0,0 +1,9 @@ +-----BEGIN PUBLIC KEY----- +MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAwov2tAABuE63f8HLArVh ++qczQZF4S1cry4j8MqbYi3pe79w7ZoBEseLjfcUl4mReuMHTEzBJzXCbQcGzvqKg +s/GrIZM7UliCuVW2LUMFoiGTqksIwtOh1CHTvSVUk8HkyeF4EY7/Yb864xUBhbFm +w4BfQAyClW4TKSxSQXOO6aaCH5aaLOQNLcOHyWcMoKe9jn3MIM0R+8iuuL4ZI9hN +yGGcULgrtxodm7GG28ZoqNHduZ3AmGu1JgBvzRYQFb7bAGOgumD8FJWdi1flFo8d +yMpqI+ScrHy0L2NQDrL1tLWwb25d4BDQQcZJ5VD45BrfzMhRxnzsMNP7ezpLUVzR +sQIDAQAB +-----END PUBLIC KEY----- diff --git a/tests/fixtures/spp_attest/certs/ark.pem b/tests/fixtures/spp_attest/certs/ark.pem new file mode 100644 index 000000000..a68860826 --- /dev/null +++ b/tests/fixtures/spp_attest/certs/ark.pem @@ -0,0 +1,37 @@ +-----BEGIN CERTIFICATE----- +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 +/wMz1R1BHg== +-----END CERTIFICATE----- diff --git a/tests/fixtures/spp_attest/certs/ask.pem b/tests/fixtures/spp_attest/certs/ask.pem new file mode 100644 index 000000000..215b78409 --- /dev/null +++ b/tests/fixtures/spp_attest/certs/ask.pem @@ -0,0 +1,37 @@ +-----BEGIN CERTIFICATE----- +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 +-----END CERTIFICATE----- diff --git a/tests/fixtures/spp_attest/certs/vcek.pem b/tests/fixtures/spp_attest/certs/vcek.pem new file mode 100644 index 000000000..5aa311edb --- /dev/null +++ b/tests/fixtures/spp_attest/certs/vcek.pem @@ -0,0 +1,31 @@ +-----BEGIN CERTIFICATE----- +MIIFPzCCAvOgAwIBAgIBADBBBgkqhkiG9w0BAQowNKAPMA0GCWCGSAFlAwQCAgUA +oRwwGgYJKoZIhvcNAQEIMA0GCWCGSAFlAwQCAgUAogMCATAwezEUMBIGA1UECwwL +RW5naW5lZXJpbmcxCzAJBgNVBAYTAlVTMRQwEgYDVQQHDAtTYW50YSBDbGFyYTEL +MAkGA1UECAwCQ0ExHzAdBgNVBAoMFkFkdmFuY2VkIE1pY3JvIERldmljZXMxEjAQ +BgNVBAMMCVNFVi1HZW5vYTAeFw0yNjA3MTAxMDI1NTVaFw0zMzA3MTAxMDI1NTVa +MHoxFDASBgNVBAsMC0VuZ2luZWVyaW5nMQswCQYDVQQGEwJVUzEUMBIGA1UEBwwL +U2FudGEgQ2xhcmExCzAJBgNVBAgMAkNBMR8wHQYDVQQKDBZBZHZhbmNlZCBNaWNy +byBEZXZpY2VzMREwDwYDVQQDDAhTRVYtVkNFSzB2MBAGByqGSM49AgEGBSuBBAAi +A2IABPHxcxuAjHaFRSb8vDREDRlrfIdArfltk1GgZXq+j4XatAbEtXcnJ30H7wTY +TUWlR9BZNz3gVfyiGGzxa/MrN/IJWeDRmdQCq4Q2ZEqYCfdu50ooMcDaUJ4OXI6D +ASaaJqOCARMwggEPMBAGCSsGAQQBnHgBAQQDAgEAMBQGCSsGAQQBnHgBAgQHFgVH +ZW5vYTARBgorBgEEAZx4AQMBBAMCAQowEQYKKwYBBAGceAEDAgQDAgEAMBEGCisG +AQQBnHgBAwQEAwIBADARBgorBgEEAZx4AQMFBAMCAQAwEQYKKwYBBAGceAEDBgQD +AgEAMBEGCisGAQQBnHgBAwcEAwIBADARBgorBgEEAZx4AQMDBAMCARswEQYKKwYB +BAGceAEDCAQDAgFYME0GCSsGAQQBnHgBBARAEc62vVumd/aKVf/JqHeasg3qKOdM +pRK6j+Vs4iAkLR+fY1tbbJxyxbBj9bKyggCUyr6OKZ2e6PFan3cxOrUjtzBBBgkq +hkiG9w0BAQowNKAPMA0GCWCGSAFlAwQCAgUAoRwwGgYJKoZIhvcNAQEIMA0GCWCG +SAFlAwQCAgUAogMCATADggIBACjBnX+KgFZZCM4jNu6FSNDqsgH6qomPkbsil7dm +r1cPhi36DkjfXqfIPivo7+inUpjPSSUZKIrj2UHMetDXlGb5GIXKy3mX1v7hcfHy +MTa3kB1Bnug40efi9KXk2GfqwMoArsH22PSY0TZZlWyd361ExFU2YIyWkKgCF1ZC +HJvqyNaaWsuKwCIufaEVLZyv41lwglUzwVI2zkyPmqh/nPgLNrGlsvx3zLQd4PEy +GeGKsmzp/usICD+uHA99xjvVuWR2p0hEvRuS07MGvfLPaJCCfQoTdtlQoqVFhjO+ +a+EJQaPs1bGwmyVHwjBHg27KpHgUXyZn+51RgZKHQwm929AxsplCIn0Z68kcAVdU +QHWk8D5Xz55AVB21U2JcUiFdz/Anq04QG3M41jMncIp810IDLtzpfT/yNwDHBct3 +ve9cMbPAZDr+X4n9wTD6aYn/gPqBL2/mVSK21cfdsXST31IBCezEBMGWyWcWkwl3 +PbsmkKardTzZ39PUlGqgGsxZdwasP8CD7sM1/H0Z7s6jxRtxvRCHH1ckDaF4p8WQ +IkXswXP/43KYy07z96E7lFTbA0nhR094VGAsLonIHzKpYTd0pCaguZZQa52rd5ed +O9LoYeFQQihRJYSPNpl6MetNLznLWTyotGS8RW108/2DWOjoRAOnOptHLttL3vbg +ueYV +-----END CERTIFICATE----- diff --git a/tests/fixtures/spp_attest/cpu-appraisal.json b/tests/fixtures/spp_attest/cpu-appraisal.json new file mode 100644 index 000000000..ea8523b90 --- /dev/null +++ b/tests/fixtures/spp_attest/cpu-appraisal.json @@ -0,0 +1,93 @@ +{ + "bundle": "/home/azureuser/b-bundle", + "chip_id": null, + "cpuid": { + "family": 25, + "model": 17, + "step": 1 + }, + "hcla_version": 2, + "host_data": null, + "measurement": null, + "pcr_sha256": "b162f46105c80d3e45028e37cc649404c9d65297ad1cda8f953208582060b0e3", + "release_path": "/home/azureuser/b-bundle/release-py/release.json", + "report_version": 5, + "status": "ok", + "steps": [ + { + "detail": "sig=HCLA version=2 request_type=2", + "name": "hcla", + "status": "ok" + }, + { + "detail": "report_data == SHA-256(runtime JSON)", + "name": "runtime-binding", + "status": "ok" + }, + { + "detail": "VCEK chains to pinned SEV-Genoa roots", + "name": "amd-chain", + "status": "ok" + }, + { + "detail": "VCEK signed report bytes 0..0x29f", + "name": "amd-report-signature", + "status": "ok" + }, + { + "detail": "version=5 vmpl=0 debug_allowed=False", + "name": "snp-policy", + "status": "ok" + }, + { + "detail": "bundle AK public key matches AMD-bound HCLAkPub", + "name": "ak-binding", + "status": "ok" + }, + { + "detail": "AK quote signature valid and extraData matches verifier nonce + guest key", + "name": "quote", + "status": "ok" + }, + { + "detail": "record-then-pin v1 fingerprint=b162f46105c80d3e45028e37cc649404c9d65297ad1cda8f953208582060b0e3", + "name": "pcr-policy", + "status": "ok" + }, + { + "detail": "AES-GCM release written to /home/azureuser/b-bundle/release-py/release.json", + "name": "key-release", + "status": "ok" + } + ], + "tcb": { + "committed": { + "boot_loader": 10, + "fmc": null, + "microcode": 88, + "snp": 27, + "tee": 0 + }, + "current": { + "boot_loader": 10, + "fmc": null, + "microcode": 88, + "snp": 27, + "tee": 0 + }, + "launch": { + "boot_loader": 10, + "fmc": null, + "microcode": 88, + "snp": 27, + "tee": 0 + }, + "reported": { + "boot_loader": 10, + "fmc": null, + "microcode": 88, + "snp": 27, + "tee": 0 + } + } +} diff --git a/tests/fixtures/spp_attest/gpu-envelope.tlv b/tests/fixtures/spp_attest/gpu-envelope.tlv new file mode 100644 index 0000000000000000000000000000000000000000..4aea59172ff967ebc5941ded43a11a01dc2741b1 GIT binary patch literal 9108 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 UVfB@GZ0QXf$BLDyZ literal 0 HcmV?d00001 diff --git a/tests/fixtures/spp_attest/guest_x25519.pub.der b/tests/fixtures/spp_attest/guest_x25519.pub.der new file mode 100644 index 0000000000000000000000000000000000000000..3f47d3131314ade2094fb884c7d80bdb6ef9ffba GIT binary patch literal 44 zcmXreGGJw6)=tf1R%A$&|M9!-E%VE?V^@>w^CR|75GmvgJFActbg8f9fI__v09qUo AcmMzZ literal 0 HcmV?d00001 diff --git a/tests/fixtures/spp_attest/hcl_report.bin b/tests/fixtures/spp_attest/hcl_report.bin new file mode 100644 index 0000000000000000000000000000000000000000..e9d8a9a0361a1d686faa35ec1c479140a8a11104 GIT binary patch literal 2600 zcmeZp_HkrlU|?wBWMBZ%3}}Frfq{XCfssL;ff+2rsH^~{VFWfkj0_A6Twn##5h`FB zLF{1`7Tj_*HfycvQwI?@*L911i+EowaCx`&Sner{qlPaLDzUQ!q_yv{{5Yo@bmK(d zsRL{?FQn+SCL}1$5PZJGv7+FLD4*$ztKshLTk?Xh2>wkJHpgxXLUfA$O}o9%UR6zd zzxLIigpbEIU*s#WmR)~6GIdUiz%XnnpG9=6`G9HEmq)=ZrrllE%V z$%XqL3o_exn2DD$hvA55t(S%Gk9j&?HaGth0I+bB6aNzS4N^vs7qT|I?gD3M#tt^OK{abLJEs-H`ls)21edDW~@JY0jPZ;$zhOazm@F z%G+V)8=5mRfG~{D4z&tKL%D;FVqWk3o?r4-IG3K_sfv9(5%T9JGwlzxE0{yKR;Eg zGhT9;))af*xbs%qwm2+_lg4QdF7rplX%hlRSQr>UIRjJ)msLM}U?G)YMZBom)s0FqQH&P>l!iq%odE~!+qQVI%oRMJsORkBiY40Lo- z(oxD&vQjF~FEc7}baX0pH8W1L@bGag3d_(fOs)!yax)1wOfIT4$+GY*Op44jE=V=E zEH{tJcXBOG^~p+04$U#i4N5Kb^$2mTa`LK*a83$LcCRWc^iD61cQ5jc@-+|5$#gCa z3per!^>xe7bPp-aF7_-h@y{@H_6R8p4hzk;@W`%Abu)2|G>?x=vM@8L2zAOxa?33@ zaY_qxtaQ!^Hwp0$t_Ti{@b@=MOmy}zO-%Ih5A^d%_V=g^Pxj6CPPNR+GxqiLH3-tR z$Sf`OF^TfD%$FE#W^cPgt2iVSp1 zGEZ`J_fId)b+K^s3Qx&2Ov`c0w@9hDoU&J%?PT#@kQPiF7b(J z#l;l{AqG*|d12nV5k|=+mHCmyc_nUn$zJI}Rk_(7Wr2C_<^_qt!Jcl0xz2fR9>t!i zC82>9hRJ3H`9*;x1=;?(`C*=Z2F8(D5hZ@5nFayg?!~3aNdd*)Vg7kp5dmhYerEZ` zc}|I*Vcv<^Mv=M&=9Wb{Auib|zRvF6;l{c~J_bSl$)+I%Atgc1<|h6*X6_YX>6PJ0 zVdnmURRNZjA;wAZhGn^FNjWK&sX_5hrA7vhks;=#N1x1;8B}!IG zW_eLjd69`;$tK=~p;7UbWsbVJrO9q7k$(B5WjO(Po*{XDz7fS9Il2Z)I!eW<$)!c9 zx=H!@B`|YK3Ubk{0tpqQ78Pd}mmusf%MH!TEG$j+1Z8w%7fT~^XH#cg12c0YT@zO` yLtRG;LjzrB7gI|^S3@@wBU2NlS{>YbpR~05bUi literal 0 HcmV?d00001 diff --git a/tests/fixtures/spp_attest/nonce.hex b/tests/fixtures/spp_attest/nonce.hex new file mode 100644 index 000000000..f83c1627d --- /dev/null +++ b/tests/fixtures/spp_attest/nonce.hex @@ -0,0 +1 @@ +a892c0c66c8a5abf562b00223099061ae2c789673515554ccee5ea276a377ef1 diff --git a/tests/fixtures/spp_attest/quote.msg b/tests/fixtures/spp_attest/quote.msg new file mode 100644 index 0000000000000000000000000000000000000000..ca1c0e23a1cabca647a797a31bd81fde3f4582c5 GIT binary patch literal 145 zcmew#;_Tia!Jx#zJ(XdKkgQ+Wjkm8(i$*^F{Eek*X_-^WYWaYo_ zYyRmZmM_{)$cYLJwyr(@LeDV2l6SEeP1^US_p40lL$CSgXBC+&w3qCw%p`2W3D)}W zGv20UZ7=HnrQ=-DbNoxn@_nB6kL0^gmj1e~wwAC7A4Hy>{-s&>ynSWa>Z8&Juhl6( z-1%e~qsBsk+zV^p^F$LifshJ9@(K*=%l`0R)QmCJQ0M=2&E(S?AH$_@IHKpRUM3L{ p_~pa7*@R6Xq=JyV0wDzi006OEb^ZVV literal 0 HcmV?d00001 diff --git a/tests/fixtures/spp_attest/quote.sig b/tests/fixtures/spp_attest/quote.sig new file mode 100644 index 0000000000000000000000000000000000000000..84b936cb270e4c61ce8a96be8fb45992752ef78f GIT binary patch literal 262 zcmZP&Vc=$D*eUxa_q$Y&#mvf{;ywHGlsAa1UH88=THda!M&o>e)af54>w+6+C+uU9 zRr&YT_~W#!_MFY-Pu?}Gm3?E&+ahgMa>HK#| zov$J;niLf8m-OCnT0*DtX~-_^{l_yjo%zkqzc3N*Ud-;dW5<=-U(Pn&U)rn^enh%K z=Dq$0C$m*WyRK&MI^S+&$G3G@P{7(SD!ckHX6snkZ3ZC1xH+ot5x319tCs&lu+Qj=x(E=*rJej*iZmQ*?Ag^4Cq9ni!^>+SjK!cixMSQS-|Ut+py}hna6^&d315Fg`of zDi{sr4myf?z4Lp1$y?!EdV;4a_VGl>pLe~!U987>G2b0WUfu`6?>f$f3L3|8d=PsS zl;k-r8v1fQ$e9RISc<$z@to?0MtPTW#Co Sup~|zr#ZOH9~Gxf2mk;k)wmx3 literal 0 HcmV?d00001 diff --git a/tests/fixtures/spp_attest/runtime_data.json b/tests/fixtures/spp_attest/runtime_data.json new file mode 100644 index 000000000..180861765 --- /dev/null +++ b/tests/fixtures/spp_attest/runtime_data.json @@ -0,0 +1 @@ +{"keys":[{"kid":"HCLAkPub","key_ops":["sign"],"kty":"RSA","e":"AQAB","n":"wov2tAABuE63f8HLArVh-qczQZF4S1cry4j8MqbYi3pe79w7ZoBEseLjfcUl4mReuMHTEzBJzXCbQcGzvqKgs_GrIZM7UliCuVW2LUMFoiGTqksIwtOh1CHTvSVUk8HkyeF4EY7_Yb864xUBhbFmw4BfQAyClW4TKSxSQXOO6aaCH5aaLOQNLcOHyWcMoKe9jn3MIM0R-8iuuL4ZI9hNyGGcULgrtxodm7GG28ZoqNHduZ3AmGu1JgBvzRYQFb7bAGOgumD8FJWdi1flFo8dyMpqI-ScrHy0L2NQDrL1tLWwb25d4BDQQcZJ5VD45BrfzMhRxnzsMNP7ezpLUVzRsQ"},{"kid":"HCLEkPub","key_ops":["encrypt"],"kty":"RSA","e":"AQAB","n":"ssLFMAAA47b_FHHQH-PRZiM_fUftb9s83-fsj9Lcc_anhsLsg1_PbxDSNl8kCRVXuhJMWvFZtijWIno-L80oNLk2sHPFRI3_o0Aq5-9CJvlb0wFamRu2_M0PxBALJZOK8j8zBiRqC6WxTB1wk_rK8D_afssx0T0ZknVK-X2ctyoYsntFncJgRzmkHvQnG7paSSIF1mCnFHsIetUQ81c6porQtpkO-oVIN03YjXtNui0PKGsucbPsKVOnjXP6eN6o3nBaIVKak2Y-p79rlTDkdMCGKW3-2L0ROc5TpTtRC74Ol6GxVgyWbV7OQzP9yT3b_1vmfbld9eR_Bu20AYT7uw"}],"vm-configuration":{"console-enabled":true,"root-cert-thumbprint":"6nZZnYaJc4KqUZ_yvA-mucFdYNouvlPnITnNMXsHl-0","secure-boot":true,"tpm-enabled":true,"tpm-persisted":true,"vmUniqueId":"3D927C5C-0672-4E61-A810-CD591E1F4254"},"user-data":"00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000"} \ No newline at end of file diff --git a/tests/services/test_spp_attest_binding.py b/tests/services/test_spp_attest_binding.py new file mode 100644 index 000000000..8c4b86002 --- /dev/null +++ b/tests/services/test_spp_attest_binding.py @@ -0,0 +1,89 @@ +# SPDX-License-Identifier: AGPL-3.0-only +# Copyright (c) 2026 sol pbc + +from __future__ import annotations + +import hashlib +from pathlib import Path + +import pytest + +from solstone.think.services.spp_attest import VerificationError +from solstone.think.services.spp_attest.binding import ( + check_envelope_nonce, + composite_binding_hash, +) +from solstone.think.services.spp_attest.tlv import decode_gpu_envelope + +FIXTURE_DIR = Path(__file__).resolve().parents[1] / "fixtures" / "spp_attest" +BINDING_HEX = "268901922d7b8444139f3d3e3edfcc3dd860491e313b243d94fb97ba5b312ea2" +GPU_ENVELOPE_SHA256 = "1475f7e95bb35ca86eca946a581964428dfc39c9f1fb5ca5e61ab018fde376d3" + + +def _nonce() -> bytes: + return bytes.fromhex("".join((FIXTURE_DIR / "nonce.hex").read_text().split())) + + +def _tlv_bytes() -> bytes: + return (FIXTURE_DIR / "gpu-envelope.tlv").read_bytes() + + +def _field_spans(data: bytes) -> dict[int, tuple[int, int, int]]: + count = int.from_bytes(data[8:10], "big") + offset = 10 + spans: dict[int, tuple[int, int, int]] = {} + for _index in range(count): + header_start = offset + field_id = int.from_bytes(data[offset : offset + 2], "big") + length = int.from_bytes(data[offset + 2 : offset + 6], "big") + value_start = offset + 6 + value_end = value_start + length + spans[field_id] = (header_start, value_start, value_end) + offset = value_end + return spans + + +def test_composite_binding_hash_matches_quote_extra_data() -> None: + tlv = _tlv_bytes() + binding = composite_binding_hash( + nonce=_nonce(), + channel_binding=(FIXTURE_DIR / "guest_x25519.pub.der").read_bytes(), + envelope_tlv=tlv, + ) + + assert binding.hex() == BINDING_HEX + assert hashlib.sha256(tlv).hexdigest() == GPU_ENVELOPE_SHA256 + + +def test_check_envelope_nonce_accepts_owner_and_spdm_nonce() -> None: + envelope = decode_gpu_envelope(_tlv_bytes()) + + check_envelope_nonce(envelope, _nonce()) + + +def test_check_envelope_nonce_rejects_foreign_field_one_nonce() -> None: + data = bytearray(_tlv_bytes()) + _header_start, value_start, _value_end = _field_spans(data)[1] + data[value_start] ^= 0x01 + envelope = decode_gpu_envelope(bytes(data)) + + with pytest.raises(VerificationError, match="field-1 nonce"): + check_envelope_nonce(envelope, _nonce()) + + +def test_check_envelope_nonce_rejects_spdm_nonce_splice() -> None: + data = bytearray(_tlv_bytes()) + _header_start, value_start, _value_end = _field_spans(data)[2] + data[value_start + 4] ^= 0x01 + envelope = decode_gpu_envelope(bytes(data)) + + with pytest.raises(VerificationError, match="SPDM report nonce"): + check_envelope_nonce(envelope, _nonce()) + + +def test_check_envelope_nonce_rejects_wrong_owner_nonce() -> None: + envelope = decode_gpu_envelope(_tlv_bytes()) + wrong_nonce = b"\x00" * 32 + + with pytest.raises(VerificationError, match="owner nonce|field-1 nonce"): + check_envelope_nonce(envelope, wrong_nonce) diff --git a/tests/services/test_spp_attest_purity.py b/tests/services/test_spp_attest_purity.py new file mode 100644 index 000000000..98a23f379 --- /dev/null +++ b/tests/services/test_spp_attest_purity.py @@ -0,0 +1,125 @@ +# SPDX-License-Identifier: AGPL-3.0-only +# Copyright (c) 2026 sol pbc + +from __future__ import annotations + +import ast +from pathlib import Path + +PACKAGE_DIR = ( + Path(__file__).resolve().parents[2] + / "solstone" + / "think" + / "services" + / "spp_attest" +) +BANNED_IMPORT_ROOTS = {"subprocess", "socket", "urllib", "requests"} +BANNED_WRITE_ATTRS = { + "write_text", + "write_bytes", + "mkdir", + "unlink", + "rename", + "replace", + "rmtree", + "atomic_write", + "atomic_replace", +} +BANNED_WRITE_NAMES = {"atomic_write", "atomic_replace"} +WRITE_MODE_CHARS = frozenset({"w", "a", "x", "+"}) + + +def test_spp_attest_package_stays_pure_python_read_only() -> None: + files = sorted(PACKAGE_DIR.rglob("*.py")) + assert files, f"no Python files found under {PACKAGE_DIR}" + + findings: list[str] = [] + for path in files: + tree = ast.parse(path.read_text(encoding="utf-8"), filename=str(path)) + for node in ast.walk(tree): + findings.extend(_scan_node(path, node)) + + assert findings == [] + + +def _scan_node(path: Path, node: ast.AST) -> list[str]: + if isinstance(node, ast.Import): + return _scan_import(path, node) + if isinstance(node, ast.ImportFrom): + return _scan_import_from(path, node) + if isinstance(node, ast.Call): + return _scan_call(path, node) + return [] + + +def _scan_import(path: Path, node: ast.Import) -> list[str]: + findings: list[str] = [] + for alias in node.names: + root = alias.name.split(".", maxsplit=1)[0] + if root in BANNED_IMPORT_ROOTS: + findings.append(f"{path}:{node.lineno}: banned import {alias.name}") + return findings + + +def _scan_import_from(path: Path, node: ast.ImportFrom) -> list[str]: + findings: list[str] = [] + module = node.module or "" + root = module.split(".", maxsplit=1)[0] + if root in BANNED_IMPORT_ROOTS: + findings.append(f"{path}:{node.lineno}: banned import from {module}") + if module == "shutil": + for alias in node.names: + if alias.name == "which": + findings.append(f"{path}:{node.lineno}: banned import shutil.which") + for alias in node.names: + if alias.name in BANNED_WRITE_NAMES: + findings.append(f"{path}:{node.lineno}: banned write helper {alias.name}") + return findings + + +def _scan_call(path: Path, node: ast.Call) -> list[str]: + func = node.func + if isinstance(func, ast.Attribute): + if _is_shutil_which(func): + return [f"{path}:{node.lineno}: banned shutil.which call"] + if _is_json_dump(func): + return [f"{path}:{node.lineno}: banned json.dump call"] + if func.attr in BANNED_WRITE_ATTRS: + return [f"{path}:{node.lineno}: banned write API {func.attr}"] + if isinstance(func, ast.Name): + if func.id in BANNED_WRITE_NAMES: + return [f"{path}:{node.lineno}: banned write helper {func.id}"] + if func.id == "open" and _open_uses_write_mode(node): + return [f"{path}:{node.lineno}: banned write-mode open call"] + return [] + + +def _is_shutil_which(func: ast.Attribute) -> bool: + return ( + func.attr == "which" + and isinstance(func.value, ast.Name) + and func.value.id == "shutil" + ) + + +def _is_json_dump(func: ast.Attribute) -> bool: + return ( + func.attr == "dump" + and isinstance(func.value, ast.Name) + and func.value.id == "json" + ) + + +def _open_uses_write_mode(node: ast.Call) -> bool: + mode_node = None + if len(node.args) >= 2: + mode_node = node.args[1] + for keyword in node.keywords: + if keyword.arg == "mode": + mode_node = keyword.value + break + if mode_node is None: + return False + if isinstance(mode_node, ast.Constant) and isinstance(mode_node.value, str): + return any(char in mode_node.value for char in WRITE_MODE_CHARS) + return True diff --git a/tests/services/test_spp_attest_snp.py b/tests/services/test_spp_attest_snp.py new file mode 100644 index 000000000..0b414bfbd --- /dev/null +++ b/tests/services/test_spp_attest_snp.py @@ -0,0 +1,209 @@ +# SPDX-License-Identifier: AGPL-3.0-only +# Copyright (c) 2026 sol pbc + +from __future__ import annotations + +import json +import shutil +from pathlib import Path + +import pytest +from cryptography.hazmat.primitives import serialization +from cryptography.hazmat.primitives.asymmetric import rsa + +from solstone.think.services.spp_attest import ( + Policy, + VerificationError, + appraise_cpu_leg, +) +from solstone.think.services.spp_attest import snp as snp_module + +FIXTURE_DIR = Path(__file__).resolve().parents[1] / "fixtures" / "spp_attest" +PCR_SHA256_HEX = "b162f46105c80d3e45028e37cc649404c9d65297ad1cda8f953208582060b0e3" +EXPECTED_STEP_NAMES = [ + "hcla", + "runtime-binding", + "amd-chain", + "amd-report-signature", + "snp-policy", + "ak-binding", + "quote", + "pcr-policy", +] +EXPECTED_TCB = { + "boot_loader": 10, + "microcode": 88, + "snp": 27, + "tee": 0, + "fmc": None, +} + + +def _appraise( + bundle_dir: Path = FIXTURE_DIR, + *, + envelope_tlv: bytes | None = None, + channel_binding: bytes | None = None, + roots_dir: Path | None = None, + policy: Policy | None = None, +): + kwargs = { + "envelope_tlv": envelope_tlv + if envelope_tlv is not None + else (FIXTURE_DIR / "gpu-envelope.tlv").read_bytes(), + "channel_binding": channel_binding + if channel_binding is not None + else (FIXTURE_DIR / "guest_x25519.pub.der").read_bytes(), + } + if roots_dir is not None: + kwargs["roots_dir"] = roots_dir + if policy is not None: + kwargs["policy"] = policy + return appraise_cpu_leg(bundle_dir, **kwargs) + + +def _copy_bundle(tmp_path: Path) -> Path: + bundle = tmp_path / "bundle" + shutil.copytree(FIXTURE_DIR, bundle) + return bundle + + +def _expected_steps_from_fixture() -> list[dict[str, str]]: + data = json.loads((FIXTURE_DIR / "cpu-appraisal.json").read_text(encoding="utf-8")) + return [step for step in data["steps"] if step["name"] != "key-release"] + + +def _actual_steps(result) -> list[dict[str, str]]: + return [ + {"name": step.name, "status": step.status, "detail": step.detail} + for step in result.steps + ] + + +def _tlv_field_spans(data: bytes) -> dict[int, tuple[int, int, int]]: + count = int.from_bytes(data[8:10], "big") + offset = 10 + spans: dict[int, tuple[int, int, int]] = {} + for _index in range(count): + header_start = offset + field_id = int.from_bytes(data[offset : offset + 2], "big") + length = int.from_bytes(data[offset + 2 : offset + 6], "big") + value_start = offset + 6 + value_end = value_start + length + spans[field_id] = (header_start, value_start, value_end) + offset = value_end + return spans + + +def _mutate_tlv_field_one_nonce(tlv: bytes) -> bytes: + data = bytearray(tlv) + _header_start, value_start, _value_end = _tlv_field_spans(data)[1] + data[value_start] ^= 0x01 + return bytes(data) + + +def test_appraise_cpu_leg_positive_matches_captured_cpu_appraisal() -> None: + result = _appraise() + + assert len(result.steps) == 8 + assert [step.name for step in result.steps] == EXPECTED_STEP_NAMES + assert _actual_steps(result) == _expected_steps_from_fixture() + assert result.report_version == 5 + assert result.hcla_version == 2 + assert result.cpuid == {"family": 25, "model": 17, "step": 1} + assert result.tcb == { + "current": EXPECTED_TCB, + "reported": EXPECTED_TCB, + "committed": EXPECTED_TCB, + "launch": EXPECTED_TCB, + } + assert result.pcr_sha256 == PCR_SHA256_HEX + + +def test_appraise_cpu_leg_records_standalone_report_difference( + tmp_path: Path, +) -> None: + bundle = _copy_bundle(tmp_path) + (bundle / "report.bin").write_bytes(b"not the HCLA-embedded report") + + result = _appraise(bundle) + + assert len(result.steps) == 9 + assert [step.name for step in result.steps][:2] == ["hcla", "standalone-report"] + assert result.report_version == 5 + assert result.cpuid == {"family": 25, "model": 17, "step": 1} + + +def test_appraise_cpu_leg_rejects_tampered_snp_report_signature( + tmp_path: Path, +) -> None: + bundle = _copy_bundle(tmp_path) + hcl_path = bundle / "hcl_report.bin" + hcl = bytearray(hcl_path.read_bytes()) + signed_measurement_offset = ( + snp_module.HCL_REPORT_OFFSET + snp_module.SNP_OFF_MEASUREMENT + ) + hcl[signed_measurement_offset] ^= 0x01 + hcl_path.write_bytes(bytes(hcl)) + + with pytest.raises(VerificationError, match="VCEK did not sign"): + _appraise(bundle) + + +def test_appraise_cpu_leg_rejects_foreign_root_set_selection( + tmp_path: Path, +) -> None: + roots_dir = tmp_path / "roots" + shutil.copytree(snp_module.DEFAULT_ROOTS_DIR / "Milan", roots_dir / "Milan") + + with pytest.raises(VerificationError, match="no pinned AMD ASK"): + _appraise(roots_dir=roots_dir) + + +def test_appraise_cpu_leg_rejects_broken_amd_chain( + tmp_path: Path, +) -> None: + roots_dir = tmp_path / "roots" + genoa_dir = roots_dir / "Genoa" + genoa_dir.mkdir(parents=True) + shutil.copy2( + snp_module.DEFAULT_ROOTS_DIR / "Genoa" / "ask.pem", genoa_dir / "ask.pem" + ) + shutil.copy2( + snp_module.DEFAULT_ROOTS_DIR / "Milan" / "ark.pem", genoa_dir / "ark.pem" + ) + + with pytest.raises(VerificationError, match="certificate signature invalid"): + _appraise(roots_dir=roots_dir) + + +def test_appraise_cpu_leg_rejects_foreign_ak_public_key( + tmp_path: Path, +) -> None: + bundle = _copy_bundle(tmp_path) + key = rsa.generate_private_key(public_exponent=65537, key_size=2048) + (bundle / "akpub.pem").write_bytes( + key.public_key().public_bytes( + serialization.Encoding.PEM, + serialization.PublicFormat.SubjectPublicKeyInfo, + ) + ) + + with pytest.raises(VerificationError, match="does not match AMD-bound HCLAkPub"): + _appraise(bundle) + + +def test_appraise_cpu_leg_rejects_non_matching_pcr_pin() -> None: + policy = Policy(pcr_mode="pin", pcr_pins={"00" * 32}) + + with pytest.raises(VerificationError, match="not in pinned policy"): + _appraise(policy=policy) + + +def test_appraise_cpu_leg_rejects_tlv_splice_before_appraisal_steps() -> None: + envelope_tlv = _mutate_tlv_field_one_nonce( + (FIXTURE_DIR / "gpu-envelope.tlv").read_bytes() + ) + + with pytest.raises(VerificationError, match="field-1 nonce"): + _appraise(envelope_tlv=envelope_tlv) diff --git a/tests/services/test_spp_attest_tlv.py b/tests/services/test_spp_attest_tlv.py new file mode 100644 index 000000000..72b21b6e7 --- /dev/null +++ b/tests/services/test_spp_attest_tlv.py @@ -0,0 +1,140 @@ +# SPDX-License-Identifier: AGPL-3.0-only +# Copyright (c) 2026 sol pbc + +from __future__ import annotations + +import hashlib +from pathlib import Path + +import pytest + +from solstone.think.services.spp_attest import VerificationError +from solstone.think.services.spp_attest.tlv import ( + decode_gpu_envelope, + extract_spdm_nonce, +) + +FIXTURE_DIR = Path(__file__).resolve().parents[1] / "fixtures" / "spp_attest" +GPU_ENVELOPE_SHA256 = "1475f7e95bb35ca86eca946a581964428dfc39c9f1fb5ca5e61ab018fde376d3" + + +def _tlv_bytes() -> bytes: + return (FIXTURE_DIR / "gpu-envelope.tlv").read_bytes() + + +def _field_spans(data: bytes) -> dict[int, tuple[int, int, int]]: + count = int.from_bytes(data[8:10], "big") + offset = 10 + spans: dict[int, tuple[int, int, int]] = {} + for _index in range(count): + header_start = offset + field_id = int.from_bytes(data[offset : offset + 2], "big") + length = int.from_bytes(data[offset + 2 : offset + 6], "big") + value_start = offset + 6 + value_end = value_start + length + spans[field_id] = (header_start, value_start, value_end) + offset = value_end + return spans + + +def _replace_field(data: bytes, field_id: int, value: bytes) -> bytes: + spans = _field_spans(data) + header_start, value_start, value_end = spans[field_id] + updated = bytearray(data) + updated[header_start + 2 : header_start + 6] = len(value).to_bytes(4, "big") + return bytes(updated[:value_start] + value + updated[value_end:]) + + +def test_decode_gpu_envelope_extracts_nonce_structurally() -> None: + data = _tlv_bytes() + + envelope = decode_gpu_envelope(data) + + assert [field.field_id for field in envelope.fields] == [1, 2, 3, 4, 5, 6, 7] + assert len(envelope.nonce) == 32 + assert extract_spdm_nonce(envelope.spdm_report) == envelope.nonce + assert hashlib.sha256(data).hexdigest() == GPU_ENVELOPE_SHA256 + + +def test_decode_gpu_envelope_rejects_wrong_magic() -> None: + data = bytearray(_tlv_bytes()) + data[0] ^= 0x01 + + with pytest.raises(VerificationError, match="magic"): + decode_gpu_envelope(bytes(data)) + + +def test_decode_gpu_envelope_rejects_wrong_field_count() -> None: + data = bytearray(_tlv_bytes()) + data[8:10] = (6).to_bytes(2, "big") + + with pytest.raises(VerificationError, match="field_count"): + decode_gpu_envelope(bytes(data)) + + +def test_decode_gpu_envelope_rejects_duplicate_and_missing_field_id() -> None: + data = bytearray(_tlv_bytes()) + header_start, _value_start, _value_end = _field_spans(data)[7] + data[header_start : header_start + 2] = (6).to_bytes(2, "big") + + with pytest.raises(VerificationError, match="duplicate.*missing"): + decode_gpu_envelope(bytes(data)) + + +def test_decode_gpu_envelope_rejects_out_of_order_field_id() -> None: + data = bytearray(_tlv_bytes()) + spans = _field_spans(data) + field3_header, _field3_start, _field3_end = spans[3] + field4_header, _field4_start, _field4_end = spans[4] + data[field3_header : field3_header + 2] = (4).to_bytes(2, "big") + data[field4_header : field4_header + 2] = (3).to_bytes(2, "big") + + with pytest.raises(VerificationError, match="out of order"): + decode_gpu_envelope(bytes(data)) + + +def test_decode_gpu_envelope_rejects_unknown_field_id() -> None: + data = bytearray(_tlv_bytes()) + header_start, _value_start, _value_end = _field_spans(data)[7] + data[header_start : header_start + 2] = (8).to_bytes(2, "big") + + with pytest.raises(VerificationError, match="unknown field"): + decode_gpu_envelope(bytes(data)) + + +def test_decode_gpu_envelope_rejects_trailing_bytes() -> None: + with pytest.raises(VerificationError, match="trailing"): + decode_gpu_envelope(_tlv_bytes() + b"\x00") + + +def test_decode_gpu_envelope_rejects_field_length_overrun() -> None: + data = bytearray(_tlv_bytes()) + header_start, _value_start, value_end = _field_spans(data)[7] + length = value_end - _value_start + data[header_start + 2 : header_start + 6] = (length + 1).to_bytes(4, "big") + + with pytest.raises(VerificationError, match="overruns"): + decode_gpu_envelope(bytes(data)) + + +def test_decode_gpu_envelope_rejects_short_nonce_field() -> None: + data = _replace_field(_tlv_bytes(), 1, b"short") + + with pytest.raises(VerificationError, match="field 1 nonce"): + decode_gpu_envelope(data) + + +def test_extract_spdm_nonce_rejects_bad_header() -> None: + data = bytearray(_tlv_bytes()) + _header_start, value_start, _value_end = _field_spans(data)[2] + data[value_start] ^= 0x01 + + with pytest.raises(VerificationError, match="GET_MEASUREMENTS"): + decode_gpu_envelope(bytes(data)) + + +def test_extract_spdm_nonce_rejects_short_report() -> None: + data = _replace_field(_tlv_bytes(), 2, b"\x11\xe0\x01\xff") + + with pytest.raises(VerificationError, match="too short"): + decode_gpu_envelope(data) diff --git a/tests/services/test_spp_attest_tpm_quote.py b/tests/services/test_spp_attest_tpm_quote.py new file mode 100644 index 000000000..a6ae83c4c --- /dev/null +++ b/tests/services/test_spp_attest_tpm_quote.py @@ -0,0 +1,176 @@ +# SPDX-License-Identifier: AGPL-3.0-only +# Copyright (c) 2026 sol pbc + +from __future__ import annotations + +import hashlib +from pathlib import Path + +import pytest + +from solstone.think.services.spp_attest import VerificationError +from solstone.think.services.spp_attest.binding import composite_binding_hash +from solstone.think.services.spp_attest.tpm_quote import ( + TpmQuoteVerifier, + _parse_pcrs, + _parse_quote_msg, + verify_quote, +) + +FIXTURE_DIR = Path(__file__).resolve().parents[1] / "fixtures" / "spp_attest" +BINDING_HEX = "268901922d7b8444139f3d3e3edfcc3dd860491e313b243d94fb97ba5b312ea2" +PCR_DIGEST_HEX = "114baecc10432c71b9e3358af011650788eb0899bd450c844cc4dafad9d3a777" +PCR_SHA256_HEX = "b162f46105c80d3e45028e37cc649404c9d65297ad1cda8f953208582060b0e3" + + +def _binding() -> bytes: + return composite_binding_hash( + nonce=bytes.fromhex("".join((FIXTURE_DIR / "nonce.hex").read_text().split())), + channel_binding=(FIXTURE_DIR / "guest_x25519.pub.der").read_bytes(), + envelope_tlv=(FIXTURE_DIR / "gpu-envelope.tlv").read_bytes(), + ) + + +def _verify_quote( + *, + quote_msg: bytes | None = None, + quote_sig: bytes | None = None, + quote_pcrs: bytes | None = None, + binding: bytes | None = None, +) -> None: + verify_quote( + ak_pub_pem=(FIXTURE_DIR / "akpub.pem").read_bytes(), + quote_msg=quote_msg + if quote_msg is not None + else (FIXTURE_DIR / "quote.msg").read_bytes(), + quote_sig=quote_sig + if quote_sig is not None + else (FIXTURE_DIR / "quote.sig").read_bytes(), + quote_pcrs=quote_pcrs + if quote_pcrs is not None + else (FIXTURE_DIR / "quote.pcrs").read_bytes(), + expected_binding=binding if binding is not None else _binding(), + ) + + +def _first_digest_size_offset(pcrs: bytes) -> int: + selection_count_offset = 0 + selection_count_size = 4 + selection_slot_count = 8 + selection_slot_size = 16 + offset = selection_count_offset + selection_count_size + offset += selection_slot_count * selection_slot_size + digest_list_count_size = 4 + offset += digest_list_count_size + digest_count_size = 4 + return offset + digest_count_size + + +def _mutate_first_digest_buffer(pcrs: bytes) -> bytes: + data = bytearray(pcrs) + size_offset = _first_digest_size_offset(pcrs) + buffer_start = size_offset + 2 + data[buffer_start] ^= 0x01 + return bytes(data) + + +def _mutate_first_selection_byte(pcrs: bytes) -> bytes: + data = bytearray(pcrs) + selection_count_size = 4 + hash_alg_size = 2 + sizeof_select_size = 1 + pcr_select_start = selection_count_size + hash_alg_size + sizeof_select_size + data[pcr_select_start] ^= 0x01 + return bytes(data) + + +def test_verify_quote_accepts_fixture_bytes_and_vectors() -> None: + quote_msg = (FIXTURE_DIR / "quote.msg").read_bytes() + quote_pcrs = (FIXTURE_DIR / "quote.pcrs").read_bytes() + + _verify_quote() + + quote = _parse_quote_msg(quote_msg, _binding()) + selections, digest_buffers = _parse_pcrs(quote_pcrs) + assert quote.extra_data.hex() == BINDING_HEX + assert quote.pcr_digest.hex() == PCR_DIGEST_HEX + assert hashlib.sha256(b"".join(digest_buffers)).hexdigest() == PCR_DIGEST_HEX + assert hashlib.sha256(quote_pcrs).hexdigest() == PCR_SHA256_HEX + assert selections[0].selected_pcrs() == (0, 2, 4, 7, 8, 9, 15, 16, 22, 23) + + +def test_tpm_quote_verifier_facade_accepts_fixture_paths() -> None: + TpmQuoteVerifier().verify( + FIXTURE_DIR / "akpub.pem", + FIXTURE_DIR / "quote.msg", + FIXTURE_DIR / "quote.sig", + FIXTURE_DIR / "quote.pcrs", + BINDING_HEX, + ) + + +def test_verify_quote_rejects_flipped_signature_byte() -> None: + quote_sig = bytearray((FIXTURE_DIR / "quote.sig").read_bytes()) + quote_sig[-1] ^= 0x01 + + with pytest.raises(VerificationError, match="signature invalid"): + _verify_quote(quote_sig=bytes(quote_sig)) + + +def test_verify_quote_rejects_wrong_extra_data_binding() -> None: + binding = bytearray(_binding()) + binding[0] ^= 0x01 + + with pytest.raises(VerificationError, match="extraData mismatch"): + _verify_quote(binding=bytes(binding)) + + +def test_verify_quote_rejects_mutated_pcr_value() -> None: + quote_pcrs = _mutate_first_digest_buffer((FIXTURE_DIR / "quote.pcrs").read_bytes()) + + with pytest.raises(VerificationError, match="PCR digest mismatch"): + _verify_quote(quote_pcrs=quote_pcrs) + + +def test_verify_quote_rejects_pcr_selection_mismatch() -> None: + quote_pcrs = _mutate_first_selection_byte((FIXTURE_DIR / "quote.pcrs").read_bytes()) + + with pytest.raises(VerificationError, match="selection"): + _verify_quote(quote_pcrs=quote_pcrs) + + +def test_verify_quote_rejects_unsupported_signature_algorithm() -> None: + quote_sig = bytearray((FIXTURE_DIR / "quote.sig").read_bytes()) + quote_sig[0:2] = (0x0015).to_bytes(2, "big") + + with pytest.raises(VerificationError, match="signature alg"): + _verify_quote(quote_sig=bytes(quote_sig)) + + +def test_verify_quote_rejects_unsupported_signature_hash_algorithm() -> None: + quote_sig = bytearray((FIXTURE_DIR / "quote.sig").read_bytes()) + quote_sig[2:4] = (0x0004).to_bytes(2, "big") + + with pytest.raises(VerificationError, match="hashAlg"): + _verify_quote(quote_sig=bytes(quote_sig)) + + +def test_verify_quote_rejects_trailing_quote_msg_bytes() -> None: + quote_msg = (FIXTURE_DIR / "quote.msg").read_bytes() + b"\x00" + + with pytest.raises(VerificationError, match="trailing"): + _verify_quote(quote_msg=quote_msg) + + +def test_verify_quote_rejects_digest_size_over_64() -> None: + quote_pcrs = bytearray((FIXTURE_DIR / "quote.pcrs").read_bytes()) + size_offset = _first_digest_size_offset(bytes(quote_pcrs)) + quote_pcrs[size_offset : size_offset + 2] = (65).to_bytes(2, "little") + + with pytest.raises(VerificationError, match="exceeds 64"): + _verify_quote(quote_pcrs=bytes(quote_pcrs)) + + +def test_verify_quote_rejects_trailing_pcrs_bytes() -> None: + with pytest.raises(VerificationError, match="trailing"): + _verify_quote(quote_pcrs=(FIXTURE_DIR / "quote.pcrs").read_bytes() + b"\x00")