voltage-verify 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- voltage_verify/__init__.py +4 -0
- voltage_verify/__main__.py +9 -0
- voltage_verify/attest.py +184 -0
- voltage_verify/bundle.py +101 -0
- voltage_verify/canonical.py +67 -0
- voltage_verify/cli.py +184 -0
- voltage_verify/data/intel_sgx_root_ca.pem +16 -0
- voltage_verify/der.py +92 -0
- voltage_verify/manifest.py +99 -0
- voltage_verify/nvidia.py +196 -0
- voltage_verify/pcs.py +322 -0
- voltage_verify/registry.py +102 -0
- voltage_verify/selftest.py +136 -0
- voltage_verify/tdx.py +327 -0
- voltage_verify/verify.py +263 -0
- voltage_verify-0.1.0.dist-info/METADATA +191 -0
- voltage_verify-0.1.0.dist-info/RECORD +20 -0
- voltage_verify-0.1.0.dist-info/WHEEL +4 -0
- voltage_verify-0.1.0.dist-info/entry_points.txt +2 -0
- voltage_verify-0.1.0.dist-info/licenses/LICENSE +21 -0
voltage_verify/attest.py
ADDED
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
# SPDX-License-Identifier: MIT
|
|
2
|
+
"""The part that runs inside the attested VM, as root.
|
|
3
|
+
|
|
4
|
+
It reads the manifest the verifier prepared, derives the two commitments, asks the Intel TDX
|
|
5
|
+
guest driver for a quote over ``commitment512`` (through the kernel's configfs TSM
|
|
6
|
+
interface), asks NVIDIA's attestation service for GPU claims over ``commitment256`` (through
|
|
7
|
+
the nv-attestation-sdk), fetches the public collateral needed for offline verification, and
|
|
8
|
+
writes one bundle. It chooses nothing: every hash comes from the manifest.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import errno
|
|
14
|
+
import json
|
|
15
|
+
import platform
|
|
16
|
+
import secrets
|
|
17
|
+
import shutil
|
|
18
|
+
import subprocess
|
|
19
|
+
import time
|
|
20
|
+
import urllib.request
|
|
21
|
+
from datetime import datetime, timezone
|
|
22
|
+
from pathlib import Path
|
|
23
|
+
from typing import Any
|
|
24
|
+
|
|
25
|
+
from . import pcs, tdx
|
|
26
|
+
from .bundle import Bundle
|
|
27
|
+
from .canonical import commitments
|
|
28
|
+
from .manifest import validate_manifest
|
|
29
|
+
|
|
30
|
+
TSM_REPORT_DIR = Path("/sys/kernel/config/tsm/report")
|
|
31
|
+
NRAS_GPU_URL = "https://nras.attestation.nvidia.com/v3/attest/gpu"
|
|
32
|
+
NRAS_OCSP_URL = "https://ocsp.ndis.nvidia.com/"
|
|
33
|
+
NRAS_RIM_URL = "https://rim.attestation.nvidia.com/v1/rim/"
|
|
34
|
+
NRAS_JWKS_URL = "https://nras.attestation.nvidia.com/.well-known/jwks.json"
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class AttestError(RuntimeError):
|
|
38
|
+
"""Raised when evidence cannot be produced on this machine."""
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def tsm_quote(report_data: bytes, attempts: int = 5) -> bytes:
|
|
42
|
+
"""Request a TDX quote through configfs TSM. Retries only on EINVAL (concurrent request)."""
|
|
43
|
+
if len(report_data) != 64:
|
|
44
|
+
raise AttestError("report_data must be exactly 64 bytes")
|
|
45
|
+
if not TSM_REPORT_DIR.is_dir():
|
|
46
|
+
raise AttestError(f"{TSM_REPORT_DIR} is missing: not a TDX guest, or configfs TSM not mounted (run as root)")
|
|
47
|
+
for attempt in range(attempts):
|
|
48
|
+
entry = TSM_REPORT_DIR / f"voltage-verify-{secrets.token_hex(4)}"
|
|
49
|
+
try:
|
|
50
|
+
entry.mkdir()
|
|
51
|
+
(entry / "inblob").write_bytes(report_data)
|
|
52
|
+
quote = (entry / "outblob").read_bytes()
|
|
53
|
+
provider = (entry / "provider").read_text().strip() if (entry / "provider").exists() else ""
|
|
54
|
+
if provider and provider != "tdx_guest":
|
|
55
|
+
raise AttestError(f"TSM provider is {provider!r}, not tdx_guest")
|
|
56
|
+
return quote
|
|
57
|
+
except OSError as err:
|
|
58
|
+
if err.errno != errno.EINVAL or attempt == attempts - 1:
|
|
59
|
+
raise AttestError(f"TSM quote request failed: {err}") from err
|
|
60
|
+
time.sleep(0.05 * (attempt + 1))
|
|
61
|
+
finally:
|
|
62
|
+
try:
|
|
63
|
+
entry.rmdir()
|
|
64
|
+
except OSError:
|
|
65
|
+
pass
|
|
66
|
+
raise AttestError("TSM quote request failed after retries")
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def gpu_count() -> int:
|
|
70
|
+
try:
|
|
71
|
+
import pynvml # type: ignore
|
|
72
|
+
|
|
73
|
+
pynvml.nvmlInit()
|
|
74
|
+
return int(pynvml.nvmlDeviceGetCount())
|
|
75
|
+
except Exception: # noqa: BLE001
|
|
76
|
+
smi = shutil.which("nvidia-smi")
|
|
77
|
+
if not smi:
|
|
78
|
+
return 0
|
|
79
|
+
out = subprocess.run([smi, "-L"], capture_output=True, text=True, timeout=30, check=False).stdout
|
|
80
|
+
return sum(1 for line in out.splitlines() if line.startswith("GPU "))
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def nras_attest(nonce_hex: str, mode: str) -> Any:
|
|
84
|
+
"""Run the nv-attestation-sdk against NRAS with our nonce. Returns the parsed token array."""
|
|
85
|
+
try:
|
|
86
|
+
from nv_attestation_sdk import attestation # type: ignore
|
|
87
|
+
except ImportError as err:
|
|
88
|
+
raise AttestError("nv-attestation-sdk is not installed: pip install 'voltage-verify[attest]'") from err
|
|
89
|
+
client = attestation.Attestation()
|
|
90
|
+
client.set_name("voltage-verify")
|
|
91
|
+
client.set_nonce(nonce_hex)
|
|
92
|
+
client.set_claims_version("2.0")
|
|
93
|
+
client.add_verifier(
|
|
94
|
+
attestation.Devices.GPU,
|
|
95
|
+
attestation.Environment.REMOTE,
|
|
96
|
+
NRAS_GPU_URL,
|
|
97
|
+
"",
|
|
98
|
+
ocsp_url=NRAS_OCSP_URL,
|
|
99
|
+
rim_url=NRAS_RIM_URL,
|
|
100
|
+
)
|
|
101
|
+
if mode == "multi-gpu-ppcie":
|
|
102
|
+
# On a multi-GPU node in NVIDIA Protected PCIe mode the SDK refuses "standalone"
|
|
103
|
+
# attestation; ppcie_mode=False means "not standalone" for this SDK version.
|
|
104
|
+
options = {"ppcie_mode": False}
|
|
105
|
+
try:
|
|
106
|
+
evidence = client.get_evidence(options=options)
|
|
107
|
+
except TypeError:
|
|
108
|
+
evidence = client.get_evidence(options)
|
|
109
|
+
try:
|
|
110
|
+
ok = client.attest(evidence, options=options)
|
|
111
|
+
except TypeError:
|
|
112
|
+
ok = client.attest(evidence)
|
|
113
|
+
else:
|
|
114
|
+
evidence = client.get_evidence()
|
|
115
|
+
ok = client.attest(evidence)
|
|
116
|
+
token = client.get_token()
|
|
117
|
+
if not token:
|
|
118
|
+
raise AttestError("NRAS returned no token")
|
|
119
|
+
parsed = json.loads(token) if isinstance(token, str) else token
|
|
120
|
+
if not ok:
|
|
121
|
+
raise AttestError("NRAS attestation did not succeed (token kept for diagnosis): " + json.dumps(parsed)[:300])
|
|
122
|
+
return parsed
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def fetch_json(url: str) -> dict[str, Any]:
|
|
126
|
+
req = urllib.request.Request(url, headers={"User-Agent": pcs.USER_AGENT})
|
|
127
|
+
with urllib.request.urlopen(req, timeout=30) as resp: # noqa: S310
|
|
128
|
+
return json.loads(resp.read().decode("utf-8"))
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def environment_facts() -> dict[str, Any]:
|
|
132
|
+
facts: dict[str, Any] = {"kernel": platform.release(), "python": platform.python_version()}
|
|
133
|
+
smi = shutil.which("nvidia-smi")
|
|
134
|
+
if smi:
|
|
135
|
+
for key, args in (("gpus", ["-L"]), ("conf_compute", ["conf-compute", "-q"])):
|
|
136
|
+
try:
|
|
137
|
+
run = subprocess.run([smi, *args], capture_output=True, text=True, timeout=30, check=False)
|
|
138
|
+
facts[key] = run.stdout.strip()
|
|
139
|
+
except (OSError, subprocess.SubprocessError):
|
|
140
|
+
pass
|
|
141
|
+
return facts
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def run_attest(manifest_path: Path, out_path: Path, mode: str = "auto", with_collateral: bool = True) -> Bundle:
|
|
145
|
+
manifest = json.loads(manifest_path.read_text(encoding="utf-8"))
|
|
146
|
+
validate_manifest(manifest)
|
|
147
|
+
com = commitments(manifest)
|
|
148
|
+
|
|
149
|
+
quote_raw = tsm_quote(com.sha512)
|
|
150
|
+
quote = tdx.parse_quote(quote_raw)
|
|
151
|
+
if quote.report.report_data != com.sha512:
|
|
152
|
+
raise AttestError("the TDX quote does not carry the requested report_data")
|
|
153
|
+
|
|
154
|
+
if mode == "auto":
|
|
155
|
+
mode = "multi-gpu-ppcie" if gpu_count() > 1 else "single-gpu"
|
|
156
|
+
nras = nras_attest(com.sha256_hex, mode)
|
|
157
|
+
|
|
158
|
+
jwks: dict[str, Any] | None = None
|
|
159
|
+
collateral: dict[str, Any] | None = None
|
|
160
|
+
if with_collateral:
|
|
161
|
+
try:
|
|
162
|
+
jwks = fetch_json(NRAS_JWKS_URL)
|
|
163
|
+
except Exception as err: # noqa: BLE001
|
|
164
|
+
print(f"warning: could not fetch NRAS JWKS for offline verification: {err}")
|
|
165
|
+
try:
|
|
166
|
+
pck = tdx.pck_info(quote.pck_leaf)
|
|
167
|
+
collateral = {"intel": pcs.fetch_collateral(pck.fmspc, pcs.pck_ca_kind(quote)).to_json()}
|
|
168
|
+
except Exception as err: # noqa: BLE001
|
|
169
|
+
print(f"warning: could not fetch Intel collateral for offline verification: {err}")
|
|
170
|
+
|
|
171
|
+
bundle = Bundle(
|
|
172
|
+
manifest=manifest,
|
|
173
|
+
commitments={"sha256": com.sha256_hex, "sha512": com.sha512_hex},
|
|
174
|
+
quote=quote.raw,
|
|
175
|
+
report_data=com.sha512,
|
|
176
|
+
nvidia_mode=mode,
|
|
177
|
+
nras=nras,
|
|
178
|
+
jwks=jwks,
|
|
179
|
+
collateral=collateral,
|
|
180
|
+
environment=environment_facts(),
|
|
181
|
+
created_at=datetime.now(timezone.utc),
|
|
182
|
+
)
|
|
183
|
+
bundle.save(out_path)
|
|
184
|
+
return bundle
|
voltage_verify/bundle.py
ADDED
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
# SPDX-License-Identifier: MIT
|
|
2
|
+
"""The evidence bundle: one JSON file that carries everything a verifier needs.
|
|
3
|
+
|
|
4
|
+
Format ``voltage-verify-bundle/1``::
|
|
5
|
+
|
|
6
|
+
{
|
|
7
|
+
"format": "voltage-verify-bundle/1",
|
|
8
|
+
"tool": {"name": "voltage-verify", "version": "0.1.0"},
|
|
9
|
+
"created_at": "2026-09-12T01:02:03+00:00",
|
|
10
|
+
"manifest": { ... the workload manifest, verbatim ... },
|
|
11
|
+
"commitments": {"sha256": "<hex>", "sha512": "<hex>"},
|
|
12
|
+
"tdx": {"quote_b64": "<base64>", "report_data_hex": "<hex>"},
|
|
13
|
+
"nvidia": {"mode": "single-gpu" | "multi-gpu-ppcie", "nras": [...], "jwks": {...}},
|
|
14
|
+
"collateral": {"intel": { ... pcs.Collateral ... }},
|
|
15
|
+
"environment": { ... free-form facts recorded on the VM ... }
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
The manifest is the only input a verifier must trust nothing about: every other field is
|
|
19
|
+
either derived from it (commitments), signed by Intel (quote, collateral) or signed by NVIDIA
|
|
20
|
+
(NRAS tokens). The JWKS and Intel collateral are embedded so that verification can run
|
|
21
|
+
offline against the material of the day; an online verification refetches and compares.
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
from __future__ import annotations
|
|
25
|
+
|
|
26
|
+
import base64
|
|
27
|
+
import json
|
|
28
|
+
from dataclasses import dataclass, field
|
|
29
|
+
from datetime import datetime, timezone
|
|
30
|
+
from pathlib import Path
|
|
31
|
+
from typing import Any
|
|
32
|
+
|
|
33
|
+
from . import __version__
|
|
34
|
+
|
|
35
|
+
FORMAT = "voltage-verify-bundle/1"
|
|
36
|
+
MANIFEST_FORMAT = "voltage-verify-manifest/1"
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class BundleError(ValueError):
|
|
40
|
+
"""Raised when a bundle file is not usable."""
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
@dataclass
|
|
44
|
+
class Bundle:
|
|
45
|
+
manifest: dict[str, Any]
|
|
46
|
+
commitments: dict[str, str]
|
|
47
|
+
quote: bytes
|
|
48
|
+
report_data: bytes
|
|
49
|
+
nvidia_mode: str
|
|
50
|
+
nras: Any
|
|
51
|
+
jwks: dict[str, Any] | None
|
|
52
|
+
collateral: dict[str, Any] | None
|
|
53
|
+
environment: dict[str, Any] = field(default_factory=dict)
|
|
54
|
+
created_at: datetime = field(default_factory=lambda: datetime.now(timezone.utc))
|
|
55
|
+
tool: dict[str, str] = field(default_factory=lambda: {"name": "voltage-verify", "version": __version__})
|
|
56
|
+
|
|
57
|
+
def to_json(self) -> dict[str, Any]:
|
|
58
|
+
return {
|
|
59
|
+
"format": FORMAT,
|
|
60
|
+
"tool": self.tool,
|
|
61
|
+
"created_at": self.created_at.isoformat(timespec="seconds"),
|
|
62
|
+
"manifest": self.manifest,
|
|
63
|
+
"commitments": self.commitments,
|
|
64
|
+
"tdx": {"quote_b64": base64.b64encode(self.quote).decode("ascii"), "report_data_hex": self.report_data.hex()},
|
|
65
|
+
"nvidia": {"mode": self.nvidia_mode, "nras": self.nras, "jwks": self.jwks},
|
|
66
|
+
"collateral": self.collateral,
|
|
67
|
+
"environment": self.environment,
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
def save(self, path: Path) -> None:
|
|
71
|
+
path.write_text(json.dumps(self.to_json(), indent=2, sort_keys=True), encoding="utf-8")
|
|
72
|
+
|
|
73
|
+
@classmethod
|
|
74
|
+
def from_json(cls, data: dict[str, Any]) -> Bundle:
|
|
75
|
+
if data.get("format") != FORMAT:
|
|
76
|
+
raise BundleError(f"unsupported bundle format {data.get('format')!r}")
|
|
77
|
+
try:
|
|
78
|
+
created = datetime.fromisoformat(str(data["created_at"]).replace("Z", "+00:00"))
|
|
79
|
+
return cls(
|
|
80
|
+
manifest=data["manifest"],
|
|
81
|
+
commitments=dict(data["commitments"]),
|
|
82
|
+
quote=base64.b64decode(data["tdx"]["quote_b64"]),
|
|
83
|
+
report_data=bytes.fromhex(data["tdx"]["report_data_hex"]),
|
|
84
|
+
nvidia_mode=str(data["nvidia"]["mode"]),
|
|
85
|
+
nras=data["nvidia"]["nras"],
|
|
86
|
+
jwks=data["nvidia"].get("jwks"),
|
|
87
|
+
collateral=data.get("collateral"),
|
|
88
|
+
environment=dict(data.get("environment") or {}),
|
|
89
|
+
created_at=created,
|
|
90
|
+
tool=dict(data.get("tool") or {}),
|
|
91
|
+
)
|
|
92
|
+
except (KeyError, TypeError, ValueError) as err:
|
|
93
|
+
raise BundleError(f"bundle is missing or has a malformed field: {err}") from err
|
|
94
|
+
|
|
95
|
+
@classmethod
|
|
96
|
+
def load(cls, path: Path) -> Bundle:
|
|
97
|
+
try:
|
|
98
|
+
data = json.loads(path.read_text(encoding="utf-8"))
|
|
99
|
+
except (OSError, ValueError) as err:
|
|
100
|
+
raise BundleError(f"cannot read {path}: {err}") from err
|
|
101
|
+
return cls.from_json(data)
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
# SPDX-License-Identifier: MIT
|
|
2
|
+
"""Canonical serialisation of the workload manifest and the two commitments derived from it.
|
|
3
|
+
|
|
4
|
+
The manifest is serialised the way RFC 8785 (JSON Canonicalization Scheme) does for the
|
|
5
|
+
value types this tool emits: object keys sorted by code point, no insignificant whitespace,
|
|
6
|
+
UTF-8, no floats. Two parties that hold the same manifest therefore compute byte-identical
|
|
7
|
+
input and, from it, the same commitments:
|
|
8
|
+
|
|
9
|
+
* ``commitment256`` = SHA-256(canonical manifest): the 32-byte nonce handed to the NVIDIA
|
|
10
|
+
attestation flow (NRAS echoes it back in ``eat_nonce``).
|
|
11
|
+
* ``commitment512`` = SHA-512(canonical manifest): the 64-byte ``report_data`` written into
|
|
12
|
+
the Intel TDX quote request (the quote embeds it verbatim).
|
|
13
|
+
|
|
14
|
+
Anyone who changes a single byte of the manifest (image digest, model digest, challenge,
|
|
15
|
+
anything) changes both commitments, and both proofs stop matching.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
import hashlib
|
|
21
|
+
import json
|
|
22
|
+
from dataclasses import dataclass
|
|
23
|
+
from typing import Any
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class CanonicalError(ValueError):
|
|
27
|
+
"""Raised when a manifest cannot be canonicalised deterministically."""
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _reject_floats(value: Any, path: str = "$") -> None:
|
|
31
|
+
if isinstance(value, float):
|
|
32
|
+
raise CanonicalError(f"floats are not allowed in a manifest (at {path})")
|
|
33
|
+
if isinstance(value, dict):
|
|
34
|
+
for k, v in value.items():
|
|
35
|
+
if not isinstance(k, str):
|
|
36
|
+
raise CanonicalError(f"object keys must be strings (at {path})")
|
|
37
|
+
_reject_floats(v, f"{path}.{k}")
|
|
38
|
+
elif isinstance(value, list):
|
|
39
|
+
for i, v in enumerate(value):
|
|
40
|
+
_reject_floats(v, f"{path}[{i}]")
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def canonical_bytes(manifest: dict[str, Any]) -> bytes:
|
|
44
|
+
"""Return the canonical UTF-8 encoding of ``manifest``."""
|
|
45
|
+
_reject_floats(manifest)
|
|
46
|
+
return json.dumps(manifest, sort_keys=True, separators=(",", ":"), ensure_ascii=False).encode("utf-8")
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
@dataclass(frozen=True)
|
|
50
|
+
class Commitments:
|
|
51
|
+
canonical: bytes
|
|
52
|
+
sha256: bytes
|
|
53
|
+
sha512: bytes
|
|
54
|
+
|
|
55
|
+
@property
|
|
56
|
+
def sha256_hex(self) -> str:
|
|
57
|
+
return self.sha256.hex()
|
|
58
|
+
|
|
59
|
+
@property
|
|
60
|
+
def sha512_hex(self) -> str:
|
|
61
|
+
return self.sha512.hex()
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def commitments(manifest: dict[str, Any]) -> Commitments:
|
|
65
|
+
"""Compute both commitments of a manifest."""
|
|
66
|
+
raw = canonical_bytes(manifest)
|
|
67
|
+
return Commitments(raw, hashlib.sha256(raw).digest(), hashlib.sha512(raw).digest())
|
voltage_verify/cli.py
ADDED
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
# SPDX-License-Identifier: MIT
|
|
2
|
+
"""Command line entry point."""
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
import argparse
|
|
7
|
+
import json
|
|
8
|
+
import sys
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
|
|
11
|
+
from . import __version__
|
|
12
|
+
from .bundle import Bundle, BundleError
|
|
13
|
+
from .canonical import commitments
|
|
14
|
+
from .manifest import ManifestError, build_manifest
|
|
15
|
+
from .registry import RegistryError
|
|
16
|
+
from .verify import Report, verify_bundle
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _print_report(report: Report) -> None:
|
|
20
|
+
width = max(len(c.name) for c in report.checks) if report.checks else 20
|
|
21
|
+
for check in report.checks:
|
|
22
|
+
print(f"[{check.label}] {check.name.ljust(width)} {check.detail}")
|
|
23
|
+
facts = report.facts
|
|
24
|
+
if "tdx" in facts:
|
|
25
|
+
t = facts["tdx"]
|
|
26
|
+
print(f"\nTDX: MRTD {t['mr_td'][:32]}... TCB {t.get('tcb_status', '?')} QE {t.get('qe_status', '?')}")
|
|
27
|
+
if "nvidia" in facts:
|
|
28
|
+
for d in facts["nvidia"]["devices"]:
|
|
29
|
+
print(f"GPU: {d['name']} {d['hwmodel']} driver {d['driver']} vbios {d['vbios']} measurements {d['measres']}")
|
|
30
|
+
print("\nRESULT:", "VERIFIED" if report.ok else "NOT VERIFIED (" + ", ".join(report.failed()) + ")")
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def cmd_manifest(args: argparse.Namespace) -> int:
|
|
34
|
+
extra: dict[str, str] = {}
|
|
35
|
+
for item in args.extra or []:
|
|
36
|
+
if "=" not in item:
|
|
37
|
+
print(f"--extra expects key=value, got {item!r}", file=sys.stderr)
|
|
38
|
+
return 2
|
|
39
|
+
key, value = item.split("=", 1)
|
|
40
|
+
extra[key] = value
|
|
41
|
+
try:
|
|
42
|
+
manifest = build_manifest(
|
|
43
|
+
image=args.image,
|
|
44
|
+
image_digest=args.image_digest,
|
|
45
|
+
artifacts=[Path(p) for p in (args.artifact or [])],
|
|
46
|
+
extra=extra,
|
|
47
|
+
statement=args.statement,
|
|
48
|
+
challenge=None if args.challenge in (None, "auto") else args.challenge,
|
|
49
|
+
)
|
|
50
|
+
except (ManifestError, RegistryError) as err:
|
|
51
|
+
print(f"error: {err}", file=sys.stderr)
|
|
52
|
+
return 2
|
|
53
|
+
Path(args.output).write_text(json.dumps(manifest, indent=2, sort_keys=True), encoding="utf-8")
|
|
54
|
+
com = commitments(manifest)
|
|
55
|
+
print(f"manifest written to {args.output}")
|
|
56
|
+
print(f"challenge {manifest['challenge']}")
|
|
57
|
+
print(f"commitment256 {com.sha256_hex} (NVIDIA nonce)")
|
|
58
|
+
print(f"commitment512 {com.sha512_hex[:64]}... (TDX report_data)")
|
|
59
|
+
print("keep the challenge: pass it to `verify --challenge` to reject replayed bundles")
|
|
60
|
+
return 0
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def cmd_attest(args: argparse.Namespace) -> int:
|
|
64
|
+
from .attest import AttestError, run_attest # imported late: VM-only dependencies
|
|
65
|
+
|
|
66
|
+
try:
|
|
67
|
+
bundle = run_attest(Path(args.manifest), Path(args.output), mode=args.mode, with_collateral=not args.no_collateral)
|
|
68
|
+
except AttestError as err:
|
|
69
|
+
print(f"error: {err}", file=sys.stderr)
|
|
70
|
+
return 2
|
|
71
|
+
print(f"bundle written to {args.output} ({bundle.nvidia_mode}, {len(bundle.quote)}-byte quote)")
|
|
72
|
+
return 0
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def _load(path: str) -> Bundle | None:
|
|
76
|
+
try:
|
|
77
|
+
return Bundle.load(Path(path))
|
|
78
|
+
except BundleError as err:
|
|
79
|
+
print(f"error: {err}", file=sys.stderr)
|
|
80
|
+
return None
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def cmd_verify(args: argparse.Namespace) -> int:
|
|
84
|
+
bundle = _load(args.bundle)
|
|
85
|
+
if bundle is None:
|
|
86
|
+
return 2
|
|
87
|
+
report = verify_bundle(
|
|
88
|
+
bundle,
|
|
89
|
+
expected_challenge=args.challenge,
|
|
90
|
+
expected_image_digest=args.image_digest,
|
|
91
|
+
online=not args.offline,
|
|
92
|
+
allowed_hwmodels=set(args.hwmodel) if args.hwmodel else None,
|
|
93
|
+
require_devices=args.gpus,
|
|
94
|
+
)
|
|
95
|
+
if args.json:
|
|
96
|
+
print(json.dumps(report.to_json(), indent=2))
|
|
97
|
+
else:
|
|
98
|
+
_print_report(report)
|
|
99
|
+
return 0 if report.ok else 1
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def cmd_inspect(args: argparse.Namespace) -> int:
|
|
103
|
+
bundle = _load(args.bundle)
|
|
104
|
+
if bundle is None:
|
|
105
|
+
return 2
|
|
106
|
+
data = bundle.to_json()
|
|
107
|
+
data["tdx"]["quote_b64"] = f"<{len(bundle.quote)} bytes>"
|
|
108
|
+
data["nvidia"]["jwks"] = "<embedded>" if bundle.jwks else None
|
|
109
|
+
data["collateral"] = "<embedded>" if bundle.collateral else None
|
|
110
|
+
print(json.dumps(data, indent=2, sort_keys=True))
|
|
111
|
+
return 0
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def cmd_selftest(args: argparse.Namespace) -> int:
|
|
115
|
+
from .selftest import run_selftest
|
|
116
|
+
|
|
117
|
+
bundle = _load(args.bundle)
|
|
118
|
+
if bundle is None:
|
|
119
|
+
return 2
|
|
120
|
+
clean, results = run_selftest(bundle, online=not args.offline, expected_challenge=args.challenge)
|
|
121
|
+
print("clean bundle:", "VERIFIED" if clean.ok else "NOT VERIFIED (" + ", ".join(clean.failed()) + ")")
|
|
122
|
+
all_ok = clean.ok
|
|
123
|
+
for r in results:
|
|
124
|
+
all_ok = all_ok and r.passed
|
|
125
|
+
rejected = ", ".join(r.failed_checks) or "nothing"
|
|
126
|
+
print(f"[{'PASS' if r.passed else 'FAIL'}] {r.name.ljust(16)} {r.description} -> rejected on {rejected}")
|
|
127
|
+
print("\nSELFTEST:", "PASS" if all_ok else "FAIL")
|
|
128
|
+
return 0 if all_ok else 1
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
132
|
+
p = argparse.ArgumentParser(
|
|
133
|
+
prog="voltage-verify",
|
|
134
|
+
description="Bind TDX and NVIDIA attestations to a workload and verify them.",
|
|
135
|
+
)
|
|
136
|
+
p.add_argument("--version", action="version", version=f"voltage-verify {__version__}")
|
|
137
|
+
sub = p.add_subparsers(dest="command", required=True)
|
|
138
|
+
|
|
139
|
+
m = sub.add_parser("manifest", help="build the workload manifest on the verifier's machine")
|
|
140
|
+
m.add_argument("--image", help="container image reference, e.g. ghcr.io/org/app:1.2.3")
|
|
141
|
+
m.add_argument("--image-digest", help="sha256:... digest, when the registry cannot be queried")
|
|
142
|
+
m.add_argument("--artifact", action="append", help="file to hash (model weights, config); repeatable")
|
|
143
|
+
m.add_argument("--extra", action="append", help="key=value to include in the manifest; repeatable")
|
|
144
|
+
m.add_argument("--statement", help="free text describing the run")
|
|
145
|
+
m.add_argument("--challenge", default="auto", help="32-byte hex challenge, or 'auto' to generate one")
|
|
146
|
+
m.add_argument("-o", "--output", default="manifest.json")
|
|
147
|
+
m.set_defaults(func=cmd_manifest)
|
|
148
|
+
|
|
149
|
+
a = sub.add_parser("attest", help="produce the evidence bundle; run as root inside the TDX VM")
|
|
150
|
+
a.add_argument("--manifest", required=True)
|
|
151
|
+
a.add_argument("-o", "--output", default="bundle.json")
|
|
152
|
+
a.add_argument("--mode", choices=["auto", "single-gpu", "multi-gpu-ppcie"], default="auto")
|
|
153
|
+
a.add_argument("--no-collateral", action="store_true", help="do not embed Intel/NVIDIA collateral")
|
|
154
|
+
a.set_defaults(func=cmd_attest)
|
|
155
|
+
|
|
156
|
+
v = sub.add_parser("verify", help="verify a bundle")
|
|
157
|
+
v.add_argument("bundle")
|
|
158
|
+
v.add_argument("--challenge", help="the challenge you issued; rejects replayed bundles")
|
|
159
|
+
v.add_argument("--image-digest", help="expected sha256:... of the workload image")
|
|
160
|
+
v.add_argument("--offline", action="store_true", help="use the collateral embedded in the bundle")
|
|
161
|
+
v.add_argument("--hwmodel", action="append", help="allowed GPU model claim, e.g. GH100; repeatable")
|
|
162
|
+
v.add_argument("--gpus", type=int, help="exact number of attested GPUs expected")
|
|
163
|
+
v.add_argument("--json", action="store_true")
|
|
164
|
+
v.set_defaults(func=cmd_verify)
|
|
165
|
+
|
|
166
|
+
i = sub.add_parser("inspect", help="print a bundle without the bulky fields")
|
|
167
|
+
i.add_argument("bundle")
|
|
168
|
+
i.set_defaults(func=cmd_inspect)
|
|
169
|
+
|
|
170
|
+
s = sub.add_parser("selftest", help="verify a bundle, then check that six mutations are rejected")
|
|
171
|
+
s.add_argument("bundle")
|
|
172
|
+
s.add_argument("--challenge")
|
|
173
|
+
s.add_argument("--offline", action="store_true")
|
|
174
|
+
s.set_defaults(func=cmd_selftest)
|
|
175
|
+
return p
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def main(argv: list[str] | None = None) -> int:
|
|
179
|
+
args = build_parser().parse_args(argv)
|
|
180
|
+
return int(args.func(args))
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
if __name__ == "__main__":
|
|
184
|
+
sys.exit(main())
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
-----BEGIN CERTIFICATE-----
|
|
2
|
+
MIICjzCCAjSgAwIBAgIUImUM1lqdNInzg7SVUr9QGzknBqwwCgYIKoZIzj0EAwIw
|
|
3
|
+
aDEaMBgGA1UEAwwRSW50ZWwgU0dYIFJvb3QgQ0ExGjAYBgNVBAoMEUludGVsIENv
|
|
4
|
+
cnBvcmF0aW9uMRQwEgYDVQQHDAtTYW50YSBDbGFyYTELMAkGA1UECAwCQ0ExCzAJ
|
|
5
|
+
BgNVBAYTAlVTMB4XDTE4MDUyMTEwNDUxMFoXDTQ5MTIzMTIzNTk1OVowaDEaMBgG
|
|
6
|
+
A1UEAwwRSW50ZWwgU0dYIFJvb3QgQ0ExGjAYBgNVBAoMEUludGVsIENvcnBvcmF0
|
|
7
|
+
aW9uMRQwEgYDVQQHDAtTYW50YSBDbGFyYTELMAkGA1UECAwCQ0ExCzAJBgNVBAYT
|
|
8
|
+
AlVTMFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEC6nEwMDIYZOj/iPWsCzaEKi7
|
|
9
|
+
1OiOSLRFhWGjbnBVJfVnkY4u3IjkDYYL0MxO4mqsyYjlBalTVYxFP2sJBK5zlKOB
|
|
10
|
+
uzCBuDAfBgNVHSMEGDAWgBQiZQzWWp00ifODtJVSv1AbOScGrDBSBgNVHR8ESzBJ
|
|
11
|
+
MEegRaBDhkFodHRwczovL2NlcnRpZmljYXRlcy50cnVzdGVkc2VydmljZXMuaW50
|
|
12
|
+
ZWwuY29tL0ludGVsU0dYUm9vdENBLmRlcjAdBgNVHQ4EFgQUImUM1lqdNInzg7SV
|
|
13
|
+
Ur9QGzknBqwwDgYDVR0PAQH/BAQDAgEGMBIGA1UdEwEB/wQIMAYBAf8CAQEwCgYI
|
|
14
|
+
KoZIzj0EAwIDSQAwRgIhAOW/5QkR+S9CiSDcNoowLuPRLsWGf/Yi7GSX94BgwTwg
|
|
15
|
+
AiEA4J0lrHoMs+Xo5o/sX6O9QWxHRAvZUGOdRQ7cvqRXaqI=
|
|
16
|
+
-----END CERTIFICATE-----
|
voltage_verify/der.py
ADDED
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
# SPDX-License-Identifier: MIT
|
|
2
|
+
"""A deliberately small DER reader, enough to walk the Intel SGX PCK certificate extension.
|
|
3
|
+
|
|
4
|
+
The extension (OID 1.2.840.113741.1.13.1) is a SEQUENCE of SEQUENCE { OID, value } pairs whose
|
|
5
|
+
values are OCTET STRINGs, INTEGERs, ENUMERATEDs or nested SEQUENCEs. Nothing else is needed,
|
|
6
|
+
so nothing else is implemented; unknown tags are returned as raw bytes.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from dataclasses import dataclass
|
|
12
|
+
from typing import Any
|
|
13
|
+
|
|
14
|
+
TAG_INTEGER = 0x02
|
|
15
|
+
TAG_OCTET_STRING = 0x04
|
|
16
|
+
TAG_OID = 0x06
|
|
17
|
+
TAG_ENUMERATED = 0x0A
|
|
18
|
+
TAG_SEQUENCE = 0x30
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class DerError(ValueError):
|
|
22
|
+
"""Raised on malformed DER."""
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@dataclass(frozen=True)
|
|
26
|
+
class Node:
|
|
27
|
+
tag: int
|
|
28
|
+
value: Any # int | bytes | str (OID) | list[Node]
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _read_length(data: bytes, pos: int) -> tuple[int, int]:
|
|
32
|
+
first = data[pos]
|
|
33
|
+
pos += 1
|
|
34
|
+
if first < 0x80:
|
|
35
|
+
return first, pos
|
|
36
|
+
count = first & 0x7F
|
|
37
|
+
if count == 0 or count > 4 or pos + count > len(data):
|
|
38
|
+
raise DerError("unsupported DER length encoding")
|
|
39
|
+
return int.from_bytes(data[pos : pos + count], "big"), pos + count
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _decode_oid(raw: bytes) -> str:
|
|
43
|
+
if not raw:
|
|
44
|
+
raise DerError("empty OID")
|
|
45
|
+
first = raw[0]
|
|
46
|
+
parts = [str(first // 40), str(first % 40)]
|
|
47
|
+
value = 0
|
|
48
|
+
for byte in raw[1:]:
|
|
49
|
+
value = (value << 7) | (byte & 0x7F)
|
|
50
|
+
if not byte & 0x80:
|
|
51
|
+
parts.append(str(value))
|
|
52
|
+
value = 0
|
|
53
|
+
if value:
|
|
54
|
+
raise DerError("truncated OID")
|
|
55
|
+
return ".".join(parts)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def decode(data: bytes) -> list[Node]:
|
|
59
|
+
"""Decode a concatenation of DER TLVs into nodes (SEQUENCEs are decoded recursively)."""
|
|
60
|
+
nodes: list[Node] = []
|
|
61
|
+
pos = 0
|
|
62
|
+
while pos < len(data):
|
|
63
|
+
if pos + 2 > len(data):
|
|
64
|
+
raise DerError("truncated TLV")
|
|
65
|
+
tag = data[pos]
|
|
66
|
+
length, body_start = _read_length(data, pos + 1)
|
|
67
|
+
body_end = body_start + length
|
|
68
|
+
if body_end > len(data):
|
|
69
|
+
raise DerError("TLV runs past the end of the buffer")
|
|
70
|
+
body = data[body_start:body_end]
|
|
71
|
+
if tag == TAG_SEQUENCE:
|
|
72
|
+
nodes.append(Node(tag, decode(body)))
|
|
73
|
+
elif tag == TAG_OID:
|
|
74
|
+
nodes.append(Node(tag, _decode_oid(body)))
|
|
75
|
+
elif tag in (TAG_INTEGER, TAG_ENUMERATED):
|
|
76
|
+
nodes.append(Node(tag, int.from_bytes(body, "big", signed=True)))
|
|
77
|
+
else:
|
|
78
|
+
nodes.append(Node(tag, body))
|
|
79
|
+
pos = body_end
|
|
80
|
+
return nodes
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def pairs(sequence: Node) -> dict[str, Node]:
|
|
84
|
+
"""Turn a SEQUENCE of { OID, value } SEQUENCEs into a dict keyed by dotted OID."""
|
|
85
|
+
if sequence.tag != TAG_SEQUENCE:
|
|
86
|
+
raise DerError("expected a SEQUENCE")
|
|
87
|
+
out: dict[str, Node] = {}
|
|
88
|
+
for item in sequence.value:
|
|
89
|
+
if item.tag != TAG_SEQUENCE or len(item.value) != 2 or item.value[0].tag != TAG_OID:
|
|
90
|
+
raise DerError("expected SEQUENCE { OID, value }")
|
|
91
|
+
out[item.value[0].value] = item.value[1]
|
|
92
|
+
return out
|