Skip to content

feat(verifier): say which trust anchor os_image_hash_verified came from - #1266

Open
kvinwang wants to merge 6 commits into
nextfrom
fix/verifier-result-contract
Open

kvinwang wants to merge 6 commits into
nextfrom
fix/verifier-result-contract

Conversation

@kvinwang

Copy link
Copy Markdown
Collaborator

Six audit findings on the verifier's result contract — what a relying party can safely read off a response — worked to a conclusion: four confirmed, two refuted. Several are documentation and pinning rather than behaviour, which is the right outcome for a contract.

cargo test -p dstack-verifier -p dstack-attest -p dstack-types: all green. cargo clippy --all-targets on each: clean, except one pre-existing items after a test module warning in dstack-attest (verified present on origin/next by stashing).

The one that reproduces on next

TDX-lite lets a requester mint an os_image_hash and still get os_image_hash_verified: true

Took the real tdx-lite-attestation.json fixture, appended one valid line to the checksum_file it carries, set os_image_hash = sha256(new manifest), re-encoded. On origin/next that returns:

is_valid: true
os_image_hash_verified: true
os_image_hash: <the attacker's own value>

with nothing in the response distinguishing it from the legacy path, where the hash is checked against a downloaded image.

This is self-consistent by design — it is only meaningful because the caller allowlists os_image_hash — but the two paths' guarantees differ by an entire trust anchor and a caller could not tell which one ran. The new test tdx_lite_os_image_hash_is_anchored_to_the_requesters_own_document was run verbatim (minus the new assertion) in a scratch worktree at origin/next: it passes, i.e. the mint reproduces.

Added os_image_hash_anchor: Option<OsImageHashAnchor>published_image | measurement_document | quoted_pcrs — filled from one exhaustive os_image_hash_anchor() that matches on the same two things verify_os_image_hash dispatches on, so a new platform variant fails the build in both places rather than silently defaulting.

Compatibility checked rather than assumed: default Nonenull; the only consumer in the tree that models the response is ct_monitor/src/main.rs:56 and it has no deny_unknown_fields; no SDK models it at all (the KMS and dstack-auth do their own verification), so there is no four-language parity obligation.

Also confirmed

os_image_is_dev is only knowable on the legacy path, and the README said otherwise

verification.rs:944 is the only assignment. I checked what a dev image actually differs by: is_dev exists only in metadata.json, and no measurement document carries it — Tdx/Sev/Gcp/AwsOsImageMeasurementDocument all carry checksum_file, which binds metadata.json's digest, not its contents. So it is not knowable on any self-contained path, and "populate it everywhere" would mean adding metadata.json to the measured material.

The README was wrong — it listed only GCP TDX and Nitro Enclave as the null cases, while TDX-lite (the primary path today), SEV-SNP and AWS are all null too. So a relying party following its advice to "reject dev images" silently had no signal.

Corrected in both the prose and the two inline samples, with the replacement advice stated: allowlist os_image_hash — a dev image is a different image with a different hash. self_contained_paths_report_no_os_image_metadata keeps README and code from drifting apart again.

A malformed vm_config was reported as a measurement failure

the error must name the malformed config, not the measurement:
  OS image hash verification failed: Failed to download image :
  ... (http://127.0.0.1:9/should-not-download/.tar.gz): Connection refused

Note the empty hash in the URL — and that an all-defaults VmConfig also silently selects Legacy. require_declared_os_image_hash now rejects an empty or non-32-byte os_image_hash with vm_config declares no os_image_hash. Verified all five paths use a 32-byte sha256, and that the SNP nested-config fallback cannot diverge for any shape the VMM emits.

The measurement cache had no eviction

store_measurements_in_cache writes one file per VM shape and nothing removes it — including when the MRs then fail to match.

A cap, not a TTL: entries never go stale, since a shape deterministically yields one measurement set, so the only reason to drop one is space. MEASUREMENT_CACHE_MAX_ENTRIES = 1024 (~400 B/entry ≈ 400 KB, far above any real deployment), evicting oldest-by-mtime, filtering on .json so a concurrent NamedTempFile is untouched, and best-effort — a prune failure is not a verification failure. Composes with #1251: touches only store_measurements_in_cache, not vm_config_cache_key.

Refuted

report_data domain tags are a parsing convention, not a capability

The claim was that an in-CVM app minting a quote with report_data = sha512("ratls-cert:" || <any SPKI>) forges a key-possession proof. Four links break it:

  1. It is not a tdx_quote/prefix quirk. DstackGuest.GetQuote, Attest and v1 Attest all take the 64 bytes verbatim through pad64 — arbitrary report_data is the documented primitive, not a hole in one method.
  2. Identity is not forgeable. app_id, compose_hash, instance_id and the MRs come from the system-owned RTMR3 log, and EmitEvent was removed in 0.6.0 with the message "runtime RTMR3 events are system-owned and cannot be extended by apps".
  3. Every consumer that reads ratls-cert: as possession also forces key use. ra-rpc/client.rs:157 and rocket_helper.rs:548 verify against a completed handshake's key; the KMS's sign_cert runs csr.verify(&signature) before verify_with_ra_pubkey.
  4. kms-root-ca: buys nothing. No code in the tree verifies that tag; it is produced once by dstack-util gen-ca-cert inside the KMS CVM, and a minted one carries the minter's identity. Minting one with the KMS's identity means being inside the KMS CVM — a single trust domain.

Net: an app can only re-prove what GetTlsKey/IssueCert already give it. report_data_domain_tags_are_reproducible_by_any_caller pins that Custom("ratls-cert") ≡ RaTlsCert byte-for-byte and that "raw" passes content through, so the tag is demonstrably not a boundary. New security-model.md section plus a verification-checklist item. No guest-agent code touched, so no overlap with #1256.

deny_unknown_fields would break the documented workflow

VerificationRequest: verifier/README.md tells callers to curl .../GetQuote -o quote.json and post it verbatim — and the GetQuote response carries report_data. Denying unknowns rejects the documented workflow.

VmConfig: a forward-compatible wire type flowing VMM → guest → KMS/verifier, whose own doc comments say fields are "absent on images built before this landed". Denying unknowns makes a newer VMM's config unverifiable by an older verifier — failing closed on an honest deployment.

Both pinned with tests; the README now states that extra fields are ignored. The other half of that finding — the misleading error — is fixed above.

TCB is surfaced, not gated — confirmed as documented, and now pinned

validate_tcb never reads report.status, and dcap-qvl 0.5.3's only gate is TcbStatus::is_valid(), false for Revoked alone. So /verify returns is_valid: true for OutOfDate, exactly as security-model.md:370 says. The gap was that nothing pinned it, so a dcap-qvl bump could change it silently.

Extracted CvmVerifier::verify_attested as a seam, then two matrices: all seven TcbStatus values through validate_tcb, and tcb_status_is_surfaced_without_changing_is_valid, which drives a real verified TDX attestation through the whole result path with only the status varied and pins the exact is_valid / tcb_status / boot_info.tcbStatus triple per status — including ""null, which fails closed. The README now says outright that is_valid ignores it.

Merge safety

git merge-tree against #1205, #1238, #1249, #1251, #1252 and #1256: all six merge clean. I also built and ran cargo test -p dstack-verifier -p dstack-attest -p dstack-types on the merged trees for #1251 (the big overlap), #1238 (whose GCP hunk sits next to the new precondition), #1205 and #1249 — all green. The README edit near the os_image_is_dev sample sits close to #1251's event_log_verified comment hunk; merge-tree confirms no conflict.

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant