Skip to content

feat(dkg): add TDXValidationHook for TDX attestation#831

Merged
0xHansLee merged 4 commits into
dkg/devfrom
dkg/impl-tdx-val-hook
May 29, 2026
Merged

feat(dkg): add TDXValidationHook for TDX attestation#831
0xHansLee merged 4 commits into
dkg/devfrom
dkg/impl-tdx-val-hook

Conversation

@0xHansLee

@0xHansLee 0xHansLee commented May 20, 2026

Copy link
Copy Markdown
Contributor

Summary

On-chain validator for TDX V4/V5 attestation quotes used by the DKG flow. Mirrors SGXValidationHook in structure (Ownable2Step + Pausable) with TDX-specific quote field offsets.

Identity model (v3 — RTMR3-bound binary, RTMR2-included platform)

Split along the operator / cloud boundary, where "operator-controlled" means the running Go binary:

  • Binary identity (operator-controlled): keccak256(RTMR3), whitelisted via EnclaveTypeData.codeCommitment (same path as SGX MRENCLAVE). RTMR3 is self-extended by the kernel exactly once at startup with SHA-384(/proc/self/exe) (see story-kernel #68), so its post-extend value SHA-384(0x00..00 || SHA-384(elf)) is fully determined by the running ELF.
  • Platform identity (cloud-managed): keccak256(MRTD || RTMR0 || RTMR1 || RTMR2), approved via the hook's approvePlatform admin. RTMR2 moves into the platform half because it measures TD initrd + kernel cmdline — both boot-image properties decided at TD launch by the firmware/boot chain, never the userspace Go binary loaded from disk after boot.

Why not keccak256(RTMR2) for the binary half: on every direct-launch cloud TD measured, RTMR2 contains the TD initrd + cmdline digest. The story-kernel Go binary is loaded from the rootfs after boot and is never fed into RTMR2 by any link of the boot chain — so a keccak256(RTMR2) rule would commit to a boot-image artifact, not to the running ELF. The v3 split corrects this by claiming RTMR3 explicitly for the userspace binary half.

This decouples binary release governance from cloud firmware vintage rotation, so each axis updates independently. REPORT_DATA[0:32] carries the kernel-bound caller commitment from DKG.register and is compared against expectedDataCommitment, mirroring SGX's instance binding.

Onboarding flow

A TDX enclave type is enabled in two steps:

  1. Governance: DKG.whitelistEnclaveType(enclaveType, { codeCommitment = keccak256(RTMR3), validationHookAddr = tdxProxy }).
  2. Hook owner: TDXValidationHook.approvePlatform(keccak256(MRTD || RTMR0 || RTMR1 || RTMR2), label).

Contract changes

  • TDXValidationHook (new). _computeBinaryCommitment reads RTMR3 at offset 520; _computePlatformCommitment assembles the 192-byte preimage from MRTD (offset 184) + the contiguous RTMR0/RTMR1/RTMR2 block (offsets 376..519).
  • ITDXValidationHook (new). Admin surface: setAutomataValidationAddr, approvePlatform, revokePlatform, isPlatformApproved.
  • IAutomataDcapAttestationFee: adds 1-arg overload (PR feat(dkg): let SGXValidationHook follow standard TCB via Automata verifier #816 pattern) so standard TCB transitions take effect automatically.

DKG.sol / IDKG.sol are unchanged — ABI/storage compatible with the live deployment.

Adversary cost — both halves must succeed

To pass register() an attacker must produce a TDX quote where:

  • RTMR3 hashes to an approved binary commitment, and
  • MRTD || RTMR0 || RTMR1 || RTMR2 hashes to an approved platform commitment.

The RTMR3 self-extend can only push values into RTMR3 (RTMRs are append-only). An attacker binary B' that self-extends RTMR3 with SHA-384(honest_B) to forge the binary commitment still has to be loaded by the boot chain — which measures the kernel image + initrd into RTMR1 and the initrd + cmdline into RTMR2. Either modification shifts the platform commitment off any honest entry, and the platform-approval check fails.

@0xHansLee 0xHansLee self-assigned this May 20, 2026
@codecov

codecov Bot commented May 20, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.

📢 Thoughts on this report? Let us know!

0xHansLee added a commit that referenced this pull request May 25, 2026
Adds the devnet helper kit used to bring up the 4-validator SGX+TDX chain
that exercises PR #831 + story-kernel PR #68:

- contracts/script/dcap-devnet/*.sh -- DCAP collateral upload, SGX hook
  rewire, full devnet bring-up scripts (real V3QuoteVerifier).
- contracts/script/dcap-devnet/add-alloc-extras.py -- alloc patcher that
  injects MockAutomataDcap bytecode and other non-Create3 prereqs.
- contracts/script/dcap-devnet/p256verifier_bytecode.txt -- pre-built
  P256Verifier bytecode for inline alloc injection.
- .claude/devnet-test/DEVNET_RUNBOOK.md -- long-form runbook for the
  SGX + DCAP devnet path (force-tracked under .claude).
- .claude/devnet-test/TDX_DEVNET_RUNBOOK.md -- TDX delta runbook on top
  of the SGX runbook.
- GenerateAlloc.s.sol -- USE_DEPLOYER_AS_OWNER toggle (default true) so
  the deployer key can call whitelistEnclaveType / approvePlatform /
  scheduleUpgrade directly without scheduling through the
  TimelockController. forge tests assume timelock ownership and will
  fail with this flag on, expected for devnet-only configuration.

This commit is intentionally devnet-only and is NOT meant to be merged
into the PR branch; it lives on dkg/hans-temp-test and is rebased on top
of dkg/impl-tdx-val-hook for each E2E test cycle.
0xHansLee added a commit that referenced this pull request May 25, 2026
Adds the devnet helper kit used to bring up the 4-validator SGX+TDX chain
that exercises PR #831 + story-kernel PR #68:

- contracts/script/dcap-devnet/*.sh -- DCAP collateral upload, SGX hook
  rewire, full devnet bring-up scripts (real V3QuoteVerifier).
- contracts/script/dcap-devnet/add-alloc-extras.py -- alloc patcher that
  injects MockAutomataDcap bytecode and other non-Create3 prereqs.
- contracts/script/dcap-devnet/p256verifier_bytecode.txt -- pre-built
  P256Verifier bytecode for inline alloc injection.
- .claude/devnet-test/DEVNET_RUNBOOK.md -- long-form runbook for the
  SGX + DCAP devnet path (force-tracked under .claude).
- .claude/devnet-test/TDX_DEVNET_RUNBOOK.md -- TDX delta runbook on top
  of the SGX runbook.
- GenerateAlloc.s.sol -- USE_DEPLOYER_AS_OWNER toggle (default true) so
  the deployer key can call whitelistEnclaveType / approvePlatform /
  scheduleUpgrade directly without scheduling through the
  TimelockController. forge tests assume timelock ownership and will
  fail with this flag on, expected for devnet-only configuration.

This commit is intentionally devnet-only and is NOT meant to be merged
into the PR branch; it lives on dkg/hans-temp-test and is rebased on top
of dkg/impl-tdx-val-hook for each E2E test cycle.
0xHansLee added a commit that referenced this pull request May 26, 2026
Adds the devnet helper kit used to bring up the 4-validator SGX+TDX chain
that exercises PR #831 + story-kernel PR #68:

- contracts/script/dcap-devnet/*.sh -- DCAP collateral upload, SGX hook
  rewire, full devnet bring-up scripts (real V3QuoteVerifier).
- contracts/script/dcap-devnet/add-alloc-extras.py -- alloc patcher that
  injects MockAutomataDcap bytecode and other non-Create3 prereqs.
- contracts/script/dcap-devnet/p256verifier_bytecode.txt -- pre-built
  P256Verifier bytecode for inline alloc injection.
- .claude/devnet-test/DEVNET_RUNBOOK.md -- long-form runbook for the
  SGX + DCAP devnet path (force-tracked under .claude).
- .claude/devnet-test/TDX_DEVNET_RUNBOOK.md -- TDX delta runbook on top
  of the SGX runbook.
- GenerateAlloc.s.sol -- USE_DEPLOYER_AS_OWNER toggle (default true) so
  the deployer key can call whitelistEnclaveType / approvePlatform /
  scheduleUpgrade directly without scheduling through the
  TimelockController. forge tests assume timelock ownership and will
  fail with this flag on, expected for devnet-only configuration.

This commit is intentionally devnet-only and is NOT meant to be merged
into the PR branch; it lives on dkg/hans-temp-test and is rebased on top
of dkg/impl-tdx-val-hook for each E2E test cycle.
@0xHansLee
0xHansLee force-pushed the dkg/impl-tdx-val-hook branch from 1f9cfc1 to 14bb38c Compare May 26, 2026 12:15
@wiz-837b06c6da

wiz-837b06c6da Bot commented May 26, 2026

Copy link
Copy Markdown

Wiz Scan Summary

Scanner Findings
Vulnerability Finding Vulnerabilities -
Data Finding Sensitive Data -
Secret Finding Secrets -
IaC Misconfiguration IaC Misconfigurations 1 Low
SAST Finding SAST Findings -
Software Management Finding Software Management Findings -
Total 1 Low

View scan details in Wiz

To detect these findings earlier in the dev lifecycle, try using Wiz Code VS Code Extension.

@0xHansLee
0xHansLee force-pushed the dkg/impl-tdx-val-hook branch from 9f296ee to 5f8f10a Compare May 26, 2026 13:12
0xHansLee added 2 commits May 27, 2026 10:06
On-chain validator for Intel TDX V4/V5 attestation quotes used by the
DKG flow. Mirrors SGXValidationHook in structure (Ownable2Step +
Pausable) with TDX-specific quote field offsets.

Identity model (schema v3):
- Binary identity   = keccak256(RTMR3), whitelisted via
  EnclaveTypeData.codeCommitment (the same DKG slot SGX uses for
  MRENCLAVE). RTMR3 is self-extended by the kernel exactly once at
  startup with SHA-384(/proc/self/exe), so its post-extend value
  SHA-384(0x00..00 || SHA-384(elf)) is fully determined by the
  running ELF.
- Platform identity = keccak256(MRTD || RTMR0 || RTMR1 || RTMR2),
  approved via the hook's approvePlatform admin. MRTD + RTMR0 pin
  SKU/TDVF, RTMR1 captures OVMF + bootloader vintage, RTMR2 pins
  the TD initrd + cmdline.

Why not keccak256(RTMR2) for the binary half: RTMR2 measures the TD
initrd + kernel cmdline, both boot-image properties decided at TD
launch by the firmware/boot chain. The userspace Go binary is loaded
from the rootfs after boot and is never fed into RTMR2 by any link of
the boot chain — so a keccak256(RTMR2) rule would commit to a
boot-image artifact, not to the running ELF. RTMR3 is the first
software-defined register the TD payload can claim, so the kernel
extends it explicitly for the binary half.

Decomposing the commitment along the operator / cloud boundary lets
binary releases and cloud firmware vintage rotations evolve
independently — N kernel binaries × M platform vintages collapse from
N×M into N + M governance rows.

REPORT_DATA[0:32] carries the kernel-bound caller commitment from
DKG.register and is compared against expectedDataCommitment,
mirroring SGX's instance binding.

Contract changes:
- TDXValidationHook (new). _computeBinaryCommitment reads RTMR3 at
  offset 520; _computePlatformCommitment assembles the 192-byte
  preimage from MRTD (offset 184) + the contiguous RTMR0/RTMR1/RTMR2
  block (offsets 376..519).
- ITDXValidationHook (new). Admin surface: setAutomataValidationAddr,
  approvePlatform, revokePlatform, isPlatformApproved.
- IAutomataDcapAttestationFee: 1-arg overload (PR #816 pattern) so
  standard TCB transitions take effect automatically.
- DeployTDXValidationHook script + GenerateAlloc opt-in toggle for
  TDX devnet bring-up.

DKG.sol / IDKG.sol are unchanged — ABI/storage compatible with the
live deployment.

To pass register() an attacker must produce a TDX quote where RTMR3
hashes to an approved binary commitment AND MRTD || RTMR0 || RTMR1 ||
RTMR2 hashes to an approved platform commitment. The platform half
catches initrd / rootfs forgeries (which shift RTMR1 and RTMR2);
RTMR3's append-only nature plus the kernel claiming it before any
operator code runs catches userspace tampering.

Tests:
- 30 TDXValidationHookTest cases pass (including decomposition tests
  for same-binary-different-platform and same-platform-different-binary).
- 64 DKGTest cases continue to pass (no regression).
- E2E on devnet with real Automata DCAP: TDX register-tx
  0x1ff101c150caa3b1506908123374ded76af1cc35d779d728016e1f9f7c55cd39
  at block 12015, status=1, with both AttestationFee + DKG.Registered
  logs emitted.
GitHub Actions runners are intermittently failing to download the SHAs
that the v5 moving tag of actions/setup-go and the v2 moving tag of
pnpm/action-setup currently resolve to:

  ##[error]An action could not be found at the URI
    'https://codeload.github.com/actions/setup-go/tar.gz/40f1582b...'

The same SHAs are fetchable from outside the runner network, so this
is a runner-side cache miss against the resolved v5 / v2 commits.
Bumping to v6 and v4 forces resolution to fresh SHAs. No behavioural
change — both v6 setup-go and v4 pnpm/action-setup are drop-ins for
our usage.
@0xHansLee
0xHansLee force-pushed the dkg/impl-tdx-val-hook branch from 5f8f10a to 5fe4b9b Compare May 27, 2026 01:07
@0xHansLee
0xHansLee marked this pull request as ready for review May 27, 2026 03:54
@jinn-agent

jinn-agent Bot commented May 27, 2026

Copy link
Copy Markdown

The new TDXValidationHook contract is well-structured and its field-offset arithmetic and assembly extractors look correct. A few reliability and deployment-safety concerns remain.

Highest concern: TDXValidationHook inherits PausableUpgradeable but never exposes pause()/unpause(), and validateReport carries no whenNotPaused guard — the emergency-stop capability that justifies the inheritance is completely non-functional.

Deployment risk: setTDXValidationHook() is now called unconditionally from setPredeploys() (the DKG_INCLUDE_TDX flag from the prior review was removed rather than set to false). Every chain — including mainnet — will whitelist enclave type 2 with the placeholder keccak256("TDX_BINARY_COMMITMENT_PLACEHOLDER…") code commitment and two pre-known GCP platform commitments. The placeholder values are publicly computable, so any quote that Automata passes with RTMR3 = SHA-384(TDX_BINARY_COMMITMENT_PLACEHOLDER_v3_RTMR3_self_extend) would satisfy all checks on a live chain.

Deployment script sentinel: TDX_CODE_COMMITMENT = 0x000…0002 in DeployTDXValidationHook.s.sol is a hardcoded placeholder; running the script as-is on a real network whitelists a well-known value.

The CI action upgrades (setup-go@v6, pnpm/action-setup@v4) look fine.

Coverage note: the TDXValidationHookStorageLocation constant cannot be mechanically verified here — operators should cross-check it equals keccak256(abi.encode(uint256(keccak256("story.TDXValidationHook")) - 1)) & ~bytes32(uint256(0xff)).


Review iteration 3 · Commit 61fe8be · 2026-05-29T06:02:27Z

@jinn-agent jinn-agent Bot 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.

Found 1 critical issue(s) that need attention.

Comment thread contracts/script/GenerateAlloc.s.sol Outdated
Comment thread contracts/script/GenerateAlloc.s.sol Outdated
// enclaveType=2 with the binary commitment above, then calls approvePlatform for each
// configured platform commitment. Operators flip this to true on TDX-capable devnets;
// it is false by default so mainnet/SGX-only deployments are unaffected.
bool private constant DKG_INCLUDE_TDX = true;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

should be false here?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Removed the constant and make the script set TDXValidationHook contract by default in ddf3d7d

0xHansLee added 2 commits May 29, 2026 14:45
Remove the DKG_INCLUDE_TDX gate so setTDXValidationHook is always invoked.
# Conflicts:
#	contracts/src/interfaces/external/IAutomataDcapAttestationFee.sol
@0xHansLee
0xHansLee merged commit 9567630 into dkg/dev May 29, 2026
12 of 13 checks passed
@0xHansLee
0xHansLee deleted the dkg/impl-tdx-val-hook branch May 29, 2026 06:04
0xHansLee added a commit that referenced this pull request Jun 12, 2026
…844)

## Problem

`DKG.sol::register` pins `dkgPubKey` to 64 bytes and `enclaveCommKey` to
65 bytes, but the kernel produces — and the consensus client verifies —
a **32-byte** ed25519 `dkgPubKey` and a **64-byte** secp256k1 `commKey`
(uncompressed, `0x04` prefix stripped). Every `register` therefore
reverts on the length check, so DKG cannot bootstrap on any chain
running the current contracts.

The strict checks were added in #831 (previously the fields were only
required to be non-empty), which is why this surfaced only after the
up-to-date contracts were actually deployed.

## Fix

Require `dkgPubKey == 32` and `enclaveCommKey == 64` to match what the
kernel emits and the CL verifies. Both fields stay fixed-length, so the
`abi.encodePacked` report-data preimage remains injective and
`calculateReportData` still mirrors it.

issue: piplabs/story-kernel#73
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.

3 participants