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.
Files changed (54) hide show
  1. entropy_protocol/__init__.py +11 -0
  2. entropy_protocol/_unicode14.py +120 -0
  3. entropy_protocol/attest.py +62 -0
  4. entropy_protocol/attested/__init__.py +6 -0
  5. entropy_protocol/attested/amd.py +221 -0
  6. entropy_protocol/attested/appraise.py +325 -0
  7. entropy_protocol/attested/attester.py +58 -0
  8. entropy_protocol/attested/authority.py +239 -0
  9. entropy_protocol/attested/azure_cgpu.py +186 -0
  10. entropy_protocol/attested/binding.py +71 -0
  11. entropy_protocol/attested/capture.py +109 -0
  12. entropy_protocol/attested/ear.py +94 -0
  13. entropy_protocol/attested/enroll.py +119 -0
  14. entropy_protocol/attested/evidence.py +106 -0
  15. entropy_protocol/attested/hcl.py +109 -0
  16. entropy_protocol/attested/nvidia.py +168 -0
  17. entropy_protocol/attested/ocsp.py +90 -0
  18. entropy_protocol/attested/pki.py +121 -0
  19. entropy_protocol/attested/rim.py +130 -0
  20. entropy_protocol/attested/service.py +242 -0
  21. entropy_protocol/attested/tpm.py +259 -0
  22. entropy_protocol/backend_hf.py +439 -0
  23. entropy_protocol/batch.py +537 -0
  24. entropy_protocol/calibrate.py +99 -0
  25. entropy_protocol/canon.py +321 -0
  26. entropy_protocol/canongen.py +325 -0
  27. entropy_protocol/cli.py +493 -0
  28. entropy_protocol/cose.py +260 -0
  29. entropy_protocol/crypto.py +140 -0
  30. entropy_protocol/delivery.py +31 -0
  31. entropy_protocol/detect.py +246 -0
  32. entropy_protocol/gateway.py +370 -0
  33. entropy_protocol/hpke.py +105 -0
  34. entropy_protocol/mcp.py +159 -0
  35. entropy_protocol/pack.py +217 -0
  36. entropy_protocol/plain.py +122 -0
  37. entropy_protocol/predicates.py +37 -0
  38. entropy_protocol/profiles.py +45 -0
  39. entropy_protocol/record.py +116 -0
  40. entropy_protocol/scrapi.py +203 -0
  41. entropy_protocol/seal.py +232 -0
  42. entropy_protocol/statements.py +148 -0
  43. entropy_protocol/stats.py +20 -0
  44. entropy_protocol/tlog.py +147 -0
  45. entropy_protocol/ts3161.py +205 -0
  46. entropy_protocol/typography.py +73 -0
  47. entropy_protocol/verify.py +232 -0
  48. entropy_protocol-0.1.0.dist-info/METADATA +255 -0
  49. entropy_protocol-0.1.0.dist-info/RECORD +54 -0
  50. entropy_protocol-0.1.0.dist-info/WHEEL +5 -0
  51. entropy_protocol-0.1.0.dist-info/entry_points.txt +2 -0
  52. entropy_protocol-0.1.0.dist-info/licenses/LICENSE +201 -0
  53. entropy_protocol-0.1.0.dist-info/licenses/NOTICE +4 -0
  54. 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