quantbom 0.6.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 (45) hide show
  1. quantbom/__init__.py +497 -0
  2. quantbom/_bitorder.py +121 -0
  3. quantbom/_circuit.py +77 -0
  4. quantbom/_collect.py +661 -0
  5. quantbom/_drift.py +333 -0
  6. quantbom/_fidelity.py +280 -0
  7. quantbom/_merge.py +187 -0
  8. quantbom/_qir.py +224 -0
  9. quantbom/_scan.py +118 -0
  10. quantbom/_sign.py +284 -0
  11. quantbom/_stats.py +125 -0
  12. quantbom/_verify.py +530 -0
  13. quantbom/actions/__init__.py +27 -0
  14. quantbom/actions/base.py +68 -0
  15. quantbom/actions/benchmark.py +53 -0
  16. quantbom/actions/bind_backend.py +71 -0
  17. quantbom/actions/capture.py +106 -0
  18. quantbom/actions/check_qir.py +44 -0
  19. quantbom/actions/check_results.py +75 -0
  20. quantbom/actions/compare_native.py +88 -0
  21. quantbom/actions/disclose_error_handling.py +66 -0
  22. quantbom/actions/execute.py +85 -0
  23. quantbom/actions/inventory.py +24 -0
  24. quantbom/actions/layout.py +118 -0
  25. quantbom/actions/prove_equivalence.py +225 -0
  26. quantbom/actions/record_provenance.py +49 -0
  27. quantbom/actions/verify_conversion.py +53 -0
  28. quantbom/attest.py +363 -0
  29. quantbom/cli.py +378 -0
  30. quantbom/frameworks/__init__.py +67 -0
  31. quantbom/frameworks/base.py +155 -0
  32. quantbom/frameworks/braket.py +132 -0
  33. quantbom/frameworks/cirq.py +106 -0
  34. quantbom/frameworks/cudaq.py +223 -0
  35. quantbom/frameworks/pennylane.py +116 -0
  36. quantbom/frameworks/qiskit.py +67 -0
  37. quantbom/frameworks/qrisp.py +267 -0
  38. quantbom/harness.json +1730 -0
  39. quantbom/harness.py +88 -0
  40. quantbom-0.6.0.dist-info/METADATA +207 -0
  41. quantbom-0.6.0.dist-info/RECORD +45 -0
  42. quantbom-0.6.0.dist-info/WHEEL +5 -0
  43. quantbom-0.6.0.dist-info/entry_points.txt +2 -0
  44. quantbom-0.6.0.dist-info/licenses/LICENSE +202 -0
  45. quantbom-0.6.0.dist-info/top_level.txt +1 -0
quantbom/_verify.py ADDED
@@ -0,0 +1,530 @@
1
+ """Offline verification from a pip install — hybrid signing model, step 2 (MVP_PLAN.md B2).
2
+
3
+ quantbom keys --pin # fetch the service's signing keys once, pin them
4
+ quantbom verify .quantbom/my-app@1.0 # a mirror directory, a collected bundle, or a zip of either
5
+
6
+ An auditor who should not have to trust QuantBOM, or install its service code,
7
+ re-derives from the bytes in front of them everything that can be re-derived
8
+ without the gate:
9
+
10
+ artifact_hashes every hash-bound artifact re-hashed against the QBOM, and
11
+ every claim bound to a hash-verified report compared with it
12
+ evidence_signatures the producer's attestation and the service's receipt:
13
+ Ed25519 over the DSSE PAE, key ids, roles, subjects vs bytes
14
+ csnap_bindings the CSNAP's reproducibility hash recomputed from its own
15
+ payload; its input digest is this QBOM; its counts and
16
+ verdict follow from its control results
17
+ certificate_bindings the certificate's reproducibility hash recomputed; it names
18
+ this CSNAP and this QBOM; its evidence_signing block names the
19
+ keys the QBOM names; requirement statuses follow from the
20
+ control statuses they list, the overall status from them
21
+ certificate_signature Ed25519 over the certificate bytes, by a key that is ANCHORED:
22
+ pinned from /v1/keys, or supplied — never only the key the
23
+ bundle itself carries
24
+
25
+ What it does not do, and says so: re-run the gate's controls. That needs the gate
26
+ (python verify_bundle.py in the repository), and the verdict here is about
27
+ integrity and authorship, not policy. Verdicts: VERIFIED, FAILED, or UNANCHORED
28
+ when every check passed but no trust anchor was available for the service key.
29
+
30
+ The lists below mirror the gate's (mod08_cyclonedx_prototype.py);
31
+ tests/harness/test_sdk_verify.py pins each pair equal.
32
+ """
33
+ from __future__ import annotations
34
+
35
+ import base64
36
+ import hashlib
37
+ import json
38
+ import os
39
+ import shutil
40
+ import tempfile
41
+ import urllib.request
42
+ import zipfile
43
+ from datetime import datetime, timezone
44
+ from pathlib import Path
45
+ from typing import Any
46
+
47
+ from ._sign import release_identity
48
+
49
+ from ._sign import ATTESTATION_REL, PAYLOAD_TYPE, PUBKEY_REL, SigningUnavailable, _pae, key_id
50
+
51
+ # ---- the gate's binding tables, mirrored ----------------------------------
52
+ HASH_VERIFIED_ARTIFACT_PAIRS = [
53
+ ("secqdevops:circuit-artifact-ref", "secqdevops:circuit-sha256"),
54
+ ("secqdevops:transpiled-circuit-ref", "secqdevops:transpiled-circuit-sha256"),
55
+ ("secqdevops:qasm-program-ref", "secqdevops:qasm-program-sha256"),
56
+ ("secqdevops:qir-program-ref", "secqdevops:qir-program-sha256"),
57
+ ("secqdevops:qir-consistency-ref", "secqdevops:qir-consistency-sha256"),
58
+ ("secqdevops:benchmark-ref", "secqdevops:benchmark-sha256"),
59
+ ("secqdevops:equivalence-ref", "secqdevops:equivalence-sha256"),
60
+ ("secqdevops:conversion-fidelity-ref", "secqdevops:conversion-fidelity-sha256"),
61
+ ("secqdevops:native-program-ref", "secqdevops:native-program-sha256"),
62
+ ("secqdevops:harness-log-ref", "secqdevops:harness-log-sha256"),
63
+ ("secqdevops:error-handling-ref", "secqdevops:error-handling-sha256"),
64
+ ("secqdevops:expected-distribution-ref", "secqdevops:expected-distribution-sha256"),
65
+ ("secqdevops:calibration-ref", "secqdevops:calibration-sha256"),
66
+ ("secqdevops:ct-proof-ref", "secqdevops:ct-proof-sha256"),
67
+ ("secqdevops:cbom-ref", "secqdevops:cbom-sha256"),
68
+ ("secqdevops:crypto-agility-ref", "secqdevops:crypto-agility-sha256"),
69
+ ("secqdevops:vex-ref", "secqdevops:vex-sha256"),
70
+ ("secqdevops:cvd-policy-ref", "secqdevops:cvd-policy-sha256"),
71
+ ("secqdevops:test-report-ref", "secqdevops:test-report-sha256"),
72
+ ("secqdevops:backend-target-ref", "secqdevops:backend-target-sha256"),
73
+ ("secqdevops:runtime-job-ref", "secqdevops:runtime-job-sha256"),
74
+ ("secqdevops:result-summary-ref", "secqdevops:result-summary-sha256"),
75
+ ("secqdevops:native-result-ref", "secqdevops:native-result-sha256"),
76
+ ]
77
+ DIGEST_OPTIONAL_REFS = {
78
+ "secqdevops:backend-target-ref",
79
+ "secqdevops:runtime-job-ref",
80
+ "secqdevops:result-summary-ref",
81
+ }
82
+ STRICT_WITHHOLDABLE_REFS = {
83
+ "secqdevops:circuit-artifact-ref",
84
+ "secqdevops:transpiled-circuit-ref",
85
+ "secqdevops:qasm-program-ref",
86
+ }
87
+ VERDICT_BOUND_REPORTS = {
88
+ "secqdevops:equivalence-ref": "secqdevops:equivalence-verdict",
89
+ "secqdevops:conversion-fidelity-ref": "secqdevops:conversion-fidelity-verdict",
90
+ "secqdevops:qir-consistency-ref": "secqdevops:qir-consistency-verdict",
91
+ "secqdevops:native-result-ref": "secqdevops:native-result-verdict",
92
+ }
93
+ CLAIM_BOUND_REPORTS = {
94
+ "secqdevops:runtime-job-ref": [("secqdevops:runtime-job-status", "job_status")],
95
+ "secqdevops:backend-target-ref": [("secqdevops:execution-target", "execution_target")],
96
+ "secqdevops:error-handling-ref": [("secqdevops:error-mitigation", "mitigation_status")],
97
+ "secqdevops:calibration-ref": [("secqdevops:calibration-age-hours", "calibration_age_hours"),
98
+ ("secqdevops:calibration-captured-at", "captured_at")],
99
+ "secqdevops:equivalence-ref": [("secqdevops:equivalence-kind", "equivalence_kind")],
100
+ "secqdevops:conversion-fidelity-ref": [("secqdevops:conversion-fidelity-method", "method"),
101
+ ("secqdevops:native-program-sha256", "native_export_sha256")],
102
+ }
103
+ # the two signing roles, as the gate's sign_evidence.ROLES names them
104
+ ROLES = {
105
+ "producer": {"ref": "secqdevops:signature-ref", "pub": "secqdevops:signing-pubkey-ref", "kid": "secqdevops:signing-key-id"},
106
+ "service": {"ref": "secqdevops:service-attestation-ref", "pub": "secqdevops:service-signing-pubkey-ref",
107
+ "kid": "secqdevops:service-signing-key-id"},
108
+ }
109
+ # the fields whose canonical JSON the reproducibility hashes are taken over
110
+ # (the gate's CSNAP_PAYLOAD_KEYS; certify.py's CERTIFICATE_PAYLOAD_KEYS)
111
+ CSNAP_PAYLOAD_KEYS = ("release_id", "catalogue_version", "overall_status", "summary", "control_results",
112
+ "evidence_completeness", "findings", "input_digests")
113
+ CERTIFICATE_PAYLOAD_KEYS = ("scheme", "scheme_long", "assessment_type", "mapping_version", "standards_basis", "privacy_mode",
114
+ "evidence_signing", "evidence_validity", "subject", "overall_status", "coverage", "requirements", "gaps")
115
+
116
+ PINNED_KEYS = Path.home() / ".quantbom" / "service-keys.json"
117
+ LEGACY_PINNED_KEYS = Path.home() / ".zentroq" / "service-keys.json" # pinned before the rename (0.6.0)
118
+ MIRROR_SIGNING_PUB = "signing.pub" # the service's certificate key, as the mirror receives it
119
+
120
+
121
+ # ---- small helpers, kept identical in effect to the gate's ------------------
122
+ def _sha256_bytes(raw: bytes) -> str:
123
+ return "sha256:" + hashlib.sha256(raw).hexdigest()
124
+
125
+
126
+ def _sha256_json(data: Any) -> str:
127
+ return _sha256_bytes(json.dumps(data, sort_keys=True, separators=(",", ":"), ensure_ascii=True).encode("utf-8"))
128
+
129
+
130
+ def _is_sha256(value: str | None) -> bool:
131
+ if not value or not value.startswith("sha256:"):
132
+ return False
133
+ digest = value.split(":", 1)[1]
134
+ return len(digest) == 64 and all(c in "0123456789abcdef" for c in digest.lower())
135
+
136
+
137
+ def _props(sbom: dict[str, Any]) -> dict[str, str]:
138
+ return {str(p["name"]): str(p["value"]) for p in sbom.get("metadata", {}).get("properties", [])
139
+ if p.get("name") and p.get("value") is not None}
140
+
141
+
142
+ def _load(path: Path) -> Any:
143
+ return json.loads(path.read_text(encoding="utf-8"))
144
+
145
+
146
+ def _check(name: str, passed: bool, detail: Any) -> dict[str, Any]:
147
+ return {"check": name, "status": "PASS" if passed else "FAIL", "detail": detail}
148
+
149
+
150
+ def _crypto():
151
+ try:
152
+ from cryptography.exceptions import InvalidSignature
153
+ from cryptography.hazmat.primitives import serialization
154
+ except ImportError as exc:
155
+ raise SigningUnavailable('verifying signatures needs the cryptography package: pip install "quantbom[signing]"') from exc
156
+ return serialization, InvalidSignature
157
+
158
+
159
+ def _raw_public(public_key) -> bytes:
160
+ serialization, _ = _crypto()
161
+ return public_key.public_bytes(encoding=serialization.Encoding.Raw, format=serialization.PublicFormat.Raw)
162
+
163
+
164
+ # ---- 1. artifact hashes and bound claims -----------------------------------
165
+ def check_artifacts(sbom: dict[str, Any], base_dir: Path) -> dict[str, Any]:
166
+ props = _props(sbom)
167
+ strict = props.get("secqdevops:privacy-mode") == "strict"
168
+ findings: list[str] = []
169
+ checked = attested = 0
170
+ for ref_prop, sha_prop in HASH_VERIFIED_ARTIFACT_PAIRS:
171
+ ref, claimed = props.get(ref_prop), props.get(sha_prop)
172
+ if not ref and not claimed:
173
+ continue
174
+ if ref and not claimed and ref_prop in DIGEST_OPTIONAL_REFS:
175
+ continue # a declaration without a digest: unverified, not invalid (the gate's rule)
176
+ if not ref or not claimed:
177
+ findings.append(f"{ref_prop}/{sha_prop} must be provided together"); continue
178
+ if not _is_sha256(claimed):
179
+ findings.append(f"{sha_prop} is not a valid sha256:<digest> value"); continue
180
+ path = Path(ref) if Path(ref).is_absolute() else (base_dir / ref).resolve()
181
+ if not path.exists():
182
+ if strict and ref_prop in STRICT_WITHHOLDABLE_REFS:
183
+ attested += 1 # withheld on purpose: the digest is the producer's claim, auditable on-site
184
+ continue
185
+ findings.append(f"referenced artifact for {ref_prop} not found: {ref}"); continue
186
+ raw = path.read_bytes()
187
+ actual = _sha256_bytes(raw)
188
+ if actual != claimed:
189
+ findings.append(f"hash mismatch for {ref_prop} ({ref}): QBOM claims {claimed}, bytes hash to {actual}"); continue
190
+ checked += 1
191
+ # a verdict or claim declared beside a hash-verified report must be the report's own
192
+ bound = [(VERDICT_BOUND_REPORTS[ref_prop], "verdict")] if ref_prop in VERDICT_BOUND_REPORTS else []
193
+ bound += CLAIM_BOUND_REPORTS.get(ref_prop, [])
194
+ if not bound:
195
+ continue
196
+ try:
197
+ doc = json.loads(raw.decode("utf-8"))
198
+ except (ValueError, UnicodeDecodeError):
199
+ findings.append(f"{ref_prop} ({ref}) is not a readable JSON report"); continue
200
+ for claim_prop, field in bound:
201
+ if claim_prop not in props and field != "verdict":
202
+ continue # claims are checked when declared; a verdict is always expected
203
+ declared = str(props.get(claim_prop, "")).lower()
204
+ in_file = str((doc.get(field, "") if isinstance(doc, dict) else "")).lower()
205
+ if declared != in_file:
206
+ findings.append(f"{claim_prop} declares {declared or '(none)'!r} but the hash-verified {ref} records "
207
+ f"{in_file or '(none)'!r} — evidence is internally inconsistent")
208
+ detail = findings or f"{checked} artifact(s) re-hashed and match the QBOM" + (f"; {attested} withheld (privacy strict), digests client-attested" if attested else "")
209
+ return _check("artifact_hashes", not findings, detail)
210
+
211
+
212
+ # ---- 2. evidence signatures (producer attestation, service receipt) ----------
213
+ def _verify_role(role: str, props: dict[str, str], base_dir: Path) -> list[str]:
214
+ serialization, InvalidSignature = _crypto()
215
+ spec = ROLES[role]
216
+ att_path, pub_path = (base_dir / props[spec["ref"]]).resolve(), (base_dir / props[spec["pub"]]).resolve()
217
+ if not att_path.exists() or not pub_path.exists():
218
+ return [f"attestation or public key not found ({props[spec['ref']]}, {props[spec['pub']]})"]
219
+ envelope = _load(att_path)
220
+ public_key = serialization.load_pem_public_key(pub_path.read_bytes())
221
+ payload = base64.b64decode(envelope["payload"])
222
+ try:
223
+ public_key.verify(base64.b64decode(envelope["signatures"][0]["sig"]), _pae(payload))
224
+ except InvalidSignature:
225
+ return ["Ed25519 signature does not verify against the declared public key"]
226
+ findings: list[str] = []
227
+ if envelope.get("payloadType") != PAYLOAD_TYPE:
228
+ findings.append(f"unexpected payloadType {envelope.get('payloadType')!r}")
229
+ actual_kid = key_id(_raw_public(public_key))
230
+ env_kid = str(envelope["signatures"][0].get("keyid") or "")
231
+ if env_kid and env_kid != actual_kid:
232
+ findings.append(f"attestation keyid {env_kid[:19]}… is not the bundled public key's id {actual_kid[:19]}…")
233
+ declared = props.get(spec["kid"])
234
+ if declared and declared != actual_kid:
235
+ findings.append(f"{spec['kid']} {declared[:19]}… is not the bundled public key's id {actual_kid[:19]}…")
236
+ statement = json.loads(payload)
237
+ stated_role = statement.get("predicate", {}).get("signer_role")
238
+ if stated_role and stated_role != role:
239
+ findings.append(f"attestation declares signer_role {stated_role!r} but is referenced as the {role} signature")
240
+ for subject in statement.get("subject", []):
241
+ ref_prop, claimed = subject["name"], subject["digest"]["sha256"]
242
+ value = props.get(ref_prop)
243
+ if not value:
244
+ findings.append(f"signed subject property not present in QBOM: {ref_prop}"); continue
245
+ artifact = (base_dir / value).resolve()
246
+ if not artifact.exists():
247
+ findings.append(f"signed subject not found on disk: {ref_prop} -> {value}"); continue
248
+ actual = hashlib.sha256(artifact.read_bytes()).hexdigest()
249
+ if actual != claimed:
250
+ findings.append(f"signed subject {ref_prop} tampered: attestation {claimed[:12]}… != bytes {actual[:12]}…")
251
+ return findings
252
+
253
+
254
+ def signers(sbom: dict[str, Any], base_dir: Path) -> list[dict[str, Any]]:
255
+ """Every signature the QBOM declares, verified: [{role, key_id, ok, findings}] (the gate's `signers`)."""
256
+ props = _props(sbom)
257
+ out = []
258
+ for role, spec in ROLES.items():
259
+ if not (props.get(spec["ref"]) and props.get(spec["pub"])):
260
+ continue # the collector's unsigned placeholder (signature-ref alone) is not a signature
261
+ try:
262
+ findings = _verify_role(role, props, base_dir)
263
+ except SigningUnavailable:
264
+ raise
265
+ except Exception as exc: # an unreadable envelope or key is a finding, not a crash
266
+ findings = [f"could not verify: {type(exc).__name__}: {exc}"]
267
+ out.append({"role": role, "key_id": props.get(spec["kid"]), "ok": not findings, "findings": findings})
268
+ return out
269
+
270
+
271
+ def check_evidence_signatures(sbom: dict[str, Any], base_dir: Path) -> dict[str, Any]:
272
+ """The gate's rule (sign_evidence.verify): the producer's signature must be there and hold;
273
+ a service receipt must hold if present, and never stands in for the producer."""
274
+ present = signers(sbom, base_dir)
275
+ findings = [f"{s['role']}: {f}" for s in present for f in s["findings"]]
276
+ if not any(s["role"] == "producer" for s in present):
277
+ findings.insert(0, "no producer signature" + (" (service receipt only: a receipt is not authorship)" if present else " and no service countersignature")
278
+ + " — the releasing organisation signs with quantbom keygen / quantbom sign")
279
+ roles = " + ".join(s["role"] for s in present)
280
+ return _check("evidence_signatures", not findings, findings or f"{roles} signature(s) valid; key ids match; subjects match the bytes")
281
+
282
+
283
+ # ---- 3. the CSNAP is bound to this QBOM and to itself -------------------------
284
+ def check_csnap(sbom: dict[str, Any], csnap: dict[str, Any]) -> dict[str, Any]:
285
+ findings: list[str] = []
286
+ claimed_sbom = csnap.get("input_digests", {}).get("sbom_sha256")
287
+ if claimed_sbom != _sha256_json(sbom):
288
+ findings.append(f"the CSNAP was computed over a different QBOM (input sbom_sha256 {str(claimed_sbom)[:19]}… != this file {_sha256_json(sbom)[:19]}…)")
289
+ missing = [k for k in CSNAP_PAYLOAD_KEYS if k not in csnap]
290
+ if missing:
291
+ findings.append(f"CSNAP lacks payload fields {missing}")
292
+ elif _sha256_json({k: csnap[k] for k in CSNAP_PAYLOAD_KEYS}) != csnap.get("reproducibility_hash"):
293
+ findings.append("CSNAP reproducibility_hash is not the hash of its own payload (contents edited after the gate ran)")
294
+ results = csnap.get("control_results", [])
295
+ counts = {s: sum(1 for r in results if r.get("status") == s) for s in ("PASS", "FAIL", "N-A")}
296
+ summary = csnap.get("summary", {})
297
+ expect = {"total_controls": len(results), "passed": counts["PASS"], "failed": counts["FAIL"], "not_applicable": counts["N-A"]}
298
+ if any(summary.get(k) != v for k, v in expect.items()):
299
+ findings.append(f"CSNAP summary {summary} does not count its control results {expect}")
300
+ verdict = "COMPLIANT" if counts["FAIL"] == 0 else "NON_COMPLIANT"
301
+ if csnap.get("overall_status") != verdict:
302
+ findings.append(f"CSNAP overall_status {csnap.get('overall_status')!r} but its control results give {verdict!r}")
303
+ return _check("csnap_bindings", not findings, findings or f"hash recomputed; computed over this QBOM; {counts['PASS']} PASS / {counts['FAIL']} FAIL / {counts['N-A']} N-A give {verdict}")
304
+
305
+
306
+ # ---- 4. the certificate is bound to the CSNAP, the QBOM and its own arithmetic ----
307
+ def check_certificate(sbom: dict[str, Any], csnap: dict[str, Any] | None, cert: dict[str, Any]) -> dict[str, Any]:
308
+ findings: list[str] = []
309
+ missing = [k for k in CERTIFICATE_PAYLOAD_KEYS if k not in cert]
310
+ if missing:
311
+ findings.append(f"certificate lacks payload fields {missing}")
312
+ elif _sha256_json({k: cert[k] for k in CERTIFICATE_PAYLOAD_KEYS}) != cert.get("reproducibility_hash"):
313
+ findings.append("certificate reproducibility_hash is not the hash of its own payload (contents edited after issue)")
314
+ subject = cert.get("subject", {})
315
+ if subject.get("sbom_sha256") != _sha256_json(sbom):
316
+ findings.append("the certificate's subject is a different QBOM (subject.sbom_sha256 differs from this file)")
317
+ if subject.get("sbom_serial") and subject.get("sbom_serial") != sbom.get("serialNumber"):
318
+ findings.append("the certificate's subject.sbom_serial is not this QBOM's serialNumber")
319
+ if csnap is not None:
320
+ if subject.get("csnap_reproducibility_hash") != csnap.get("reproducibility_hash") or subject.get("csnap_id") != csnap.get("csnap_id"):
321
+ findings.append("the certificate names a different CSNAP than the one beside it")
322
+ by_id = {r["control_id"]: r.get("status") for r in csnap.get("control_results", [])}
323
+ for req in cert.get("requirements", []):
324
+ for c in req.get("controls", []):
325
+ if by_id.get(c["control_id"]) != c.get("status"):
326
+ findings.append(f"requirement {req['id']} lists {c['control_id']} as {c.get('status')}, the CSNAP says {by_id.get(c['control_id'])}")
327
+ # who signed: the certificate's statement must be the QBOM's
328
+ props = _props(sbom)
329
+ es = cert.get("evidence_signing", {})
330
+ producer = props.get("secqdevops:signing-key-id") if props.get("secqdevops:signing-pubkey-ref") else None
331
+ service = props.get("secqdevops:service-signing-key-id") if props.get("secqdevops:service-signing-pubkey-ref") else None
332
+ if (es.get("producer_key_id"), es.get("service_key_id")) != (producer, service):
333
+ findings.append(f"certificate evidence_signing names keys {es.get('producer_key_id')!r}/{es.get('service_key_id')!r}; the QBOM names {producer!r}/{service!r}")
334
+ if cert.get("privacy_mode", "standard") != props.get("secqdevops:privacy-mode", "standard"):
335
+ findings.append("certificate privacy_mode differs from the QBOM's")
336
+ # requirement statuses follow from the control statuses they list; the overall status from them
337
+ n_sat = n_unsat = n_uncov = 0
338
+ for req in cert.get("requirements", []):
339
+ present = req.get("controls", [])
340
+ effective = [c for c in present if c.get("status") in ("PASS", "FAIL")]
341
+ if not present or not effective:
342
+ expect = "NOT_COVERED"
343
+ elif any(c.get("status") == "FAIL" for c in present):
344
+ expect = "NOT_SATISFIED"
345
+ else:
346
+ expect = "SATISFIED"
347
+ if req.get("status") != expect:
348
+ findings.append(f"requirement {req.get('id')} is {req.get('status')} but its controls give {expect}")
349
+ n_sat += expect == "SATISFIED"; n_unsat += expect == "NOT_SATISFIED"; n_uncov += expect == "NOT_COVERED"
350
+ overall = "NON_COMPLIANT" if n_unsat else ("CRA-ALIGNED" if n_uncov == 0 else "PARTIALLY_ASSESSED")
351
+ if cert.get("requirements") and cert.get("overall_status") != overall:
352
+ findings.append(f"certificate overall_status {cert.get('overall_status')!r} but its requirements give {overall!r}")
353
+ cov = cert.get("coverage", {})
354
+ if cert.get("requirements") and (cov.get("satisfied"), cov.get("not_satisfied"), cov.get("not_covered")) != (n_sat, n_unsat, n_uncov):
355
+ findings.append(f"certificate coverage {cov} does not count its requirements ({n_sat}/{n_unsat}/{n_uncov})")
356
+ return _check("certificate_bindings", not findings, findings or
357
+ f"hash recomputed; names this QBOM{' and this CSNAP' if csnap is not None else ''}; signers as the QBOM declares; "
358
+ f"{n_sat} satisfied / {n_unsat} not satisfied / {n_uncov} not covered give {overall}")
359
+
360
+
361
+ # ---- 5. the certificate signature, by an anchored key -------------------------
362
+ def load_anchor(keys_file: str | os.PathLike | None = None, service_key: str | os.PathLike | None = None) -> dict[str, Any] | None:
363
+ """Trusted service keys {key_id: {pem, status}} and where they came from; None when nothing is anchored."""
364
+ serialization, _ = _crypto()
365
+ if service_key:
366
+ pem = Path(service_key).read_bytes()
367
+ kid = key_id(_raw_public(serialization.load_pem_public_key(pem)))
368
+ return {"source": f"--service-key {service_key}", "keys": {kid: {"pem": pem.decode("ascii"), "status": "supplied"}}}
369
+ path = Path(keys_file) if keys_file else next((k for k in (PINNED_KEYS, LEGACY_PINNED_KEYS) if k.is_file()), None)
370
+ if path is None:
371
+ return None
372
+ doc = _load(path)
373
+ keys = {}
374
+ for k in doc.get("keys", []):
375
+ pem = k["public_key_pem"]
376
+ kid = key_id(_raw_public(serialization.load_pem_public_key(pem.encode("ascii"))))
377
+ if k.get("key_id") and k["key_id"] != kid:
378
+ raise ValueError(f"{path}: key {k['key_id'][:19]}… is not the id of the key beside it ({kid[:19]}…) — the file is corrupt")
379
+ keys[kid] = {"pem": pem, "status": k.get("status", "current")}
380
+ label = "pinned " + str(path) + (f" (fetched {doc['fetched_at']} from {doc['api_url']})" if doc.get("fetched_at") else "")
381
+ return {"source": label, "keys": keys}
382
+
383
+
384
+ def check_certificate_signature(cert_path: Path, att_path: Path, bundled_pub: Path | None, anchor: dict[str, Any] | None) -> dict[str, Any]:
385
+ serialization, InvalidSignature = _crypto()
386
+ envelope = _load(att_path)
387
+ env_kid = str(envelope["signatures"][0].get("keyid") or "")
388
+ payload = base64.b64decode(envelope["payload"])
389
+ sig = base64.b64decode(envelope["signatures"][0]["sig"])
390
+ anchored = anchor is not None and env_kid in anchor["keys"]
391
+ if anchor is not None and not anchored:
392
+ return _check("certificate_signature", False,
393
+ f"signed by key {env_kid[:19]}…, which is not among the trusted service keys ({anchor['source']}: {[k[:19] + '…' for k in anchor['keys']] or 'none'})")
394
+ if anchored:
395
+ pem = anchor["keys"][env_kid]["pem"].encode("ascii")
396
+ if anchor["keys"][env_kid]["status"] == "revoked":
397
+ return _check("certificate_signature", False, f"signed by key {env_kid[:19]}…, which the service lists as REVOKED")
398
+ elif bundled_pub is not None and bundled_pub.is_file():
399
+ pem = bundled_pub.read_bytes()
400
+ else:
401
+ return _check("certificate_signature", False, "no public key to verify with: pin the service keys (quantbom keys --pin) or pass --keys / --service-key")
402
+ public_key = serialization.load_pem_public_key(pem)
403
+ actual_kid = key_id(_raw_public(public_key))
404
+ if env_kid and env_kid != actual_kid:
405
+ return _check("certificate_signature", False, f"attestation keyid {env_kid[:19]}… is not the id of the key it is verified with ({actual_kid[:19]}…)")
406
+ try:
407
+ public_key.verify(sig, _pae(payload))
408
+ except InvalidSignature:
409
+ return _check("certificate_signature", False, "Ed25519 signature does not verify against the service key")
410
+ statement = json.loads(payload)
411
+ claimed = statement["subject"][0]["digest"]["sha256"]
412
+ actual = hashlib.sha256(cert_path.read_bytes()).hexdigest()
413
+ if actual != claimed:
414
+ return _check("certificate_signature", False, f"certificate bytes changed since signing: {claimed[:12]}… != {actual[:12]}…")
415
+ status = anchor["keys"][env_kid]["status"] if anchored else None
416
+ detail = (f"valid; key {env_kid[:19]}… is anchored ({status}; {anchor['source']})" if anchored
417
+ else f"valid against the key the bundle carries ({env_kid[:19]}…) — NOT anchored: run `quantbom keys --pin` or pass --keys")
418
+ out = _check("certificate_signature", True, detail)
419
+ out["anchored"] = anchored
420
+ return out
421
+
422
+
423
+ # ---- locating the pieces -----------------------------------------------------
424
+ class Located:
425
+ def __init__(self, path: Path, certificate: Path | None = None):
426
+ self.tmp: str | None = None
427
+ root = Path(path)
428
+ if root.is_file() and zipfile.is_zipfile(root):
429
+ self.tmp = tempfile.mkdtemp(prefix="quantbom-verify-")
430
+ with zipfile.ZipFile(root) as zf:
431
+ zf.extractall(self.tmp)
432
+ inner = [p for p in Path(self.tmp).iterdir() if p.is_dir()]
433
+ root = inner[0] if len(inner) == 1 and not (Path(self.tmp) / "sbom.cdx.json").exists() else Path(self.tmp)
434
+ if not root.is_dir():
435
+ raise FileNotFoundError(f"{path} is not a directory or a zip")
436
+ if (root / "sbom.cdx.json").is_file():
437
+ self.sbom, self.base = root / "sbom.cdx.json", root
438
+ elif (root / "evidence" / "sbom.cdx.json").is_file(): # an AEXP: refs resolve from the bundle root
439
+ self.sbom, self.base = root / "evidence" / "sbom.cdx.json", root
440
+ else:
441
+ raise FileNotFoundError(f"{path}: no sbom.cdx.json (a mirror directory under .quantbom/, a `quantbom collect` bundle, or an AEXP zip)")
442
+ self.root = root
443
+ self.csnap = root / "csnap.json" if (root / "csnap.json").is_file() else None
444
+ cert = Path(certificate) if certificate else root / "certificate.json"
445
+ self.cert = cert if cert.is_file() else None
446
+ att = cert.with_name(cert.name + ".att.json")
447
+ self.cert_att = att if att.is_file() else None
448
+ self.cert_pub = cert.with_name(MIRROR_SIGNING_PUB) if cert.with_name(MIRROR_SIGNING_PUB).is_file() else (
449
+ root / "evidence" / "signing.pub" if self.base != root and (root / "evidence" / "signing.pub").is_file() else None)
450
+
451
+ def close(self) -> None:
452
+ if self.tmp:
453
+ shutil.rmtree(self.tmp, ignore_errors=True)
454
+
455
+
456
+ # ---- the verdict ---------------------------------------------------------------
457
+ def verify(path: str | os.PathLike, *, certificate: str | os.PathLike | None = None,
458
+ keys_file: str | os.PathLike | None = None, service_key: str | os.PathLike | None = None) -> dict[str, Any]:
459
+ """Verify a mirror directory, a collected bundle, or a zip of either. See the module docstring."""
460
+ loc = Located(path, certificate)
461
+ try:
462
+ sbom = _load(loc.sbom)
463
+ checks = [check_artifacts(sbom, loc.base), check_evidence_signatures(sbom, loc.base)]
464
+ csnap = _load(loc.csnap) if loc.csnap else None
465
+ cert = _load(loc.cert) if loc.cert else None
466
+ if csnap is not None:
467
+ checks.append(check_csnap(sbom, csnap))
468
+ if cert is not None:
469
+ checks.append(check_certificate(sbom, csnap, cert))
470
+ if loc.cert_att is not None:
471
+ checks.append(check_certificate_signature(loc.cert, loc.cert_att, loc.cert_pub, load_anchor(keys_file, service_key)))
472
+ else:
473
+ checks.append(_check("certificate_signature", False, "certificate.json.att.json not found beside the certificate: the certificate is unsigned"))
474
+ failed = [c["check"] for c in checks if c["status"] != "PASS"]
475
+ anchored = any(c.get("anchored") for c in checks if c["check"] == "certificate_signature")
476
+ verdict = "FAILED" if failed else ("VERIFIED" if cert is None or anchored else "UNANCHORED")
477
+ return {
478
+ "verdict": verdict,
479
+ **({"service_checks": csnap["service_checks"]} if csnap and csnap.get("service_checks") else {}),
480
+ "release_id": release_identity(sbom),
481
+ "certificate_id": (cert or {}).get("certificate_id"),
482
+ "source": str(path),
483
+ "independent": True,
484
+ "anchored": anchored,
485
+ "checks": checks,
486
+ "not_checked": ["control verdicts: the gate's controls were not re-evaluated here (python verify_bundle.py in the "
487
+ "QuantBOM repository re-runs the gate); this verdict covers integrity and authorship, not policy"],
488
+ "note": ("digests, signatures and bindings re-derived from bytes; claimed status fields were not trusted"
489
+ + ("" if verdict != "UNANCHORED" else "; every check passed but the service key is only the one the bundle carries — "
490
+ "pin the service's keys (quantbom keys --pin) and verify again")),
491
+ }
492
+ finally:
493
+ loc.close()
494
+
495
+
496
+ # ---- pinning the service's keys -------------------------------------------------
497
+ def pin_keys(api_url: str, out: str | os.PathLike | None = None) -> dict[str, Any]:
498
+ """Fetch /v1/keys once and store it, with provenance, as the local trust anchor."""
499
+ serialization, _ = _crypto()
500
+ url = api_url.rstrip("/") + "/v1/keys"
501
+ with urllib.request.urlopen(urllib.request.Request(url, headers={"Accept": "application/json"}), timeout=30) as resp:
502
+ doc = json.loads(resp.read().decode("utf-8"))
503
+ for k in doc.get("keys", []): # refuse a document whose key ids do not match its keys
504
+ kid = key_id(_raw_public(serialization.load_pem_public_key(k["public_key_pem"].encode("ascii"))))
505
+ if k.get("key_id") != kid:
506
+ raise ValueError(f"{url}: key {k.get('key_id')} is not the id of the key beside it ({kid}); not pinned")
507
+ doc["api_url"] = api_url.rstrip("/")
508
+ doc["fetched_at"] = datetime.now(timezone.utc).replace(microsecond=0).isoformat().replace("+00:00", "Z")
509
+ path = Path(out) if out else PINNED_KEYS
510
+ path.parent.mkdir(parents=True, exist_ok=True)
511
+ path.write_text(json.dumps(doc, indent=2, ensure_ascii=True) + "\n", encoding="utf-8")
512
+ return {"pinned": str(path), "api_url": doc["api_url"], "fetched_at": doc["fetched_at"],
513
+ "keys": [{"key_id": k["key_id"], "status": k.get("status"), "pem_sha256": k.get("pem_sha256")} for k in doc.get("keys", [])],
514
+ "notes": doc.get("notes", [])}
515
+
516
+
517
+ def render(report: dict[str, Any]) -> str:
518
+ lines = [f"{report['verdict']} {report.get('release_id') or '?'}" + (f" {report['certificate_id']}" if report.get("certificate_id") else "")]
519
+ for c in report["checks"]:
520
+ mark = "■" if c["status"] == "PASS" else "✗"
521
+ detail = c["detail"] if isinstance(c["detail"], str) else "; ".join(map(str, c["detail"]))
522
+ lines.append(f" {mark} {c['check']:<22} {detail}")
523
+ for cid, sc in (report.get("service_checks") or {}).items():
524
+ what = (f"{sc.get('verdict')} ({sc.get('kind')}) with {sc.get('tool')}, {'agrees' if sc.get('agrees') else 'disagrees'} with the producer"
525
+ if sc.get("attempted") else f"not attempted: {sc.get('note')}")
526
+ lines.append(f" · {cid:<22} re-derived by the issuing service: {what}")
527
+ for n in report.get("not_checked", []):
528
+ lines.append(f" □ {n}")
529
+ lines.append(f" {report['note']}")
530
+ return "\n".join(lines)
@@ -0,0 +1,27 @@
1
+ """Harness action registry (HARNESS_PLAN.md §2.2).
2
+
3
+ Execution order follows data dependencies: capture -> execute -> bind_backend
4
+ (uses the run's backend) -> disclose_error_handling -> check_results ->
5
+ compare_native (needs the run's counts) -> check_qir -> benchmark (records the
6
+ declared expectation) -> prove_equivalence -> verify_conversion ->
7
+ record_provenance -> inventory. Property ORDER in the QBOM is independent of this: see layout.py.
8
+ """
9
+ from __future__ import annotations
10
+
11
+ from . import (bind_backend, benchmark, capture, check_qir, check_results, compare_native, disclose_error_handling,
12
+ execute, inventory, prove_equivalence, record_provenance, verify_conversion)
13
+ from .base import ActionResult, Context
14
+ from .layout import PROPERTY_ORDER, assemble
15
+
16
+ # Recorded in every QBOM and harness log; must equal harness_manifest.json.
17
+ HARNESS_VERSION = "0.16.0"
18
+
19
+ REGISTRY = (capture, execute, bind_backend, disclose_error_handling, check_results, compare_native, check_qir, benchmark,
20
+ prove_equivalence, verify_conversion, record_provenance, inventory)
21
+
22
+
23
+ def run_all(ctx: Context) -> list[ActionResult]:
24
+ return [action.run(ctx) for action in REGISTRY]
25
+
26
+
27
+ __all__ = ["HARNESS_VERSION", "REGISTRY", "PROPERTY_ORDER", "ActionResult", "Context", "assemble", "run_all"]
@@ -0,0 +1,68 @@
1
+ """Harness actions (HARNESS_PLAN.md §2.2).
2
+
3
+ Each action is one regulation-mapped verb: it reads what earlier actions put
4
+ on the shared context, writes its own artifacts, and returns the QBOM
5
+ properties it emits together with an outcome — never a silent skip.
6
+
7
+ Outcomes: `produced`, `not_requested` (opt-in not asked for),
8
+ `not_applicable(reason)` (cannot apply to this input) and
9
+ `unsupported(reason)` (the toolchain or framework cannot do it).
10
+ """
11
+ from __future__ import annotations
12
+
13
+ from dataclasses import dataclass, field
14
+ from pathlib import Path
15
+ from typing import Any
16
+
17
+ PRODUCED = "produced"
18
+ NOT_REQUESTED = "not_requested"
19
+ NOT_APPLICABLE = "not_applicable"
20
+ UNSUPPORTED = "unsupported"
21
+
22
+
23
+ @dataclass
24
+ class Context:
25
+ """Inputs plus the blackboard actions read from and write to."""
26
+
27
+ release_id: str
28
+ out_dir: Path
29
+ qiskit_circuit: Any
30
+ qrisp_circuit: Any
31
+ frontend_label: str
32
+ source_format: str
33
+ backend_choice: str
34
+ equivalence_check: bool
35
+ expected_distribution: dict[str, float] | None
36
+ expected_tolerance: float
37
+ expected_declaration: dict[str, Any] | None
38
+ conversion_fidelity: dict[str, Any] | None
39
+ qiskit_version: str
40
+ qrisp_version: str
41
+ #: (text, format) of the native program as the framework exports it — the
42
+ #: bytes QNT-06 compared; written hash-bound so the native program itself is
43
+ #: fingerprinted, not only the report about it (FRAMEWORK_REGULATION_MAP §5)
44
+ native_export: tuple[str, str] | None = None
45
+ #: the native program sampled on the framework's own simulator (QNT-09):
46
+ #: an adapter's native_run() result plus "framework"/"shots", or {"reason": …}
47
+ #: when it could not run; None for Qiskit and file inputs, which are the
48
+ #: shared format itself
49
+ native_result: dict[str, Any] | None = None
50
+ #: preconditions found by _circuit.inspect_circuit (unbound parameters, dynamic features)
51
+ circuit_info: dict[str, Any] = field(default_factory=dict)
52
+ state: dict[str, Any] = field(default_factory=dict)
53
+
54
+ @property
55
+ def quantum_dir(self) -> Path:
56
+ return self.out_dir / "quantum"
57
+
58
+ @property
59
+ def evidence_dir(self) -> Path:
60
+ return self.out_dir / "evidence"
61
+
62
+
63
+ @dataclass
64
+ class ActionResult:
65
+ action: str
66
+ outcome: str
67
+ properties: list[dict[str, str]] = field(default_factory=list)
68
+ reason: str | None = None