Skip to content

GCP TDX image identity rests on one lane: MRTD/RTMR0-2 are never compared, and PCR7/PCR12 are not quoted although AWS NitroTPM binds both #1296

Description

@kvinwang

Summary

Classification: DESIGN. All claims argued from code — no GCP hardware was available, and nothing
here is demonstrated.

On GCP TDX the OS-image identity rests on exactly two comparisons: PCR0 against a hardcoded constant,
and one 32-byte UKI Authenticode digest. The same quote carries MRTD, RTMR0, RTMR1 and RTMR2, and
none of them is compared against anything — which is mostly a platform constraint, and I want to say
so up front. What is a dstack choice, and is the part worth deciding on, is that the quoted PCR
selection omits PCR7 (Secure Boot policy) and PCR12 (kernel command line / credentials), both of
which AWS NitroTPM — the other TPM platform in this tree — binds.

Tree: origin/next @ 030fbb2183.


1. What the design currently is

verify_os_image_hash_for_gcp_tdx, dstack/verifier/src/verification.rs:1219-1278:

const EXPECTED_PCR0: [u8; 32] =
    hex!("0cca9ec161b09288802e5a112255d21340ed5b797f5fe29cecccfd8f67b9f802");   // :1225-1226
...
if pcr0.value != EXPECTED_PCR0 { bail!("PCR 0 mismatch: expected GCP OVMF v2, ...") }   // :1257-1262
let event_28_digest = &pcr2_events.get(2).context("Event 28 not found")?.digest;       // :1263
if event_28_digest != expected_uki_hash { bail!("UKI hash mismatch...") }               // :1266-1272

expected_uki_hash is GcpOsImageMeasurement::uki_authenticode_sha256
(dstack/dstack-types/src/lib.rs:1618-1621) — the document's only field.

What is not compared.

  1. MRTD, RTMR0, RTMR1, RTMR2. All four are in the GCP quote. The GCP arm of the OS-image dispatch
    (verification.rs:812-814) computes no expected value for any of them, and decode_mr_gcp_tpm
    (dstack/dstack-attest/src/attestation.rs:1855-1879) builds mr_aggregated from PCR0, PCR2 and
    the replayed runtime PCR only — the TDX registers are not in the MR either. Compare TDX-legacy
    (verification.rs:1390-1417) and TDX-lite (:1089-1091), which compare all four.
  2. PCR7 and PCR12. The quoted selection is a constant in dstack's own code:
    dstack_pcr_policy() = sha256 [0, 2, 14], dstack/tpm-attest/src/lib.rs:33-35. AWS NitroTPM
    binds PCR4, PCR7 and PCR12 (AWS_NITRO_TPM_BOOT_PCRS, dstack-attest/src/attestation.rs:1273,
    compared at verification.rs:1209-1215).
  3. dstack.rootfs_hash. SEV-SNP (dstack-mr/src/sev.rs:118,760,876) and TDX
    (dstack-mr/src/tdx.rs:331) both require the measured cmdline to carry a well-formed
    dstack.rootfs_hash. There is no such call on the GCP path.

PCR0 as a firmware pin. EXPECTED_PCR0 is a single constant in source — not config, not an
allowlist, not versioned.

What GCP does well, for balance. Its runtime-event lane is the best in the tree: the guest
extends both RTMR3 and TPM PCR14 (dstack-attest/src/lib.rs:189-201), and the verifier replays and
compares both (attestation.rs:1808-1815 and :1090-1101), plus binds the vTPM quote to the TDX
quote via qualified_data == sha256(tdx_quote) (:1086-1089). Nothing in this issue is about the
runtime lane.

2. The steelman

(1) is close to forced. On GCP the TD is launched by Google's firmware and the payload is booted
through UEFI, so MRTD and RTMR0–2 describe a boot chain dstack neither builds nor publishes an
expected value for. There is no measurement.gcp.cbor field that could carry an expected MRTD,
because dstack does not produce GCP's TDVF. Routing image identity through the vTPM instead is a
reasonable response to that constraint, and the UKI Authenticode digest is a genuinely good anchor:
a UKI bundles kernel, cmdline and initrd into one PE image, so 32 bytes really do cover all three.

(3) follows from (1): with the cmdline inside the UKI, a separate dstack.rootfs_hash check adds
little.

(2) is the weakest link in the steelman. PCR7 and PCR12 are ordinary TPM PCRs available on the
same vTPM that already supplies PCR0, PCR2 and PCR14. The likely history is "we bound what the UKI
digest and the runtime lane needed", not a decision that Secure Boot state and external command-line
material are out of scope — especially since the sibling platform binds them.

(4) EXPECTED_PCR0 as a constant is a pragmatic pin on the GCP OVMF build that was current when
the path was written, and a constant fails closed, which is the right direction.

3. What it costs

One lane instead of two. TDX-legacy has two independent image checks — recompute every MR from
the downloaded image and replay the event log. GCP has one. If the vTPM lane is wrong for any
reason, the TDX registers sitting in the same quote will not catch it. This is not hypothetical as a
class: PR #1238 found that the UKI digest was being read from an event log that need never have been
quoted, and documents that POST /verify and the KMS path failed closed only "for an unrelated
reason … an accident in a different function written for a different purpose". #1238 fixes which
event log is read; it does not add a second lane.

Secure Boot state is not in the image identity on GCP but is on AWS. Whatever requireTcbUpToDate
and allowedOsImages mean on AWS, they mean strictly less on GCP in this respect.

Positional extraction. pcr2_events.get(2) (:1263) identifies "Event 28" by index rather than
by event type or description, with a GCP-OVMF-specific comment at :1254-1255. Any firmware change
that adds or reorders a PCR-2 event silently moves what is compared.

Agility. A GCP OVMF roll changes PCR0, EXPECTED_PCR0 stops matching, and every GCP verification
fails at once. The only remedy is a dstack release. That is an availability cost, not a security one,
but it is the kind of thing that gets worked around under pressure.

4. Improvement direction

Cheapest first.

  1. Free / cheap — write down what GCP binds and what it does not, in verifier/README.md and
    docs/security/security-model.md, including that MRTD/RTMR0–2 are present in the quote and not
    compared, and why. Today a reader of the GCP path reasonably assumes it behaves like the TDX path
    because it is the same quote format. (This overlaps the result-contract issue filed alongside
    this one.)
  2. Cheap, no measurement change — make EXPECTED_PCR0 a configured allowlist rather than a
    source constant, so a GCP firmware roll is an operator action rather than a release, and so more
    than one GCP OVMF build can be accepted during a transition. Keeps the fail-closed default.
  3. Cheap, composes with fix(tpm-qvl): bound collateral fetching from untrusted URLs, and bind the GCP image identity to an attested PCR #1238 — match the PCR-2 event by type/description instead of
    pcr2_events.get(2). fix(tpm-qvl): bound collateral fetching from untrusted URLs, and bind the GCP image identity to an attested PCR #1238 already changes this call site to read the attested subset; picking
    the event by identity rather than index is a natural addition rather than a conflict.
  4. Deployment event — add PCR7 and PCR12 to dstack_pcr_policy() and to the GCP image-identity
    comparison, mirroring AWS_NITRO_TPM_BOOT_PCRS. This changes the quoted selection, so guests and
    verifiers must move together; if the new PCRs enter mr_aggregated then every allowlisted GCP
    measurement has to be re-issued. Worth deciding deliberately: binding them without putting them
    in mr_aggregated (a separate equality check against a value in measurement.gcp.cbor, the way
    AWS's boot_pcr_digest works) gets most of the benefit with a much smaller migration.
  5. Probably not worth it — modelling GCP's TDVF to produce expected MRTD/RTMR0–2. It would mean
    tracking Google's firmware builds, which is the problem (2) exists to avoid. Recording this as
    considered-and-rejected is more useful than leaving it implicit.

5. Dedup

NOVEL. Adjacent, not overlapping:

6. Reachability

  • (1) + (2): the host / cloud operator, within whatever the vTPM lane does not already cover.
    Credential: host control of the VM shape. Frequency: host-controlled. I have not demonstrated any
    concrete substitution — a UKI change is caught by the Authenticode digest, and I did not identify
    something that changes PCR7 or PCR12 without also changing PCR2. Treat the reachability as
    unestablished; the finding is the missing defence, not a demonstrated bypass.
  • (4) EXPECTED_PCR0: nobody triggers it; it is an availability and agility cost borne on Google's
    release schedule.

A full per-platform parity matrix backing this issue — with a file:line or an explicit "nothing",
annotated [platform] or [dstack], in every cell — is in
.agent/TRUST-CHAIN-platform-parity.md.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions