From 708e938ab454a1b08f5e44e6af9c276eab950f3e Mon Sep 17 00:00:00 2001 From: Andrey Lazarev Date: Wed, 14 Oct 2026 23:40:00 +0000 Subject: [PATCH] spec(appraisal): 3.1.5, a layer is established by its event log or by a reference value (#279) As merged in #477 a layer was established only when its event log replayed to the quoted value, so a quote equal to signed reference values with no log could not be affirming under rule 2, though RFC 9334 treats appraisal against reference values as ordinary appraisal. A layer that holds measurements is now established by either route. The holds-measurements condition binds both: a quoted value equal to the initial value, or on a TPM to a separator alone, is layer-not-measured even when a reference matches it. A reference that gives only a composite establishes no layer. A presented log that does not replay is decided under rule 3 first. Vector 45 carries the published v2.1.0-rc14 release measurement: PCR 0, 1, 4, 6, 8, 9 established by reference with no log, PCR 2, 3, 5, 7 layer-not-measured, status affirming. A test holds it to the per-layer reference values. Records do not change. Signed-off-by: Andrey Lazarev --- CHANGELOG.md | 17 ++++ .../45-rc14-established-by-reference.json | 95 +++++++++++++++++++ .../platform-measurement-appraisal/README.md | 30 ++++-- ..._platform_measurement_appraisal_vectors.py | 33 ++++++- spec/trace-v0.2.md | 14 ++- ..._platform_measurement_appraisal_vectors.py | 49 +++++++++- 6 files changed, 223 insertions(+), 15 deletions(-) create mode 100644 examples/platform-measurement-appraisal/45-rc14-established-by-reference.json diff --git a/CHANGELOG.md b/CHANGELOG.md index e91e7260..b1190af5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,23 @@ Format: [Semantic Versioning](https://semver.org/). Spec versions follow `MAJOR. ## [Unreleased] +- **Section 3.1.5: a layer is established by its event log or by a reference value + (#279).** As merged, a layer was `established` only when its event log replayed to the + quoted value, so a quote that equals signed reference values with no log could not be + `affirming` under rule 2, although appraisal against reference values is ordinary + appraisal in RFC 9334. A layer that holds measurements is now established by either + route. The condition that it holds measurements binds both: a layer whose quoted value is + the initial value, or on a TPM the value of a separator alone, is `layer-not-measured` + even when a reference value matches it; a reference that gives only a composite + establishes no layer; and a presented log that does not replay is decided under rule 3 + first, whatever the reference says. This narrows the BREAKING entry of #477: a verifier + that reports `affirming` after a reference match is conformant when each layer its policy + requires holds measurements and equals its reference value. Vector `45` in + `examples/platform-measurement-appraisal/` carries the published v2.1.0-rc14 release + measurement: six layers established by reference with no log, four separator-only layers + `layer-not-measured`, status `affirming`. Records do not change. Proposed and written by + @revenue7-eng; carried by @imran-siddique as sponsoring maintainer. + - **Revocation store and `allow_embedded_key` are type-checked.** `verify_record` accepted a dict or a string as `revocation`: a dict was searched by its field names, so a revoked key verified, and a string was searched by substring. Both now raise diff --git a/examples/platform-measurement-appraisal/45-rc14-established-by-reference.json b/examples/platform-measurement-appraisal/45-rc14-established-by-reference.json new file mode 100644 index 00000000..893010f1 --- /dev/null +++ b/examples/platform-measurement-appraisal/45-rc14-established-by-reference.json @@ -0,0 +1,95 @@ +{ + "name": "rc14-established-by-reference", + "description": "Real measurement: the attestation agent's quotes over PCR 0 to 9 from the v2.1.0-rc14 release image on slot B, appraised against the signed release reference. The evidence carries no event log. PCR 0, 1, 4, 6, 8 and 9 equal their reference values and hold measurements, so they are established by the reference-value route. PCR 2, 3, 5 and 7 equal the value a separator alone produces: the reference matches them and they are layer-not-measured. The verifier's policy requires PCR 0, 1, 4, 6, 8 and 9, all established, so the status is affirming; the record does not carry that policy.", + "spec": "spec/trace-v0.2.md#315-appraisalplatform_measurement-what-a-matching-measurement-covers", + "source": "https://github.com/revenue7-eng/tactiq-os/releases/tag/v2.1.0-rc14 (l3-evidence-rc14.tar.gz, rim-rock5a.json and rim-rock5a.json.p7s; checker scripts/appraise-layers.py in the same repository)", + "record": { + "eat_profile": "tag:agentrust-io.com,2026:trace-v0.2", + "iat": 1785000000, + "subject": "spiffe://edge.example/device/bench-001", + "model": { + "provider": "anthropic", + "model_id": "claude-sonnet-4-6" + }, + "runtime": { + "platform": "tpm2", + "measurement": "sha256:dc7051b201f970dbf438f9561975bb8801eb394170294417b05c4449e1283438" + }, + "policy": { + "bundle_hash": "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "enforcement_mode": "enforce" + }, + "data_class": "internal", + "build_provenance": { + "slsa_level": 0, + "digest": "sha256:00ea48600a3a4fcd35a35cea2296fcbe18cb2c92243ece1a5d1992d0dae41202" + }, + "appraisal": { + "status": "affirming", + "verifier": "https://verifier.example/platform-measurement", + "platform_measurement": { + "measurement": "sha256:dc7051b201f970dbf438f9561975bb8801eb394170294417b05c4449e1283438", + "layers": { + "pcr:0": { + "outcome": "established" + }, + "pcr:1": { + "outcome": "established" + }, + "pcr:2": { + "outcome": "not-established", + "reason": "layer-not-measured" + }, + "pcr:3": { + "outcome": "not-established", + "reason": "layer-not-measured" + }, + "pcr:4": { + "outcome": "established" + }, + "pcr:5": { + "outcome": "not-established", + "reason": "layer-not-measured" + }, + "pcr:6": { + "outcome": "established" + }, + "pcr:7": { + "outcome": "not-established", + "reason": "layer-not-measured" + }, + "pcr:8": { + "outcome": "established" + }, + "pcr:9": { + "outcome": "established" + } + } + } + }, + "cnf": { + "jwk": { + "kty": "OKP", + "crv": "Ed25519", + "x": "shaEEVtRR8nWCpp--gRa2Hu4AbHYpKKTdF3EuE5COU8" + } + }, + "signature": "eyhpnPFEEnAoO2PITJDzwxtIucsp1X4bOXsl-xiDbU-EPR6HBn5u_hAXy_pp6b5gubYdNDd4df35-Ffg508DAQ" + }, + "expected": { + "outcome": "accept", + "codes": [], + "reading": { + "pcr:0": { + "outcome": "established" + }, + "pcr:2": { + "outcome": "not-established", + "reason": "layer-not-measured" + }, + "pcr:9": { + "outcome": "established" + } + } + } +} diff --git a/examples/platform-measurement-appraisal/README.md b/examples/platform-measurement-appraisal/README.md index 171d6ac5..dae1c6fe 100644 --- a/examples/platform-measurement-appraisal/README.md +++ b/examples/platform-measurement-appraisal/README.md @@ -1,6 +1,6 @@ # Platform-measurement appraisal vectors -Forty-four signed Trust Records for `appraisal.platform_measurement`, spec section 3.1.5, +Forty-five signed Trust Records for `appraisal.platform_measurement`, spec section 3.1.5, raised in [#279](https://github.com/agentrust-io/trace-spec/issues/279). Each file is a test-vector envelope: the record is under `record`, the verdict under `expected`. @@ -49,15 +49,27 @@ rule binds `tpm2` only. ## Where the real measurements come from -`09` and `10` carry the `pcrDigest` of published quotes on a physical board with a +`09`, `10` and `45` carry the `pcrDigest` of published quotes on a physical board with a discrete TPM (TactiQ OS on Rock 5A, Infineon SLB9670), with the evidence and an offline -checker at the URL in `source`: the v2.1.0-rc13 release reference, in which PCR 2, 3, 5 -and 7 hold a single separator only, and a development-image cold-start quote, in which -IMA measurements in PCR 11 and 12 replay and nothing shows they were appraised. That -quote was taken by hand with `tpm2_quote` under the board's registered attestation key; -the image's attestation agent quotes PCR 0 to 9 only, so PCR 10 to 12 are not in its -envelope. Neither -has a twin, because a twin would state something untrue about that evidence. The two-boot +checker at the URL in `source`: + +- `09`, the v2.1.0-rc13 release reference, in which PCR 2, 3, 5 and 7 hold a single + separator only. +- `10`, a development-image cold-start quote, in which IMA measurements in PCR 11 and 12 + replay and nothing shows they were appraised. That quote was taken by hand with + `tpm2_quote` under the board's registered attestation key; the image's attestation agent + quotes PCR 0 to 9 only, so PCR 10 to 12 are not in its envelope. +- `45`, the v2.1.0-rc14 release: the attestation agent's quotes over PCR 0 to 9, appraised + against the signed release reference with no event log. PCR 0, 1, 4, 6, 8 and 9 are + established by the reference-value route of section 3.1.5. PCR 2, 3, 5 and 7 equal the + value a separator alone produces; the reference matches them, and they are + `layer-not-measured`. Every layer the verifier's policy requires is established, so the + status is `affirming`. The record does not say which route established a layer; the + per-layer reference values are in the release's `rim-rock5a.json`, and + `tests/test_platform_measurement_appraisal_vectors.py` holds the vector to them. + +None of the three has a twin, because a twin would state something untrue about that +evidence. The two-boot case exists only in the synthetic pair: the development loader on that board resets the TPM before measuring, so the board did not produce the case. diff --git a/examples/platform-measurement-appraisal/gen_platform_measurement_appraisal_vectors.py b/examples/platform-measurement-appraisal/gen_platform_measurement_appraisal_vectors.py index a7c3fe11..76cf8ad5 100644 --- a/examples/platform-measurement-appraisal/gen_platform_measurement_appraisal_vectors.py +++ b/examples/platform-measurement-appraisal/gen_platform_measurement_appraisal_vectors.py @@ -24,7 +24,7 @@ the cases. Every pair is synthetic, `"synthetic": true`: its measurement and its per-layer result are made up to exercise one case, not taken from a platform. -**Where real measurements come from.** Two accepting vectors carry the `pcrDigest` of +**Where real measurements come from.** Three accepting vectors carry the `pcrDigest` of published quotes on a physical board with a discrete TPM (TactiQ OS on Rock 5A, Infineon SLB9670), and say so in `source`; neither has a twin, because a twin would have to state something untrue about that evidence: @@ -36,6 +36,11 @@ unappraised is not in the log: `measured-not-appraised`. The quote was taken by hand with `tpm2_quote` under the board's registered attestation key; the image's attestation agent quotes PCR 0 to 9 only. +- `45` is the v2.1.0-rc14 release: the attestation agent's quotes over PCR 0 to 9 on slot + B, appraised against the signed release reference with no event log. PCR 0, 1, 4, 6, 8 + and 9 are established by the reference-value route; PCR 2, 3, 5 and 7 equal the value a + separator alone produces and are `layer-not-measured` although the reference matches + them. With every layer the policy requires established, the status is `affirming`. The two-boot case exists only in the synthetic pair: the development loader on that board resets the TPM before measuring, so the board did not produce the case. These are the @@ -74,6 +79,7 @@ #: The two real measurements: `pcrDigest` of published quotes. RC13 = "sha256:2ee6f2d22d3b8e704d1ee8119220aed62a5caff4624c5804b8185debdff1f745" DEV_COLD = "sha256:266c3432e78cde16a9e9e63c479c4d07aad80ac049b25d655bfd897fb3b19746" +RC14_B = "sha256:dc7051b201f970dbf438f9561975bb8801eb394170294417b05c4449e1283438" SOURCE_RC13 = ( "https://github.com/revenue7-eng/tactiq-os/releases/tag/v2.1.0-rc13 " "(VERIFY-L3-rc13.md and l3-evidence-rc13.tar.gz)" @@ -82,6 +88,11 @@ "https://github.com/revenue7-eng/tactiq-os/blob/main/measurements/" "tpm-quotes-dev-20261002.md (cold.* files and check.py beside it)" ) +SOURCE_RC14 = ( + "https://github.com/revenue7-eng/tactiq-os/releases/tag/v2.1.0-rc14 " + "(l3-evidence-rc14.tar.gz, rim-rock5a.json and rim-rock5a.json.p7s; checker " + "scripts/appraise-layers.py in the same repository)" +) PCRS_0_9 = [f"pcr:{i}" for i in range(10)] @@ -407,6 +418,26 @@ def reject(n: int, name: str, description: str, code: str, *, layers: Any, f"Twin of {n:02d}: the same record with the member that breaks {code} put " "right. The only difference is that member.", rec=record(**twin), reading=twin_reading, twin_of=n)) + + # Real measurement, unpaired: layers established by a reference value, no event log. + rc14 = {p: (not_established("layer-not-measured") + if p in ("pcr:2", "pcr:3", "pcr:5", "pcr:7") else established()) + for p in PCRS_0_9} + out.append(vector(45, "rc14-established-by-reference", + "Real measurement: the attestation agent's quotes over PCR 0 to 9 from the " + "v2.1.0-rc14 release image on slot B, appraised against the signed release " + "reference. The evidence carries no event log. PCR 0, 1, 4, 6, 8 and 9 equal " + "their reference values and hold measurements, so they are established by the " + "reference-value route. PCR 2, 3, 5 and 7 equal the value a separator alone " + "produces: the reference matches them and they are layer-not-measured. The " + "verifier's policy requires PCR 0, 1, 4, 6, 8 and 9, all established, so the " + "status is affirming; the record does not carry that policy.", + rec=record(measurement=RC14_B, status="affirming", layers=rc14), + reading={"pcr:0": read("established"), + "pcr:2": read("not-established", "layer-not-measured"), + "pcr:9": read("established")}, + source=SOURCE_RC14)) + OUT.mkdir(parents=True, exist_ok=True) for name, doc in out: (OUT / name).write_bytes((json.dumps(doc, indent=2) + "\n").encode()) diff --git a/spec/trace-v0.2.md b/spec/trace-v0.2.md index 41322448..5d82d69e 100644 --- a/spec/trace-v0.2.md +++ b/spec/trace-v0.2.md @@ -371,18 +371,26 @@ Example result, in the appraisal of a verifier that re-ran the function and obta #### 3.1.5 `appraisal.platform_measurement`: what a matching measurement covers + On a measured-boot platform `runtime.measurement` is a composite digest over many layers. A verifier that compares it against a reference learns that the composite is the one the reference names. It does not learn which layers recorded anything, which were appraised before they ran, or whether the evidence describes one boot. Three conditions are routinely collapsed into an outcome a completed check would have produced. The last column is that collapse, the error this section removes, not what this section prescribes: | Reason | Condition | Collapsed, without this section, into | |---|---|---| | `layer-not-measured` | The layer holds no measurement: its value is the initial value, or on a TPM a separator and nothing else. A match confirms only that nothing was recorded. | pass | -| `measured-not-appraised` | The layer's measurements replay exactly to the quoted value, so the evidence establishes what ran. Nothing in the evidence establishes that what ran was appraised before it ran. | pass | +| `measured-not-appraised` | The layer holds measurements and the evidence establishes what ran in it, by its event log or by a reference value. Nothing in the evidence establishes that what ran was appraised before it ran. | pass | | `evidence-spans-multiple-boots` | The evidence does not establish that the quote and the event log describe the same single boot, and the log does not replay to the quoted value. A platform whose measurement registers survive a warm reboot produces this, and so do a quote and a log taken from different boots, and a truncated log. | a failure, read as tampering | The first two collapse into a pass. The third collapses the other way, into a failure. Under this section all three are `not-established`, which is neither: it never passes, and it is not a finding of tampering. All three are decided against evidence the record does not carry (a quote, an event log, a reference), so they are appraisal outcomes and not properties of `runtime`. -A layer is `established` when the evidence establishes what ran in it: the layer holds measurements, its event log replays from the initial value to the quoted value, and nothing else the verifier's policy requires of that layer is missing. Where the policy requires that what ran was appraised and the evidence does not show it, the layer is `measured-not-appraised`. A log that replays from the initial value to the quoted value accounts for every extension since the register was last reset, so for that layer the number of boots does not change what the evidence establishes; `evidence-spans-multiple-boots` arises only when the log does not replay. The reason is named for its most common cause, a platform whose registers survive a warm reboot, and covers the other causes its definition lists. +A layer is `established` when the evidence establishes what ran in it and nothing else the verifier's policy requires of that layer is missing. For a layer that holds measurements, the evidence establishes what ran by either of two routes: + +- **Event log.** The layer's event log replays from the initial value to the quoted value. +- **Reference value.** The quoted value equals a reference value for that layer, from a source the verifier's policy trusts and not taken from the evidence under appraisal. This is appraisal against reference values as RFC 9334 describes it. A reference value is per layer: a quoted composite that equals the composite of a reference's per-layer values over the same selection establishes each of those values, and a reference that gives only a composite establishes no layer, because it does not show which layers hold measurements. + +A presented event log that does not replay to the quoted value is decided under rule 3 first, and a matching reference value does not then establish the layer. Otherwise, either route requires that the layer holds measurements. A layer whose quoted value is the initial value, or on a TPM the value a separator alone produces, is `layer-not-measured` whichever route matched: a reference value equal to it, like a log that holds a separator and nothing else, confirms only that nothing was recorded. Neither route shows that what ran was appraised before it ran. + +Where the policy requires that what ran was appraised and the evidence does not show it, the layer is `measured-not-appraised`. A log that replays from the initial value to the quoted value accounts for every extension since the register was last reset, so for that layer the number of boots does not change what the evidence establishes; `evidence-spans-multiple-boots` arises only when the log does not replay. The reason is named for its most common cause, a platform whose registers survive a warm reboot, and covers the other causes its definition lists. **Placement.** The result is carried as `appraisal.platform_measurement`, a member of `appraisal` in its own right, as `appraisal.provenance_depth_verified` is. It is not an `appraisal.method` value, so a record can carry it next to a re-execution result (§3.1.4). It is attributed to `appraisal.verifier`; in a record signed only by its producer that attribution is the producer's report, as §3.1.4 states. @@ -429,7 +437,7 @@ Example result, for a reference composite in which one layer holds a separator o **Relation to the reference library.** `verify_record` already reports a caller-supplied appraiser's per-layer report as its `platform_measurement` field (#457), with the same three causes written `layer_not_measured`, `measured_not_appraised` and `evidence_spans_multiple_boots`. That field is library output and binds no record. This section names the outcomes a record carries. -**Out of scope.** This section does not say how a verifier decides each outcome, and fixes no quote or event-log format; those belong to the platform's profile or annex. +**Out of scope.** This section does not say how a verifier decides each outcome, and fixes no quote, event-log or reference-value format; those belong to the platform's profile or annex. ### 3.2 Wire format diff --git a/tests/test_platform_measurement_appraisal_vectors.py b/tests/test_platform_measurement_appraisal_vectors.py index e2fa5a58..d98d1530 100644 --- a/tests/test_platform_measurement_appraisal_vectors.py +++ b/tests/test_platform_measurement_appraisal_vectors.py @@ -16,6 +16,7 @@ from __future__ import annotations import base64 +import hashlib import json import pathlib from collections.abc import Callable @@ -86,7 +87,7 @@ def _reading_failures(reader: Reader) -> list[str]: def test_the_corpus_did_not_shrink() -> None: """Pinned, so deleting an awkward vector is a visible act rather than a quiet one.""" - assert len(VECTORS) == 44 + assert len(VECTORS) == 45 def test_every_code_is_carried_by_two_vectors() -> None: @@ -200,12 +201,56 @@ def measurement(directory: str, stem: str) -> str: assert measurement("platform-measurement-appraisal", "10-dev-measured-not-appraised") == ( measurement("platform-measurement", "16-dev-measured-not-appraised")) sourced = [p.stem for p in VECTORS if "source" in _load(p)] - assert sourced == ["09-rc13-layer-not-measured", "10-dev-measured-not-appraised"] + assert sourced == ["09-rc13-layer-not-measured", "10-dev-measured-not-appraised", + "45-rc14-established-by-reference"] for path in VECTORS: doc = _load(path) assert ("source" in doc) != bool(doc.get("synthetic")), path.stem +#: The slot B values of the signed v2.1.0-rc14 release reference (rim-rock5a.json in the +#: release cited by vector 45), PCR 0 to 9 of the SHA-256 bank. +RC14_B_REFERENCE = [ + "9bd4ab6216b55c5bbe172f31737ed87c8a585faa3b45c4caf225b0778ac3aafe", + "5cfee5d43ded096884b9b418f1b98bfbf3ff62c127b089d41b41dd21b084c5df", + "e21b703ee69c77476bccb43ec0336a9a1b2914b378944f7b00a10214ca8fea93", + "e21b703ee69c77476bccb43ec0336a9a1b2914b378944f7b00a10214ca8fea93", + "a6278b42d6d50ec180f4ae9377338d7e8f699e030f6c010736e9c06238d12983", + "e21b703ee69c77476bccb43ec0336a9a1b2914b378944f7b00a10214ca8fea93", + "0c4e26e8c610a7225e3f1c2af066007d94b21d45f59d62ebb1f5a22833125552", + "e21b703ee69c77476bccb43ec0336a9a1b2914b378944f7b00a10214ca8fea93", + "deaeb4564c4eb51d7cb2d3ebe9d052187b36cb1dd54a1885c5ebd508838109fe", + "cfc7d8042593e188c59d2fd523f07a95d06dd3160f0955d8c34b0eb067f517b6", +] + + +def test_a_reference_equal_to_a_separator_alone_is_not_measured() -> None: + """Vector 45, the reference-value route. Its measurement is the composite of the + reference values, so a quote equal to it equals the reference in every layer. The + layers whose reference value is what a separator alone produces from the initial + value are exactly the layer-not-measured ones; every other layer is established, + and with all of them established the status may be affirming (rule 2). The + separator event data checked are the two values TCG defines for EV_SEPARATOR, + 0x00000000 and 0xFFFFFFFF.""" + initial = bytes(32) + separator_alone = { + hashlib.sha256(initial + hashlib.sha256(data).digest()).hexdigest() + for data in (b"\x00" * 4, b"\xff" * 4)} + doc = _load(VECTOR_DIR / "45-rc14-established-by-reference.json") + record = doc["record"] + composite = hashlib.sha256(b"".join(bytes.fromhex(v) for v in RC14_B_REFERENCE)) + assert record["runtime"]["measurement"] == "sha256:" + composite.hexdigest() + layers = record["appraisal"]["platform_measurement"]["layers"] + assert set(layers) == {f"pcr:{i}" for i in range(10)} + for i, value in enumerate(RC14_B_REFERENCE): + if value in separator_alone or value == initial.hex(): + want = {"outcome": "not-established", "reason": "layer-not-measured"} + else: + want = {"outcome": "established"} + assert layers[f"pcr:{i}"] == want, i + assert record["appraisal"]["status"] == "affirming" + + def test_the_reference_reader_reads_every_vector() -> None: assert not _reading_failures(read_layer)