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.
- quantbom/__init__.py +497 -0
- quantbom/_bitorder.py +121 -0
- quantbom/_circuit.py +77 -0
- quantbom/_collect.py +661 -0
- quantbom/_drift.py +333 -0
- quantbom/_fidelity.py +280 -0
- quantbom/_merge.py +187 -0
- quantbom/_qir.py +224 -0
- quantbom/_scan.py +118 -0
- quantbom/_sign.py +284 -0
- quantbom/_stats.py +125 -0
- quantbom/_verify.py +530 -0
- quantbom/actions/__init__.py +27 -0
- quantbom/actions/base.py +68 -0
- quantbom/actions/benchmark.py +53 -0
- quantbom/actions/bind_backend.py +71 -0
- quantbom/actions/capture.py +106 -0
- quantbom/actions/check_qir.py +44 -0
- quantbom/actions/check_results.py +75 -0
- quantbom/actions/compare_native.py +88 -0
- quantbom/actions/disclose_error_handling.py +66 -0
- quantbom/actions/execute.py +85 -0
- quantbom/actions/inventory.py +24 -0
- quantbom/actions/layout.py +118 -0
- quantbom/actions/prove_equivalence.py +225 -0
- quantbom/actions/record_provenance.py +49 -0
- quantbom/actions/verify_conversion.py +53 -0
- quantbom/attest.py +363 -0
- quantbom/cli.py +378 -0
- quantbom/frameworks/__init__.py +67 -0
- quantbom/frameworks/base.py +155 -0
- quantbom/frameworks/braket.py +132 -0
- quantbom/frameworks/cirq.py +106 -0
- quantbom/frameworks/cudaq.py +223 -0
- quantbom/frameworks/pennylane.py +116 -0
- quantbom/frameworks/qiskit.py +67 -0
- quantbom/frameworks/qrisp.py +267 -0
- quantbom/harness.json +1730 -0
- quantbom/harness.py +88 -0
- quantbom-0.6.0.dist-info/METADATA +207 -0
- quantbom-0.6.0.dist-info/RECORD +45 -0
- quantbom-0.6.0.dist-info/WHEEL +5 -0
- quantbom-0.6.0.dist-info/entry_points.txt +2 -0
- quantbom-0.6.0.dist-info/licenses/LICENSE +202 -0
- 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"]
|
quantbom/actions/base.py
ADDED
|
@@ -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
|