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,325 @@
1
+ """Appraisal of an `ep1/azure-cgpu/v1` enrollment (ARCHITECTURE §7d): every check named, the EAR built from them.
2
+
3
+ The same function serves the authority at enrollment and the offline check later; what differs is where the
4
+ collateral comes from (fetched live, or read back from the evidence package) and the time of the check (now, or the
5
+ result's issue time). Nothing here reaches the network: `collateral` brings RIMs and OCSP responses in.
6
+
7
+ Policy (JSON; reference values with their provenance):
8
+ {"id", "lifetime", "amd": {"product", "min_tcb": {...}, "measurements": [hex]},
9
+ "vtpm": {"pcrs": {"<index>": [hex]}},
10
+ "gpu": {"count", "rim": "required" | "off", "ocsp": "required" | "off"},
11
+ "identities": {"weights": [hex], "tokenizer": [hex], "code": [hex], "class": [...], "params": [hex]}}
12
+ An empty allow-list accepts any value and says so in the result (the check is reported as not performed).
13
+ """
14
+ from __future__ import annotations
15
+
16
+ import datetime as dt
17
+ import hashlib
18
+ from dataclasses import dataclass, field
19
+
20
+ from cryptography import x509
21
+
22
+ from .. import cose
23
+ from . import amd, ear, hcl, nvidia, ocsp, pki, rim, tpm
24
+ from .binding import BindingInput
25
+
26
+
27
+ @dataclass
28
+ class Check:
29
+ name: str
30
+ ok: bool | None # None = not performed (and why, in detail)
31
+ detail: str = ""
32
+
33
+
34
+ @dataclass
35
+ class Appraisal:
36
+ checks: list = field(default_factory=list)
37
+ submods: dict = field(default_factory=dict)
38
+ identities: dict = field(default_factory=dict)
39
+ statement_key: object = None
40
+ target: str = ""
41
+
42
+ @property
43
+ def ok(self) -> bool:
44
+ return all(c.ok is not False for c in self.checks) and bool(self.checks)
45
+
46
+ def failed(self) -> list:
47
+ return [c for c in self.checks if c.ok is False]
48
+
49
+
50
+ def _thim_chain(thim: dict) -> tuple[bytes, bytes, bytes]:
51
+ from cryptography.hazmat.primitives import serialization
52
+ vcek = x509.load_pem_x509_certificate(thim["vcekCert"].encode())
53
+ chain = x509.load_pem_x509_certificates(thim["certificateChain"].encode())
54
+ enc = serialization.Encoding.DER
55
+ ask = next(c for c in chain if c.issuer != c.subject)
56
+ ark = next(c for c in chain if c.issuer == c.subject)
57
+ return vcek.public_bytes(enc), ask.public_bytes(enc), ark.public_bytes(enc)
58
+
59
+
60
+ def appraise(req: dict, *, rrp_id: str, policy: dict, at: dt.datetime, collateral: dict) -> Appraisal:
61
+ """Every check, named. The evidence is untrusted: a shape no check anticipated fails closed, as "evidence.format",
62
+ and never raises (an authority must answer every request)."""
63
+ try:
64
+ return _appraise(req, rrp_id=rrp_id, policy=policy, at=at, collateral=collateral)
65
+ except Exception as exc: # noqa: BLE001
66
+ a = Appraisal(target=str(req.get("target", "")) if isinstance(req, dict) else "")
67
+ a.checks.append(Check("evidence.format", False, f"{type(exc).__name__}: {exc}"))
68
+ return a
69
+
70
+
71
+ def _appraise(req: dict, *, rrp_id: str, policy: dict, at: dt.datetime, collateral: dict) -> Appraisal:
72
+ """``req`` is a read enrollment request (enroll.read_request); ``collateral`` = {"rims": {id: xml},
73
+ "ocsp": [{"serial": hex, "nonce": bytes, "response": bytes, ...}], "amd_crl": der}."""
74
+ a = Appraisal(target=req["target"])
75
+ ev = cose._thaw(cose.loads(req["evidence"]))
76
+ add = lambda name, ok, detail="": a.checks.append(Check(name, ok, detail)) # noqa: E731
77
+ pol_amd, pol_vtpm, pol_gpu = policy.get("amd", {}), policy.get("vtpm", {}), policy.get("gpu", {})
78
+ claims = ev.get("claims", {})
79
+
80
+ # ---- binding: the request's own fields recomputed, the key that signed the CSR
81
+ try:
82
+ key, cn = pki.read_csr(req["csr"])
83
+ add("binding.csr_proof_of_possession", True)
84
+ a.statement_key = key
85
+ except pki.PKIError as exc:
86
+ add("binding.csr_proof_of_possession", False, str(exc))
87
+ key, cn = None, ""
88
+ add("binding.csr_subject_is_target", cn == req["target"], f"CSR names {cn!r}")
89
+ bi = None
90
+ try:
91
+ bi = BindingInput(handle=req["handle"], rrp_id=rrp_id, target=req["target"],
92
+ csr_digest=hashlib.sha256(req["csr"]).digest(),
93
+ statement_key=pki.spki(key) if key else b"", weights=claims["weights"],
94
+ tokenizer=claims["tokenizer"], code=claims["code"], sealing_class=claims["class"],
95
+ params=claims["params"])
96
+ expected = bi.value()
97
+ except (KeyError, ValueError) as exc:
98
+ add("binding.fields", False, str(exc))
99
+ expected = b""
100
+ a.identities = {"weights": claims.get("weights", b""), "tokenizer": claims.get("tokenizer", b""),
101
+ "code": claims.get("code", b""), "class": claims.get("class", ""),
102
+ "params": claims.get("params", b""), "binding": expected,
103
+ "key": hashlib.sha256(pki.spki(key)).digest() if key else b""}
104
+ if "params_map" in claims:
105
+ from .binding import params_digest
106
+ add("binding.params_map_digest", params_digest(claims["params_map"]) == claims["params"])
107
+ for name, allowed in policy.get("identities", {}).items():
108
+ value = claims.get(name)
109
+ shown = value.hex() if isinstance(value, bytes) else str(value)
110
+ if not allowed:
111
+ add(f"identity.{name}", None, f"no reference values; observed {shown}")
112
+ else:
113
+ add(f"identity.{name}", shown in allowed, f"observed {shown}")
114
+
115
+ # ---- cpu: the paravisor's report under AMD's chain
116
+ cpu_ok = {"hardware": False, "config": False, "identity": False}
117
+ h = None
118
+ try:
119
+ h = hcl.parse(ev["hcl"])
120
+ add("cpu.hcl.parse", True, f"report type {h.report_type}, hash type {h.hash_type}")
121
+ except (hcl.HCLError, amd.SNPError, KeyError) as exc:
122
+ add("cpu.hcl.parse", False, str(exc))
123
+ if h is not None:
124
+ product = pol_amd.get("product", "Genoa")
125
+ try:
126
+ vcek_der, ask_der, ark_der = _thim_chain(ev["thim"])
127
+ ext = amd.verify_chain(vcek_der, ask_der, ark_der, product, at)
128
+ add("cpu.chain.to_pinned_ark", True, f"ARK-{product}; {ext.get('productName', '?')}")
129
+ if pol_amd.get("crl", "required") != "off":
130
+ crl = collateral.get("amd_crl")
131
+ try:
132
+ if crl is None:
133
+ raise amd.SNPError("no CRL")
134
+ amd.check_crl(crl, ask_der, ark_der, at)
135
+ add("cpu.chain.ask_not_revoked", True, "AMD CRL")
136
+ except (amd.SNPError, ValueError) as exc:
137
+ add("cpu.chain.ask_not_revoked", False, str(exc))
138
+ amd.verify_report(h.snp, vcek_der)
139
+ add("cpu.report.signature", True)
140
+ parts = amd.tcb_parts(h.snp.reported_tcb, product)
141
+ same = all(ext.get(k) == v for k, v in parts.items())
142
+ add("cpu.report.tcb_matches_vcek", same, f"reported {parts}")
143
+ minimum = pol_amd.get("min_tcb", {})
144
+ add("cpu.report.tcb_at_least_minimum", amd.tcb_at_least(parts, minimum) if minimum else None,
145
+ f"minimum {minimum}" if minimum else "no minimum in the policy")
146
+ cpu_ok["hardware"] = same and (not minimum or amd.tcb_at_least(parts, minimum))
147
+ except (amd.SNPError, KeyError, StopIteration, ValueError) as exc:
148
+ add("cpu.chain.to_pinned_ark", False, str(exc))
149
+ add("cpu.report.debug_off", not h.snp.debug, f"policy 0x{h.snp.policy:x}")
150
+ if pol_amd.get("alias_check_complete", True):
151
+ add("cpu.report.alias_check_complete", h.snp.alias_check_complete,
152
+ f"platform info 0x{h.snp.platform_info:x}")
153
+ bits = pol_amd.get("mitigation_vector_bits", [])
154
+ if bits:
155
+ add("cpu.report.mitigation_vector", all(h.snp.current_mit_vector >> b & 1 for b in bits),
156
+ f"current 0x{h.snp.current_mit_vector:x}, required bits {bits}")
157
+ refs, id_keys = pol_amd.get("measurements", []), pol_amd.get("id_key_digests", [])
158
+ m, idk = h.snp.measurement.hex(), h.snp.id_key_digest.hex()
159
+ by_measurement, by_key = m in refs, h.snp.id_block and idk in id_keys
160
+ endorsed = (by_measurement or by_key) if (refs or id_keys) else None
161
+ add("cpu.report.launch_endorsed", endorsed,
162
+ ("measurement in the reference set" if by_measurement else
163
+ f"ID block signed by pinned ID key {idk[:16]}… (family {h.snp.family_id.hex()[:8]}, image "
164
+ f"{h.snp.image_id.hex()[:8]}, guest SVN {h.snp.guest_svn})" if by_key else
165
+ f"measurement {m}, ID key {idk if h.snp.id_block else 'none'}: neither in the policy")
166
+ + ("" if (refs or id_keys) else "; no reference values"))
167
+ cpu_ok["config"] = not h.snp.debug and (endorsed is not False) and \
168
+ (h.snp.alias_check_complete or not pol_amd.get("alias_check_complete", True)) and \
169
+ all(h.snp.current_mit_vector >> b & 1 for b in bits)
170
+ rd = hcl.report_data_binds_claims(h)
171
+ add("cpu.report.report_data_is_hash_of_runtime_claims", rd)
172
+ cpu_ok["identity"] = rd
173
+ a.submods["cpu"] = ear.appraisal(
174
+ {ear.HARDWARE: 2 if cpu_ok["hardware"] else 96, ear.CONFIGURATION: 2 if cpu_ok["config"] else 96,
175
+ ear.RUNTIME_OPAQUE: 2 if cpu_ok["hardware"] else 96, ear.INSTANCE_IDENTITY: 2 if cpu_ok["identity"] else 96},
176
+ [policy.get("id", "")],
177
+ attester={"measurement": h.snp.measurement, "reported_tcb": h.snp.reported_tcb, "policy": h.snp.policy,
178
+ "chip_id": h.snp.chip_id, "vmpl": h.snp.vmpl, "id_key_digest": h.snp.id_key_digest,
179
+ "family_id": h.snp.family_id, "image_id": h.snp.image_id, "guest_svn": h.snp.guest_svn}
180
+ if h else None)
181
+
182
+ # ---- vtpm: user data, the attestation key, the quote and the boot chain
183
+ vt: dict = {"identity": False, "exec": False, "config": False}
184
+ if h is not None:
185
+ ud_ok = bool(expected) and h.user_data == expected
186
+ add("vtpm.runtime_claims.user_data_is_binding", ud_ok)
187
+ vmc = h.claims.get("vm-configuration", {})
188
+ add("vtpm.runtime_claims.secure_boot", vmc.get("secure-boot") is True)
189
+ add("vtpm.runtime_claims.tpm_enabled", vmc.get("tpm-enabled") is True)
190
+ vt["config"] = vmc.get("secure-boot") is True and vmc.get("tpm-enabled") is True
191
+ jwk = h.jwk("HCLAkPub")
192
+ try:
193
+ akpub = hcl.rsa_from_jwk(jwk or {})
194
+ q = ev["quote"]
195
+ tpm.verify_quote(q["msg"], q["sig"], akpub)
196
+ add("vtpm.quote.signature_by_bound_ak", True)
197
+ quote = tpm.parse_attest(q["msg"])
198
+ qd = bool(expected) and quote.extra == hashlib.sha256(expected).digest()
199
+ add("vtpm.quote.qualifying_data_is_binding_digest", qd)
200
+ values = tpm.split_values(q["pcrs"], quote)
201
+ add("vtpm.quote.pcr_digest", tpm.pcr_digest(quote, values) == quote.pcr_digest)
202
+ log = tpm.parse_event_log(ev["eventlog"])
203
+ later = tpm.parse_systemd_log(ev["systemd_tpm_log"]) if ev.get("systemd_tpm_log") else []
204
+ replayed = tpm.replay_with(log, later)
205
+ # PCR 11 is extended again after boot (systemd-pcrphase): without systemd's log it cannot be replayed
206
+ uncovered = {11} if not later and any(e.pcr == 11 for e in log.events) else set()
207
+ judged = {i: v for i, v in replayed.items() if i not in uncovered}
208
+ mism = [i for i, v in judged.items() if values.get((tpm.ALG_SHA256, i)) not in (None, v)]
209
+ add("vtpm.eventlog.replays_to_quoted_pcrs", not mism, f"PCRs replayed {sorted(judged)}; mismatched "
210
+ f"{mism}" if mism else f"PCRs replayed {sorted(judged)}"
211
+ + (f"; not covered without systemd's log: {sorted(uncovered)}" if uncovered else ""))
212
+ uki = tpm.uki_sections(log)
213
+ ref_uki = pol_vtpm.get("uki", {})
214
+ bad_uki = [k for k, allowed in ref_uki.items() if uki.get(k, b"").hex() not in allowed]
215
+ if ref_uki:
216
+ add("vtpm.uki_sections.in_reference_set", not bad_uki,
217
+ f"outside the reference set: {bad_uki}" if bad_uki else ", ".join(sorted(ref_uki)))
218
+ vt["claims"] = {"pcr4": values.get((tpm.ALG_SHA256, 4), b""), "pcr7": values.get((tpm.ALG_SHA256, 7), b""),
219
+ "uki": uki}
220
+ refs = pol_vtpm.get("pcrs", {})
221
+ bad = [i for i, allowed in refs.items() if values.get((tpm.ALG_SHA256, int(i)), b"").hex() not in allowed]
222
+ add("vtpm.pcrs.in_reference_set", (not bad) if refs else None,
223
+ f"outside the reference set: {bad}" if bad else ("" if refs else "no reference values"))
224
+ vt["identity"] = ud_ok and qd
225
+ vt["exec"] = (not mism) and (not bad if refs else True) and not bad_uki
226
+ except (hcl.HCLError, tpm.TPMError, KeyError) as exc:
227
+ add("vtpm.quote.signature_by_bound_ak", False, str(exc))
228
+ a.submods["vtpm"] = ear.appraisal(
229
+ {ear.INSTANCE_IDENTITY: 2 if vt["identity"] else 96, ear.EXECUTABLES: 3 if vt["exec"] else 33,
230
+ ear.CONFIGURATION: 2 if vt["config"] else 32}, [policy.get("id", "")], attester=vt.get("claims"))
231
+
232
+ # ---- gpus: chain, signature over the handle, manifests, revocation
233
+ gpus = ev.get("gpus", [])
234
+ want = pol_gpu.get("count")
235
+ add("gpu.count", (len(gpus) == want) if want else bool(gpus), f"{len(gpus)} GPU(s)")
236
+ for g in gpus:
237
+ label = f"gpu{g.get('index', 0)}"
238
+ gk = {"hardware": False, "exec": False, "config": False}
239
+ try:
240
+ chain = nvidia.load_chain(g["chain"])
241
+ nvidia.verify_chain(chain, nvidia.DEVICE_ROOT_SHA256, at)
242
+ add(f"{label}.chain.to_pinned_root", True, chain[0].subject.rfc4514_string())
243
+ rep = nvidia.parse(g["report"])
244
+ nvidia.verify_report(rep, chain[0])
245
+ add(f"{label}.report.signature", True)
246
+ add(f"{label}.report.nonce_is_handle", rep.nonce == req["handle"])
247
+ add(f"{label}.report.fwid_matches_certificate",
248
+ rep.opaque.get("fwid", b"") == nvidia.certificate_fwid(chain[0]))
249
+ gk["hardware"] = rep.nonce == req["handle"]
250
+ drv = nvidia.text(rep.opaque.get("driver_version", b""))
251
+ vb = nvidia.vbios_version(rep.opaque.get("vbios_version", b""))
252
+ add(f"{label}.report.versions_match_nvml", drv == g.get("driver") and vb.lower() == g.get("vbios", "").lower(),
253
+ f"report {drv} / {vb}; NVML {g.get('driver')} / {g.get('vbios')}")
254
+ cc = g.get("cc", {})
255
+ gk["config"] = cc.get("feature") == 1 and cc.get("devtools") == 0
256
+ add(f"{label}.cc_mode_on_devtools_off", gk["config"], f"NVML {cc}")
257
+ revocation = pol_gpu.get("ocsp", "required") != "off"
258
+ if pol_gpu.get("rim", "required") != "off":
259
+ gk["exec"] = _rims(add, label, rep, drv, vb, collateral, at, revocation)
260
+ else:
261
+ add(f"{label}.rim", None, "manifests not checked by policy")
262
+ if revocation:
263
+ _ocsp_chain(add, label, chain, chain[1:-1], collateral, at, nvidia.DEVICE_ROOT_SHA256)
264
+ else:
265
+ add(f"{label}.ocsp", None, "revocation not checked by policy")
266
+ except (nvidia.GPUError, KeyError, ValueError) as exc:
267
+ add(f"{label}.chain.to_pinned_root", False, str(exc))
268
+ a.submods[label] = ear.appraisal(
269
+ {ear.HARDWARE: 2 if gk["hardware"] else 96, ear.EXECUTABLES: 2 if gk["exec"] else 33,
270
+ ear.CONFIGURATION: 2 if gk["config"] else 96, ear.RUNTIME_OPAQUE: 2 if gk["config"] else 96},
271
+ [policy.get("id", "")], nonce=req["handle"])
272
+ return a
273
+
274
+
275
+ def rim_ids(rep: nvidia.GPUReport) -> dict:
276
+ """The driver and VBIOS manifest ids for a report, as NVIDIA's verifier composes them from its opaque data."""
277
+ up = lambda k: nvidia.text(rep.opaque.get(k, b"")).upper() # noqa: E731
278
+ vb = nvidia.vbios_version(rep.opaque.get("vbios_version", b""))
279
+ return {"driver": rim.driver_id(nvidia.text(rep.opaque.get("driver_version", b""))),
280
+ "vbios": rim.vbios_id(up("project"), up("project_sku"), up("chip_sku"), vb.replace(".", "").upper())}
281
+
282
+
283
+ def _rims(add, label, rep, drv, vb, collateral, at, revocation: bool) -> bool:
284
+ ids = rim_ids(rep)
285
+ gold = {}
286
+ for kind, rid in ids.items():
287
+ xml = collateral.get("rims", {}).get(rid)
288
+ if xml is None:
289
+ add(f"{label}.rim.{kind}", False, f"{rid} not available")
290
+ return False
291
+ try:
292
+ gold[kind] = rim.golden(rim.verify(xml, at))
293
+ add(f"{label}.rim.{kind}.signature", True, rid)
294
+ except (rim.RIMError, KeyError) as exc:
295
+ add(f"{label}.rim.{kind}.signature", False, f"{rid}: {exc}")
296
+ return False
297
+ if revocation:
298
+ from lxml import etree
299
+ rc = rim._chain(etree.fromstring(xml, etree.XMLParser(resolve_entities=False, no_network=True)))
300
+ _ocsp_chain(add, f"{label}.rim.{kind}", rc, rc[:-1], collateral, at, nvidia.RIM_ROOT_SHA256)
301
+ nvdec = rep.opaque.get("nvdec0_status", b"")
302
+ bad = rim.compare(rep.measurements, gold["driver"], gold["vbios"], nvdec[0] if nvdec else None)
303
+ add(f"{label}.rim.measurements_match", not bad, f"mismatched {bad}" if bad else
304
+ f"{len(rep.measurements)} measurements")
305
+ return not bad
306
+
307
+
308
+ def _ocsp_chain(add, label, chain, asked, collateral, at, root_sha256) -> None:
309
+ """Revocation of ``asked`` (members of ``chain``, leaf first) from the kept responses. For a GPU chain NVIDIA's
310
+ verifier asks from the second certificate to the one below the root (the leaf is the per-boot attestation key);
311
+ for a RIM chain, every certificate below the root."""
312
+ responses = collateral.get("ocsp", [])
313
+ for cert in asked:
314
+ issuer = chain[chain.index(cert) + 1]
315
+ sn = format(cert.serial_number, "x")
316
+ name = f"{label}.ocsp.{cert.subject.rfc4514_string().split(',')[0].split('=')[-1][:32]}"
317
+ last = "no response"
318
+ for r in (x for x in responses if x["serial"] == sn):
319
+ try:
320
+ add(name, ocsp.check(r["response"], cert, issuer, r["nonce"], root_sha256, at, chain) == "good", "good")
321
+ break
322
+ except nvidia.GPUError as exc:
323
+ last = str(exc)
324
+ else:
325
+ add(name, False, last)
@@ -0,0 +1,58 @@
1
+ """The sealer's side of an enrollment, inside the confidential VM (ARCHITECTURE §7d).
2
+
3
+ A fresh Ed25519 statement key is generated in this process and never written anywhere; its CSR, the model and code
4
+ identities and the authority's handle go into the binding value; the binding goes into the paravisor's report (vTPM
5
+ guest input), SHA-256 of it into the quote's qualifying data, and the handle into every GPU's report. The authority
6
+ named in the binding is the one this sealer is configured with: an initiation naming another is refused.
7
+ """
8
+ from __future__ import annotations
9
+
10
+ import hashlib
11
+ from dataclasses import dataclass
12
+
13
+ from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
14
+
15
+ from .. import cose
16
+ from . import enroll, pki
17
+ from .binding import BindingInput, code_identity, params_digest
18
+
19
+
20
+ @dataclass
21
+ class Enrollment:
22
+ key: Ed25519PrivateKey
23
+ request: bytes
24
+ binding: BindingInput
25
+
26
+
27
+ def evidence_azure_cgpu(binding_value: bytes, handle: bytes, claims: dict) -> bytes:
28
+ from . import azure_cgpu as az
29
+ ev = {"hcl": az.guest_report(binding_value), "quote": az.quote(hashlib.sha256(binding_value).digest()),
30
+ "thim": az.thim(), "eventlog": az.event_log(), "gpus": az.gpus(handle), "claims": claims}
31
+ ima = az.ima_log()
32
+ if ima:
33
+ ev["ima"] = ima
34
+ sd = az.systemd_tpm_log()
35
+ if sd:
36
+ ev["systemd_tpm_log"] = sd
37
+ akc = az.ak_cert()
38
+ if akc:
39
+ ev["akcert"] = akc
40
+ return cose.dumps(ev)
41
+
42
+
43
+ def prepare(*, initiation: bytes, rrp_id: str, target: str, weights: bytes, tokenizer: bytes, sealing_class: str,
44
+ params: dict, credential_type: str = enroll.CRED_STATEMENT, collect=evidence_azure_cgpu) -> Enrollment:
45
+ ini = enroll.read_initiation(initiation)
46
+ if ini["rrp_id"] != rrp_id:
47
+ raise enroll.EnrollError(f"initiation names authority {ini['rrp_id']!r}, this sealer is configured for "
48
+ f"{rrp_id!r}")
49
+ key = Ed25519PrivateKey.generate()
50
+ csr = pki.make_csr(key, target)
51
+ bi = BindingInput(handle=ini["handle"], rrp_id=rrp_id, target=target, csr_digest=hashlib.sha256(csr).digest(),
52
+ statement_key=pki.spki(key.public_key()), weights=weights, tokenizer=tokenizer,
53
+ code=code_identity(), sealing_class=sealing_class, params=params_digest(params))
54
+ claims = {"weights": weights, "tokenizer": tokenizer, "code": bi.code, "class": sealing_class,
55
+ "params": bi.params, "params_map": params}
56
+ ev = collect(bi.value(), ini["handle"], claims)
57
+ req = enroll.request(target=target, credential_type=credential_type, handle=ini["handle"], evidence=ev, csr=csr)
58
+ return Enrollment(key=key, request=req, binding=bi)
@@ -0,0 +1,239 @@
1
+ """The credential authority (ARCHITECTURE §7d): issues handles, appraises enrollment requests, certifies keys.
2
+
3
+ One directory per authority:
4
+ root.key Ed25519 private key (PKCS#8 PEM, mode 0600); it signs certificates and attestation results
5
+ root.der the root certificate - what relying parties pin
6
+ authority.json {"rrp_id", "name", "verifier_id": [developer, build]}
7
+ policy.json the appraisal policy: reference values with their provenance (appraise.py)
8
+ handles.json the handle book (enroll.HandleBook)
9
+ issued/NNNN/ per request: request.cbor, collateral.cbor, appraisal.json, ear.cose, and cert.der when affirmed
10
+ Collateral is fetched live at enrollment - AMD's CRL, NVIDIA's manifests and OCSP answers - and kept with the
11
+ request, so the same appraisal can be repeated offline at the result's issue time.
12
+
13
+ `python -m entropy_protocol.attested.authority init|initiate|enroll|show ...`
14
+ """
15
+ from __future__ import annotations
16
+
17
+ import argparse
18
+ import datetime as dt
19
+ import hashlib
20
+ import json
21
+ import os
22
+ import sys
23
+ from dataclasses import dataclass
24
+ from pathlib import Path
25
+
26
+ from cryptography import x509
27
+ from cryptography.hazmat.primitives import serialization
28
+ from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
29
+
30
+ from .. import ENGINE, cose
31
+ from . import amd, appraise, ear, enroll, nvidia, ocsp, pki, rim
32
+
33
+ EAR_DEVELOPER = "https://github.com/entropy-ep1/entropy-protocol"
34
+ SKEW = 300
35
+
36
+
37
+ @dataclass
38
+ class Outcome:
39
+ appraisal: appraise.Appraisal
40
+ ear: bytes
41
+ cert: bytes | None
42
+ dir: Path
43
+ status: int
44
+
45
+
46
+ class Authority:
47
+ def __init__(self, path: Path):
48
+ self.path = Path(path)
49
+ meta = json.loads((self.path / "authority.json").read_text())
50
+ self.rrp_id, self.name = meta["rrp_id"], meta["name"]
51
+ self.verifier_id = tuple(meta.get("verifier_id", (EAR_DEVELOPER, ENGINE)))
52
+ self.root = x509.load_der_x509_certificate((self.path / "root.der").read_bytes())
53
+ self.handles = enroll.HandleBook(self.path / "handles.json")
54
+
55
+ @property
56
+ def policy(self) -> dict:
57
+ return json.loads((self.path / "policy.json").read_text())
58
+
59
+ def _key(self) -> Ed25519PrivateKey:
60
+ return serialization.load_pem_private_key((self.path / "root.key").read_bytes(), None)
61
+
62
+ def initiate(self, ttl: int = 600) -> bytes:
63
+ return enroll.initiation(self.handles.issue(ttl), self.rrp_id, ttl)
64
+
65
+ def enroll(self, request: bytes, *, now: dt.datetime | None = None, collateral: dict | None = None) -> Outcome:
66
+ now = now or dt.datetime.now(dt.timezone.utc).replace(microsecond=0)
67
+ out = self._next_dir()
68
+ (out / "request.cbor").write_bytes(request)
69
+ policy = self.policy
70
+ try:
71
+ req = enroll.read_request(request)
72
+ except (enroll.EnrollError, ValueError) as exc:
73
+ return self._refuse(out, f"malformed request: {exc}", now, policy)
74
+ if collateral is None:
75
+ collateral = fetch_collateral(req, policy)
76
+ (out / "collateral.cbor").write_bytes(cose.dumps(collateral))
77
+ a = appraise.appraise(req, rrp_id=self.rrp_id, policy=policy, at=now, collateral=collateral)
78
+ try:
79
+ self.handles.spend(req["handle"], now.timestamp())
80
+ a.checks.insert(0, appraise.Check("binding.handle_issued_unexpired_unused", True))
81
+ except enroll.EnrollError as exc:
82
+ a.checks.insert(0, appraise.Check("binding.handle_issued_unexpired_unused", False, str(exc)))
83
+ if req["profile"] != enroll.PROFILE_AZURE_CGPU:
84
+ a.checks.append(appraise.Check("request.profile", False, req["profile"]))
85
+ status = ear.worst(s[ear.STATUS] for s in a.submods.values()) if a.submods else ear.CONTRAINDICATED
86
+ if not a.ok:
87
+ status = ear.CONTRAINDICATED
88
+ lifetime = int(policy.get("lifetime", 86400))
89
+ ext = {"target": req["target"], "credential_type": req["credential_type"],
90
+ "failed": [c.name for c in a.failed()], **a.identities}
91
+ topo = {"cpu": [k for k in a.submods if k != "cpu"]} if "cpu" in a.submods else None
92
+ c = ear.claims(a.submods, self.verifier_id, nonce=req["handle"], lifetime=lifetime, topology=topo, ep1=ext,
93
+ iat=int(now.timestamp()))
94
+ c[ear.STATUS] = status
95
+ signed = ear.sign(c, self._key(), [pki.der(self.root)])
96
+ (out / "ear.cose").write_bytes(signed)
97
+ cert = None
98
+ if status == ear.AFFIRMING and a.statement_key is not None:
99
+ # valid from a few minutes before issue: the sealer checks it against its own clock
100
+ cert = pki.der(pki.issue(self._key(), self.root, a.statement_key, req["target"], lifetime=lifetime + SKEW,
101
+ eku=pki.STATEMENT_EKU, not_before=now - dt.timedelta(seconds=SKEW)))
102
+ (out / "cert.der").write_bytes(cert)
103
+ _write_appraisal(out, a, status, now)
104
+ return Outcome(a, signed, cert, out, status)
105
+
106
+ def _refuse(self, out: Path, reason: str, now: dt.datetime, policy: dict) -> Outcome:
107
+ a = appraise.Appraisal(checks=[appraise.Check("request.format", False, reason)])
108
+ c = ear.claims({}, self.verifier_id, nonce=b"\x00" * 8, lifetime=int(policy.get("lifetime", 86400)),
109
+ iat=int(now.timestamp()))
110
+ c[ear.STATUS] = ear.CONTRAINDICATED
111
+ signed = ear.sign(c, self._key(), [pki.der(self.root)])
112
+ (out / "ear.cose").write_bytes(signed)
113
+ _write_appraisal(out, a, ear.CONTRAINDICATED, now)
114
+ return Outcome(a, signed, None, out, ear.CONTRAINDICATED)
115
+
116
+ def _next_dir(self) -> Path:
117
+ issued = self.path / "issued"
118
+ issued.mkdir(exist_ok=True)
119
+ n = 1 + max((int(p.name) for p in issued.iterdir() if p.name.isdigit()), default=0)
120
+ d = issued / f"{n:04d}"
121
+ d.mkdir()
122
+ return d
123
+
124
+
125
+ def _write_appraisal(out: Path, a: appraise.Appraisal, status: int, now: dt.datetime) -> None:
126
+ (out / "appraisal.json").write_text(json.dumps({
127
+ "time": now.isoformat(), "status": status, "ok": a.ok, "target": a.target,
128
+ "checks": [{"name": c.name, "ok": c.ok, "detail": c.detail} for c in a.checks],
129
+ "identities": {k: (v.hex() if isinstance(v, bytes) else v) for k, v in a.identities.items()}}, indent=1))
130
+
131
+
132
+ def fetch_collateral(req: dict, policy: dict) -> dict:
133
+ """AMD's CRL, NVIDIA's manifests for every GPU in the evidence, and OCSP answers for the GPU and manifest chains.
134
+ What cannot be fetched is left out: the appraisal then fails the check that needed it, by name."""
135
+ ev = cose._thaw(cose.loads(req["evidence"]))
136
+ out: dict = {"rims": {}, "ocsp": []}
137
+ product = policy.get("amd", {}).get("product", "Genoa")
138
+ if policy.get("amd", {}).get("crl", "required") != "off":
139
+ try:
140
+ out["amd_crl"] = amd.fetch_crl(product)
141
+ except OSError as exc:
142
+ out["errors"] = [f"amd crl: {exc}"]
143
+ asked: set = set()
144
+
145
+ def ask(cert: x509.Certificate, issuer: x509.Certificate) -> None:
146
+ key = (cert.serial_number, cert.issuer.rfc4514_string())
147
+ if key in asked:
148
+ return
149
+ asked.add(key)
150
+ n = ocsp.fresh_nonce()
151
+ try:
152
+ resp = ocsp.post(ocsp.request(cert, issuer, n))
153
+ except OSError as exc:
154
+ out.setdefault("errors", []).append(f"ocsp {cert.subject.rfc4514_string()}: {exc}")
155
+ return
156
+ out["ocsp"].append({"serial": format(cert.serial_number, "x"), "nonce": n, "response": resp,
157
+ "asked_at": dt.datetime.now(dt.timezone.utc).isoformat()})
158
+
159
+ for g in ev.get("gpus", []):
160
+ try:
161
+ chain = nvidia.load_chain(g["chain"])
162
+ rep = nvidia.parse(g["report"])
163
+ except (nvidia.GPUError, KeyError, ValueError):
164
+ continue
165
+ for cert, issuer in zip(chain[1:-1], chain[2:]):
166
+ ask(cert, issuer)
167
+ for rid in appraise.rim_ids(rep).values():
168
+ if rid in out["rims"]:
169
+ continue
170
+ try:
171
+ xml = rim.fetch(rid)
172
+ except OSError as exc:
173
+ out.setdefault("errors", []).append(f"rim {rid}: {exc}")
174
+ continue
175
+ out["rims"][rid] = xml
176
+ from lxml import etree
177
+ try:
178
+ rc = rim._chain(etree.fromstring(xml, etree.XMLParser(resolve_entities=False, no_network=True)))
179
+ except (rim.RIMError, etree.XMLSyntaxError):
180
+ continue
181
+ for cert, issuer in zip(rc[:-1], rc[1:]):
182
+ ask(cert, issuer)
183
+ return out
184
+
185
+
186
+ def init(path: Path, rrp_id: str, name: str, policy: dict) -> None:
187
+ path = Path(path)
188
+ path.mkdir(parents=True, exist_ok=False)
189
+ key = Ed25519PrivateKey.generate()
190
+ fd = os.open(path / "root.key", os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600)
191
+ with os.fdopen(fd, "wb") as fh:
192
+ fh.write(key.private_bytes(serialization.Encoding.PEM, serialization.PrivateFormat.PKCS8,
193
+ serialization.NoEncryption()))
194
+ (path / "root.der").write_bytes(pki.der(pki.make_root(key, name, "Entropy Protocol reference authority")))
195
+ (path / "authority.json").write_text(json.dumps({"rrp_id": rrp_id, "name": name,
196
+ "verifier_id": [EAR_DEVELOPER, ENGINE]}, indent=1))
197
+ (path / "policy.json").write_text(json.dumps(policy, indent=1))
198
+
199
+
200
+ def main(argv: list[str] | None = None) -> int:
201
+ ap = argparse.ArgumentParser(prog="entropy-authority")
202
+ sub = ap.add_subparsers(dest="cmd", required=True)
203
+ i = sub.add_parser("init")
204
+ i.add_argument("--dir", required=True)
205
+ i.add_argument("--rrp-id", required=True)
206
+ i.add_argument("--name", required=True)
207
+ i.add_argument("--policy", required=True, help="policy JSON (reference values)")
208
+ n = sub.add_parser("initiate")
209
+ n.add_argument("--dir", required=True)
210
+ n.add_argument("--ttl", type=int, default=600)
211
+ n.add_argument("--out", required=True)
212
+ e = sub.add_parser("enroll")
213
+ e.add_argument("--dir", required=True)
214
+ e.add_argument("--request", required=True)
215
+ e.add_argument("--ear-out")
216
+ e.add_argument("--cert-out")
217
+ a = ap.parse_args(argv)
218
+ if a.cmd == "init":
219
+ init(Path(a.dir), a.rrp_id, a.name, json.loads(Path(a.policy).read_text()))
220
+ print(f"authority {a.name} ({a.rrp_id}) in {a.dir}; root sha256 "
221
+ f"{hashlib.sha256((Path(a.dir) / 'root.der').read_bytes()).hexdigest()}")
222
+ return 0
223
+ auth = Authority(Path(a.dir))
224
+ if a.cmd == "initiate":
225
+ Path(a.out).write_bytes(auth.initiate(a.ttl))
226
+ return 0
227
+ o = auth.enroll(Path(a.request).read_bytes())
228
+ if a.ear_out:
229
+ Path(a.ear_out).write_bytes(o.ear)
230
+ if o.cert and a.cert_out:
231
+ Path(a.cert_out).write_bytes(o.cert)
232
+ failed = [f"{c.name}: {c.detail}" for c in o.appraisal.failed()]
233
+ print(json.dumps({"status": o.status, "certified": o.cert is not None, "dir": str(o.dir), "failed": failed,
234
+ "not_performed": [c.name for c in o.appraisal.checks if c.ok is None]}, indent=1))
235
+ return 0 if o.cert else 4
236
+
237
+
238
+ if __name__ == "__main__":
239
+ sys.exit(main())