From 6e7b80b76747e4131cbabfd28eb1d52cc1835359 Mon Sep 17 00:00:00 2001 From: Imran Siddique Date: Fri, 26 Jun 2026 16:13:27 -0700 Subject: [PATCH] refactor(attestation): align nonce definition to the implemented thumbprint+salt MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The codebase carried two nonce definitions. The implemented and verifier-enforced one (startup.py + cmcp_verify._verify_key_binding + test_verify.py) is: nonce = JWK_thumbprint(tee_public_key)(32) || random_salt(32) with the session bound separately via the signed gateway.session_id. A second, unused definition -- SHA-256(tee_public_key || session_id) -- lived in make_nonce, the spec, provider docstrings, and the claim4 experiment/tests. This aligns those to the implemented design (the better one: RFC 7638 standard key binding, per-startup freshness via salt, and a lifecycle that fits a startup-time hardware report). - make_nonce now returns thumbprint||salt; adds jwk_thumbprint() helper - docs/spec/attestation.md §3.3 rewritten (+ §3.3.1 session binding); provider report_data references updated - claim4 experiment + tests + README rewritten around key binding / freshness / session-binding-via-signature - test_tee make_nonce tests updated Verified: ruff + mypy clean, 79 affected tests pass, claim4 experiment exits 0. NOTE: the TPM *verifier* (cmcp_verify/tpm.py qualifying_data) still checks the old SHA-256(pubkey||session_id) formula, disagreeing with the TPM provider (which uses the startup nonce). That is a separate, pre-existing verifier bug flagged for a hardware-tested follow-up; not changed here. --- docs/spec/attestation.md | 40 +++-- .../claim4-trace-claim-nonce/README.md | 40 +++-- experiments/claim4-trace-claim-nonce/run.py | 163 +++++++++--------- src/cmcp_runtime/tee/base.py | 40 ++++- tests/unit/test_claim4_trace_claim_nonce.py | 68 ++++---- tests/unit/test_tee.py | 37 ++-- 6 files changed, 217 insertions(+), 171 deletions(-) diff --git a/docs/spec/attestation.md b/docs/spec/attestation.md index 631f703..b097000 100644 --- a/docs/spec/attestation.md +++ b/docs/spec/attestation.md @@ -55,7 +55,7 @@ measurement = SHA-256(PCR0 || PCR1 || PCR2 || PCR3 || PCR4 || PCR5 || PCR6 || PC Each PCR value is the raw 32-byte SHA-256 digest read from the TPM. Concatenation is in bank index order (0 through 7), no separators. The result is a 32-byte SHA-256 digest encoded as lowercase hex. The PCR bank used is SHA-256. If the platform only offers a SHA-1 bank, the runtime logs a warning and uses SHA-1 PCR values zero-extended to 32 bytes before hashing; this is noted in `attestation_report.measurement_note: "sha1-bank-fallback"`. -Quote generation: the gateway calls `TPM2_Quote` with the nonce set to `SHA-256(tee_public_key || session_id)` (see Section 3.3). The quote and its signature are stored in `attestation_report.raw_evidence` (base64-encoded) for verifier use. +Quote generation: the gateway calls `TPM2_Quote` with `qualifying_data` set to the §3.3 nonce (`JWK_thumbprint(tee_public_key) || random_salt`). The quote and its signature are stored in `attestation_report.raw_evidence` (base64-encoded) for verifier use. #### SEV-SNP (High Assurance) @@ -69,7 +69,7 @@ What goes in `attestation_report.measurement`: measurement = SNP_REPORT.measurement ``` -`SNP_REPORT.measurement` is the 48-byte launch measurement field from the AMD SEV-SNP attestation report, encoded as lowercase hex (96 characters). It is obtained by calling `ioctl(fd, SNP_GET_REPORT, &req)` on `/dev/sev-guest` with the `report_data` field set to `SHA-256(tee_public_key || session_id)` (zero-padded to 64 bytes as required by the SNP interface). +`SNP_REPORT.measurement` is the 48-byte launch measurement field from the AMD SEV-SNP attestation report, encoded as lowercase hex (96 characters). It is obtained by calling `ioctl(fd, SNP_GET_REPORT, &req)` on `/dev/sev-guest` with the 64-byte `report_data` field set to the §3.3 nonce (`JWK_thumbprint(tee_public_key) || random_salt`, which fills the field exactly). The full SNP report structure is stored in `attestation_report.raw_evidence` (base64-encoded) for verifier use. @@ -91,7 +91,7 @@ measurement = { } ``` -Each value is a 48-byte SHA-384 digest encoded as lowercase hex (96 characters). `MRTD` is the measurement of the initial TD contents. `RTMR0`-`RTMR3` are the runtime measurement registers. The TD report is obtained via `ioctl(fd, TDX_CMD_GET_REPORT0, &req)` with `reportdata` set to `SHA-256(tee_public_key || session_id)` zero-padded to 64 bytes. +Each value is a 48-byte SHA-384 digest encoded as lowercase hex (96 characters). `MRTD` is the measurement of the initial TD contents. `RTMR0`-`RTMR3` are the runtime measurement registers. The TD report is obtained via `ioctl(fd, TDX_CMD_GET_REPORT0, &req)` with the 64-byte `reportdata` set to the §3.3 nonce (`JWK_thumbprint(tee_public_key) || random_salt`, which fills the field exactly). The full TD report and quote are stored in `attestation_report.raw_evidence` for verifier use. @@ -102,7 +102,7 @@ Detection conditions: What goes in `attestation_report.measurement`: -The Opaque Managed Runtime provides a dedicated attestation API. The runtime calls `GET $OPAQUE_RUNTIME_ENDPOINT/v1/attestation` with the nonce `SHA-256(tee_public_key || session_id)` as a query parameter. The response includes an Opaque-specific measurement blob and a signed attestation certificate chain rooted in Opaque's hardware root of trust. The measurement field is set to the `measurement` field from the Opaque attestation response (format defined by the Opaque Runtime SDK; currently a 32-byte SHA-256 encoded as lowercase hex). The full response is stored in `attestation_report.raw_evidence`. +The Opaque Managed Runtime provides a dedicated attestation API. The runtime calls `GET $OPAQUE_RUNTIME_ENDPOINT/v1/attestation` with the §3.3 nonce (`JWK_thumbprint(tee_public_key) || random_salt`) as a query parameter. The response includes an Opaque-specific measurement blob and a signed attestation certificate chain rooted in Opaque's hardware root of trust. The measurement field is set to the `measurement` field from the Opaque attestation response (format defined by the Opaque Runtime SDK; currently a 32-byte SHA-256 encoded as lowercase hex). The full response is stored in `attestation_report.raw_evidence`. ### 1.3 Software-Only Development Fallback @@ -249,7 +249,7 @@ If this check fails, the TRACE Claim is considered stale. Verifiers must reject Attestation refresh without service interruption: -1. While the enclave is running, call the TEE's attestation API again with a fresh timestamp and the same nonce (`SHA-256(tee_public_key || session_id)`). +1. While the enclave is running, call the TEE's attestation API again with a fresh timestamp and the same §3.3 nonce (`JWK_thumbprint(tee_public_key) || random_salt`). 2. Replace `attestation_report` in the runtime's in-memory state with the new report. 3. Update `attestation_generated_at` to the current UTC timestamp. 4. All subsequent TRACE Claims use the new `attestation_report` and new `attestation_generated_at`. @@ -267,25 +267,31 @@ Sessions cannot outlive the attestation. If a session reaches `max_session_durat ### 3.3 Replay Prevention -The `attestation_report.report_data` field contains a nonce that binds the hardware-generated report to a specific key and session: +The `attestation_report.report_data` field contains a 64-byte nonce that binds the hardware-generated report to the gateway's TEE key: ``` -nonce = SHA-256(tee_public_key_bytes || session_id_bytes) +nonce = JWK_thumbprint(tee_public_key) (32 bytes) || random_salt (32 bytes) ``` -- `tee_public_key_bytes`: the raw 32-byte Ed25519 public key. -- `session_id_bytes`: the UTF-8 encoding of the session UUID (36 bytes including hyphens). -- The resulting 32-byte SHA-256 digest is passed as the `report_data` / `user_data` / `nonce` field when requesting the hardware attestation report. The exact field name varies by provider but the semantic is the same: a caller-supplied value that is included in the signed measurement. +- `JWK_thumbprint(tee_public_key)`: the RFC 7638 JWK Thumbprint of the Ed25519 public key — SHA-256 over the canonical JSON of the required OKP members in lexicographic order (`crv`, `kty`, `x`). This is re-derivable by any verifier from `cnf.jwk.x`. +- `random_salt`: 32 random bytes generated once per enclave startup, so two enclave instances produce distinct nonces even with the same key (e.g. blue-green deploy). +- The 64-byte value is passed as the `report_data` / `user_data` / `reportdata` / `qualifying_data` field when requesting the hardware attestation report. The field name varies by provider; the semantic is the same: a caller-supplied value included in the signed measurement. -Verifier check: +Verifier check (key binding, CRYPTO-001): ``` -expected_nonce = SHA-256(tee_public_key_bytes || session_id_bytes) -actual_nonce = base64url_decode(attestation_report.report_data) -assert expected_nonce == actual_nonce +expected_fingerprint = JWK_thumbprint(base64url_decode(cnf.jwk.x)) +actual_nonce = base64url_decode(trace.runtime.nonce) +assert actual_nonce[:32] == expected_fingerprint ``` -A TRACE Claim replayed from a different session (different `session_id`) or from a different enclave instance (different `tee_public_key`) fails this check because the `report_data` in the hardware-signed attestation report will not match the recomputed nonce. This prevents an attacker from reusing a valid attestation report across sessions or keys. +A TRACE Claim whose `cnf.jwk` public key was substituted after attestation fails this check, because the embedded `report_data` (hardware-signed) will not match the re-derived thumbprint. A claim produced by a different enclave instance carries a different key (and salt), so it fails too. + +**Session binding** is carried separately, by `gateway.session_id` inside the Ed25519-signed claim body — not by the nonce. The hardware report is generated once per enclave instance at startup, before any session exists, so it cannot bind a specific `session_id`. Because the signature covers `session_id`, a claim cannot be presented under a different session without breaking verification. See §3.3.1. + +#### 3.3.1 Session binding + +The signed claim body includes `gateway.session_id`. Any change to it invalidates the Ed25519 signature, so a valid claim for session A cannot be replayed as session B. For the cross-organizational case (Phase 2), the gateway and server TEEs each produce a claim carrying the **same** `session_id`; the shared identifier links the two independently-signed, independently-key-bound claims. --- @@ -467,7 +473,7 @@ Full set of fields relevant to attestation: "attestation_report": { "provider": "<'tpm' | 'sev-snp' | 'tdx' | 'opaque' | 'software-only'>", "measurement": "", - "report_data": "", + "report_data": "", "raw_evidence": "" }, "attestation_assurance": "<'medium' | 'high' | 'highest' | 'none'>", @@ -504,7 +510,7 @@ A relying party verifying a TRACE Claim must perform all of the following checks 1. Parse and validate the JSON structure against the TRACE Claim schema. 2. Verify `signature`: compute `SHA-256(canonical_json(claim_without_signature))`, verify Ed25519 signature using `tee_public_key`. 3. Verify attestation freshness: `now - attestation_generated_at < attestation_validity_seconds`. -4. Verify nonce binding: `SHA-256(tee_public_key_bytes || session_id_bytes) == base64url_decode(attestation_report.report_data)`. +4. Verify key binding: `JWK_thumbprint(base64url_decode(cnf.jwk.x)) == base64url_decode(trace.runtime.nonce)[:32]`. Session linkage is checked separately via the signed `gateway.session_id` (§3.3.1). 5. Verify hardware report: validate `attestation_report.raw_evidence` using the provider's verification SDK (e.g., AMD SEV-SNP `snp-validate`, Intel TDX `tdx-attest`, TPM quote verification via TSS2). Confirm the report's `report_data` field matches the nonce from step 4. 6. Check `attestation_assurance` is acceptable for the use case (e.g., compliance use requires `"high"` or `"highest"`; reject `"none"`). 7. Verify `policy_bundle.hash` matches the policy bundle the verifier expects was in use. diff --git a/experiments/claim4-trace-claim-nonce/README.md b/experiments/claim4-trace-claim-nonce/README.md index 1284b2f..4ba4511 100644 --- a/experiments/claim4-trace-claim-nonce/README.md +++ b/experiments/claim4-trace-claim-nonce/README.md @@ -1,22 +1,32 @@ -# Claim 4: TRACE Claim Session-Bound Nonce and Selective Disclosure Resistance +# Claim 4: TRACE Claim Key-Bound Nonce and Selective Disclosure Resistance -**Claim:** Operator-Trust-Free Governance Proof Artifact with Session-Bound Attestation Nonce -**Paper:** `agentrust-io/papers/trace-claim.md` +**Claim:** Operator-Trust-Free Governance Proof Artifact with Key-Bound Attestation Nonce +**Paper:** `agentrust-io/papers/cmcp/` (Property 4) --- ## What this measures -The TRACE Claim nonce construction `SHA-256(tee_public_key_bytes || session_id_bytes)` binds each attestation report to a specific session and TEE instance. This experiment verifies: +The TRACE Claim nonce (`docs/spec/attestation.md` §3.3) is: + +``` +nonce = JWK_thumbprint(tee_public_key) (32 bytes) || random_salt (32 bytes) +``` + +The first 32 bytes are the RFC 7638 JWK Thumbprint of the gateway public key, so a +verifier re-derives them from `cnf.jwk.x` and confirms they equal `report_data[:32]` +(key / instance binding). The remaining 32 bytes are a per-startup random salt, so +each enclave instance produces a distinct, fresh nonce. The session is bound through +`gateway.session_id` inside the Ed25519-signed claim body, **not** the nonce. | Property | Claim | |---|---| -| P1 — Nonce determinism | Same key + session → same nonce | -| P2 — Session binding | Different session_id → different nonce | -| P3 — Instance binding | Different TEE key → different nonce for the same session | -| P4 — Replay prevention | Claim from session A fails nonce check for session B | -| P5 — Signature tamper-evident | Replacing session_id in a signed claim breaks Ed25519 signature | -| P6 — Selective disclosure resistance | Removing one audit entry changes bundle_hash; export signature fails | +| P1 — Thumbprint determinism | Same key → same thumbprint, re-derivable from `cnf.jwk.x` | +| P2 — Key binding | `report_data[:32]` equals the thumbprint | +| P3 — Instance binding | Different TEE key → different thumbprint | +| P4 — Freshness | Different salt → different nonce across startups | +| P5 — Session binding | Replacing `session_id` in a signed claim breaks the Ed25519 signature | +| P6 — Selective disclosure resistance | Removing one audit entry changes `bundle_hash`; export signature fails | --- @@ -29,8 +39,10 @@ python experiments/claim4-trace-claim-nonce/run.py --- -## Note on P4 - -In software-only mode, the nonce binding is demonstrated as a mathematical check: the nonce embedded in the claim (computed at claim-generation time for session A) is shown to differ from the verifier's expected nonce for session B. In hardware TEE mode, the nonce is hardware-signed inside the TEE and cannot be forged by the operator. The mathematical check becomes a hardware-enforced check. +## Note on hardware mode -P5 (signature tamper-evidence) is fully enforced in software and does not require hardware. +In software-only mode the bindings are demonstrated as mathematical checks. In +hardware TEE mode the nonce is committed into the hardware-signed `report_data` +field, so the operator cannot forge a thumbprint for a different key without +compromising the TEE. Session binding (P5) and selective-disclosure resistance +(P6) are enforced in software and do not require hardware. diff --git a/experiments/claim4-trace-claim-nonce/run.py b/experiments/claim4-trace-claim-nonce/run.py index 980d41c..a84644a 100644 --- a/experiments/claim4-trace-claim-nonce/run.py +++ b/experiments/claim4-trace-claim-nonce/run.py @@ -1,31 +1,31 @@ """ -Claim 4: Operator-trust-free governance proof artifact with session-bound nonce. +Claim 4: Operator-trust-free governance proof artifact with key-bound nonce. -The TRACE Claim nonce construction binds each attestation report to a specific -session and TEE instance. An attacker cannot replay a valid TRACE Claim from a -different session or a different TEE instance. +The TRACE Claim nonce binds each attestation report to the gateway's TEE key, and +the session is bound through the signed claim body. An operator cannot replay a +valid TRACE Claim under a different session or forge one for a different TEE key. -Nonce construction (hardware mode): - nonce = SHA-256(tee_public_key_bytes || session_id_bytes) +Nonce construction (hardware mode), per docs/spec/attestation.md §3.3: + nonce = JWK_thumbprint(tee_public_key) (32 bytes) || random_salt (32 bytes) -This nonce is set as the 'report_data' / 'user_data' field when requesting -the hardware attestation report. A verifier checks that the nonce in the -hardware-signed report matches SHA-256(claim.cnf.jwk.x || claim.gateway.session_id). +The first 32 bytes are the RFC 7638 JWK Thumbprint of the gateway public key, so a +verifier re-derives them from cnf.jwk.x and confirms they equal report_data[:32] +(key / instance binding). The remaining 32 bytes are a per-startup random salt so +every enclave instance produces a distinct, fresh nonce. Session linkage is NOT in +the nonce: it is carried by gateway.session_id inside the Ed25519-signed claim body. Properties demonstrated (software simulation): -P1 Nonce is deterministic for the same key and session_id. -P2 Nonce changes when session_id changes (session binding). -P3 Nonce changes when the TEE key changes (instance binding). -P4 A TRACE Claim produced for session A cannot be replayed for session B -- - the nonce in the claim would not match the verifier's expected nonce for B. -P5 Replacing session_id in a signed claim breaks the Ed25519 signature -- - an attacker cannot forge a valid claim for a different session. -P6 Selective disclosure: removing one audit entry from the export changes the - bundle hash, invalidating the gateway's signature over the export. - -Note: P4 is demonstrated as a mathematical check in software mode. In hardware -mode, the nonce is hardware-signed and cannot be forged by the operator. +P1 The JWK thumbprint is deterministic for a given key, and a verifier can + re-derive it from cnf.jwk.x. +P2 report_data[:32] equals the thumbprint -> the report is bound to this key. +P3 A different TEE key yields a different thumbprint (instance binding). +P4 A different salt yields a different nonce (freshness across startups). +P5 Session binding: replacing session_id in a signed claim breaks the Ed25519 + signature -- an attacker cannot present a claim under a different session. +P6 Removing one audit entry from the export changes the bundle hash, which + invalidates the gateway's signature over the export (selective disclosure + resistance). Running: pip install -e . @@ -50,13 +50,7 @@ canonical_json, generate_trace_claim, ) - - -def _compute_nonce(public_key_hex: str, session_id: str) -> str: - """SHA-256(tee_public_key_bytes || session_id_bytes) as hex.""" - key_bytes = bytes.fromhex(public_key_hex) - session_bytes = session_id.encode("utf-8") - return hashlib.sha256(key_bytes + session_bytes).hexdigest() +from cmcp_runtime.tee.base import jwk_thumbprint, make_nonce def _verify_sig(claim_dict: dict, pub_hex: str) -> bool: @@ -71,12 +65,12 @@ def _verify_sig(claim_dict: dict, pub_hex: str) -> bool: return False -def _stub_claim(session_id: str, signing_key: SigningKey, nonce_hex: str): +def _stub_claim(session_id: str, signing_key: SigningKey, nonce: bytes): """Generate a minimal TRACE Claim with an explicit nonce in report_data.""" report = AttestationReportInfo( provider="tpm", measurement="sha256:" + "ab" * 32, - report_data=nonce_hex, + report_data=nonce.hex(), attestation_generated_at="2026-06-25T00:00:00Z", attestation_validity_seconds=3600, ) @@ -102,73 +96,77 @@ def _result(label: str, value: str) -> None: def main() -> int: print() - print("Claim 4 | TRACE Claim nonce binding and selective disclosure resistance") + print("Claim 4 | TRACE Claim key binding and disclosure resistance") print("=" * 72) - # --- P1 & P2: Nonce determinism and session binding --- + # --- P1: thumbprint determinism + verifier re-derivation --- print() - print("P1 + P2 Nonce determinism and session binding") + print("P1 JWK thumbprint determinism") key = SigningKey() - nonce_A1 = _compute_nonce(key.public_key_hex, "session-A") - nonce_A2 = _compute_nonce(key.public_key_hex, "session-A") - nonce_B = _compute_nonce(key.public_key_hex, "session-B") - _result("Nonce(key, session-A) run 1", f"sha256:{nonce_A1}") - _result("Nonce(key, session-A) run 2", f"sha256:{nonce_A2}") - _result("Nonce(key, session-B)", f"sha256:{nonce_B}") - if nonce_A1 != nonce_A2: - print(" FAIL: nonce not deterministic") + salt = b"\x11" * 32 + tp1 = jwk_thumbprint(key.public_key_bytes) + tp2 = jwk_thumbprint(key.public_key_bytes) + _result("thumbprint run 1", tp1.hex()) + _result("thumbprint run 2", tp2.hex()) + if tp1 != tp2: + print(" FAIL: thumbprint not deterministic") return 1 - if nonce_A1 == nonce_B: - print(" FAIL: different session_ids produced the same nonce") + print(" PASS: thumbprint is deterministic and re-derivable from cnf.jwk.x") + + # --- P2: report_data binds the key --- + print() + print("P2 report_data[:32] equals the thumbprint (key binding)") + nonce = make_nonce(key.public_key_bytes, salt) + _result("nonce", nonce.hex()) + _result("report_data[:32]", nonce[:32].hex()) + if nonce[:32] != tp1: + print(" FAIL: report_data[:32] does not match the thumbprint") return 1 - print(" PASS: nonce is deterministic; changes with session_id") + print(" PASS: report is bound to this gateway key") - # --- P3: Instance binding (different key) --- + # --- P3: instance binding (different key) --- print() - print("P3 Instance binding -- different TEE key -> different nonce") + print("P3 Instance binding -- different TEE key -> different thumbprint") key2 = SigningKey() - nonce_key2 = _compute_nonce(key2.public_key_hex, "session-A") - _result("Key 1 nonce for session-A", f"sha256:{nonce_A1}") - _result("Key 2 nonce for session-A", f"sha256:{nonce_key2}") - if nonce_A1 == nonce_key2: - print(" FAIL: different TEE keys produced the same nonce for the same session") + tp_key2 = jwk_thumbprint(key2.public_key_bytes) + _result("key 1 thumbprint", tp1.hex()) + _result("key 2 thumbprint", tp_key2.hex()) + if tp1 == tp_key2: + print(" FAIL: different keys produced the same thumbprint") return 1 - print(" PASS: nonce changes with TEE key -- instance-binding confirmed") + print(" PASS: nonce[:32] changes with the TEE key") - # --- P4: Replay attack (mathematical check) --- + # --- P4: freshness (different salt) --- print() - print("P4 Session replay attack (mathematical verification)") - claim_A = _stub_claim("session-A", key, nonce_A1) - claim_A_dict = json.loads(claim_A.model_dump_json(exclude_none=True)) - actual_nonce_in_claim = claim_A_dict["trace"]["runtime"].get("nonce", "") - expected_nonce_for_B = base64.urlsafe_b64encode(bytes.fromhex(nonce_B)).rstrip(b"=").decode() - _result("Nonce embedded in claim (session-A)", actual_nonce_in_claim) - _result("Verifier expected nonce for session-B", expected_nonce_for_B) - if actual_nonce_in_claim == expected_nonce_for_B: - print(" FAIL: nonce would pass for the wrong session") + print("P4 Freshness -- different salt -> different nonce") + nonce_b = make_nonce(key.public_key_bytes, b"\x22" * 32) + _result("nonce (salt A)", nonce.hex()) + _result("nonce (salt B)", nonce_b.hex()) + if nonce == nonce_b: + print(" FAIL: different salts produced the same nonce") return 1 - print(" PASS: claim from session-A fails nonce check for session-B") - print(" In hardware mode, the nonce is hardware-signed; this check") - print(" is enforced by the TEE provider's endorsement chain.") + print(" PASS: per-startup salt makes each instance nonce distinct") - # --- P5: Signature breaks on session_id tamper --- + # --- P5: session binding via signed claim body --- print() - print("P5 Ed25519 signature breaks on session_id tampering") - sig_valid_original = _verify_sig(claim_A_dict, key.public_key_hex) - tampered = json.loads(json.dumps(claim_A_dict)) + print("P5 Session binding -- session_id tamper breaks the Ed25519 signature") + claim = _stub_claim("session-A", key, nonce) + claim_dict = json.loads(claim.model_dump_json(exclude_none=True)) + sig_valid_original = _verify_sig(claim_dict, key.public_key_hex) + tampered = json.loads(json.dumps(claim_dict)) tampered["gateway"]["session_id"] = "session-B" sig_valid_tampered = _verify_sig(tampered, key.public_key_hex) - _result("Signature on original claim (session-A)", "VALID" if sig_valid_original else "INVALID") - _result("Signature after replacing session_id with session-B", "VALID" if sig_valid_tampered else "INVALID") + _result("signature on original claim (session-A)", "VALID" if sig_valid_original else "INVALID") + _result("signature after replacing session_id", "VALID" if sig_valid_tampered else "INVALID") if not sig_valid_original: print(" FAIL: original claim signature invalid") return 1 if sig_valid_tampered: print(" FAIL: tampered claim signature still valid") return 1 - print(" PASS: session_id tampering immediately breaks signature") + print(" PASS: a claim cannot be presented under a different session") - # --- P6: Selective disclosure resistance --- + # --- P6: selective disclosure resistance --- print() print("P6 Selective disclosure resistance -- removing one audit entry breaks export hash") verifier_nonce = "v-nonce-abc123" @@ -183,7 +181,6 @@ def main() -> int: sort_keys=True, separators=(",", ":"), ensure_ascii=True ).encode() export_sig_raw = key.sign(export_body) - export_sig = base64.urlsafe_b64encode(export_sig_raw).rstrip(b"=").decode() entries_minus_one = [e for e in audit_entries if e["call_id"] != "call-2"] canonical_minus = json.dumps(entries_minus_one, sort_keys=True, separators=(",", ":"), ensure_ascii=True).encode() @@ -212,16 +209,16 @@ def main() -> int: # --- Summary --- print() print("Summary:") - print(" P1: Nonce deterministic PASS") - print(" P2: Nonce changes with session_id PASS") - print(" P3: Nonce changes with TEE key PASS") - print(" P4: Session-A claim fails check for session-B PASS (mathematical)") - print(" P5: session_id tamper breaks Ed25519 sig PASS") - print(" P6: Entry removal breaks export signature PASS") + print(" P1: Thumbprint deterministic / re-derivable PASS") + print(" P2: report_data[:32] binds the TEE key PASS") + print(" P3: Thumbprint changes with TEE key PASS") + print(" P4: Salt makes each instance nonce fresh PASS") + print(" P5: session_id tamper breaks Ed25519 sig PASS") + print(" P6: Entry removal breaks export signature PASS") print() - print("In hardware TEE mode, P4 becomes a hardware-enforced check:") - print("The nonce is signed by the TEE hardware. The operator cannot forge") - print("a nonce for a different session without compromising the hardware.") + print("In hardware TEE mode, the nonce is committed into the hardware-signed") + print("report_data field. The operator cannot forge a thumbprint for a different") + print("key without compromising the TEE.") print() return 0 diff --git a/src/cmcp_runtime/tee/base.py b/src/cmcp_runtime/tee/base.py index 4b4e562..0f68619 100644 --- a/src/cmcp_runtime/tee/base.py +++ b/src/cmcp_runtime/tee/base.py @@ -2,7 +2,9 @@ from __future__ import annotations +import base64 import hashlib +import json from abc import ABC, abstractmethod from dataclasses import dataclass from datetime import UTC, datetime @@ -49,9 +51,12 @@ def get_attestation_report(self, nonce: bytes) -> AttestationReport: """ Produce a hardware attestation report. - nonce should be SHA-256(tee_public_key || session_id) as defined in - docs/spec/attestation.md §3.3 — this binds the report to a specific - gateway instance and session. + nonce is the 64-byte value defined in docs/spec/attestation.md §3.3: + the RFC 7638 JWK Thumbprint of the gateway public key (32 bytes) followed + by a random salt (32 bytes). See make_nonce(). This binds the report to a + specific gateway key (verifiable from cnf.jwk) and makes each instance's + report fresh. Session linkage is carried separately by the signed claim + body (gateway.session_id), not by the nonce. """ @abstractmethod @@ -59,9 +64,32 @@ def provider_name(self) -> str: """Return the canonical provider name string for attestation_report.provider.""" -def make_nonce(tee_public_key: bytes, session_id: str) -> bytes: - """Compute the attestation nonce: SHA-256(tee_public_key || session_id_bytes).""" - return hashlib.sha256(tee_public_key + session_id.encode()).digest() +def jwk_thumbprint(tee_public_key: bytes) -> bytes: + """RFC 7638 JWK Thumbprint of an Ed25519 OKP public key (32-byte SHA-256). + + Hashes the canonical JSON of the required OKP members in lexicographic order + (crv, kty, x), matching what a verifier re-derives from cnf.jwk.x. + """ + x_b64 = base64.urlsafe_b64encode(tee_public_key).rstrip(b"=").decode() + members = json.dumps( + {"crv": "Ed25519", "kty": "OKP", "x": x_b64}, + separators=(",", ":"), + sort_keys=True, + ).encode() + return hashlib.sha256(members).digest() + + +def make_nonce(tee_public_key: bytes, salt: bytes) -> bytes: + """Compute the attestation nonce: jwk_thumbprint(pubkey) (32) || salt (32). + + The first 32 bytes bind the report to the gateway key (a verifier re-derives + the RFC 7638 thumbprint from cnf.jwk.x and checks report_data[:32]). The salt + is 32 random bytes so each enclave instance/startup produces a distinct nonce + even with the same key. See docs/spec/attestation.md §3.3. + """ + if len(salt) != 32: + raise ValueError(f"salt must be 32 bytes, got {len(salt)}") + return jwk_thumbprint(tee_public_key) + salt class SoftwareOnlyProvider(TEEProvider): diff --git a/tests/unit/test_claim4_trace_claim_nonce.py b/tests/unit/test_claim4_trace_claim_nonce.py index c3e5875..24e2840 100644 --- a/tests/unit/test_claim4_trace_claim_nonce.py +++ b/tests/unit/test_claim4_trace_claim_nonce.py @@ -1,9 +1,14 @@ -"""Tests for Claim 4: TRACE Claim nonce binding and disclosure resistance. +"""Tests for Claim 4: TRACE Claim key binding and disclosure resistance. -These tests assert the invariants the claim4 experiment demonstrates: the nonce -binds a claim to a specific session and TEE instance, a session-id swap breaks -the Ed25519 signature, and removing an audit entry breaks the export signature. -They run in CI to catch regressions in nonce construction and claim signing. +Asserts the invariants the claim4 experiment demonstrates under the implemented +nonce construction (docs/spec/attestation.md §3.3): + + nonce = JWK_thumbprint(tee_public_key) (32) || random_salt (32) + +The nonce binds the report to the gateway key (report_data[:32] is the RFC 7638 +thumbprint, re-derivable from cnf.jwk.x); the session is bound through the signed +claim body, not the nonce. These run in CI to catch regressions in nonce +construction and claim signing. """ from __future__ import annotations @@ -23,15 +28,10 @@ ToolCatalogInfo, generate_trace_claim, ) +from cmcp_runtime.tee.base import jwk_thumbprint, make_nonce - -def _compute_nonce(public_key_hex: str, session_id: str) -> str: - """SHA-256(tee_public_key_bytes || session_id_bytes) as hex.""" - return hashlib.sha256(bytes.fromhex(public_key_hex) + session_id.encode("utf-8")).hexdigest() - - -def _b64url(raw: bytes) -> str: - return base64.urlsafe_b64encode(raw).rstrip(b"=").decode() +_SALT_A = b"\x11" * 32 +_SALT_B = b"\x22" * 32 def _verify_sig(claim_dict: dict, pub_hex: str) -> bool: @@ -46,11 +46,11 @@ def _verify_sig(claim_dict: dict, pub_hex: str) -> bool: return False -def _stub_claim(session_id: str, signing_key: SigningKey, nonce_hex: str): +def _stub_claim(session_id: str, signing_key: SigningKey, nonce: bytes): report = AttestationReportInfo( provider="tpm", measurement="sha256:" + "ab" * 32, - report_data=nonce_hex, + report_data=nonce.hex(), attestation_generated_at="2026-06-25T00:00:00Z", attestation_validity_seconds=3600, ) @@ -78,41 +78,37 @@ def _stub_claim(session_id: str, signing_key: SigningKey, nonce_hex: str): ) -def test_nonce_is_deterministic(): - """The same key and session_id always produce the same nonce.""" +def test_thumbprint_is_deterministic(): + """The JWK thumbprint is deterministic for a given key.""" key = SigningKey() - assert _compute_nonce(key.public_key_hex, "session-A") == _compute_nonce(key.public_key_hex, "session-A") + assert jwk_thumbprint(key.public_key_bytes) == jwk_thumbprint(key.public_key_bytes) -def test_nonce_changes_with_session_id(): - """Changing the session_id changes the nonce (session binding).""" +def test_report_data_binds_key(): + """nonce[:32] equals the JWK thumbprint, so report_data is bound to the key.""" key = SigningKey() - assert _compute_nonce(key.public_key_hex, "session-A") != _compute_nonce(key.public_key_hex, "session-B") + nonce = make_nonce(key.public_key_bytes, _SALT_A) + assert len(nonce) == 64 + assert nonce[:32] == jwk_thumbprint(key.public_key_bytes) + assert nonce[32:] == _SALT_A -def test_nonce_changes_with_tee_key(): - """Changing the TEE key changes the nonce for the same session (instance binding).""" - key1 = SigningKey() - key2 = SigningKey() - assert _compute_nonce(key1.public_key_hex, "session-A") != _compute_nonce(key2.public_key_hex, "session-A") +def test_thumbprint_changes_with_key(): + """Different TEE keys produce different thumbprints (instance binding).""" + assert jwk_thumbprint(SigningKey().public_key_bytes) != jwk_thumbprint(SigningKey().public_key_bytes) -def test_claim_nonce_does_not_match_other_session(): - """A claim minted for session-A carries A's nonce, which fails B's expected nonce.""" +def test_salt_makes_nonce_fresh(): + """A different salt yields a different nonce for the same key (freshness).""" key = SigningKey() - nonce_a = _compute_nonce(key.public_key_hex, "session-A") - claim = _stub_claim("session-A", key, nonce_a) - embedded = json.loads(claim.model_dump_json(exclude_none=True))["trace"]["runtime"]["nonce"] - assert embedded == _b64url(bytes.fromhex(nonce_a)) - expected_for_b = _b64url(bytes.fromhex(_compute_nonce(key.public_key_hex, "session-B"))) - assert embedded != expected_for_b + assert make_nonce(key.public_key_bytes, _SALT_A) != make_nonce(key.public_key_bytes, _SALT_B) def test_session_id_tamper_breaks_signature(): """Replacing session_id in a signed claim invalidates its Ed25519 signature.""" key = SigningKey() - nonce_a = _compute_nonce(key.public_key_hex, "session-A") - claim_dict = json.loads(_stub_claim("session-A", key, nonce_a).model_dump_json(exclude_none=True)) + nonce = make_nonce(key.public_key_bytes, _SALT_A) + claim_dict = json.loads(_stub_claim("session-A", key, nonce).model_dump_json(exclude_none=True)) assert _verify_sig(claim_dict, key.public_key_hex) claim_dict["gateway"]["session_id"] = "session-B" assert not _verify_sig(claim_dict, key.public_key_hex) diff --git a/tests/unit/test_tee.py b/tests/unit/test_tee.py index 0ceffec..3c34371 100644 --- a/tests/unit/test_tee.py +++ b/tests/unit/test_tee.py @@ -2,7 +2,6 @@ from __future__ import annotations -import hashlib from datetime import UTC, datetime from unittest.mock import patch @@ -11,7 +10,7 @@ from cmcp_runtime.config import Config from cmcp_runtime.config import TEEProvider as TEEProviderEnum from cmcp_runtime.errors import AttestationProviderUnsupported -from cmcp_runtime.tee.base import SoftwareOnlyProvider, make_nonce +from cmcp_runtime.tee.base import SoftwareOnlyProvider, jwk_thumbprint, make_nonce from cmcp_runtime.tee.detect import detect_provider @@ -60,26 +59,34 @@ def test_software_only_report_note(): # ── make_nonce ──────────────────────────────────────────────────────────────── def test_make_nonce_deterministic(): + """Same key and salt produce the same 64-byte nonce.""" key = b"\xab" * 32 - sid = "session-123" - nonce1 = make_nonce(key, sid) - nonce2 = make_nonce(key, sid) - assert nonce1 == nonce2 + salt = b"\x07" * 32 + assert make_nonce(key, salt) == make_nonce(key, salt) -def test_make_nonce_sha256(): +def test_make_nonce_structure(): + """Nonce is jwk_thumbprint(key)(32) || salt(32).""" key = b"\x01" * 32 - sid = "test" - expected = hashlib.sha256(key + sid.encode()).digest() - assert make_nonce(key, sid) == expected + salt = b"\x09" * 32 + nonce = make_nonce(key, salt) + assert len(nonce) == 64 + assert nonce[:32] == jwk_thumbprint(key) + assert nonce[32:] == salt + + +def test_make_nonce_rejects_bad_salt(): + with pytest.raises(ValueError): + make_nonce(b"\x01" * 32, b"\x00" * 16) def test_make_nonce_different_inputs(): - n1 = make_nonce(b"\x01" * 32, "a") - n2 = make_nonce(b"\x02" * 32, "a") - n3 = make_nonce(b"\x01" * 32, "b") - assert n1 != n2 - assert n1 != n3 + salt = b"\x05" * 32 + n1 = make_nonce(b"\x01" * 32, salt) + n2 = make_nonce(b"\x02" * 32, salt) + n3 = make_nonce(b"\x01" * 32, b"\x06" * 32) + assert n1 != n2 # different key -> different thumbprint + assert n1 != n3 # different salt -> different nonce # ── detect_provider ───────────────────────────────────────────────────────────