From 4fba04feb971dcc1ebdfc46094829ac708f9217f Mon Sep 17 00:00:00 2001 From: Imran Siddique Date: Mon, 14 Sep 2026 13:00:28 -0700 Subject: [PATCH] feat(verify): publish a TDX capture that binds a TRACE record's signing key The July GCP captures bind a manifest hash whose input was never published, so /verify and the homepage could only say genuine silicon. A capture taken on 2026-09-14 in a new C3 trust domain puts SHA-256 of an Ed25519 key, generated inside the TD, in REPORTDATA and signs a TRACE v0.3 record with it. The quote, the record and the capture program are published under verify/fixtures. verify/key-binding.js checks the binding in the browser and the homepage panel; tools/check-key-binding.py checks the record signature, binding, quote, MRTD and program digest with the Python SDK in the verifier job. The differential now covers three captures, 5,526 inputs. verify/fixtures is marked -text, since every file there is pinned by hash. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_013aK3gVWzNdcM3hZ2o2awK2 --- .gitattributes | 3 + .github/workflows/verifier.yml | 4 +- index.html | 8 +- llms.txt | 2 +- tools/check-key-binding.py | 84 ++++++++++++++++++ tools/check-tdx-verifier.mjs | 31 +++++-- tools/tdx-differential.py | 1 + verify/fixtures/gcp-tdx-2026-09-14-capture.py | 71 +++++++++++++++ .../gcp-tdx-2026-09-14-keybind_quote.bin | Bin 0 -> 8000 bytes .../gcp-tdx-2026-09-14-keybind_record.json | 49 ++++++++++ verify/home-panel.js | 21 +++-- verify/index.html | 24 ++--- verify/key-binding.js | 31 +++++++ verify/verify-page.js | 37 ++++++-- 14 files changed, 328 insertions(+), 38 deletions(-) create mode 100644 .gitattributes create mode 100644 tools/check-key-binding.py create mode 100644 verify/fixtures/gcp-tdx-2026-09-14-capture.py create mode 100644 verify/fixtures/gcp-tdx-2026-09-14-keybind_quote.bin create mode 100644 verify/fixtures/gcp-tdx-2026-09-14-keybind_record.json create mode 100644 verify/key-binding.js diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..faa4deb --- /dev/null +++ b/.gitattributes @@ -0,0 +1,3 @@ +# Hardware captures and their records are checked byte for byte (SHA-256 pins in +# tools/check-tdx-verifier.mjs and tools/check-key-binding.py). Never convert them. +verify/fixtures/** -text diff --git a/.github/workflows/verifier.yml b/.github/workflows/verifier.yml index 09f0a5f..f922bf5 100644 --- a/.github/workflows/verifier.yml +++ b/.github/workflows/verifier.yml @@ -29,7 +29,9 @@ jobs: with: python-version: '3.12' - name: Install the Python verifier the port is checked against - run: python -m pip install "agent-manifest==0.12.0" + run: python -m pip install "agent-manifest==0.12.0" "agentrust-trace==0.10.0" + - name: Check the key-binding capture's record, signature and REPORTDATA + run: python tools/check-key-binding.py - name: Record Python verdicts run: python tools/tdx-differential.py --out differential.json - uses: actions/setup-node@v4 diff --git a/index.html b/index.html index 3e6ec19..8d8c427 100644 --- a/index.html +++ b/index.html @@ -177,17 +177,17 @@

Prove what your AI ran, and what it did.

/verify › tdx_quote.binoffline · in this browser
    -
  1. quoteIntel TDX v4, GCP C3, captured 2026-07-21
  2. +
  3. quoteIntel TDX v4, GCP C3, captured 2026-09-14
  4. step 1attestation key signature over header and TD reportnot run
  5. step 2QE report binds the attestation keynot run
  6. step 3QE report signed by the platform PCK certificatenot run
  7. step 4PCK chain ends at the pinned Intel SGX Root CAnot run
  8. -
  9. REPORTDATA32 bytes set by the guestsee note
  10. +
  11. REPORTDATAcommits to the key that signed a published TRACE recordnot run
  12. verdictgenuine Intel TDX silicon signed this quotenot run
-

NoteThis quote proves genuine Intel TDX silicon signed it. Its REPORTDATA holds a manifest hash whose input was not published, so it does not yet tie a specific record to this machine.

+

NoteGenuine Intel TDX silicon signed this quote, and its REPORTDATA commits to the key that signed the TRACE record published beside it. It does not show that the software inside the trust domain was the image anyone intended.

Runs in your browser. Nothing is sent back to us.

@@ -271,7 +271,7 @@

AMD SEV-SNP

NVIDIA H100 confidential computing

diff --git a/llms.txt b/llms.txt index 40c196f..e8aad82 100644 --- a/llms.txt +++ b/llms.txt @@ -7,7 +7,7 @@ AgenTrust is the ecosystem at https://agentrust-io.com and the GitHub organizati ## Start here - [Overview](https://agentrust-io.com/): The verifiable AI supply chain from model weights to agent actions, the hardware it is validated on, and what the evidence does and does not prove. -- [Verify an Intel TDX quote](https://agentrust-io.com/verify/): Runs the four-step DCAP check on a genuine GCP confidential VM quote in the browser, ending at the pinned Intel SGX Root CA. A pass proves genuine Intel TDX silicon signed the quote. These captures do not tie a TRACE record to that machine, and the check does not appraise TCB currency or revocation. +- [Verify an Intel TDX quote](https://agentrust-io.com/verify/): Runs the four-step DCAP check on a genuine GCP confidential VM quote in the browser, ending at the pinned Intel SGX Root CA, then checks that the quote's REPORTDATA commits to the signing key of a TRACE record published beside it. A pass proves genuine Intel TDX silicon signed the quote and bound that key. The check does not appraise TCB currency or revocation, or show that the measured image is the one anyone intended. - [10-minute tool-call tutorial](https://agentrust-io.com/quickstart/): Write a policy, observe a denied call, and inspect a signed session record on a laptop. Software mode provides no hardware isolation or hardware-backed provenance. - [Weight Custody Manifest (WCM)](https://wcm.agentrust-io.com/): Bind model-weight identity and custody terms to key-release policy. The local walkthrough uses synthetic evidence and a placeholder key; it does not load a real model or demonstrate hardware protection. - [Runnable demos](https://agentrust-io.com/demos/): Software examples for policy decisions, evidence verification, delegation, and model-weight custody. Follow each demo's stated prerequisites and limits. diff --git a/tools/check-key-binding.py b/tools/check-key-binding.py new file mode 100644 index 0000000..1d74783 --- /dev/null +++ b/tools/check-key-binding.py @@ -0,0 +1,84 @@ +"""Check the key-binding TDX capture published on /verify/. + +verify/fixtures/gcp-tdx-2026-09-14-keybind_record.json carries a TRACE v0.3 record +signed inside a GCP C3 trust domain, with the quote that trust domain produced. +The page claims four things about it, and this checks each one with the Python +tools the page's JavaScript is held to: + +1. the quote verifies to the pinned Intel SGX Root CA (agent_manifest._tdx_verify); +2. the record carries that exact quote, and claims its MRTD; +3. REPORT_DATA[0:32] is SHA-256 of the record's cnf.jwk.x, and the rest is zero; +4. the record signature verifies under that key, over the SDK's canonical bytes. + +It also checks that the published capture program hashes to the digest the record +claims as its build provenance. + +It does not validate the record against the TRACE v0.3 draft schema, which lives +in trace-spec, and it does not appraise TCB currency, revocation, or whether the +MRTD is an image anyone intended. +""" +import base64 +import hashlib +import json +from pathlib import Path +import sys + +from agent_manifest._tdx_verify import parse_tdx_quote, verify_tdx_quote +from agentrust_trace.sign import _canonical_bytes, _pubkey_from_jwk + +ROOT = Path(__file__).resolve().parents[1] +FIXTURES = ROOT / 'verify' / 'fixtures' +FIXTURE = FIXTURES / 'gcp-tdx-2026-09-14-keybind_record.json' +PROFILE = 'tag:agentrust-io.com,2026:trace-v0.3' + + +def unb64u(text): + return base64.urlsafe_b64decode(text + '=' * (-len(text) % 4)) + + +def main(): + fixture = json.loads(FIXTURE.read_text(encoding='utf-8')) + record = fixture['record'] + quote = (FIXTURES / fixture['quote_file']).read_bytes() + failures = [] + + def check(condition, message): + if not condition: + failures.append(message) + + check(record.get('eat_profile') == PROFILE, f'record profile is not {PROFILE}') + check(verify_tdx_quote(quote) is True, 'quote does not verify to the pinned Intel root') + + parsed = parse_tdx_quote(quote) + evidence = record['runtime']['evidence'] + check(evidence.get('format') == 'tdx-quote-v4', 'evidence format is not tdx-quote-v4') + check(unb64u(evidence['quote']) == quote, 'record does not carry the published quote') + check(record['runtime'].get('measurement') == 'sha384:' + parsed.mrtd.hex(), 'record does not claim the quote MRTD') + + jwk = record['cnf']['jwk'] + key = unb64u(jwk['x']) + check(jwk.get('kty') == 'OKP' and jwk.get('crv') == 'Ed25519', 'record key is not Ed25519') + check(jwk['x'] == fixture['public_key_b64u'], 'fixture key differs from the record key') + check(parsed.report_data == hashlib.sha256(key).digest() + bytes(32), 'REPORT_DATA does not commit to the record key') + check(parsed.report_data.hex() == fixture['report_data_hex'], 'fixture REPORT_DATA differs from the quote') + + body = _canonical_bytes({k: v for k, v in record.items() if k != 'signature'}) + try: + _pubkey_from_jwk(jwk).verify(unb64u(record['signature']), body) + except Exception as error: + failures.append(f'record signature does not verify: {type(error).__name__}') + + program = (FIXTURES / 'gcp-tdx-2026-09-14-capture.py').read_bytes() + digest = hashlib.sha256(program).hexdigest() + check(digest == fixture['capture_script_sha256'], 'published capture program differs from the one that ran') + check(record['build_provenance'].get('digest') == 'sha256:' + digest, 'record build provenance is not the capture program') + + if failures: + print('\n'.join(f'FAIL {f}' for f in failures)) + return 1 + print('PASS key-binding capture: quote verifies, REPORT_DATA commits to the record key, record signature verifies') + return 0 + + +if __name__ == '__main__': + sys.exit(main()) diff --git a/tools/check-tdx-verifier.mjs b/tools/check-tdx-verifier.mjs index 457d17d..1465553 100644 --- a/tools/check-tdx-verifier.mjs +++ b/tools/check-tdx-verifier.mjs @@ -12,6 +12,7 @@ */ import { readFile } from 'node:fs/promises'; import { createHash } from 'node:crypto'; +import { checkKeyBinding } from '../verify/key-binding.js'; import { verifyTdxQuote, pinnedRootFingerprint, OFF_MRTD, QUOTE_HEADER_LENGTH, } from '../verify/tdx-verify.js'; @@ -23,27 +24,45 @@ const WHEN = '2026-09-14T00:00:00Z'; // swapped or re-captured fixture fails here rather than changing what // "a genuine quote" refers to on the public page. const CAPTURES = { - 'gcp-tdx-2026-07-21-tdx_quote.bin': 'f9efbac112efe510aa8ccd20703b063591b8c2c54c474d0ff1d6500299bae0ba', - 'gcp-tdx-2026-07-21-tdx_quote_manifest.bin': '1ae04c74b564ef8795d4c4e4ffd1835d080d9dad4f8879e5cd1e8249503828b2', + 'gcp-tdx-2026-07-21-tdx_quote.bin': { + sha256: 'f9efbac112efe510aa8ccd20703b063591b8c2c54c474d0ff1d6500299bae0ba', + mrtd: '9bf86e6280ec4282b8b5822d8166410a456cdb720109aa799f0011fa63df1de3ee5e35e293fc410c061433163acb03a6', + }, + 'gcp-tdx-2026-07-21-tdx_quote_manifest.bin': { + sha256: '1ae04c74b564ef8795d4c4e4ffd1835d080d9dad4f8879e5cd1e8249503828b2', + mrtd: '9bf86e6280ec4282b8b5822d8166410a456cdb720109aa799f0011fa63df1de3ee5e35e293fc410c061433163acb03a6', + }, + // A different trust domain, captured to bind a TRACE record's signing key. + 'gcp-tdx-2026-09-14-keybind_quote.bin': { + sha256: '2217b3d640b2e4cdabd34604ea59df7f4ea23ed9702d3ec040689dca20ce1d61', + mrtd: 'c1ee9c16e3afc506cfe042c5b846a368528f3b37618eafb27469bc114cf914e9222c91618470e7f2b28ac360968270a5', + record: 'gcp-tdx-2026-09-14-keybind_record.json', + }, }; -const MRTD = '9bf86e6280ec4282b8b5822d8166410a456cdb720109aa799f0011fa63df1de3ee5e35e293fc410c061433163acb03a6'; const failures = []; const check = (condition, message) => { if (!condition) failures.push(message); }; const load = async (name) => new Uint8Array(await readFile(new URL(`verify/fixtures/${name}`, root))); -for (const [name, digest] of Object.entries(CAPTURES)) { +for (const [name, { sha256: digest, mrtd, record }] of Object.entries(CAPTURES)) { const quote = await load(name); check(createHash('sha256').update(quote).digest('hex') === digest, `${name}: not the committed hardware capture`); const result = await verifyTdxQuote(quote, { verificationTime: WHEN }); check(result.accepted, `${name}: genuine capture rejected (${result.error})`); check(result.steps.every((s) => s.status === 'pass'), `${name}: a step did not pass`); - check(result.quote && result.quote.mrtd === MRTD, `${name}: MRTD differs from the capture's`); + check(result.quote && result.quote.mrtd === mrtd, `${name}: MRTD differs from the capture's`); check(result.quote && result.quote.reportData.slice(64) === '0'.repeat(64), `${name}: REPORTDATA tail is not zero`); check(result.chain.length === 3, `${name}: expected a three-certificate PCK chain`); + // Only the key-binding capture commits to a record key; the July captures bind + // a manifest hash, and the check must say no for them rather than pass vacuously. + const fixture = JSON.parse(await readFile(new URL(`verify/fixtures/${record || CAPTURES['gcp-tdx-2026-09-14-keybind_quote.bin'].record}`, root), 'utf8')); + const binding = await checkKeyBinding(result, fixture.record, quote); + if (record) check(binding.bound && binding.sameQuote && binding.sameMeasurement, `${name}: REPORTDATA does not bind the published record key`); + else check(!binding.bound && !binding.sameQuote, `${name}: key binding passed for a quote that does not commit to the record key`); + const tampered = quote.slice(); tampered[QUOTE_HEADER_LENGTH + OFF_MRTD] ^= 0xff; const t = await verifyTdxQuote(tampered, { verificationTime: WHEN }); @@ -94,4 +113,4 @@ if (failures.length) { console.error(failures.join('\n')); process.exit(1); } -console.log('PASS both GCP TDX captures verify; tampered quote and expired chain are rejected at the right step'); +console.log('PASS all three GCP TDX captures verify, the key-binding capture commits to its record key; tampered quote and expired chain are rejected at the right step'); diff --git a/tools/tdx-differential.py b/tools/tdx-differential.py index 70db6d1..62d156b 100644 --- a/tools/tdx-differential.py +++ b/tools/tdx-differential.py @@ -30,6 +30,7 @@ CAPTURES = [ "gcp-tdx-2026-07-21-tdx_quote.bin", "gcp-tdx-2026-07-21-tdx_quote_manifest.bin", + "gcp-tdx-2026-09-14-keybind_quote.bin", ] # Fixed, so a certificate expiring can never make the two runs disagree. VERIFICATION_TIME = "2026-09-14T00:00:00+00:00" diff --git a/verify/fixtures/gcp-tdx-2026-09-14-capture.py b/verify/fixtures/gcp-tdx-2026-09-14-capture.py new file mode 100644 index 0000000..8c16df9 --- /dev/null +++ b/verify/fixtures/gcp-tdx-2026-09-14-capture.py @@ -0,0 +1,71 @@ +import base64 +import hashlib +import json +import pathlib +import time + +from agentrust_trace.sign import sign_record +from cryptography.hazmat.primitives import serialization +from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey + +# Generated inside the TD. The private half is never serialized. +key = Ed25519PrivateKey.generate() +public = key.public_key().public_bytes(serialization.Encoding.Raw, serialization.PublicFormat.Raw) +report_data = hashlib.sha256(public).digest() + bytes(32) + +report = pathlib.Path("/sys/kernel/config/tsm/report/agentrust-capture") +report.mkdir() +(report / "inblob").write_bytes(report_data) +quote = (report / "outblob").read_bytes() +provider = (report / "provider").read_text().strip() +generation = (report / "generation").read_text().strip() +report.rmdir() + +b64u = lambda raw: base64.urlsafe_b64encode(raw).rstrip(b"=").decode() +mrtd = quote[48 + 136:48 + 184] +script_sha256 = hashlib.sha256(pathlib.Path(__file__).read_bytes()).hexdigest() + +record = { + "eat_profile": "tag:agentrust-io.com,2026:trace-v0.3", + "iat": int(time.time()), + "subject": "spiffe://agentrust-io.com/capture/gcp-tdx-key-binding", + "model": {"provider": "none", "model_id": "none: attestation capture, no model loaded"}, + "runtime": { + "platform": "intel-tdx", + "measurement": "sha384:" + mrtd.hex(), + "firmware_version": "gcp-c3", + "evidence": { + "format": "tdx-quote-v4", + "quote": b64u(quote), + "collateral": "embedded", + "binds": "cnf-key", + }, + }, + # No policy governed this capture: the digest is of empty input and the mode + # only declares, it enforces nothing. + "policy": {"bundle_hash": "sha256:" + hashlib.sha256(b"").hexdigest(), "enforcement_mode": "declared"}, + "data_class": "public", + # The digest is of this capture program, published beside the capture, so it + # can be recomputed. SLSA level 0: nothing attests how the VM image was built. + "build_provenance": {"slsa_level": 0, "digest": "sha256:" + script_sha256}, + "appraisal": {"status": "none", "verifier": "https://github.com/agentrust-io/agent-manifest"}, +} +signed = sign_record(record, key) + +bundle = json.dumps( + { + "quote_b64": base64.b64encode(quote).decode(), + "public_key_b64u": b64u(public), + "report_data_hex": report_data.hex(), + "tsm_provider": provider, + "tsm_generation": generation, + "capture_script_sha256": script_sha256, + "record": signed, + }, + separators=(",", ":"), +).encode() +encoded = base64.b64encode(bundle).decode() +chunks = [encoded[i:i + 900] for i in range(0, len(encoded), 900)] +print(f"AGT-BUNDLE sha256={hashlib.sha256(bundle).hexdigest()} chunks={len(chunks)}", flush=True) +for i, chunk in enumerate(chunks): + print(f"AGT-CHUNK {i:04d} {chunk}", flush=True) diff --git a/verify/fixtures/gcp-tdx-2026-09-14-keybind_quote.bin b/verify/fixtures/gcp-tdx-2026-09-14-keybind_quote.bin new file mode 100644 index 0000000000000000000000000000000000000000..94dc34c2165fb7bba6d1fc096aba25a985d78a73 GIT binary patch literal 8000 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 Y1jZvU9)a-)j7MNR0^ { state.textContent = 'checking'; state.className = 'state'; }); try { - const response = await fetch(new URL('fixtures/gcp-tdx-2026-07-21-tdx_quote.bin', import.meta.url)); - if (!response.ok) throw new Error(`HTTP ${response.status}`); - const result = await verifyTdxQuote(new Uint8Array(await response.arrayBuffer())); + const [quoteResponse, recordResponse] = await Promise.all([ + fetch(new URL('fixtures/gcp-tdx-2026-09-14-keybind_quote.bin', import.meta.url)), + fetch(new URL('fixtures/gcp-tdx-2026-09-14-keybind_record.json', import.meta.url)), + ]); + if (!quoteResponse.ok || !recordResponse.ok) throw new Error('capture not loaded'); + const quote = new Uint8Array(await quoteResponse.arrayBuffer()); + const result = await verifyTdxQuote(quote); + const binding = await checkKeyBinding(result, (await recordResponse.json()).record, quote); for (const step of result.steps) { if (step.status === 'pass') set(step.id, 'PASS', 'pass'); else if (step.status === 'fail') set(step.id, 'FAIL', 'fail'); else set(step.id, 'not run', ''); } - set('verdict', result.accepted ? 'ACCEPTED' : 'REJECTED', result.accepted ? 'pass' : 'fail'); - set('reportdata', 'see note', 'note'); + const bound = binding.bound && binding.sameQuote && binding.sameMeasurement; + set('reportdata', bound ? 'PASS' : 'FAIL', bound ? 'pass' : 'fail'); + set('verdict', result.accepted && bound ? 'ACCEPTED' : 'REJECTED', result.accepted && bound ? 'pass' : 'fail'); } catch (error) { panel.querySelectorAll('.state').forEach((state) => { state.textContent = 'not run'; state.className = 'state'; }); } diff --git a/verify/index.html b/verify/index.html index b03447d..3c54fcb 100644 --- a/verify/index.html +++ b/verify/index.html @@ -4,7 +4,7 @@ Verify an Intel TDX Quote in Your Browser | AgenTrust - + @@ -60,12 +60,12 @@
04 · Evidence · Offline · In your browser · Intel TDX

Verify a real Intel TDX quote

-

A genuine quote from a GCP C3 confidential VM, checked on your machine the same way our Python verifier checks it: four signatures, ending at the Intel SGX Root CA pinned in this page. Nothing is uploaded.

+

A genuine quote from a GCP C3 confidential VM, checked on your machine the same way our Python verifier checks it: four signatures, ending at the Intel SGX Root CA pinned in this page. The latest capture also commits to the key that signed a TRACE record, and the page checks that too. Nothing is uploaded.

-
Two captures from 21 July 2026 · Checked against the Python verifier on 3,684 inputs · SEV-SNP and H100 validation
+
Three captures, 21 July and 14 September 2026 · Checked against the Python verifier on 5,526 inputs · SEV-SNP and H100 validation
@@ -73,7 +73,7 @@

Verify a real Intel TDX quote

Run it

Check a capture, or bring your own

-

Both captures come from one trust domain and bind different digests in REPORTDATA. You can also load a TDX v4 quote file from your own machine. It is read locally and never sent anywhere.

+

Capture 3 puts the SHA-256 of a TRACE record's signing key in REPORTDATA. The key was generated inside the trust domain and the record is published beside the quote, with the program that made both. Captures 1 and 2 come from an earlier trust domain and bind manifest digests whose inputs were not published. You can also load a TDX v4 quote file from your own machine. It is read locally and never sent anywhere.

@@ -115,13 +117,13 @@

What this proves, and what it does not

How it matches the original

A port, held to its original

This page runs verify/tdx-verify.js, a port of agent_manifest._tdx_verify: the same parse, the same checks on every length the quote declares, and the same four steps in the same order.

-

On every change, CI runs both verifiers over the two captures and 3,684 generated inputs: byte flips across every length and type field and the certificate text, a stride over everything else, and truncations at each structure edge. It fails if they disagree on a single one.

+

On every change, CI runs both verifiers over the three captures and 5,526 generated inputs: byte flips across every length and type field and the certificate text, a stride over everything else, and truncations at each structure edge. It fails if they disagree on a single one. The same job checks capture 3's record with the Python SDK: its signature, the key binding, the quote and MRTD it carries, and that the published capture program is the one the record names.

To run the original yourself:

Terminal
python -m pip install agent-manifest
-curl -fsSLO https://agentrust-io.com/verify/fixtures/gcp-tdx-2026-07-21-tdx_quote.bin
-python -c "from agent_manifest._tdx_verify import verify_tdx_quote; print(verify_tdx_quote(open('gcp-tdx-2026-07-21-tdx_quote.bin', 'rb').read()))"
+curl -fsSLO https://agentrust-io.com/verify/fixtures/gcp-tdx-2026-09-14-keybind_quote.bin +python -c "from agent_manifest._tdx_verify import verify_tdx_quote; print(verify_tdx_quote(open('gcp-tdx-2026-09-14-keybind_quote.bin', 'rb').read()))"

A genuine quote prints True.

diff --git a/verify/key-binding.js b/verify/key-binding.js new file mode 100644 index 0000000..f593dca --- /dev/null +++ b/verify/key-binding.js @@ -0,0 +1,31 @@ +/* Checks what the 2026-09-14 capture adds over a bare quote: its REPORTDATA + * commits to the signing key of the TRACE record published beside it, which is + * the `attested` grade in trace-spec's runtime evidence profile + * (REPORT_DATA[0:32] == SHA-256 of the record's cnf.jwk.x). + * + * The record signature is not checked here. That needs the SDK's canonical JSON, + * and a second canonicalizer in a page about evidence would be its own problem, + * so CI checks it with the Python SDK instead: see tools/check-key-binding.py. + */ +import { hex } from './tdx-verify.js'; + +const unb64u = (text) => Uint8Array.from( + atob(text.replace(/-/g, '+').replace(/_/g, '/') + '='.repeat((4 - (text.length % 4)) % 4)), + (c) => c.charCodeAt(0), +); + +const sameBytes = (a, b) => a.length === b.length && a.every((x, i) => x === b[i]); + +export async function checkKeyBinding(result, record, quote) { + const jwk = (record && record.cnf && record.cnf.jwk) || {}; + const evidence = (record && record.runtime && record.runtime.evidence) || {}; + const keyHash = jwk.kty === 'OKP' && typeof jwk.x === 'string' + ? hex(new Uint8Array(await crypto.subtle.digest('SHA-256', unb64u(jwk.x)))) + : null; + return { + keyHash, + bound: Boolean(result.accepted && result.quote && keyHash && result.quote.reportData.slice(0, 64) === keyHash), + sameQuote: typeof evidence.quote === 'string' && sameBytes(unb64u(evidence.quote), quote), + sameMeasurement: Boolean(result.quote) && record.runtime.measurement === `sha384:${result.quote.mrtd}`, + }; +} diff --git a/verify/verify-page.js b/verify/verify-page.js index 0a22079..6337a65 100644 --- a/verify/verify-page.js +++ b/verify/verify-page.js @@ -3,8 +3,14 @@ * object and is written with textContent, never as markup. */ import { verifyTdxQuote } from './tdx-verify.js'; +import { checkKeyBinding } from './key-binding.js'; const CAPTURES = { + keybind: { + file: 'fixtures/gcp-tdx-2026-09-14-keybind_quote.bin', + label: 'keybind_quote.bin', + record: 'fixtures/gcp-tdx-2026-09-14-keybind_record.json', + }, plain: { file: 'fixtures/gcp-tdx-2026-07-21-tdx_quote.bin', label: 'tdx_quote.bin' }, manifest: { file: 'fixtures/gcp-tdx-2026-07-21-tdx_quote_manifest.bin', label: 'tdx_quote_manifest.bin' }, }; @@ -37,7 +43,7 @@ function row(name, value) { return tr; } -function render(result, label, size) { +function render(result, label, size, binding) { $('term-title').textContent = `verify ${label}`; const body = $('term-body'); body.replaceChildren(line(`quote: ${label}, ${size} bytes`, 'dim')); @@ -47,8 +53,16 @@ function render(result, label, size) { if (step.detail) body.append(line(step.detail, 'dim')); }); if (result.quote) body.append(line(`REPORTDATA[0:32]: ${result.quote.reportData.slice(0, 64)}`, 'dim')); + const bound = binding && binding.bound && binding.sameQuote && binding.sameMeasurement; + if (binding) { + body.append(line(`record key SHA-256: ${binding.keyHash}`, 'dim')); + const [word, kind] = WORDS[bound ? 'pass' : 'fail']; + body.append(line(`key binding: REPORTDATA[0:32] is the SHA-256 of the TRACE record's signing key, and the record carries this quote and its MRTD: ${word}`, kind)); + } body.append(result.accepted - ? line('verdict: ACCEPTED. Genuine Intel TDX quote; the chain ends at the pinned Intel root.', 'ok') + ? line(bound + ? 'verdict: ACCEPTED. Genuine Intel TDX quote, and it commits to the key that signed the TRACE record published beside it.' + : 'verdict: ACCEPTED. Genuine Intel TDX quote; the chain ends at the pinned Intel root.', 'ok') : line(`verdict: REJECTED. ${result.error}`, 'alert')); body.append(line(`checked ${result.checkedAt} against this device's clock`, 'dim')); @@ -65,17 +79,24 @@ function render(result, label, size) { $('run-status').textContent = result.accepted ? `${label}: accepted` : `${label}: rejected`; } -async function run(bytes, label) { +async function run(bytes, label, record) { $('run-status').textContent = `Verifying ${label}`; - render(await verifyTdxQuote(bytes), label, bytes.byteLength); + const result = await verifyTdxQuote(bytes); + render(result, label, bytes.byteLength, record ? await checkKeyBinding(result, record, bytes) : null); +} + +async function fetchOk(file, label) { + const response = await fetch(new URL(file, import.meta.url)); + if (!response.ok) throw new Error(`could not load ${label} (HTTP ${response.status})`); + return response; } async function runCapture(key) { const capture = CAPTURES[key]; try { - const response = await fetch(new URL(capture.file, import.meta.url)); - if (!response.ok) throw new Error(`could not load ${capture.label} (HTTP ${response.status})`); - await run(new Uint8Array(await response.arrayBuffer()), capture.label); + const quote = new Uint8Array(await (await fetchOk(capture.file, capture.label)).arrayBuffer()); + const record = capture.record ? (await (await fetchOk(capture.record, 'the TRACE record')).json()).record : null; + await run(quote, capture.label, record); } catch (error) { $('term-body').replaceChildren(line(error.message, 'alert')); } @@ -95,4 +116,4 @@ input.addEventListener('change', async () => { }); $('verifier').hidden = false; -runCapture('plain'); +runCapture('keybind');