diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 00000000..13069368 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,5 @@ +# Reference-vector checksums cover exact producer bytes on every CI platform. +tests/fixtures/reference/swtpm-nv-certify-0.7.3/** -text +tests/fixtures/reference/swtpm-nv-certify-0.7.3/*.bin binary +# tpm2_nvreadpublic emits this terminal blank line; retain the checksummed output. +tests/fixtures/reference/swtpm-nv-certify-0.7.3/nv-public.yaml whitespace=-blank-at-eof diff --git a/CHANGELOG.md b/CHANGELOG.md index 8df30674..3575916b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,17 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed +- TPM NV-certify wire parsing now delegates to Agent Manifest 0.11.2 instead of + maintaining a second `TPMS_ATTEST`/`TPMS_NV_CERTIFY_INFO` parser in cMCP. + The public `parse_nv_certify` return shape and `ValueError` contract remain + intact. The shared parser also makes cMCP accept valid size-prefixed + `TPM2B_ATTEST` transport framing, reject undeclared bytes after `nvContents`, + and verify AK signatures over the canonical inner `TPMS_ATTEST` rather than + over the transport length prefix. A genuine swtpm-produced two-certify + reference pair exercises the complete chain, + signature, phase, index, and extend-relation path. The synthetic fixture CA is + test trust only and makes no hardware-provenance or vendor-enrollment claim. + - Removed AGT/`agent_os` from the production dependency graph. cMCP now owns the runtime call and response enforcement path; AGT remains isolated to CI and release governance verification. Legacy `agent_os_version` bundle metadata is diff --git a/docs/spec/tpm-security-model.md b/docs/spec/tpm-security-model.md index 1b508b9c..19d9077d 100644 --- a/docs/spec/tpm-security-model.md +++ b/docs/spec/tpm-security-model.md @@ -98,7 +98,14 @@ Because extends accumulate, one certified value cannot be checked against anythi **P2 is now implemented.** Collection is `cmcp_runtime.tee.measurement.certify_and_extend_gateway_measurement`, appraisal is `cmcp_verify.nv_certify.verify_gateway_measurement`, and startup wires both at step 3b. Only the platform attestation key is used to certify: a transient key would give a verifiable signature with no provenance, which is worse than an honest absence because it looks like evidence, so a platform without a certified key falls back to the unsigned extend and ships no evidence at all. -`agent_manifest.verify_tpm_quote` cannot appraise this, because it rejects any attest type that is not `TPM_ST_ATTEST_QUOTE` and its parser assumes a `TPML_PCR_SELECTION` union. The certificate chain is still delegated to `agent_manifest.verify_cert_chain`; only the `TPM_ST_ATTEST_NV` and `TPMT_SIGNATURE` wire formats are local, tracked upstream as agentrust-io/agent-manifest#255. +Agent Manifest 0.11.2 is the wire-format authority for this path: +`parse_tpm_nv_certify` parses and type-checks the signed common header plus +`TPMS_NV_CERTIFY_INFO`, `parse_tpmt_signature` parses the signature envelope, and +`verify_cert_chain` validates the supplied AK chain against verifier-pinned roots. +cMCP verifies the typed signature with that AK and owns the gateway-specific +two-certify phase binding, same-index requirement, expected digest, and extend +relation. `agent_manifest.verify_tpm_quote` remains quote-specific and is not used +to appraise an NV certification. **What P2 still does not give you.** The appraisal proves the gateway extended a specific digest into a specific index. It does not prove that digest corresponds to *known-good* code unless the relying party supplies an expected value; without one, `verify_gateway_measurement` reports the pair as internally consistent and says so in its details rather than implying more. Deciding what the expected digest should be for a given release is a release-engineering question, not a TPM one. diff --git a/docs/testing/hardware-validation.md b/docs/testing/hardware-validation.md index 0290c1d8..36ddb713 100644 --- a/docs/testing/hardware-validation.md +++ b/docs/testing/hardware-validation.md @@ -340,10 +340,14 @@ With both fixed, the following hold on hardware: `TPMT_SIGNATURE`. A NULL scheme resolves to the key's own, RSASSA/SHA-256 here. - **The platform AK at `0x81000003` can sign an NV certify.** This was an open question, since it is a restricted signing key; the answer is yes. -- **`parse_nv_certify`'s field offsets are correct against a real blob.** This was - the highest-risk item, as the offsets came from the TCG structures spec and had - never met real bytes. `indexName`, `offset` and `nvContents` all parse, and - `nvContents` equals the value returned by `TPM2_NV_Read`. +- **The signed NV field layout was checked against a real blob.** This was the + highest-risk item, as the offsets came from the TCG structures spec and had + never met real bytes. `indexName`, `offset` and `nvContents` all parsed, and + `nvContents` equalled the value returned by `TPM2_NV_Read`. That run exercised + cMCP's then-local parser. cMCP's public `parse_nv_certify` adapter now delegates + wire parsing to `agent_manifest.parse_tpm_nv_certify`; the committed swtpm + reference pair independently exercises that current boundary without adding a + hardware-provenance claim. - The extend relation holds on hardware across two consecutive starts: run 1 provisioned the index and run 2 reused it, with run 2's `pre` equal to run 1's `post`, which is the accumulation the two-certify design exists to handle. diff --git a/src/cmcp_runtime/tee/measurement.py b/src/cmcp_runtime/tee/measurement.py index 97109ebb..b5f2c28d 100644 --- a/src/cmcp_runtime/tee/measurement.py +++ b/src/cmcp_runtime/tee/measurement.py @@ -44,8 +44,11 @@ The certify path was validated in the same session, after that run found two defects in it: the ``nv_certify`` call omitted the required ``in_scheme`` and ``size`` arguments, and a freshly provisioned index cannot be certified at all. Both -are fixed; the platform AK does sign an NV certify, and the parser's field offsets -are correct against a real blob. See docs/testing/hardware-validation.md. +are fixed; the platform AK does sign an NV certify, and its signed field layout +matched the value read from the hardware index. That run exercised cMCP's +then-local parser. The current adapter delegates to Agent Manifest, with the +committed swtpm reference pair independently exercising that parser boundary but +making no new hardware-provenance claim. See docs/testing/hardware-validation.md. ## Predictability is deliberately left to the verifier diff --git a/src/cmcp_verify/nv_certify.py b/src/cmcp_verify/nv_certify.py index 6a1ed8c0..97a97fd8 100644 --- a/src/cmcp_verify/nv_certify.py +++ b/src/cmcp_verify/nv_certify.py @@ -28,16 +28,19 @@ would already fail the chain check, but making the role explicit means a verifier never has to infer it from ordering. -## Why this is not delegated to agent-manifest - -``agent_manifest.verify_tpm_quote`` rejects any attest type that is not -``TPM_ST_ATTEST_QUOTE``, and ``parse_tpm_quote`` assumes the attested union is a -``TPML_PCR_SELECTION`` followed by a PCR digest. An NV certify carries a -``TPMS_NV_CERTIFY_INFO`` instead, so neither can read it. The certificate chain -*is* delegated (``agent_manifest.verify_cert_chain``), matching how -:mod:`cmcp_verify.sev_snp` and :mod:`cmcp_verify.tdx` already work. Teaching -agent-manifest ``TPM_ST_ATTEST_NV`` is tracked in agentrust-io/agent-manifest#255, -alongside the same gap for ``TPMT_SIGNATURE``. +## Boundary with agent-manifest + +Agent Manifest owns the TPM wire formats: ``parse_tpm_nv_certify`` parses the +signed common header and the ``TPMS_NV_CERTIFY_INFO`` union, accepts bare +``TPMS_ATTEST`` and size-prefixed ``TPM2B_ATTEST`` transport framing, and requires +the union payload to consume the complete inner structure. Its returned +``attest.raw`` is the exact inner byte range signed by the attestation key. + +cMCP keeps the gateway-specific policy: the two-certify phase bindings, same-index +check, expected gateway digest, and extend relation. It also preserves the public +``parse_nv_certify`` compatibility adapter and performs signature verification +against the certified AK. Certificate-chain and TPM signature-envelope parsing are +delegated to Agent Manifest as well. """ from __future__ import annotations @@ -50,13 +53,17 @@ from dataclasses import dataclass, field from typing import Any -from agent_manifest import parse_tpmt_signature +from agent_manifest import TPM_ST_ATTEST_NV as _TPM_ST_ATTEST_NV +from agent_manifest import ( + TpmNvCertify, + TpmVerificationError, + parse_tpm_nv_certify, + parse_tpmt_signature, +) -# TPMS_ATTEST header constants. -_TPM_GENERATED_VALUE = 0xFF544347 -TPM_ST_ATTEST_NV = 0x8014 -_CLOCK_INFO_LEN = 17 -_FIRMWARE_VERSION_LEN = 8 +# Preserve the module-level constant imported by existing cMCP callers/tests while +# taking its value from the canonical wire-format library. +TPM_ST_ATTEST_NV = _TPM_ST_ATTEST_NV # TPM2_ALG_ID digests a signature may use. _ALG_SHA1 = 0x0004 @@ -110,50 +117,32 @@ def certify_qualifying_data(nonce: bytes, phase: bytes) -> bytes: return hashlib.sha256(b"".join(parts)).digest() -def _read_u16(buf: bytes, pos: int) -> tuple[int, int]: - if pos + 2 > len(buf): - raise ValueError("TPMS_ATTEST truncated reading a 16-bit field") - return int.from_bytes(buf[pos : pos + 2], "big"), pos + 2 +def _parse_nv_certify(attest: bytes) -> TpmNvCertify: + """Use Agent Manifest's parser while preserving cMCP's ``ValueError`` API.""" + try: + return parse_tpm_nv_certify(attest) + except TpmVerificationError as exc: + raise ValueError(str(exc)) from exc -def _read_2b(buf: bytes, pos: int) -> tuple[bytes, int]: - size, pos = _read_u16(buf, pos) - if pos + size > len(buf): - raise ValueError("TPMS_ATTEST truncated reading a sized buffer") - return buf[pos : pos + size], pos + size +def _as_nv_certify_info(parsed: TpmNvCertify) -> NvCertifyInfo: + return NvCertifyInfo( + qualifying_data=parsed.attest.qualifying_data, + index_name=parsed.info.index_name, + offset=parsed.info.offset, + nv_contents=parsed.info.nv_contents, + ) def parse_nv_certify(attest: bytes) -> NvCertifyInfo: - """Parse a ``TPMS_ATTEST`` whose attested union is ``TPMS_NV_CERTIFY_INFO``. + """Parse a bare or size-prefixed TPM NV certification. - Layout after the common header is ``indexName`` (TPM2B_NAME), ``offset`` - (UINT16), then ``nvContents`` (TPM2B_MAX_NV_BUFFER). Raises ``ValueError`` on - anything malformed, including a structure that is not an NV certify, so a quote - cannot be passed here by mistake and silently appraised. + Wire parsing is delegated to :func:`agent_manifest.parse_tpm_nv_certify`. + ``ValueError`` and the cMCP ``NvCertifyInfo`` return shape are retained for + compatibility. A quote, truncated structure, or structure with undeclared + trailing data is rejected rather than partially appraised. """ - if len(attest) < 6: - raise ValueError("TPMS_ATTEST too short") - magic = int.from_bytes(attest[0:4], "big") - if magic != _TPM_GENERATED_VALUE: - raise ValueError(f"TPMS_ATTEST magic is not TPM_GENERATED (magic={magic:#x})") - attest_type, pos = _read_u16(attest, 4) - if attest_type != TPM_ST_ATTEST_NV: - raise ValueError( - f"attestation is not an NV certify (type={attest_type:#x}, " - f"expected {TPM_ST_ATTEST_NV:#x})" - ) - _qualified_signer, pos = _read_2b(attest, pos) # TPM2B_NAME - qualifying_data, pos = _read_2b(attest, pos) # extraData - pos += _CLOCK_INFO_LEN + _FIRMWARE_VERSION_LEN - index_name, pos = _read_2b(attest, pos) # TPM2B_NAME of the NV index - offset, pos = _read_u16(attest, pos) - nv_contents, pos = _read_2b(attest, pos) # TPM2B_MAX_NV_BUFFER - return NvCertifyInfo( - qualifying_data=qualifying_data, - index_name=index_name, - offset=offset, - nv_contents=nv_contents, - ) + return _as_nv_certify_info(_parse_nv_certify(attest)) def build_envelope( @@ -259,10 +248,12 @@ def fail(reason: str, **extra: str) -> NvCertifyResult: # 2. Structure and attest type. try: - pre = parse_nv_certify(pre_attest) - post = parse_nv_certify(post_attest) + pre_parsed = _parse_nv_certify(pre_attest) + post_parsed = _parse_nv_certify(post_attest) except ValueError as exc: return fail("malformed_nv_certify", error=str(exc)) + pre = _as_nv_certify_info(pre_parsed) + post = _as_nv_certify_info(post_parsed) verified.append("structure") # 3. AK certificate chain to a pinned root, delegated to agent-manifest. @@ -284,13 +275,13 @@ def fail(reason: str, **extra: str) -> NvCertifyResult: # 4. Both signatures, against the certified AK. try: - for label, blob, sig in ( - ("pre", pre_attest, pre_sig), - ("post", post_attest, post_sig), + for label, attestation, sig in ( + ("pre", pre_parsed, pre_sig), + ("post", post_parsed, post_sig), ): - parsed = parse_tpmt_signature(sig) + parsed_signature = parse_tpmt_signature(sig) try: - _verify_signature(ak_public, parsed, blob) + _verify_signature(ak_public, parsed_signature, attestation.attest.raw) except Exception as exc: # noqa: BLE001 return fail("signature_invalid", which=label, error=str(exc)) except Exception as exc: # noqa: BLE001 diff --git a/tests/fixtures/reference/swtpm-nv-certify-0.7.3/README.md b/tests/fixtures/reference/swtpm-nv-certify-0.7.3/README.md new file mode 100644 index 00000000..68eff3bb --- /dev/null +++ b/tests/fixtures/reference/swtpm-nv-certify-0.7.3/README.md @@ -0,0 +1,85 @@ +# swtpm NV-certify reference pair + +These files are an independent-producer reference for cMCP's gateway-measurement +appraisal path. They were emitted by `swtpm` 0.7.3 and `tpm2-tools` 5.6 in an +Ubuntu 24.04 container, using a restricted RSA-2048 attestation key configured for +RSASSA/SHA-256. Neither cMCP nor Agent Manifest produced the attestation or +signature bytes. + +The pair certifies a 32-byte `TPM_NT_EXTEND` index immediately before and after +extending the gateway digest: + +```text +post-contents = SHA256(pre-contents || gateway-digest) +``` + +Both attestations are bare 173-byte `TPMS_ATTEST` structures with type +`TPM_ST_ATTEST_NV` (`0x8014`). Both signatures are 262-byte marshalled +`TPMT_SIGNATURE` values: RSASSA (`0x0014`), SHA-256 (`0x000b`), and a 256-byte +signature. `nv-public.yaml` is the output of `tpm2_nvreadpublic 0x01500018`; its +Name is checked against the Name signed into both attestations. + +## Signed bindings + +- Verifier nonce: 32 bytes of `a5`. +- Pre qualifying data: + `ac2bc955b7d97a5589f9a6c48db86f87aea9d9af22e43cc97330f7b560145fa3`. +- Post qualifying data: + `230587d8162f6a14fb8573d2b3fddec4b98413ffad0f83e804aff3740d8bfa8c`. +- NV handle used by the producer: `0x01500018`. +- Certified offset and size: `0` and `32` bytes. +- Index Name: + `000bcf69802ad7625fffd515aecef934a1632ca6c7df36bf5a4e241d33701465a854`. +- Gateway digest: + `4ea5ee68fea05586106890ded5733820bb77d919cda27bc4b8139b7cd33b8889`. + +The qualifying data are cMCP's length-prefixed, phase-separated hashes for +`pre` and `post`. The first extend used `seed-event.bin` because a newly defined +extend index is uninitialized and cannot yet be certified. + +## Certificate construction and assurance boundary + +`ak-public.pem` is the exact public key returned by `tpm2_readpublic` for the swtpm +AK. `ak-cert.pem` is a test certificate made afterwards by placing that public key +under `synthetic-root.pem`; the test asserts that the certificate and TPM output +contain identical public keys. The synthetic CA private key and all TPM state, +contexts, and private material were destroyed and are not committed. + +This corpus proves that the parser and appraisal path accept one independently +emitted software-TPM wire image, authenticate both signatures under the included +test chain, bind the two phases and index Name, and check the extend relation. It +does **not** prove a physical TPM, vendor enrollment, EK-to-AK credential +activation, non-exportability, hardware key residency, requester/TPM co-location, +boot state, runtime integrity, or safe application behavior. A software TPM may +also be operated as a remote signing oracle. + +## Producer sequence + +The essential commands were: + +```sh +tpm2_createek -G rsa -c ek.ctx -u ek.pub +tpm2_createak -C ek.ctx -G rsa -g sha256 -s rsassa \ + -c ak.ctx -u ak.pub -n ak.name +tpm2_flushcontext -t + +tpm2_nvdefine 0x01500018 -C o -g sha256 -s 32 \ + -a 'ownerread|ownerwrite|nt=extend' +tpm2_nvextend 0x01500018 -C o -i seed-event.bin + +tpm2_nvcertify -C ak.ctx -c o -g sha256 -s rsassa -f tss \ + -q ac2bc955b7d97a5589f9a6c48db86f87aea9d9af22e43cc97330f7b560145fa3 \ + --size 32 --offset 0 --attestation pre-attest.bin \ + -o pre-tpmt-signature.bin 0x01500018 + +tpm2_nvextend 0x01500018 -C o -i gateway-digest.bin + +tpm2_nvcertify -C ak.ctx -c o -g sha256 -s rsassa -f tss \ + -q 230587d8162f6a14fb8573d2b3fddec4b98413ffad0f83e804aff3740d8bfa8c \ + --size 32 --offset 0 --attestation post-attest.bin \ + -o post-tpmt-signature.bin 0x01500018 +``` + +`tpm2_print` 5.6 does not understand the `TPM_ST_ATTEST_NV` union. The tests +therefore parse the signed structures with the released Agent Manifest parser and +independently compare the signed Name with `tpm2_nvreadpublic` output. diff --git a/tests/fixtures/reference/swtpm-nv-certify-0.7.3/SHA256SUMS b/tests/fixtures/reference/swtpm-nv-certify-0.7.3/SHA256SUMS new file mode 100644 index 00000000..9c7f9887 --- /dev/null +++ b/tests/fixtures/reference/swtpm-nv-certify-0.7.3/SHA256SUMS @@ -0,0 +1,12 @@ +6f13324e7cd6d5302bc8af2d66321c8597bb04256cba31660da403534a545dd5 ak-cert.pem +2695b54c92d2fc515cc3769ddc93682977a2bc073900ff3300a473f5f7d9bd10 ak-public.pem +e1ff313ca44d96a8e827915e3a004a8c72d1ca48844ab3f4a8ac5707d4bbd19c gateway-digest.bin +1c39e6d7f1fbbf5c86142288c52d8c0d19438a7ab5a303ef62517f1edd6edc2a nv-public.yaml +d6f807d21ea2d255897451c0e3ec528648a51b2d58097fec891fd654b5d27e7e post-attest.bin +eba6ca529787d3a63e04eebacf829f5b86cc3bd30de7112269116ba53edbdd0d post-contents.bin +5e22d7c3220dfd5b0f9b029d57d65b1a5c80d1235f7d6d12d0d1564a1a582c0b post-tpmt-signature.bin +d94c9c11c152e16a57c2daa7090f57da94b068b0e43511d73bd6fa5dc1b26e29 pre-attest.bin +25496e06bd981ee16f2f177f1c69106ff712a77d259ef7616e203bcba572b523 pre-contents.bin +03288dc5973a51bd44ff47b156a524be1f837ea69d3cdfb3a08827afeef37d9e pre-tpmt-signature.bin +ee20f2868946b0095c0bd65d8564237cc5cc1debea71c5dc3ead281124ae05f6 seed-event.bin +8f33533725b2db95f1e3c7b8bf0240923fff6b34c4fcf88d7b72589d89b0fddc synthetic-root.pem diff --git a/tests/fixtures/reference/swtpm-nv-certify-0.7.3/ak-cert.pem b/tests/fixtures/reference/swtpm-nv-certify-0.7.3/ak-cert.pem new file mode 100644 index 00000000..87fdf9b0 --- /dev/null +++ b/tests/fixtures/reference/swtpm-nv-certify-0.7.3/ak-cert.pem @@ -0,0 +1,13 @@ +-----BEGIN CERTIFICATE----- +MIIB+DCCAZ8CAQIwCgYIKoZIzj0EAwIwJDEiMCAGA1UEAwwZY01DUCBzd3RwbSBy +ZWZlcmVuY2Ugcm9vdDAeFw0yNjA5MDIwMjIyNDdaFw0zNjA4MzAwMjIyNDdaMCIx +IDAeBgNVBAMMF2NNQ1Agc3d0cG0gcmVmZXJlbmNlIEFLMIIBIjANBgkqhkiG9w0B +AQEFAAOCAQ8AMIIBCgKCAQEA1thCyGmNJrEcUYRUXsU4qEg0j395Qc4HC/E/1AZ/ +OQ/G1TADykn/oZVkJwF7cXcJeTDakXHzNBs5E6w1g1ejdOF6j7USiTgu1VrMLxhB +aJlg3E/5YwCStKbOXpMDj0hXEC+3U2k4EhqjuKp3GSylUtvbloAAChF/0iRoOxyV +XFm4O6rVZdMXz9U+/7Jymms9Xqqvn9zTLkU+XBnaxpWigstDoC5CBcbAtycXZE1k +/C7QCyTZvrimqmtNlCDGmP8hyK+bNAuBWvaeE/JwWIhZpr5dWapi4WBKp7hO4K9G +W5UfzWEz2vcG11MXO4aEO2Q1MbSWlDG97E6yYHNg2/xYtwIDAQABMAoGCCqGSM49 +BAMCA0cAMEQCIB3DLSZTnN/U8FUEUHrRd7FiKQRZT8xEaHCesgtaDCK9AiB8A7lK +wMfkjnfckyK4Yxe8eR0fiJtS6XEBfpQKrGp8Eg== +-----END CERTIFICATE----- diff --git a/tests/fixtures/reference/swtpm-nv-certify-0.7.3/ak-public.pem b/tests/fixtures/reference/swtpm-nv-certify-0.7.3/ak-public.pem new file mode 100644 index 00000000..3990afe9 --- /dev/null +++ b/tests/fixtures/reference/swtpm-nv-certify-0.7.3/ak-public.pem @@ -0,0 +1,9 @@ +-----BEGIN PUBLIC KEY----- +MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA1thCyGmNJrEcUYRUXsU4 +qEg0j395Qc4HC/E/1AZ/OQ/G1TADykn/oZVkJwF7cXcJeTDakXHzNBs5E6w1g1ej +dOF6j7USiTgu1VrMLxhBaJlg3E/5YwCStKbOXpMDj0hXEC+3U2k4EhqjuKp3GSyl +UtvbloAAChF/0iRoOxyVXFm4O6rVZdMXz9U+/7Jymms9Xqqvn9zTLkU+XBnaxpWi +gstDoC5CBcbAtycXZE1k/C7QCyTZvrimqmtNlCDGmP8hyK+bNAuBWvaeE/JwWIhZ +pr5dWapi4WBKp7hO4K9GW5UfzWEz2vcG11MXO4aEO2Q1MbSWlDG97E6yYHNg2/xY +twIDAQAB +-----END PUBLIC KEY----- diff --git a/tests/fixtures/reference/swtpm-nv-certify-0.7.3/gateway-digest.bin b/tests/fixtures/reference/swtpm-nv-certify-0.7.3/gateway-digest.bin new file mode 100644 index 00000000..ad0ca7e7 --- /dev/null +++ b/tests/fixtures/reference/swtpm-nv-certify-0.7.3/gateway-digest.bin @@ -0,0 +1 @@ +NhUhs8 w͢{ĸ|; \ No newline at end of file diff --git a/tests/fixtures/reference/swtpm-nv-certify-0.7.3/nv-public.yaml b/tests/fixtures/reference/swtpm-nv-certify-0.7.3/nv-public.yaml new file mode 100644 index 00000000..e8fe8de2 --- /dev/null +++ b/tests/fixtures/reference/swtpm-nv-certify-0.7.3/nv-public.yaml @@ -0,0 +1,10 @@ +0x1500018: + name: 000bcf69802ad7625fffd515aecef934a1632ca6c7df36bf5a4e241d33701465a854 + hash algorithm: + friendly: sha256 + value: 0xB + attributes: + friendly: ownerwrite|nt=0x1|ownerread|written + value: 0x20020042 + size: 32 + diff --git a/tests/fixtures/reference/swtpm-nv-certify-0.7.3/post-attest.bin b/tests/fixtures/reference/swtpm-nv-certify-0.7.3/post-attest.bin new file mode 100644 index 00000000..69368f03 Binary files /dev/null and b/tests/fixtures/reference/swtpm-nv-certify-0.7.3/post-attest.bin differ diff --git a/tests/fixtures/reference/swtpm-nv-certify-0.7.3/post-contents.bin b/tests/fixtures/reference/swtpm-nv-certify-0.7.3/post-contents.bin new file mode 100644 index 00000000..80d8d831 --- /dev/null +++ b/tests/fixtures/reference/swtpm-nv-certify-0.7.3/post-contents.bin @@ -0,0 +1 @@ +.lL0n.S=Bb:/]x#V \ No newline at end of file diff --git a/tests/fixtures/reference/swtpm-nv-certify-0.7.3/post-tpmt-signature.bin b/tests/fixtures/reference/swtpm-nv-certify-0.7.3/post-tpmt-signature.bin new file mode 100644 index 00000000..bbfd3c98 Binary files /dev/null and b/tests/fixtures/reference/swtpm-nv-certify-0.7.3/post-tpmt-signature.bin differ diff --git a/tests/fixtures/reference/swtpm-nv-certify-0.7.3/pre-attest.bin b/tests/fixtures/reference/swtpm-nv-certify-0.7.3/pre-attest.bin new file mode 100644 index 00000000..af148ba3 Binary files /dev/null and b/tests/fixtures/reference/swtpm-nv-certify-0.7.3/pre-attest.bin differ diff --git a/tests/fixtures/reference/swtpm-nv-certify-0.7.3/pre-contents.bin b/tests/fixtures/reference/swtpm-nv-certify-0.7.3/pre-contents.bin new file mode 100644 index 00000000..cde1d027 --- /dev/null +++ b/tests/fixtures/reference/swtpm-nv-certify-0.7.3/pre-contents.bin @@ -0,0 +1 @@ +L:I;+iy \ No newline at end of file diff --git a/tests/fixtures/reference/swtpm-nv-certify-0.7.3/pre-tpmt-signature.bin b/tests/fixtures/reference/swtpm-nv-certify-0.7.3/pre-tpmt-signature.bin new file mode 100644 index 00000000..56cf044a Binary files /dev/null and b/tests/fixtures/reference/swtpm-nv-certify-0.7.3/pre-tpmt-signature.bin differ diff --git a/tests/fixtures/reference/swtpm-nv-certify-0.7.3/seed-event.bin b/tests/fixtures/reference/swtpm-nv-certify-0.7.3/seed-event.bin new file mode 100644 index 00000000..1777fc2d --- /dev/null +++ b/tests/fixtures/reference/swtpm-nv-certify-0.7.3/seed-event.bin @@ -0,0 +1 @@ +yT+%i;ש4ӏ \ No newline at end of file diff --git a/tests/fixtures/reference/swtpm-nv-certify-0.7.3/synthetic-root.pem b/tests/fixtures/reference/swtpm-nv-certify-0.7.3/synthetic-root.pem new file mode 100644 index 00000000..44f1c64a --- /dev/null +++ b/tests/fixtures/reference/swtpm-nv-certify-0.7.3/synthetic-root.pem @@ -0,0 +1,11 @@ +-----BEGIN CERTIFICATE----- +MIIBrDCCAVOgAwIBAgIUE6+q70duBW8wyyYiMNYYryxgsbQwCgYIKoZIzj0EAwIw +JDEiMCAGA1UEAwwZY01DUCBzd3RwbSByZWZlcmVuY2Ugcm9vdDAeFw0yNjA5MDIw +MjIyNDdaFw0zNjA4MzAwMjIyNDdaMCQxIjAgBgNVBAMMGWNNQ1Agc3d0cG0gcmVm +ZXJlbmNlIHJvb3QwWTATBgcqhkjOPQIBBggqhkjOPQMBBwNCAARANBe7aD3FQdTm +oA/IuU1xZPPhIJt8wqeUF2VdIzK4fvta4lfLgsDT45cguqlfIDKgBF9UG+MKCa8k +MHECDIP8o2MwYTAdBgNVHQ4EFgQUzLy1poKFkMXlj9OICF8WmHXhjOYwHwYDVR0j +BBgwFoAUzLy1poKFkMXlj9OICF8WmHXhjOYwDwYDVR0TAQH/BAUwAwEB/zAOBgNV +HQ8BAf8EBAMCAQYwCgYIKoZIzj0EAwIDRwAwRAIgK7g2vAGhxc6KX/ZbxZKHf9U8 +J7AWDHb2IyvoK1mQXqQCIFB2viPKPbnUJQ6EXxmpknwAzx87vzeXE2CTCC4M2wEW +-----END CERTIFICATE----- diff --git a/tests/integration/test_nv_certify_swtpm_reference.py b/tests/integration/test_nv_certify_swtpm_reference.py new file mode 100644 index 00000000..3f95b0d9 --- /dev/null +++ b/tests/integration/test_nv_certify_swtpm_reference.py @@ -0,0 +1,140 @@ +"""Independent-producer coverage for the gateway NV-certify appraisal path. + +The fixture was emitted by swtpm/tpm2-tools, not by either parser under test. Its +certificate is deliberately synthetic and establishes no hardware provenance; see +the fixture README for the exact evidence and assurance boundary. +""" + +from __future__ import annotations + +import hashlib +from pathlib import Path + +import pytest +import yaml +from agent_manifest import parse_tpm_nv_certify, parse_tpmt_signature +from cryptography import x509 +from cryptography.hazmat.primitives.serialization import ( + Encoding, + PublicFormat, + load_pem_public_key, +) + +from cmcp_verify.nv_certify import NvCertifyResult, build_envelope, verify_gateway_measurement + +_FIXTURE = Path(__file__).parents[1] / "fixtures" / "reference" / "swtpm-nv-certify-0.7.3" +_NONCE = b"\xa5" * 32 +_PRE_QUALIFYING_DATA = bytes.fromhex( + "ac2bc955b7d97a5589f9a6c48db86f87aea9d9af22e43cc97330f7b560145fa3" +) +_POST_QUALIFYING_DATA = bytes.fromhex( + "230587d8162f6a14fb8573d2b3fddec4b98413ffad0f83e804aff3740d8bfa8c" +) + + +def _read(name: str) -> bytes: + return (_FIXTURE / name).read_bytes() + + +def _tpm2b(attest: bytes) -> bytes: + return len(attest).to_bytes(2, "big") + attest + + +def _envelope(*, size_prefixed: bool = False, post_signature: bytes | None = None) -> bytes: + pre = _read("pre-attest.bin") + post = _read("post-attest.bin") + return build_envelope( + pre_attest=_tpm2b(pre) if size_prefixed else pre, + pre_signature=_read("pre-tpmt-signature.bin"), + post_attest=_tpm2b(post) if size_prefixed else post, + post_signature=( + post_signature if post_signature is not None else _read("post-tpmt-signature.bin") + ), + gateway_digest=_read("gateway-digest.bin"), + components={"producer": "swtpm 0.7.3", "purpose": "reference test"}, + ) + + +def _verify(envelope: bytes, *, expected_nonce: bytes = _NONCE) -> NvCertifyResult: + root = _read("synthetic-root.pem") + return verify_gateway_measurement( + envelope, + ak_chain_pem=_read("ak-cert.pem") + root, + trusted_roots_pem=root, + expected_nonce=expected_nonce, + expected_gateway_digest=_read("gateway-digest.bin"), + ) + + +def test_swtpm_reference_artifacts_are_self_consistent() -> None: + pre = parse_tpm_nv_certify(_read("pre-attest.bin")) + post = parse_tpm_nv_certify(_read("post-attest.bin")) + nv_public = yaml.safe_load(_read("nv-public.yaml")) + public_entry = next(iter(nv_public.values())) + expected_index_name = bytes.fromhex(public_entry["name"]) + + assert pre.attest.qualifying_data == _PRE_QUALIFYING_DATA + assert post.attest.qualifying_data == _POST_QUALIFYING_DATA + assert pre.info.index_name == post.info.index_name == expected_index_name + assert pre.info.offset == post.info.offset == 0 + assert pre.info.nv_contents == _read("pre-contents.bin") + assert post.info.nv_contents == _read("post-contents.bin") + assert pre.info.nv_contents == hashlib.sha256(bytes(32) + _read("seed-event.bin")).digest() + assert ( + post.info.nv_contents + == hashlib.sha256(pre.info.nv_contents + _read("gateway-digest.bin")).digest() + ) + + signature = parse_tpmt_signature(_read("pre-tpmt-signature.bin")) + assert signature.sig_alg == 0x0014 # TPM2_ALG_RSASSA + assert signature.hash_alg == 0x000B # TPM2_ALG_SHA256 + assert len(signature.signature) == 256 + + certified_public = x509.load_pem_x509_certificate(_read("ak-cert.pem")).public_key() + swtpm_public = load_pem_public_key(_read("ak-public.pem")) + assert certified_public.public_bytes( + Encoding.DER, PublicFormat.SubjectPublicKeyInfo + ) == swtpm_public.public_bytes(Encoding.DER, PublicFormat.SubjectPublicKeyInfo) + + +def test_swtpm_reference_artifact_checksums() -> None: + entries = {} + for line in (_FIXTURE / "SHA256SUMS").read_text().splitlines(): + digest, name = line.split(" ", 1) + entries[name] = digest + + assert entries + assert set(entries) == { + path.name for path in _FIXTURE.iterdir() if path.name not in {"README.md", "SHA256SUMS"} + } + for name, digest in entries.items(): + assert hashlib.sha256(_read(name)).hexdigest() == digest + + +@pytest.mark.parametrize("size_prefixed", [False, True], ids=["TPMS_ATTEST", "TPM2B_ATTEST"]) +def test_swtpm_reference_pair_verifies(size_prefixed: bool) -> None: + result = _verify(_envelope(size_prefixed=size_prefixed)) + assert result.verified, result.failure_reason + assert result.verified_fields == [ + "envelope", + "structure", + "ak_chain", + "signatures", + "freshness", + "index_identity", + "extend_relation", + ] + + +def test_swtpm_reference_pair_rejects_replay_under_a_new_nonce() -> None: + result = _verify(_envelope(), expected_nonce=b"\xa6" * 32) + assert not result.verified + assert result.failure_reason == "pre_binding_mismatch" + + +def test_swtpm_reference_pair_rejects_signature_tampering() -> None: + signature = bytearray(_read("post-tpmt-signature.bin")) + signature[-1] ^= 0x01 + result = _verify(_envelope(post_signature=bytes(signature))) + assert not result.verified + assert result.failure_reason == "signature_invalid" diff --git a/tests/unit/test_nv_certify.py b/tests/unit/test_nv_certify.py index 5eebd11c..49bea0d9 100644 --- a/tests/unit/test_nv_certify.py +++ b/tests/unit/test_nv_certify.py @@ -69,6 +69,11 @@ def build_nv_attest( return bytes(out) +def _tpm2b_attest(attest: bytes) -> bytes: + """Wrap a signed TPMS_ATTEST in its TPM2B_ATTEST transport framing.""" + return len(attest).to_bytes(2, "big") + attest + + def _tpmt_rsassa(sig: bytes) -> bytes: return struct.pack(">HH", _ALG_RSASSA, _ALG_SHA256) + struct.pack(">H", len(sig)) + sig @@ -135,6 +140,7 @@ def make_envelope( post_phase: bytes = PHASE_POST, pre_index: bytes = INDEX_NAME, post_index: bytes = INDEX_NAME, + size_prefixed: bool = False, ) -> bytes: if post_contents is None: post_contents = hashlib.sha256(pre_contents + gateway_digest).digest() @@ -149,9 +155,11 @@ def make_envelope( index_name=post_index, ) return build_envelope( - pre_attest=pre, + # The TPM signs TPMS_ATTEST. TPM2B_ATTEST is transport framing and is + # deliberately added only after the signature has been produced. + pre_attest=_tpm2b_attest(pre) if size_prefixed else pre, pre_signature=fx.sign(pre), - post_attest=post, + post_attest=_tpm2b_attest(post) if size_prefixed else post, post_signature=fx.sign(post), gateway_digest=gateway_digest, components={"code": "x", "policy": "y", "config": "z"}, @@ -178,6 +186,23 @@ def test_parse_reads_the_certify_fields() -> None: assert info.offset == 0 +def test_parse_accepts_size_prefixed_tpm2b_attest() -> None: + """TPM libraries may return either the inner struct or TPM2B framing.""" + qd = certify_qualifying_data(NONCE, PHASE_PRE) + attest = build_nv_attest(qualifying_data=qd, nv_contents=POST_CONTENTS) + info = parse_nv_certify(_tpm2b_attest(attest)) + assert info.qualifying_data == qd + assert info.nv_contents == POST_CONTENTS + assert info.index_name == INDEX_NAME + assert info.offset == 0 + + +def test_parse_rejects_trailing_bytes_after_nv_contents() -> None: + attest = build_nv_attest(qualifying_data=b"x", nv_contents=POST_CONTENTS) + with pytest.raises(ValueError, match="trailing bytes"): + parse_nv_certify(attest + b"\x00") + + def test_parse_rejects_a_quote() -> None: """A quote must not be readable as an NV certify. @@ -237,6 +262,28 @@ def test_valid_pair_verifies_ecdsa() -> None: assert result.verified, result.failure_reason +def test_valid_size_prefixed_pair_verifies_inner_signed_attestations() -> None: + """The outer TPM2B length is framing; the AK signs the inner TPMS_ATTEST.""" + fx = Fixture() + result = verify(fx, make_envelope(fx, size_prefixed=True)) + assert result.verified, result.failure_reason + assert "signatures" in result.verified_fields + + +def test_size_prefixed_pair_rejects_signatures_over_transport_framing() -> None: + """A TPM signs the inner structure, never the outer two-byte length.""" + fx = Fixture() + payload = json.loads(make_envelope(fx, size_prefixed=True)) + pre_transport = base64.b64decode(payload["pre_attest"]) + post_transport = base64.b64decode(payload["post_attest"]) + payload["pre_signature"] = base64.b64encode(fx.sign(pre_transport)).decode() + payload["post_signature"] = base64.b64encode(fx.sign(post_transport)).decode() + + result = verify(fx, json.dumps(payload).encode()) + assert not result.verified + assert result.failure_reason == "signature_invalid" + + def test_without_an_expected_digest_it_notes_the_weaker_claim() -> None: """Internal consistency is not the same as matching a known-good value.""" fx = Fixture()