diff --git a/proof/real_attest.py b/proof/real_attest.py index 3e38e64..1d37389 100644 --- a/proof/real_attest.py +++ b/proof/real_attest.py @@ -134,6 +134,24 @@ def detect_capabilities() -> dict: # Evidence generation (miner side — runs inside the CVM) # ============================================================================ +def gpu_sdk_nonce(nonce: str) -> str: + """Canonical nonce form for the NVIDIA attestation SDK / GPU token claim. + + The handshake nonce is committed on-chain as ``"0x" + 64 hex`` (66 chars), + but nv-attestation-sdk requires exactly 64 hex chars with NO prefix + ("Invalid Nonce Size" otherwise). Strip a leading 0x and lowercase so the + GENERATED token's nonce claim and the VALIDATOR's comparison use the same + form. (Issue 3 of the 2026-06-22 CC-hardware report.) + + NOTE: TDX report_data binding intentionally keeps the FULL handshake nonce + (see ``_get_tdx_quote``) — do not route TDX through here. + """ + n = nonce.strip() + if n[:2].lower() == "0x": + n = n[2:] + return n.lower() + + def _get_gpu_evidence(nonce: str) -> tuple[Optional[str], Optional[str]]: """Generate NVIDIA GPU attestation evidence + verify via NRAS. @@ -142,8 +160,12 @@ def _get_gpu_evidence(nonce: str) -> tuple[Optional[str], Optional[str]]: try: from nv_attestation_sdk import attestation - client = attestation.Attestation() - client.set_nonce(nonce) + # Issue 1 (2026-06-22 CC report): Attestation() with no name makes + # nv-attestation-sdk 2.7.3 return an empty get_token(). A name is + # required for a non-empty EAT JWT. + client = attestation.Attestation("ralph-miner") + # Issue 3: SDK rejects the 0x-prefixed 66-char nonce; pass 64 hex. + client.set_nonce(gpu_sdk_nonce(nonce)) client.add_verifier( attestation.Devices.GPU, attestation.Environment.REMOTE, @@ -175,26 +197,45 @@ def _get_tdx_quote(nonce: str, user_data: str) -> tuple[Optional[str], Optional[ report_data = hashlib.sha256((nonce + user_data).encode()).digest() + # Method 1: configfs-tsm interface (portable across kernel versions). + # Issue 2 (2026-06-22 CC report): creating the report node under + # /sys/kernel/config/tsm/report needs root, but the proof test runs as a + # non-root user. An operator can PRE-PROVISION a writable report node and + # point us at it via RALPH_TSM_REPORT_PATH (e.g. root pre-creates + # /sys/kernel/config/tsm/report/ralph and chowns it to the run user before + # the proof harness starts). If unset we still attempt makedirs (works when + # the harness has the privilege) and emit an actionable error if not. + preprov = os.environ.get("RALPH_TSM_REPORT_PATH", "").strip() + tsm_path = preprov or "/sys/kernel/config/tsm/report/ralph" + created = False try: - # Method 1: Direct /dev/tdx-guest ioctl (Linux kernel TDX guest driver) - tdx_dev = "/dev/tdx-guest" if os.path.exists("/dev/tdx-guest") else "/dev/tdx_guest" - # The tdx-guest device expects a specific ioctl; for now we use the - # configfs-tsm interface which is more portable across kernel versions. - tsm_path = "/sys/kernel/config/tsm/report/ralph" - os.makedirs(tsm_path, exist_ok=True) + if not preprov: + try: + os.makedirs(tsm_path, exist_ok=True) + created = True + except PermissionError: + print( + "[attest] TDX quote: cannot create the configfs-tsm report node " + f"at {tsm_path} (needs root). Run the quote step with privilege, " + "or have root pre-create+chown a node and set " + "RALPH_TSM_REPORT_PATH to it. Falling back to trustauthority-cli." + ) + raise with open(f"{tsm_path}/inblob", "wb") as f: f.write(report_data[:64].ljust(64, b"\0")) with open(f"{tsm_path}/outblob", "rb") as f: quote_bytes = f.read() quote_hex = quote_bytes.hex() - # Cleanup - try: - os.rmdir(tsm_path) - except Exception: - pass + if created: + try: + os.rmdir(tsm_path) + except Exception: + pass return quote_hex, None # No JWT for local TDX; validator verifies the raw quote + except PermissionError: + pass # already logged; fall through to Method 2 except Exception as e: - print(f"[attest] TDX quote generation failed: {e}") + print(f"[attest] TDX quote generation (configfs) failed: {e}") try: # Method 2: Intel Trust Authority client (if installed) @@ -324,6 +365,40 @@ def generate_attestation( # Verification (validator side) # ============================================================================ +def _extract_gpu_jwt(token): + """Extract the outer JWT string from the nv-attestation-sdk get_token() value. + + get_token() returns the detached-EAT BUNDLE, not a bare JWT: + [["JWT", ], {"GPU-0": , ...}] + (or a JSON string of that). The stub used to feed the whole bundle to + jwt.decode → "Invalid header string" (the 2026-06-22 CC-hardware report). + Returns the outer JWT string; falls back to the input unchanged if it + already looks like a bare JWT (back-compat + unit-test fixtures). + + NOTE: Part B (real NRAS verify) parses the FULL bundle — outer + each + detached per-GPU token + the submods digest binding. This helper only + pulls the outer JWT for the testnet stub's best-effort nonce check. + """ + import json as _json + + val = token + if isinstance(val, str): + s = val.strip() + if not s.startswith("[") and not s.startswith("{"): + return s # already a bare JWT + try: + val = _json.loads(s) + except Exception: + return token + if isinstance(val, (list, tuple)) and val: + head = val[0] + if isinstance(head, (list, tuple)) and len(head) >= 2 and isinstance(head[1], str): + return head[1] + if isinstance(head, str): + return head + return token + + def verify_gpu_token(token: str, expected_nonce: str) -> tuple[bool, str]: """Verify an NVIDIA GPU attestation JWT token. @@ -353,12 +428,17 @@ def verify_gpu_token(token: str, expected_nonce: str) -> tuple[bool, str]: ) try: import jwt - claims = jwt.decode(token, options={"verify_signature": False}) - token_nonce = claims.get("nonce", claims.get("eat_nonce", "")) - if expected_nonce and token_nonce != expected_nonce: + # get_token() returns the detached-EAT bundle; decode the OUTER JWT, + # not the whole bundle (fixes "Invalid header string"). + inner = _extract_gpu_jwt(token) + claims = jwt.decode(inner, options={"verify_signature": False}) + token_nonce = claims.get("eat_nonce", claims.get("nonce", "")) + # Compare in the SDK's 64-hex form (token claim has no 0x prefix). + exp = gpu_sdk_nonce(expected_nonce) if expected_nonce else "" + if exp and gpu_sdk_nonce(token_nonce) != exp: return False, ( - f"nonce mismatch in GPU token (expected {expected_nonce[:16]}, " - f"got {token_nonce[:16]})" + f"nonce mismatch in GPU token (expected {exp[:16]}, " + f"got {gpu_sdk_nonce(token_nonce)[:16]})" ) return True, "GPU token accepted (stub: signature unchecked)" except ImportError: diff --git a/tests/test_real_attest_nonce.py b/tests/test_real_attest_nonce.py new file mode 100644 index 0000000..af48a55 --- /dev/null +++ b/tests/test_real_attest_nonce.py @@ -0,0 +1,100 @@ +"""Tests for the 2026-06-22 CC-hardware attestation generation fixes. + +Covers the fully-CPU-testable parts: the GPU-SDK nonce normalization (Issue 3) +and that verify_gpu_token compares in the normalized form. The Attestation() +name fix (Issue 1) and the TDX privilege path (Issue 2) need real CC hardware +to exercise end-to-end and are validated there. +""" +from __future__ import annotations + +import importlib.util + +import pytest + +from proof import real_attest as RA + + +# ----------------------------------------------------------- nonce normalization +def test_gpu_sdk_nonce_strips_0x_and_lowercases(): + raw = "0x" + "AB" * 32 # 66 chars, uppercase + out = RA.gpu_sdk_nonce(raw) + assert out == "ab" * 32 + assert len(out) == 64 + assert not out.startswith("0x") + + +def test_gpu_sdk_nonce_idempotent_on_bare_hex(): + bare = "cd" * 32 + assert RA.gpu_sdk_nonce(bare) == bare + assert RA.gpu_sdk_nonce(RA.gpu_sdk_nonce("0x" + bare)) == bare + + +def test_gpu_sdk_nonce_handles_whitespace_and_0X(): + assert RA.gpu_sdk_nonce(" 0X" + "ef" * 32 + " ") == "ef" * 32 + + +# ------------------------------------------- verify compares in normalized form +@pytest.mark.skipif( + importlib.util.find_spec("jwt") is None, reason="PyJWT not installed" +) +def test_verify_gpu_token_matches_across_0x_prefix(monkeypatch): + """A token whose nonce claim is the 64-hex form must match an expected + handshake nonce given in the on-chain 0x-prefixed form (and vice versa).""" + import jwt + + monkeypatch.setenv("RALPH_ALLOW_REAL_ATTEST_STUB", "1") # stub path for nonce check + bare = "12ab" * 16 # 64 hex + onchain = "0x" + bare + token = jwt.encode({"nonce": bare}, "secret", algorithm="HS256") + + ok, detail = RA.verify_gpu_token(token, onchain) + assert ok, detail + + # mismatched nonce still rejected + token_bad = jwt.encode({"nonce": "00" * 32}, "secret", algorithm="HS256") + ok2, _ = RA.verify_gpu_token(token_bad, onchain) + assert not ok2 + + +def test_verify_gpu_token_empty_is_rejected(): + ok, detail = RA.verify_gpu_token("", "0x" + "00" * 32) + assert not ok and "empty" in detail.lower() + + +# ------------------------------------------------ mainnet stays fail-closed (Issue 4) +def test_verify_gpu_token_failclosed_on_mainnet(monkeypatch): + monkeypatch.delenv("RALPH_ALLOW_REAL_ATTEST_STUB", raising=False) + ok, detail = RA.verify_gpu_token("nonempty.token.value", "0x" + "00" * 32) + assert not ok and "not implemented" in detail.lower() + + +# ----------------------------------- get_token() bundle parsing (2026-06-22 CC report) +def test_extract_gpu_jwt_from_bundle_and_bare(): + import json as _json + + # bundle as a Python list: [["JWT", outer], {detached...}] + bundle = [["JWT", "OUTER.JWT.STR"], {"GPU-0": "DETACHED.JWT"}] + assert RA._extract_gpu_jwt(bundle) == "OUTER.JWT.STR" + # bundle as a JSON string + assert RA._extract_gpu_jwt(_json.dumps(bundle)) == "OUTER.JWT.STR" + # bare JWT passes through unchanged (back-compat) + assert RA._extract_gpu_jwt("eyJhbG.body.sig") == "eyJhbG.body.sig" + + +@pytest.mark.skipif( + importlib.util.find_spec("jwt") is None, reason="PyJWT not installed" +) +def test_stub_accepts_real_get_token_bundle(monkeypatch): + """Regression for the miner's 'Invalid header string': the stub must parse + the get_token() bundle and decode the inner JWT, not the whole bundle.""" + import json as _json + + import jwt + + monkeypatch.setenv("RALPH_ALLOW_REAL_ATTEST_STUB", "1") + bare = "ab12" * 16 # 64 hex + onchain = "0x" + bare + outer = jwt.encode({"eat_nonce": bare}, "secret", algorithm="HS256") + bundle = _json.dumps([["JWT", outer], {"GPU-0": outer}]) # nv-sdk shape + ok, detail = RA.verify_gpu_token(bundle, onchain) + assert ok, detail