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,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())
|