Skip to content

feat(tee): TDX quote parsing, versions 4 and 5 - #1186

Merged
odesenfans merged 10 commits into
dev-2.1from
od/tdx-quote-parsing
Sep 4, 2026
Merged

feat(tee): TDX quote parsing, versions 4 and 5#1186
odesenfans merged 10 commits into
dev-2.1from
od/tdx-quote-parsing

Conversation

@odesenfans

@odesenfans odesenfans commented Sep 2, 2026

Copy link
Copy Markdown
Contributor

Increment 2 of the Intel TDX design (#1144): quote parsing in aleph_tee::tdx, versions 4 and 5. Additive except for the new pub mod tdx line — and one deliberate consequence of that line: the golden launch measurements move (see below).

tdx/quote.rs

  • parse_tdx_quote() walks header → (v5 body descriptor) → TD report body → signature data → nested certification data, through a bounds-checked cursor that names the field in every truncation error. Structure only: no signature, chain, or TCB decision is made at this layer (that's increments 3-4).
  • The v5 trap is handled and pinned: v5 interposes a body descriptor (u16 type, u32 size) so the body starts at 54, not 48. Misparsing is silent (every field is fixed-size opaque bytes; a 6-byte shift yields plausible digests), so the v5 tests assert exact register values.
  • signed_region captures the exact bytes the quote signature covers (everything before the signature-data length field; for v5 that includes the descriptor). Not just prose: a test verifies the ECDSA P-256 signature over signed_region with the embedded attestation key for both fixtures, and asserts any other framing fails.
  • Strict envelope framing: certification data must be type 6 (QE report) nesting type 5 (PCK chain), and each size must consume its envelope exactly. Trailing bytes after the signature data are ignored (quotes arrive in fixed-size padded buffers; nothing back there can influence a verified value), and a test pins the exact boundary where that tolerance begins.
  • Extractors mirror the SNP side: extract_report_data() and extract_registers() -> TdxRegisters {mrtd, rtmr1, rtmr2, mrconfigid}. rtmr0/rtmr3 stay readable on the body but out of the pinned set (deployment parameters and the reserved launch-TCB commitment respectively).

Fixtures (tests/fixtures/tdx/, with a provenance/licence README)

  • Two real quotes from independent MIT-licensed sources, byte-identical to upstream at commits pinned in the README: Phala dcap-qvl (v4, TD report 1.0) and automata-dcap-attestation (v5, TD report 1.5, non-zero mrservicetd). Both licence texts are vendored next to the files.
  • The two accepted-format cases with no public fixture are synthesized in-test: trailing non-zero bytes (appended to the v4 quote) and a v5 quote with a TD report 1.0 body (spliced).
  • Every fixture self-validates cryptographically on each test run: the quote signature over the signed region, and the QE-report binding hash SHA-256(att_key || auth_data).

Tests (16): both fixtures parse with exact pinned registers; signature and QE-binding verification as above; the synthesized trailing-bytes and v5/TD-1.0 cases; rejection of SGX quotes, versions other than 4/5, non-ECDSA-P256 key types, SGX body descriptors, descriptor/envelope size mismatches, oversized length fields, and truncation sweeps over both fixture layouts plus the derived padding boundary.

Golden measurements: nix/golden-measurements.json is re-seeded in this PR. aleph-tee links into aleph-attest-agent, which ships in the measured initrd, so the new module recompiles the measured agent and legitimately moves the launch measurements. The values were produced by nix/check-golden-measurements.sh --update on this branch, match what CI computed independently for the same tree, and a plain check re-run after the later test-only commits reports "Golden measurements match" (test and fixture files do not feed the measured build).

🤖 Generated with Claude Code

https://claude.ai/code/session_01DUWEzYLgSidwttzQGtzB43

New aleph_tee::tdx::quote module, sibling of the SNP report parser:
parse_tdx_quote() walks header, v5 body descriptor, TD report body
(1.0 and 1.5), signature data and the nested certification envelopes
through a bounds-checked cursor. Structure only: signatures, the PCK
chain and TCB policy are the next increments.

The v5 body sits at offset 54, after a body descriptor; parsing it at
the v4 offset (48) fails silently, shifting every register by six bytes
into plausible-looking digests. The parser dispatches on the header
version, and the fixture tests pin exact register values from two
independent v5 sources.

signed_region captures the exact bytes the quote signature covers:
everything before the signature-data length field, descriptor included
on v5. Confirmed against all four vendored fixtures, whose signatures
verify over that range under their embedded attestation keys. Trailing
bytes after the signature data are ignored (quotes arrive in padded
fixed-size buffers); one upstream fixture deliberately carries trailing
garbage to test exactly that.

Fixtures are byte-identical vendored copies from Phala dcap-qvl (MIT),
google/go-tdx-guest (Apache-2.0) and automata-dcap-attestation (MIT),
with provenance and vendoring-time checks in the fixture README.

@foxpatch-aleph foxpatch-aleph left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The parsing logic itself is excellent: the offset math matches the Intel TDX quote v4/v5 layout (48-byte header, TDREPORT10/15 at 584/648 with report_data last, v5 descriptor at offset 48, 384-byte QE report, type-6-wrapping-type-5 cert envelopes), all length fields are bounds-checked with no panics on untrusted input, the strict envelope-exactness checks are the right call, and the QE-binding hash recomputation in assert_structure is a clever self-validating test design. However, the PR does not compile from a fresh checkout: the four binary fixtures referenced by include_bytes! (quote.rs:380-384) were never committed because the global .bin rule in .gitignore (line 14) silently swallowed them — git ls-files shows only README.md under tests/fixtures/tdx/, and no fetch script exists. CI (cargo test --locked) will therefore fail, and the vendoring-time signature verification documented in the README is not reproducible by anyone else. Follow the existing precedent in the same .gitignore (the !...supervisor-daemon/tests/fixtures/.sqlite3 exception) or rename to the upstream .dat extension, then commit the fixture files.

rust/crates/aleph-tee/src/tdx/quote.rs (line 380): Blocking: the four fixture .bin files this and the next three include_bytes! point at are not in the commit. The global *.bin rule at .gitignore:14 ignored them during git add, so git ls-files shows only README.md in tests/fixtures/tdx/, and no script fetches them. A fresh clone fails to compile the crate (include_bytes! is resolved at compile time), and CI's cargo test --locked (test-rust.yml) will fail. Fix by adding !rust/crates/aleph-tee/tests/fixtures/tdx/*.bin to .gitignore (mirroring the existing !rust/crates/supervisor-daemon/tests/fixtures/*.sqlite3 exception on line 12) and committing the files, or renaming to the upstream .dat extension (not ignored). Ironically the committed README warns "Do not regenerate or re-encode them; tests pin exact register values" — the files it describes are missing.

rust/crates/aleph-tee/src/tdx/quote.rs (line 258): Non-blocking: once the fixtures are committed: signed_region is the one field defined by fiat in this increment, and nothing in the diff actually proves a signature verifies over it (the README records the check as vendoring-time work, which nobody else can reproduce). Consider a test verifying the ECDSA signature over signed_region with the embedded attestation_key for one fixture — k256 is already a workspace dependency — so the framing contract is pinned by CI rather than by prose.

rust/crates/aleph-tee/src/tdx/quote.rs (line 163): Nit: the error always says "truncated quote" even when take is called from parse_signature_data's inner cursor, so a short nested certification field reads "truncated quote: nested certification data type needs 2 bytes at offset 738", which is confusing. A context label (or just "truncated input") would read better. Cosmetic only.

rust/crates/aleph-tee/src/tdx/quote.rs (line 553): Nit: rejects_truncation_everywhere sweeps only the v4 fixture; a v5 sweep would additionally exercise the body-descriptor region (offsets 48-54) and the 1.5 extension tail, which are the offsets unique to this PR's new logic.

@odesenfans odesenfans added 2.1 TDX Intel TDX support. labels Sep 3, 2026
The global *.bin ignore rule silently dropped them from the previous
commit, leaving include_bytes! pointing at files only present locally.
Exempt the fixture directory, like the supervisor-daemon sqlite3
fixtures one rule above.
The framing contract (the ECDSA P-256 signature covers everything
before the signature-data length field, body descriptor included on
v5) was previously pinned only by the fixture README. Verify it in CI
with the embedded attestation key, for all four fixtures, and assert
that any other framing fails. Uses openssl, already a dependency of
the SNP verifier; k256 would not do (secp256k1, not P-256).
The truncation sweep only walked the v4 fixture, never reaching the
offsets unique to v5: the body descriptor and the TD report 1.5
extension tail. Add a v5 sweep.

The cursor error said "truncated quote" even when walking the nested
signature-data buffer, where the reported offset is relative to that
buffer, not the quote; say "truncated input" instead.
The attest agent embeds aleph-tee, so the new module moves the
measured chain. Values match what CI computed for this branch.
@odesenfans

Copy link
Copy Markdown
Contributor Author

All four findings taken, four commits:

  • Fixtures committed (blocking): the global *.bin ignore rule silently dropped them from git add; the fixture directory is now exempted like the supervisor-daemon sqlite3 fixtures, and the four files are in.
  • Signature verified in CI: new test verifies the ECDSA signature over signed_region with the embedded attestation key for all four fixtures, and asserts any other framing fails — so the v5-includes-descriptor contract is pinned by CI, not prose. One correction to the hint: k256 is secp256k1 and cannot verify P-256 quotes; the test uses openssl, already a dependency of the SNP verifier, so no new deps.
  • Truncation wording: now "truncated input" (the cursor also walks the nested signature-data buffer, where the offset is relative to that buffer — the old wording misled twice).
  • v5 truncation sweep: added, covering the body descriptor region and the TD report 1.5 extension tail.

Also re-seeded nix/golden-measurements.json: the attest agent embeds aleph-tee, so the new module moves the measured chain (the third CI failure). The remaining "Test Python code" failure is pre-existing on the dev-2.1 base (pyproject-fmt comment realignment) and is fixed by #1185.

@foxpatch-aleph foxpatch-aleph left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Excellent, security-conscious parsing code. I independently verified every empirical claim in the PR description: the v4/v5 layouts match go-tdx-guest's reference implementation field-for-field; all four fixture signatures verify over exactly the signed_region the code captures (632/702 bytes) and fail over a one-byte truncation; the QE binding hash at qe_report[320..352] matches SHA-256(attestation_key || qe_auth_data); the type-6/type-5 envelope nesting consumes exactly on all fixtures; all pinned register hex values in the tests match an independent parse; and all four fixtures are byte-identical to upstream with the licenses (MIT, Apache-2.0, MIT) as documented in the README. Bounds checking is airtight (checked_add, named truncation errors, no reachable panics, no unsafe code), and the trailing-bytes policy is sound since nothing after the signature data feeds any verified value. The three nits below are non-blocking polish items.

rust/crates/aleph-tee/src/tdx/quote.rs (line 597): The magic number PHALA_V4.len() - 71 is sig_end - 1 in disguise: the fixture carries 70 bytes of zero padding after the signature data, so this is the largest prefix that still truncates within the signature data — any prefix of length 4936..5005 would parse successfully. A short comment pinning that relationship (or computing the bound from the parsed structure) would keep the test meaningful if the fixture is ever replaced.

rust/crates/aleph-tee/src/tdx/quote.rs (line 296): The only accepted-format combination without test coverage is a v5 quote carrying a TD report 1.0 body (descriptor type 2): the body_size == TD_REPORT15_SIZE branch is only exercised as false-by-default on v4. A synthetic test mutating a v5 fixture's descriptor to type 2 / size 584 and asserting a successful parse with v15 == None and signed_region.len() == 54 + 584 would close that gap. (No fixture ships with this combination, so it must be synthesized.)

rust/crates/aleph-tee/tests/fixtures/tdx/README.md (line 12): Attribution nit: Apache-2.0 redistribution technically requires including the license text, not just naming it. For test fixtures this is a gray area, but dropping the Apache-2.0 text (or a NOTICE) into this directory alongside the README would make the vendoring airtight. Also note the Phala file lives on the master branch (not main), which matters for anyone re-verifying byte-identity.

@codecov

codecov Bot commented Sep 3, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 78.74%. Comparing base (b1baabe) to head (08ca7e7).
⚠️ Report is 13 commits behind head on dev-2.1.

Additional details and impacted files
@@             Coverage Diff             @@
##           dev-2.1    #1186      +/-   ##
===========================================
+ Coverage    78.40%   78.74%   +0.33%     
===========================================
  Files          145      145              
  Lines        15400    15766     +366     
  Branches       972      994      +22     
===========================================
+ Hits         12075    12415     +340     
- Misses        3157     3178      +21     
- Partials       168      173       +5     

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

Drop the two go-tdx-guest fixtures (Apache-2.0): their coverage is
either duplicated by the remaining quotes or synthesized in-test. The
production v4 sample's trailing-marker case becomes a synthesized test
appending bytes to the v4 quote; the second v5 sample goes (the
remaining one is the richer, with non-zero mrservicetd); every fixture
still self-validates cryptographically (quote signature, QE binding).

The two remaining quotes come from independent MIT sources whose
licence texts now sit next to the files, satisfying the notice
condition, and the README pins the exact upstream commits so
byte-identity stays re-verifiable against fixed refs. Files renamed to
version-neutral names; test constants follow.
… body

The truncation sweep's largest-failing-prefix entry was a magic
number (fixture length minus the padding plus one). Replace it with a
test deriving the signature-data end from the quote's own length
field, asserting the parse fails one byte before it and succeeds
exactly at it, which also pins where padding tolerance begins.

A v5 quote carrying a TD report 1.0 body is the one accepted format
combination with no public fixture; splice one from the v5 fixture
(same header, a type-2/584 descriptor, truncated body, original
signature section) and assert it parses with no 1.5 extension and a
638-byte signed region.
@odesenfans

Copy link
Copy Markdown
Contributor Author

All three nits taken, with one change of course on the licence one: rather than importing the Apache-2.0 text, the two go-tdx-guest fixtures are dropped entirely (35fe672). Their coverage was either duplicated or synthesizable: the trailing-marker case is now a synthesized test appending bytes to the v4 quote, the second v5 sample goes (the remaining one carries the richer non-zero mrservicetd), and both remaining quotes still self-validate cryptographically per run. The fixture set is now MIT-only from two independent sources, with both licence texts vendored next to the files and the upstream commits pinned in the README for byte-identity re-verification.

The other two nits (6537ef4): the magic sweep entry is replaced by a test deriving the signature-data end from the quote's own length field, failing one byte before and parsing exactly at it, so the padding-tolerance boundary is pinned positively; and the v5-with-TD-report-1.0 combination is covered by a spliced synthetic (a naive descriptor mutation alone would misframe the signature section, so the splice rebuilds header + type-2 descriptor + truncated body + original signature data).

Golden measurements verified unchanged locally.

@foxpatch-aleph foxpatch-aleph left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is a well-crafted, independently verifiable parsing layer. I re-derived the entire layout from the fixture bytes with a separate Python implementation and everything matches: the 48-byte header, the v5 body descriptor at offset 54, all pinned register values, the QE-report attestation-key binding hash, and — most importantly — both ECDSA signatures verify over exactly the proposed signed_region (632 bytes for v4, 702 including the descriptor for v5), with off-by-one and v4-offset negative controls failing as expected. The v5 descriptor-is-signed trap this PR guards against is real and genuinely pinned by the tests. Parsing is strictly bounds-checked with overflow-safe length arithmetic and exact envelope-size enforcement; the deferral of all verification decisions is consistently documented and consistently applied. Remaining findings are non-blocking: the PR description overstates the fixture count (four fixtures incl. two go-tdx-guest Apache-2.0 quotes; the tree vendors two MIT fixtures) and understates the test count (16, not 13), and the golden-measurement regeneration is plausibly explained by aleph-tee being linked into the measured attest-agent but deserves a confirmation note in the PR description.

rust/crates/aleph-tee/tests/fixtures/tdx/README.md (line 8): The PR description is out of sync with the tree: it claims four vendored fixtures (including go-tdx-guest tdx_prod_quote_SPR_E4 and quote_sample_v5 under Apache-2.0) and that all four fixtures parse, but the tree vendors exactly two MIT fixtures, and the tests exercise only those two. The description also says 13 tests where the module has 16. The README here is accurate — it is the description that over-claims. Consider syncing the description (or vendoring the go-tdx-guest production fixture, whose trailing-garbage case is only synthesized here) so reviewers are not told about coverage that does not exist.

nix/golden-measurements.json (line 2): The golden launch measurements changed, but the PR description frames this as a pure parsing addition with nothing existing changing. The change is plausibly legitimate — aleph-tee is linked into aleph-attest-agent, which ships in the measured initrd (nix/initrd.nix:104), so the new pub mod tdx recompiles the measured agent — but per the warning in check-golden-measurements.sh, an unrelated or stale regeneration would silently mask a reproducibility regression. Please confirm these four values were produced by check-golden-measurements.sh --update on this exact branch state, and add a sentence to the PR description explaining the golden bump so it does not read as incidental noise.

rust/crates/aleph-tee/src/tdx/quote.rs (line 113): Minor: qe_report is the only fixed-size field stored as a Vec (attestation_key, quote_signature, and qe_report_signature are all fixed arrays, and qe_report.len() == 384 is an invariant asserted in tests). A [u8; QE_REPORT_SIZE] would make that invariant a type instead of a runtime property, and it is opaque either way. Not blocking.

rust/crates/aleph-tee/src/tdx/quote.rs (line 598): Nit on the comment: any prefix of a valid quote must fail is not quite true — a prefix ending exactly at the signature-data end parses fine (which padding_tolerance_starts_exactly_at_the_signature_data_end proves). The chosen lengths all avoid the boundary so the test is right; only the prose over-claims. Consider any prefix ending inside the signature data must fail.

rust/crates/aleph-tee/src/tdx/quote.rs (line 622): Style: the raw offsets here and at lines 481/634 (632, 636, 54 + 648 + 4 + 64 + 64 + 2) could be spelled with the module constants — HEADER_SIZE + TD_REPORT15_SIZE + 4 + 64 + 64 + 2 — so the numbers stay tied to their definition. Purely cosmetic; the comments already explain the arithmetic.

It is the only fixed-size signature-data field that was runtime-length
Vec; the 384-byte invariant is now the type, and the test asserting
the length goes.
The truncation-sweep comment claimed any prefix of a valid quote must
fail, which the padding-boundary test itself disproves at exactly the
signature-data end; scope the claim. The hardcoded 632/636 offsets and
the v5 certification-size arithmetic now derive from HEADER_SIZE and
the body-size constants.
aleph-tee compiles into the measured attest agent, so the qe_report
field type change moves the launch measurements.
@odesenfans

Copy link
Copy Markdown
Contributor Author

All five taken (three commits + a description rewrite):

  • PR description synced with the tree: two MIT fixtures with synthesized cases, 16 tests, and a dedicated section explaining the golden bump. On the golden confirmation: the committed values came from check-golden-measurements.sh --update at the branch tip, matched CI's independent computation byte-for-byte, and a plain check re-run after the test-only commits reported a match, so the moves track measured-code changes only.
  • d77bcea: qe_report is now [u8; QE_REPORT_SIZE]; the length invariant is the type, and the runtime assert goes. This moved the measured agent again, so 08ca7e7 re-seeds the golden file (same procedure, at this exact tip).
  • 6291c12: truncation-sweep comment scoped to prefixes ending before the signature-data end, and the raw test offsets now derive from HEADER_SIZE and the body-size constants.

@foxpatch-aleph foxpatch-aleph left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Excellent, well-documented, rigorously tested PR. I independently re-parsed both fixtures in Python following the Intel quote v4/v5 spec: all field offsets, sizes, nested envelope framing, and exact-size consumption checks in the parser are correct; the ECDSA signature verifies over exactly the parser's signed_region (including the v5 body descriptor, confirming the six-byte-shift trap is handled right) and fails over any other framing; and the QE-report binding hash at qe_report[320..352] confirms the signature-data layout. The parser is bounds-checked and panic-free on hostile input, the trailing-bytes tolerance is cryptographically outside anything verified, the golden-measurement re-seed rationale is sound (aleph-attest-agent path-depends on aleph-tee and ships in the measured initrd), and the 16 tests meaningfully pin the fragile parts (exact register values, padding boundary, truncation sweeps, rejection paths). Only two trivial nits: A test comment that contradicts its own assertion and the README. Approve.

rust/crates/aleph-tee/src/tdx/quote.rs (line 476): This comment contradicts both the assertion below it ("fixture must carry trailing padding") and the README ("70 bytes of zero padding after the signature data"). As written, "carries zero padding" reads as if there is no padding. Suggest: "The v4 fixture carries 70 bytes of zero padding after the signature data."

rust/crates/aleph-tee/src/tdx/quote.rs (line 317): Worth an explicit note that this deliberately scopes the parser to fresh Intel-QE quotes: certification data type 5 at the outer level (cached-collateral layout) is rejected, which is a decision increments 3-4 should revisit if cached collateral is ever needed. The doc comment partially covers this, so feel free to drop.

@odesenfans
odesenfans merged commit 9c3272e into dev-2.1 Sep 4, 2026
22 checks passed
@odesenfans
odesenfans deleted the od/tdx-quote-parsing branch September 4, 2026 09:45
odesenfans added a commit that referenced this pull request Sep 7, 2026
New aleph_tee::tdx::quote module, sibling of the SNP report parser:
parse_tdx_quote() walks header, v5 body descriptor, TD report body
(1.0 and 1.5), signature data and the nested certification envelopes
through a bounds-checked cursor. Structure only: signatures, the PCK
chain and TCB policy are the next increments.

The v5 body sits at offset 54, after a body descriptor; parsing it at
the v4 offset (48) fails silently, shifting every register by six bytes
into plausible-looking digests. The parser dispatches on the header
version, and the fixture tests pin exact register values from two
independent v5 sources.

signed_region captures the exact bytes the quote signature covers:
everything before the signature-data length field, descriptor included
on v5. Confirmed against all four vendored fixtures, whose signatures
verify over that range under their embedded attestation keys. Trailing
bytes after the signature data are ignored (quotes arrive in padded
fixed-size buffers); one upstream fixture deliberately carries trailing
garbage to test exactly that.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

2.1 TDX Intel TDX support.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants