entropy-protocol 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.
- entropy_protocol/__init__.py +11 -0
- entropy_protocol/_unicode14.py +120 -0
- entropy_protocol/attest.py +62 -0
- entropy_protocol/attested/__init__.py +6 -0
- entropy_protocol/attested/amd.py +221 -0
- entropy_protocol/attested/appraise.py +325 -0
- entropy_protocol/attested/attester.py +58 -0
- entropy_protocol/attested/authority.py +239 -0
- entropy_protocol/attested/azure_cgpu.py +186 -0
- entropy_protocol/attested/binding.py +71 -0
- entropy_protocol/attested/capture.py +109 -0
- entropy_protocol/attested/ear.py +94 -0
- entropy_protocol/attested/enroll.py +119 -0
- entropy_protocol/attested/evidence.py +106 -0
- entropy_protocol/attested/hcl.py +109 -0
- entropy_protocol/attested/nvidia.py +168 -0
- entropy_protocol/attested/ocsp.py +90 -0
- entropy_protocol/attested/pki.py +121 -0
- entropy_protocol/attested/rim.py +130 -0
- entropy_protocol/attested/service.py +242 -0
- entropy_protocol/attested/tpm.py +259 -0
- entropy_protocol/backend_hf.py +439 -0
- entropy_protocol/batch.py +537 -0
- entropy_protocol/calibrate.py +99 -0
- entropy_protocol/canon.py +321 -0
- entropy_protocol/canongen.py +325 -0
- entropy_protocol/cli.py +493 -0
- entropy_protocol/cose.py +260 -0
- entropy_protocol/crypto.py +140 -0
- entropy_protocol/delivery.py +31 -0
- entropy_protocol/detect.py +246 -0
- entropy_protocol/gateway.py +370 -0
- entropy_protocol/hpke.py +105 -0
- entropy_protocol/mcp.py +159 -0
- entropy_protocol/pack.py +217 -0
- entropy_protocol/plain.py +122 -0
- entropy_protocol/predicates.py +37 -0
- entropy_protocol/profiles.py +45 -0
- entropy_protocol/record.py +116 -0
- entropy_protocol/scrapi.py +203 -0
- entropy_protocol/seal.py +232 -0
- entropy_protocol/statements.py +148 -0
- entropy_protocol/stats.py +20 -0
- entropy_protocol/tlog.py +147 -0
- entropy_protocol/ts3161.py +205 -0
- entropy_protocol/typography.py +73 -0
- entropy_protocol/verify.py +232 -0
- entropy_protocol-0.1.0.dist-info/METADATA +255 -0
- entropy_protocol-0.1.0.dist-info/RECORD +54 -0
- entropy_protocol-0.1.0.dist-info/WHEEL +5 -0
- entropy_protocol-0.1.0.dist-info/entry_points.txt +2 -0
- entropy_protocol-0.1.0.dist-info/licenses/LICENSE +201 -0
- entropy_protocol-0.1.0.dist-info/licenses/NOTICE +4 -0
- entropy_protocol-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
"""Evidence from inside an Azure confidential VM with confidential H100s (Standard_NCC*ads_H100_v5): attester side.
|
|
2
|
+
|
|
3
|
+
What the guest can obtain, and how (ARCHITECTURE §7d; sources: microsoft/openvmm vTPM and IGVM attestation code,
|
|
4
|
+
NVIDIA/nvtrust local GPU verifier):
|
|
5
|
+
- the paravisor's report: 64 octets written to vTPM NV 0x01400002 (guest attestation input) come back in the runtime
|
|
6
|
+
claims (`user-data`, hex) of a fresh report at NV 0x01400001 - HCL header, SNP report, runtime claims JSON;
|
|
7
|
+
- the vTPM attestation key (persistent handle 0x81000003; its public part is also `HCLAkPub` in the runtime claims),
|
|
8
|
+
Microsoft's certificate for it (NV 0x01C101D0), a TPM2_Quote over PCRs with chosen qualifying data, the TCG event
|
|
9
|
+
log and, when the kernel keeps one, the IMA log;
|
|
10
|
+
- the VCEK and its chain from the instance metadata service (THIM);
|
|
11
|
+
- each GPU's SPDM measurement report over a 32-octet nonce, and its certificate chain, through NVML.
|
|
12
|
+
Linux only. The vTPM steps need root and tpm2-tools; the GPU steps need NVIDIA's driver in confidential-computing
|
|
13
|
+
mode and nvidia-ml-py. Nothing here judges the evidence: appraisal is the authority's (`appraise`).
|
|
14
|
+
"""
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import json
|
|
18
|
+
import re
|
|
19
|
+
import subprocess
|
|
20
|
+
import tempfile
|
|
21
|
+
import time
|
|
22
|
+
import urllib.request
|
|
23
|
+
from pathlib import Path
|
|
24
|
+
|
|
25
|
+
NV_GUEST_INPUT = 0x01400002
|
|
26
|
+
NV_REPORT = 0x01400001
|
|
27
|
+
NV_AK_CERT = 0x01C101D0
|
|
28
|
+
AK_HANDLE = 0x81000003
|
|
29
|
+
GUEST_INPUT_SIZE = 64
|
|
30
|
+
THIM_URL = "http://169.254.169.254/metadata/THIM/amd/certification"
|
|
31
|
+
EVENT_LOG = Path("/sys/kernel/security/tpm0/binary_bios_measurements")
|
|
32
|
+
IMA_LOG = Path("/sys/kernel/security/ima/binary_runtime_measurements")
|
|
33
|
+
SYSTEMD_TPM_LOG = Path("/run/log/systemd/tpm2-measure.log") # PCR 11 extensions after boot (systemd-pcrphase)
|
|
34
|
+
QUOTE_PCRS = tuple(range(16))
|
|
35
|
+
GPU_NONCE_SIZE = 32
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class EvidenceError(RuntimeError):
|
|
39
|
+
"""The platform did not produce a piece of evidence."""
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _run(args: list[str], stdin: bytes | None = None) -> bytes:
|
|
43
|
+
p = subprocess.run(args, input=stdin, capture_output=True, check=False)
|
|
44
|
+
if p.returncode != 0:
|
|
45
|
+
raise EvidenceError(f"{' '.join(args)}: rc {p.returncode}: {p.stderr.decode(errors='replace').strip()}")
|
|
46
|
+
return p.stdout
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def nv_size(index: int) -> int | None:
|
|
50
|
+
"""Data size of an NV index, or None when it is not defined."""
|
|
51
|
+
try:
|
|
52
|
+
out = _run(["tpm2_nvreadpublic", f"0x{index:08x}"]).decode()
|
|
53
|
+
except EvidenceError:
|
|
54
|
+
return None
|
|
55
|
+
m = re.search(r"^\s*size:\s*(\d+)", out, re.M)
|
|
56
|
+
return int(m.group(1)) if m else None
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def nv_read(index: int) -> bytes:
|
|
60
|
+
size = nv_size(index)
|
|
61
|
+
if size is None:
|
|
62
|
+
raise EvidenceError(f"NV index 0x{index:08x} is not defined")
|
|
63
|
+
with tempfile.TemporaryDirectory() as d:
|
|
64
|
+
out = Path(d) / "nv.bin"
|
|
65
|
+
_run(["tpm2_nvread", "-C", "o", "-s", str(size), "-o", str(out), f"0x{index:08x}"])
|
|
66
|
+
return out.read_bytes()
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
REPORT_RENEW_SECONDS = 2.0 # openvmm tpm_device REPORT_TIMER_PERIOD
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def guest_report(user_data: bytes, tries: int = 4) -> bytes:
|
|
73
|
+
"""The paravisor's report with ``user_data`` (64 octets) in its runtime claims; defines the input index (owner
|
|
74
|
+
read/write, empty authorization, as the paravisor's own tests do) when the guest has not defined it yet.
|
|
75
|
+
|
|
76
|
+
The paravisor renews the report when the report index is read, at most once per REPORT_TIMER_PERIOD (2 s,
|
|
77
|
+
openvmm `tpm_device`); a read within that period returns the previous report (observed on Azure NCC40ads H100 v5,
|
|
78
|
+
27.10.2026: three writes 0.1-0.3 s apart, one report). So the report is read until its runtime claims carry the
|
|
79
|
+
octets just written, waiting out the period between reads."""
|
|
80
|
+
if len(user_data) != GUEST_INPUT_SIZE:
|
|
81
|
+
raise ValueError(f"guest input is {GUEST_INPUT_SIZE} octets, got {len(user_data)}")
|
|
82
|
+
if nv_size(NV_GUEST_INPUT) is None:
|
|
83
|
+
_run(["tpm2_nvdefine", "-C", "o", "-s", str(GUEST_INPUT_SIZE), "-a",
|
|
84
|
+
"ownerread|ownerwrite|authread|authwrite", f"0x{NV_GUEST_INPUT:08x}"])
|
|
85
|
+
with tempfile.TemporaryDirectory() as d:
|
|
86
|
+
src = Path(d) / "in.bin"
|
|
87
|
+
src.write_bytes(user_data)
|
|
88
|
+
_run(["tpm2_nvwrite", "-C", "o", "-i", str(src), f"0x{NV_GUEST_INPUT:08x}"])
|
|
89
|
+
for attempt in range(tries):
|
|
90
|
+
if attempt:
|
|
91
|
+
time.sleep(REPORT_RENEW_SECONDS + 0.2)
|
|
92
|
+
rep = nv_read(NV_REPORT)
|
|
93
|
+
if _user_data_of(rep) == user_data:
|
|
94
|
+
return rep
|
|
95
|
+
raise EvidenceError(f"the paravisor's report did not take the guest input after {tries} reads")
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def _user_data_of(report: bytes) -> bytes:
|
|
99
|
+
"""`user-data` of a report's runtime claims (the attester's own sanity check; the appraisal re-parses)."""
|
|
100
|
+
from .hcl import HCLError, parse
|
|
101
|
+
try:
|
|
102
|
+
return parse(report).user_data
|
|
103
|
+
except (HCLError, ValueError):
|
|
104
|
+
return b""
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def ak_public() -> dict:
|
|
108
|
+
"""The attestation key's public area (TPM2B_PUBLIC as tpm2-tools writes it) and its SubjectPublicKeyInfo (DER)."""
|
|
109
|
+
with tempfile.TemporaryDirectory() as d:
|
|
110
|
+
tss, der = Path(d) / "ak.tss", Path(d) / "ak.der"
|
|
111
|
+
_run(["tpm2_readpublic", "-c", f"0x{AK_HANDLE:08x}", "-o", str(tss)])
|
|
112
|
+
_run(["tpm2_readpublic", "-c", f"0x{AK_HANDLE:08x}", "-f", "der", "-o", str(der)])
|
|
113
|
+
return {"tss": tss.read_bytes(), "spki": der.read_bytes()}
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def ak_cert() -> bytes | None:
|
|
117
|
+
"""Microsoft's certificate for the attestation key, when the platform provisioned one."""
|
|
118
|
+
try:
|
|
119
|
+
return nv_read(NV_AK_CERT)
|
|
120
|
+
except EvidenceError:
|
|
121
|
+
return None
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def quote(qualifying: bytes, pcrs: tuple[int, ...] = QUOTE_PCRS) -> dict:
|
|
125
|
+
"""TPM2_Quote by the attestation key over the SHA-256 bank: TPMS_ATTEST as signed, TPMT_SIGNATURE, and the PCR
|
|
126
|
+
values in selection order."""
|
|
127
|
+
sel = "sha256:" + ",".join(str(i) for i in pcrs)
|
|
128
|
+
with tempfile.TemporaryDirectory() as d:
|
|
129
|
+
msg, sig, vals = Path(d) / "msg", Path(d) / "sig", Path(d) / "pcrs"
|
|
130
|
+
_run(["tpm2_quote", "-c", f"0x{AK_HANDLE:08x}", "-l", sel, "-q", qualifying.hex(), "-g", "sha256",
|
|
131
|
+
"-m", str(msg), "-s", str(sig), "-o", str(vals), "-F", "values"])
|
|
132
|
+
return {"msg": msg.read_bytes(), "sig": sig.read_bytes(), "pcrs": vals.read_bytes(), "sel": list(pcrs)}
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def event_log() -> bytes:
|
|
136
|
+
return EVENT_LOG.read_bytes()
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def ima_log() -> bytes | None:
|
|
140
|
+
return IMA_LOG.read_bytes() if IMA_LOG.exists() else None
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def systemd_tpm_log() -> bytes | None:
|
|
144
|
+
return SYSTEMD_TPM_LOG.read_bytes() if SYSTEMD_TPM_LOG.exists() else None
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def thim() -> dict:
|
|
148
|
+
"""VCEK, its chain and TCB from the instance metadata service: {"vcekCert", "certificateChain", "tcbm"} (PEM)."""
|
|
149
|
+
req = urllib.request.Request(THIM_URL, headers={"Metadata": "true"})
|
|
150
|
+
with urllib.request.urlopen(req, timeout=10) as r: # noqa: S310 - link-local metadata endpoint
|
|
151
|
+
return json.loads(r.read())
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def gpus(nonce: bytes) -> list[dict]:
|
|
155
|
+
"""Every GPU's SPDM measurement report over ``nonce`` (32 octets) and its certificate chain, with the identifiers
|
|
156
|
+
NVML gives beside them (UUID, VBIOS and driver versions, confidential-computing state)."""
|
|
157
|
+
if len(nonce) != GPU_NONCE_SIZE:
|
|
158
|
+
raise ValueError(f"GPU nonce is {GPU_NONCE_SIZE} octets, got {len(nonce)}")
|
|
159
|
+
import pynvml as nv
|
|
160
|
+
|
|
161
|
+
nv.nvmlInit()
|
|
162
|
+
try:
|
|
163
|
+
state = nv.nvmlSystemGetConfComputeState()
|
|
164
|
+
common = {"driver": _text(nv.nvmlSystemGetDriverVersion()),
|
|
165
|
+
"cc": {"environment": int(state.environment), "feature": int(state.ccFeature),
|
|
166
|
+
"devtools": int(state.devToolsMode)},
|
|
167
|
+
"ready": int(nv.nvmlSystemGetConfComputeGpusReadyState())}
|
|
168
|
+
out = []
|
|
169
|
+
for i in range(nv.nvmlDeviceGetCount()):
|
|
170
|
+
h = nv.nvmlDeviceGetHandleByIndex(i)
|
|
171
|
+
rep = nv.nvmlDeviceGetConfComputeGpuAttestationReport(h, nonce)
|
|
172
|
+
cert = nv.nvmlDeviceGetConfComputeGpuCertificate(h)
|
|
173
|
+
out.append({"index": i, "uuid": _text(nv.nvmlDeviceGetUUID(h)),
|
|
174
|
+
"vbios": _text(nv.nvmlDeviceGetVbiosVersion(h)),
|
|
175
|
+
"report": bytes(rep.attestationReport[:rep.attestationReportSize]),
|
|
176
|
+
"cec": bytes(rep.cecAttestationReport[:rep.cecAttestationReportSize])
|
|
177
|
+
if getattr(rep, "isCecAttestationReportPresent", 0) else b"",
|
|
178
|
+
"chain": bytes(cert.attestationCertChain[:cert.attestationCertChainSize]),
|
|
179
|
+
"cert_chain": bytes(cert.certChain[:cert.certChainSize]), **common})
|
|
180
|
+
return out
|
|
181
|
+
finally:
|
|
182
|
+
nv.nvmlShutdown()
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def _text(v) -> str:
|
|
186
|
+
return v.decode() if isinstance(v, bytes) else str(v)
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
"""The binding value an enrollment puts into the hardware evidence (ARCHITECTURE §7d).
|
|
2
|
+
|
|
3
|
+
SHA-512 over length-prefixed fields, each a 4-octet big-endian length followed by the octets, in this order:
|
|
4
|
+
"ep1/attested-binding/v1", the freshness handle, rrp_id, target, SHA-256 of the CSR (DER), the statement key
|
|
5
|
+
(SubjectPublicKeyInfo DER), the weights digest, the tokenizer digest, the code identity, the sealing class,
|
|
6
|
+
SHA-256 of the sealing parameters (canonical CBOR).
|
|
7
|
+
The 64 octets go where the platform lets the guest choose them: SEV-SNP REPORT_DATA, TDX REPORTDATA, or the Azure
|
|
8
|
+
vTPM guest input that the paravisor copies into its report's runtime claims. rrp_id comes from the sealer's own
|
|
9
|
+
configuration, never from the wire: only an identity the attester holds ties its evidence to one authority.
|
|
10
|
+
"""
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import hashlib
|
|
14
|
+
from dataclasses import dataclass
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
|
|
17
|
+
from .. import cose
|
|
18
|
+
|
|
19
|
+
LABEL = b"ep1/attested-binding/v1"
|
|
20
|
+
HANDLE_LEN = 32
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def lp(parts: list[bytes]) -> bytes:
|
|
24
|
+
"""Length-prefixed concatenation: 4-octet big-endian length, then the octets, for every part."""
|
|
25
|
+
return b"".join(len(p).to_bytes(4, "big") + p for p in parts)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
@dataclass(frozen=True)
|
|
29
|
+
class BindingInput:
|
|
30
|
+
handle: bytes # the authority's nonce, 32 octets
|
|
31
|
+
rrp_id: str # the authority, from the sealer's configuration
|
|
32
|
+
target: str # the sealing service being certified
|
|
33
|
+
csr_digest: bytes # SHA-256 of the certificate signing request, DER
|
|
34
|
+
statement_key: bytes # SubjectPublicKeyInfo DER of the key being certified
|
|
35
|
+
weights: bytes # weights digest, 32 octets
|
|
36
|
+
tokenizer: bytes # tokenizer digest, 32 octets
|
|
37
|
+
code: bytes # code identity, 32 octets (code_identity)
|
|
38
|
+
sealing_class: str # numerical class of the sealing pass
|
|
39
|
+
params: bytes # SHA-256 of the sealing parameters (params_digest)
|
|
40
|
+
|
|
41
|
+
def fields(self) -> list[bytes]:
|
|
42
|
+
if len(self.handle) != HANDLE_LEN:
|
|
43
|
+
raise ValueError(f"handle must be {HANDLE_LEN} octets, got {len(self.handle)}")
|
|
44
|
+
for name in ("csr_digest", "weights", "tokenizer", "code", "params"):
|
|
45
|
+
if len(getattr(self, name)) != 32:
|
|
46
|
+
raise ValueError(f"{name} must be 32 octets")
|
|
47
|
+
return [LABEL, self.handle, self.rrp_id.encode(), self.target.encode(), self.csr_digest, self.statement_key,
|
|
48
|
+
self.weights, self.tokenizer, self.code, self.sealing_class.encode(), self.params]
|
|
49
|
+
|
|
50
|
+
def value(self) -> bytes:
|
|
51
|
+
return hashlib.sha512(lp(self.fields())).digest()
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def params_digest(params: dict) -> bytes:
|
|
55
|
+
"""SHA-256 of the sealing parameters as canonical CBOR (integers and text only: no floating point on the wire)."""
|
|
56
|
+
return hashlib.sha256(cose.dumps(params)).digest()
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def package_files(root: Path | None = None) -> list[list]:
|
|
60
|
+
"""[[relative path, SHA-256], ...] of every source file of the installed package, sorted by path."""
|
|
61
|
+
root = root or Path(__file__).resolve().parents[1]
|
|
62
|
+
out = []
|
|
63
|
+
for p in sorted(root.rglob("*")):
|
|
64
|
+
if p.is_file() and "__pycache__" not in p.parts and p.suffix in (".py", ".json", ".cddl"):
|
|
65
|
+
out.append([p.relative_to(root).as_posix(), hashlib.sha256(p.read_bytes()).digest()])
|
|
66
|
+
return out
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def code_identity(root: Path | None = None) -> bytes:
|
|
70
|
+
"""SHA-256 of the canonical CBOR list of package files: the identity of the code that seals."""
|
|
71
|
+
return hashlib.sha256(cose.dumps(package_files(root))).digest()
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
"""Capture raw evidence on an Azure confidential H100 guest, for the appraisal code and its tests (ARCHITECTURE §7d).
|
|
2
|
+
|
|
3
|
+
`sudo python3 -m entropy_protocol.attested.capture --out DIR [--rounds 3]` writes, per round, a fresh 32-octet
|
|
4
|
+
handle, the 64-octet guest input derived from it (SHA-512 of a label and the handle - the shape of a binding value
|
|
5
|
+
without an enrollment behind it), the paravisor's report, a quote whose qualifying data is SHA-256 of that input and
|
|
6
|
+
every GPU's report over the handle; once: the attestation key, its Microsoft certificate, the THIM certificates, the
|
|
7
|
+
TCG event log, the IMA log when the kernel keeps one, and what the platform says about itself (os-release, kernel,
|
|
8
|
+
command line, TPM properties, nvidia-smi). MANIFEST.json lists every file with its SHA-256 and every step that
|
|
9
|
+
failed, with the error: a capture that stops at the first failure would hide the rest of the platform.
|
|
10
|
+
"""
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import argparse
|
|
14
|
+
import hashlib
|
|
15
|
+
import json
|
|
16
|
+
import os
|
|
17
|
+
import platform
|
|
18
|
+
import subprocess
|
|
19
|
+
import time
|
|
20
|
+
from pathlib import Path
|
|
21
|
+
|
|
22
|
+
import cbor2
|
|
23
|
+
|
|
24
|
+
from . import azure_cgpu as az
|
|
25
|
+
|
|
26
|
+
LABEL = b"ep1/capture/v1"
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _sh(cmd: list[str]) -> str:
|
|
30
|
+
p = subprocess.run(cmd, capture_output=True, text=True, check=False)
|
|
31
|
+
return f"$ {' '.join(cmd)}\nrc={p.returncode}\n{p.stdout}{p.stderr}"
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def main(argv: list[str] | None = None) -> int:
|
|
35
|
+
ap = argparse.ArgumentParser(prog="entropy-attested-capture")
|
|
36
|
+
ap.add_argument("--out", required=True)
|
|
37
|
+
ap.add_argument("--rounds", type=int, default=3)
|
|
38
|
+
a = ap.parse_args(argv)
|
|
39
|
+
out = Path(a.out)
|
|
40
|
+
out.mkdir(parents=True, exist_ok=True)
|
|
41
|
+
files: dict[str, str] = {}
|
|
42
|
+
errors: dict[str, str] = {}
|
|
43
|
+
|
|
44
|
+
def put(name: str, data: bytes) -> None:
|
|
45
|
+
(out / name).write_bytes(data)
|
|
46
|
+
files[name] = hashlib.sha256(data).hexdigest()
|
|
47
|
+
|
|
48
|
+
def step(name: str, fn):
|
|
49
|
+
try:
|
|
50
|
+
return fn()
|
|
51
|
+
except Exception as exc: # noqa: BLE001 - every failure is recorded and the capture goes on
|
|
52
|
+
errors[name] = f"{type(exc).__name__}: {exc}"
|
|
53
|
+
return None
|
|
54
|
+
|
|
55
|
+
for r in range(a.rounds):
|
|
56
|
+
handle = os.urandom(az.GPU_NONCE_SIZE)
|
|
57
|
+
user = hashlib.sha512(LABEL + handle).digest()
|
|
58
|
+
put(f"r{r}_handle.bin", handle)
|
|
59
|
+
put(f"r{r}_user_data.bin", user)
|
|
60
|
+
t0 = time.time()
|
|
61
|
+
rep = step(f"r{r}_report", lambda: az.guest_report(user))
|
|
62
|
+
if rep is not None:
|
|
63
|
+
put(f"r{r}_hcl_report.bin", rep)
|
|
64
|
+
q = step(f"r{r}_quote", lambda: az.quote(hashlib.sha256(user).digest()))
|
|
65
|
+
if q is not None:
|
|
66
|
+
put(f"r{r}_quote_msg.bin", q["msg"])
|
|
67
|
+
put(f"r{r}_quote_sig.bin", q["sig"])
|
|
68
|
+
put(f"r{r}_quote_pcrs.bin", q["pcrs"])
|
|
69
|
+
g = step(f"r{r}_gpus", lambda: az.gpus(handle))
|
|
70
|
+
if g is not None:
|
|
71
|
+
put(f"r{r}_gpus.cbor", cbor2.dumps(g, canonical=True))
|
|
72
|
+
files[f"r{r}_seconds"] = f"{time.time() - t0:.3f}"
|
|
73
|
+
|
|
74
|
+
ak = step("ak_public", az.ak_public)
|
|
75
|
+
if ak is not None:
|
|
76
|
+
put("ak_public.tss", ak["tss"])
|
|
77
|
+
put("ak_public.der", ak["spki"])
|
|
78
|
+
c = step("ak_cert", az.ak_cert)
|
|
79
|
+
if c:
|
|
80
|
+
put("ak_cert.der", c)
|
|
81
|
+
t = step("thim", az.thim)
|
|
82
|
+
if t is not None:
|
|
83
|
+
put("thim.json", json.dumps(t, indent=1, sort_keys=True).encode())
|
|
84
|
+
ev = step("event_log", az.event_log)
|
|
85
|
+
if ev is not None:
|
|
86
|
+
put("event_log.bin", ev)
|
|
87
|
+
ima = step("ima_log", az.ima_log)
|
|
88
|
+
if ima:
|
|
89
|
+
put("ima_log.bin", ima)
|
|
90
|
+
sd = step("systemd_tpm_log", az.systemd_tpm_log)
|
|
91
|
+
if sd:
|
|
92
|
+
put("systemd_tpm2_measure.log", sd)
|
|
93
|
+
about = "\n".join(_sh(c) for c in (
|
|
94
|
+
["cat", "/etc/os-release"], ["uname", "-a"], ["cat", "/proc/cmdline"],
|
|
95
|
+
["tpm2_getcap", "properties-fixed"], ["tpm2_getcap", "pcrs"], ["tpm2_getcap", "algorithms"],
|
|
96
|
+
["tpm2_getcap", "handles-nv-index"], ["tpm2_getcap", "handles-persistent"],
|
|
97
|
+
["nvidia-smi", "-q"], ["nvidia-smi", "conf-compute", "-q"],
|
|
98
|
+
["sh", "-c", "grep -E '^CONFIG_(IMA|INTEGRITY|SECURITY_LOCKDOWN|MODULE_SIG)' /boot/config-$(uname -r)"],
|
|
99
|
+
["sh", "-c", "dmesg | grep -i -E 'sev|snp|tpm|ima|lockdown|nvidia|cc ' | tail -80"]))
|
|
100
|
+
put("platform.txt", about.encode())
|
|
101
|
+
manifest = {"captured_at": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()), "host": platform.node(),
|
|
102
|
+
"rounds": a.rounds, "files": files, "errors": errors}
|
|
103
|
+
(out / "MANIFEST.json").write_text(json.dumps(manifest, indent=1, sort_keys=True))
|
|
104
|
+
print(json.dumps({"files": len(files), "errors": errors}, indent=1))
|
|
105
|
+
return 0 if not errors else 3
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
if __name__ == "__main__":
|
|
109
|
+
raise SystemExit(main())
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
"""EAT Attestation Result (draft-ietf-rats-ear-04) as a CWT: COSE_Sign1 over the CBOR claims-set.
|
|
2
|
+
|
|
3
|
+
Labels (draft-ietf-rats-ear-04 §3, RFC 9711): eat_profile 265, iat 6, exp 4, eat_nonce 10, submods 266,
|
|
4
|
+
ear_status 1000, ear_trustworthiness_vector 1001, ear_raw_evidence 1002, ear_appraisal_policy_ids 1003,
|
|
5
|
+
ear_verifier_id 1004 {developer 0, build 1}, ear_attester_claims 1005, ear_verifier_claims 1006,
|
|
6
|
+
ear_device_topology 1007. Tiers (AR4SI): none 0, affirming 2, warning 32, contraindicated 96. Trustworthiness vector:
|
|
7
|
+
instance-identity 0, configuration 1, executables 2, file-system 3, hardware 4, runtime-opaque 5, storage-opaque 6,
|
|
8
|
+
sourced-data 7. ep1's identities travel in a private-space claim (§4.1: integers below -65536): EP1 = -71001.
|
|
9
|
+
Signed with Ed25519 by the authority's root key itself (pki: never by an end-entity key, which a sealer also holds);
|
|
10
|
+
the protected header carries the root certificate (x5chain, 33) for convenience, and the reader compares it with
|
|
11
|
+
the root it pins.
|
|
12
|
+
"""
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import time
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
from .. import cose
|
|
19
|
+
|
|
20
|
+
PROFILE = "tag:ietf.org,2026:rats/ear#04"
|
|
21
|
+
EAT_PROFILE, IAT, EXP, NONCE, SUBMODS = 265, 6, 4, 10, 266
|
|
22
|
+
STATUS, TVECTOR, RAW_EVIDENCE, POLICY_IDS, VERIFIER_ID, ATTESTER_CLAIMS, VERIFIER_CLAIMS, TOPOLOGY = range(1000, 1008)
|
|
23
|
+
NONE, AFFIRMING, WARNING, CONTRAINDICATED = 0, 2, 32, 96
|
|
24
|
+
INSTANCE_IDENTITY, CONFIGURATION, EXECUTABLES, FILE_SYSTEM, HARDWARE, RUNTIME_OPAQUE, STORAGE_OPAQUE, SOURCED_DATA = \
|
|
25
|
+
range(8)
|
|
26
|
+
EP1 = -71001
|
|
27
|
+
X5CHAIN = 33
|
|
28
|
+
TYP = "application/eat+cwt"
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def tier_of(value: int) -> int:
|
|
32
|
+
"""The tier of a trustworthiness claim value (AR4SI §2.3: -1..1 none; 2..31 and -2..-32 affirming; 32..95 and
|
|
33
|
+
-33..-96 warning; 96..127 and -97..-128 contraindicated)."""
|
|
34
|
+
if value in (-1, 0, 1):
|
|
35
|
+
return NONE
|
|
36
|
+
v = value if value > 0 else -value - 1 # -2..-32 -> 1..31, -33..-96 -> 32..95, -97..-128 -> 96..127
|
|
37
|
+
if v >= 96:
|
|
38
|
+
return CONTRAINDICATED
|
|
39
|
+
if v >= 32:
|
|
40
|
+
return WARNING
|
|
41
|
+
return AFFIRMING
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def worst(tiers) -> int:
|
|
45
|
+
order = {AFFIRMING: 0, NONE: 1, WARNING: 2, CONTRAINDICATED: 3}
|
|
46
|
+
return max(tiers, key=lambda t: order[t], default=NONE)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def appraisal(vector: dict, policy_ids: list[str], attester: dict | None = None, verifier: dict | None = None,
|
|
50
|
+
nonce: bytes | None = None) -> dict:
|
|
51
|
+
"""One submod: status = the worst tier across the vector."""
|
|
52
|
+
a = {STATUS: worst(tier_of(v) for v in vector.values()), TVECTOR: vector, POLICY_IDS: policy_ids}
|
|
53
|
+
if attester:
|
|
54
|
+
a[ATTESTER_CLAIMS] = attester
|
|
55
|
+
if verifier:
|
|
56
|
+
a[VERIFIER_CLAIMS] = verifier
|
|
57
|
+
if nonce is not None:
|
|
58
|
+
a[NONCE] = nonce
|
|
59
|
+
return a
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def claims(submods: dict, verifier_id: tuple[str, str], *, nonce: bytes, lifetime: int, topology: dict | None = None,
|
|
63
|
+
ep1: dict | None = None, iat: int | None = None) -> dict:
|
|
64
|
+
now = int(time.time()) if iat is None else int(iat)
|
|
65
|
+
c = {EAT_PROFILE: PROFILE, IAT: now, EXP: now + int(lifetime), VERIFIER_ID: {0: verifier_id[0], 1: verifier_id[1]},
|
|
66
|
+
NONCE: nonce, SUBMODS: submods, STATUS: worst(s[STATUS] for s in submods.values())}
|
|
67
|
+
if topology:
|
|
68
|
+
c[TOPOLOGY] = topology
|
|
69
|
+
if ep1:
|
|
70
|
+
c[EP1] = ep1
|
|
71
|
+
return c
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def sign(c: dict, key, chain_der: list[bytes]) -> bytes:
|
|
75
|
+
protected = {cose.ALG: cose.ED25519, cose.TYP: TYP, X5CHAIN: chain_der[0] if len(chain_der) == 1 else chain_der}
|
|
76
|
+
return cose.sign1(protected, cose.dumps(c), key)
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def read(ear: bytes, public_key) -> dict:
|
|
80
|
+
"""Verify the signature under ``public_key`` (the result-signer's, already chained to a root) and return the claims."""
|
|
81
|
+
prot, _, payload = cose.verify1(ear, public_key)
|
|
82
|
+
if prot.get(cose.TYP) != TYP:
|
|
83
|
+
raise cose.COSEError("not an attestation result")
|
|
84
|
+
c = cose._thaw(cose.loads(payload))
|
|
85
|
+
if c.get(EAT_PROFILE) != PROFILE:
|
|
86
|
+
raise cose.COSEError("not an EAR of this profile")
|
|
87
|
+
return c
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def chain_of(ear: bytes) -> list[bytes]:
|
|
91
|
+
"""The x5chain of a signed result, leaf first (not verified here)."""
|
|
92
|
+
prot = cose.decode1(ear)[0]
|
|
93
|
+
v = prot.get(X5CHAIN)
|
|
94
|
+
return [v] if isinstance(v, bytes) else list(v or [])
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
"""Attestation-based enrollment messages and the authority's handle book (ARCHITECTURE §7d).
|
|
2
|
+
|
|
3
|
+
Message shapes follow draft-novak-rats-tacra's wire schema (CBOR maps, text keys):
|
|
4
|
+
initiation {freshness_kind: "present-nonce", handle: bstr(32), rrp_id, expires_in, mode: "enroll",
|
|
5
|
+
acceptable_csk: ["ed25519"], acceptable_evidence: [profile]}
|
|
6
|
+
request {freshness_kind, target, credential_type, handle, evidence: bstr, profile,
|
|
7
|
+
binding: {method: "binding-input", hash: "sha512"}, csr: bstr (PKCS#10 DER)}
|
|
8
|
+
evidence (profile "ep1/azure-cgpu/v1", CBOR) {hcl: bstr, thim: {vcekCert, certificateChain, tcbm},
|
|
9
|
+
quote: {msg, sig, pcrs: bstr, sel: [uint]}, eventlog: bstr, ? ima: bstr, ? akcert: bstr,
|
|
10
|
+
gpus: [{report, chain, uuid, vbios, driver, cc, ready}],
|
|
11
|
+
claims: {weights: bstr, tokenizer: bstr, code: bstr, class: tstr, params: bstr}}
|
|
12
|
+
The handle book is the authority's: a handle is issued once, expires, and is spent by the first enrollment that
|
|
13
|
+
presents it, whatever the outcome - a refused request cannot be replayed with corrections under the same handle.
|
|
14
|
+
"""
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import json
|
|
18
|
+
import os
|
|
19
|
+
import time
|
|
20
|
+
from pathlib import Path
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
from .. import cose
|
|
24
|
+
from .binding import HANDLE_LEN
|
|
25
|
+
|
|
26
|
+
PROFILE_AZURE_CGPU = "ep1/azure-cgpu/v1"
|
|
27
|
+
CRED_STATEMENT = "ep1-statement-key"
|
|
28
|
+
CRED_DETECTION = "ep1-detection-key"
|
|
29
|
+
CREDENTIAL_TYPES = (CRED_STATEMENT, CRED_DETECTION)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class EnrollError(ValueError):
|
|
33
|
+
"""A malformed message, or a handle that cannot be used."""
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def initiation(handle: bytes, rrp_id: str, expires_in: int) -> bytes:
|
|
37
|
+
return cose.dumps({"freshness_kind": "present-nonce", "handle": handle, "rrp_id": rrp_id,
|
|
38
|
+
"expires_in": int(expires_in), "mode": "enroll", "acceptable_csk": ["ed25519"],
|
|
39
|
+
"acceptable_evidence": [PROFILE_AZURE_CGPU]})
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _map(data: bytes) -> dict:
|
|
43
|
+
try:
|
|
44
|
+
m = cose._thaw(cose.loads(data))
|
|
45
|
+
except Exception as exc: # noqa: BLE001 - every decoding failure of untrusted input is the same refusal
|
|
46
|
+
raise EnrollError(f"not CBOR ({type(exc).__name__})") from exc
|
|
47
|
+
if not isinstance(m, dict):
|
|
48
|
+
raise EnrollError("not a CBOR map")
|
|
49
|
+
return m
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def read_initiation(data: bytes) -> dict:
|
|
53
|
+
m = _map(data)
|
|
54
|
+
if m.get("freshness_kind") != "present-nonce" or m.get("mode") != "enroll":
|
|
55
|
+
raise EnrollError("not an enrollment initiation with a nonce")
|
|
56
|
+
if not isinstance(m.get("handle"), bytes) or len(m["handle"]) != HANDLE_LEN:
|
|
57
|
+
raise EnrollError("handle missing or not 32 octets")
|
|
58
|
+
return m
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def request(*, target: str, credential_type: str, handle: bytes, evidence: bytes, csr: bytes,
|
|
62
|
+
profile: str = PROFILE_AZURE_CGPU) -> bytes:
|
|
63
|
+
if credential_type not in CREDENTIAL_TYPES:
|
|
64
|
+
raise EnrollError(f"unknown credential type {credential_type}")
|
|
65
|
+
return cose.dumps({"freshness_kind": "present-nonce", "target": target, "credential_type": credential_type,
|
|
66
|
+
"handle": handle, "evidence": evidence, "profile": profile,
|
|
67
|
+
"binding": {"method": "binding-input", "hash": "sha512"}, "csr": csr})
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def read_request(data: bytes) -> dict:
|
|
71
|
+
m = _map(data)
|
|
72
|
+
need = {"freshness_kind": str, "target": str, "credential_type": str, "handle": bytes, "evidence": bytes,
|
|
73
|
+
"profile": str, "binding": dict, "csr": bytes}
|
|
74
|
+
for k, t in need.items():
|
|
75
|
+
if not isinstance(m.get(k), t):
|
|
76
|
+
raise EnrollError(f"request field {k} missing or of the wrong type")
|
|
77
|
+
if m["binding"] != {"method": "binding-input", "hash": "sha512"}:
|
|
78
|
+
raise EnrollError("unsupported binding method")
|
|
79
|
+
if m["credential_type"] not in CREDENTIAL_TYPES:
|
|
80
|
+
raise EnrollError(f"unknown credential type {m['credential_type']}")
|
|
81
|
+
return m
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
class HandleBook:
|
|
85
|
+
"""Issued handles in a JSON file: {hex: {"issued": t, "expires": t, "spent": t | null}}."""
|
|
86
|
+
|
|
87
|
+
def __init__(self, path: Path):
|
|
88
|
+
self.path = Path(path)
|
|
89
|
+
|
|
90
|
+
def _load(self) -> dict:
|
|
91
|
+
return json.loads(self.path.read_text()) if self.path.exists() else {}
|
|
92
|
+
|
|
93
|
+
def _save(self, book: dict) -> None:
|
|
94
|
+
tmp = self.path.with_suffix(".tmp")
|
|
95
|
+
tmp.write_text(json.dumps(book, indent=1, sort_keys=True))
|
|
96
|
+
os.replace(tmp, self.path)
|
|
97
|
+
|
|
98
|
+
def issue(self, ttl: int, now: float | None = None) -> bytes:
|
|
99
|
+
now = time.time() if now is None else now
|
|
100
|
+
h = os.urandom(HANDLE_LEN)
|
|
101
|
+
book = self._load()
|
|
102
|
+
book[h.hex()] = {"issued": int(now), "expires": int(now + ttl), "spent": None}
|
|
103
|
+
self._save(book)
|
|
104
|
+
return h
|
|
105
|
+
|
|
106
|
+
def spend(self, handle: bytes, now: float | None = None) -> dict:
|
|
107
|
+
"""Mark the handle spent; EnrollError if it was never issued, has expired or was already spent."""
|
|
108
|
+
now = time.time() if now is None else now
|
|
109
|
+
book = self._load()
|
|
110
|
+
e = book.get(handle.hex())
|
|
111
|
+
if e is None:
|
|
112
|
+
raise EnrollError("handle was not issued by this authority")
|
|
113
|
+
if e["spent"] is not None:
|
|
114
|
+
raise EnrollError("handle already used")
|
|
115
|
+
e["spent"] = int(now)
|
|
116
|
+
self._save(book)
|
|
117
|
+
if now > e["expires"]:
|
|
118
|
+
raise EnrollError("handle expired")
|
|
119
|
+
return e
|