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/__init__.py
ADDED
|
@@ -0,0 +1,497 @@
|
|
|
1
|
+
"""QuantBOM SDK — certify a quantum release from your running Qrisp/Qiskit code.
|
|
2
|
+
|
|
3
|
+
One env var + one line. Evidence is collected locally (your source never leaves
|
|
4
|
+
the machine); only the QBOM/evidence bundle is sent to the QuantBOM service, which
|
|
5
|
+
returns a signed CRA certificate that is also mirrored to ./.quantbom/<release>/.
|
|
6
|
+
|
|
7
|
+
export QUANTBOM_API_KEY=qb_live_...
|
|
8
|
+
import quantbom
|
|
9
|
+
result = quantbom.certify(my_circuit, release="portfolio-opt@1.2.3")
|
|
10
|
+
print(result["overall_status"], result["certificate"]["certificate_id"])
|
|
11
|
+
|
|
12
|
+
Integration modes:
|
|
13
|
+
- post-run call: quantbom.certify(circuit, release=...)
|
|
14
|
+
- decorator: @quantbom.certified(release="...") (the function returns a circuit)
|
|
15
|
+
- CLI: quantbom certify --qpy circuit.qpy --release ... (see quantbom.cli)
|
|
16
|
+
- two-step: quantbom.collect(...) -> add partner evidence -> quantbom.certify(bundle=...)
|
|
17
|
+
|
|
18
|
+
Fail-soft by default: if the service is unreachable or credits are exhausted, a
|
|
19
|
+
warning is logged and your quantum workflow continues. Pass mode="gate" to raise
|
|
20
|
+
/ exit non-zero on a non-compliant or failed result (for CI).
|
|
21
|
+
"""
|
|
22
|
+
from __future__ import annotations
|
|
23
|
+
|
|
24
|
+
import io
|
|
25
|
+
import json
|
|
26
|
+
import os
|
|
27
|
+
import sys
|
|
28
|
+
import urllib.error
|
|
29
|
+
import urllib.request
|
|
30
|
+
import zipfile
|
|
31
|
+
from pathlib import Path
|
|
32
|
+
from typing import Any, Callable
|
|
33
|
+
|
|
34
|
+
# QuantBOM was called ZentroQ until 0.6.0: ZENTROQ_* settings keep working
|
|
35
|
+
for _name, _value in list(os.environ.items()):
|
|
36
|
+
if _name.startswith("ZENTROQ_"):
|
|
37
|
+
os.environ.setdefault("QUANTBOM_" + _name[len("ZENTROQ_"):], _value)
|
|
38
|
+
|
|
39
|
+
__all__ = ["certify", "certified", "collect", "configure", "keygen", "pin_keys", "sign", "verify", "wrap"]
|
|
40
|
+
|
|
41
|
+
DEFAULT_API_URL = os.environ.get("QUANTBOM_API_URL", "http://127.0.0.1:8088")
|
|
42
|
+
MIRROR_FILES = ("csnap.json", "certificate.json", "certificate.json.att.json", "signing.pub", "verification.json")
|
|
43
|
+
|
|
44
|
+
_config: dict[str, Any] = {"api_url": DEFAULT_API_URL, "api_key": os.environ.get("QUANTBOM_API_KEY"), "mirror_dir": ".quantbom"}
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def configure(api_url: str | None = None, api_key: str | None = None, mirror_dir: str | None = None) -> None:
|
|
48
|
+
if api_url:
|
|
49
|
+
_config["api_url"] = api_url
|
|
50
|
+
if api_key:
|
|
51
|
+
_config["api_key"] = api_key
|
|
52
|
+
if mirror_dir:
|
|
53
|
+
_config["mirror_dir"] = mirror_dir
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _warn(msg: str) -> None:
|
|
57
|
+
print(f"[quantbom] {msg}", file=sys.stderr)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _detect_circuit(circuit: Any) -> tuple[Any, str, Any, str]:
|
|
61
|
+
"""Return (qiskit_circuit, frontend_label, native_object_or_None, source_format).
|
|
62
|
+
|
|
63
|
+
Every supported frontend lowers to the normalized IR (qiskit.QuantumCircuit)
|
|
64
|
+
through its adapter in `frameworks/`; conversion libraries are lazy-imported
|
|
65
|
+
— if you built the object, you have its framework installed.
|
|
66
|
+
"""
|
|
67
|
+
try:
|
|
68
|
+
import qiskit # noqa: F401
|
|
69
|
+
except Exception as exc: # pragma: no cover
|
|
70
|
+
raise RuntimeError("qiskit is required to collect evidence (pip install qiskit)") from exc
|
|
71
|
+
from .frameworks import detect
|
|
72
|
+
|
|
73
|
+
adapter = detect(circuit)
|
|
74
|
+
from ._circuit import refuse_dynamic, refuse_open
|
|
75
|
+
|
|
76
|
+
refuse_open(adapter.open_parameters(circuit), how="resolve them in your framework, or give each QNode argument a value")
|
|
77
|
+
if not adapter.supports_dynamic: # before lowering: one message, not the exporter's
|
|
78
|
+
refuse_dynamic(adapter.dynamic_features(circuit), adapter.name)
|
|
79
|
+
qiskit_circuit, label, native = adapter.lower(circuit)
|
|
80
|
+
return qiskit_circuit, label, native, adapter.name
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def _collect_evidence(release: str, evidence_dir: Path, *, circuit=None, qpy=None, qasm3=None, backend: str = "basic", equivalence: bool = False, expected=None, tolerance: float = 0.05, expected_order: str = "native") -> Path:
|
|
84
|
+
"""Build the local evidence bundle (the mirror) using the bundled collector."""
|
|
85
|
+
from . import _collect as qe
|
|
86
|
+
from ._bitorder import to_measured_order
|
|
87
|
+
|
|
88
|
+
fidelity = None
|
|
89
|
+
native_export = None
|
|
90
|
+
native_result = None
|
|
91
|
+
if circuit is not None:
|
|
92
|
+
qk, label, qrisp_circ, fmt = _detect_circuit(circuit)
|
|
93
|
+
# QNT-06: only here is the native Cirq/Braket/PennyLane object still in
|
|
94
|
+
# hand, so the conversion is checked before it is discarded — and its
|
|
95
|
+
# export kept, to be written hash-bound beside the report.
|
|
96
|
+
from ._fidelity import assess_conversion, sample_native
|
|
97
|
+
from .frameworks import get as _get
|
|
98
|
+
|
|
99
|
+
fidelity = assess_conversion(circuit, qk, fmt)
|
|
100
|
+
# QNT-09: the same native object, sampled on its own framework's simulator
|
|
101
|
+
native_result = sample_native(circuit, qk, fmt)
|
|
102
|
+
if fidelity is not None:
|
|
103
|
+
try:
|
|
104
|
+
native_export = _get(fmt).native_export(circuit)
|
|
105
|
+
except Exception: # the report already says what the export could not do
|
|
106
|
+
native_export = None
|
|
107
|
+
elif qpy is not None:
|
|
108
|
+
qk, label, qrisp_circ, fmt = qe.load_circuit("qpy", str(qpy))
|
|
109
|
+
elif qasm3 is not None:
|
|
110
|
+
qk, label, qrisp_circ, fmt = qe.load_circuit("qasm3", str(qasm3))
|
|
111
|
+
else:
|
|
112
|
+
raise ValueError("provide one of: circuit=, qpy=, qasm3=")
|
|
113
|
+
|
|
114
|
+
# Expected results are written in the framework's own bit order; results
|
|
115
|
+
# are measured in Qiskit's. Translate (and validate) before anything is
|
|
116
|
+
# written, so a malformed declaration fails here rather than as `exceeded`.
|
|
117
|
+
declaration = None
|
|
118
|
+
if expected:
|
|
119
|
+
from .frameworks import get as _adapter
|
|
120
|
+
|
|
121
|
+
adapter = _adapter(fmt) if circuit is not None else None # file inputs are Qiskit's own
|
|
122
|
+
expected, declaration = to_measured_order(
|
|
123
|
+
expected, fmt, qk, expected_order,
|
|
124
|
+
native_order=adapter.bit_order_for(circuit) if adapter else None,
|
|
125
|
+
measured_order=adapter.measured_order_for(circuit) if adapter else "qubit0_last",
|
|
126
|
+
)
|
|
127
|
+
|
|
128
|
+
evidence_dir.mkdir(parents=True, exist_ok=True)
|
|
129
|
+
qe.collect(release_id=release, out_dir=evidence_dir, qiskit_circuit=qk,
|
|
130
|
+
frontend_label=label, qrisp_circuit=qrisp_circ, source_format=fmt, backend_choice=backend,
|
|
131
|
+
equivalence_check=equivalence, expected_distribution=expected, expected_tolerance=tolerance,
|
|
132
|
+
expected_declaration=declaration, conversion_fidelity=fidelity, native_export=native_export,
|
|
133
|
+
native_result=native_result)
|
|
134
|
+
return evidence_dir / "sbom.cdx.json"
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def collect(circuit=None, release: str = "release", out_dir=None, *, qpy=None, qasm3=None, backend: str = "basic",
|
|
138
|
+
equivalence: bool = False, expected=None, tolerance: float = 0.05, expected_order: str = "native",
|
|
139
|
+
environment: bool = False, syft_sbom=None, vuln_scan: bool = False, grype_output=None) -> Path:
|
|
140
|
+
"""Collect the evidence bundle locally, without contacting the service.
|
|
141
|
+
|
|
142
|
+
The first half of `certify()`, for when evidence from elsewhere must join
|
|
143
|
+
the bundle before it is certified: partner modules' attestations (MOD-05
|
|
144
|
+
tests, MOD-07 vulnerability handling, MOD-03/09 crypto) and the release
|
|
145
|
+
signature are added to the directory by their own tools — see
|
|
146
|
+
`quantbom actions` for each one's command — and the result is certified
|
|
147
|
+
with `certify(bundle=out_dir)`. Returns the path of the QBOM.
|
|
148
|
+
"""
|
|
149
|
+
out = Path(out_dir) if out_dir is not None else Path(_config["mirror_dir"]) / _safe(release)
|
|
150
|
+
sbom_path = _collect_evidence(release, out, circuit=circuit, qpy=qpy, qasm3=qasm3, backend=backend,
|
|
151
|
+
equivalence=equivalence, expected=expected, tolerance=tolerance,
|
|
152
|
+
expected_order=expected_order)
|
|
153
|
+
_merge_environment_sbom(sbom_path, syft_sbom=syft_sbom, environment=environment)
|
|
154
|
+
_scan_vulnerabilities(sbom_path, vuln_scan=vuln_scan, grype_output=grype_output)
|
|
155
|
+
return sbom_path
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def _zip_dir(evidence_dir: Path, exclude: tuple[str, ...] = ()) -> bytes:
|
|
159
|
+
buf = io.BytesIO()
|
|
160
|
+
with zipfile.ZipFile(buf, "w", compression=zipfile.ZIP_DEFLATED) as zf:
|
|
161
|
+
for path in sorted(evidence_dir.rglob("*")):
|
|
162
|
+
rel = str(path.relative_to(evidence_dir))
|
|
163
|
+
# mirror files live at the bundle ROOT; matching by basename once dropped
|
|
164
|
+
# the producer's evidence/signing.pub, so a producer signature could not
|
|
165
|
+
# verify on the service (masked while the service re-signed everything)
|
|
166
|
+
if path.is_file() and rel not in MIRROR_FILES and rel not in exclude:
|
|
167
|
+
zf.write(path, arcname=rel)
|
|
168
|
+
return buf.getvalue()
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
# privacy="strict": these circuit-IR files never leave the machine. The bytes
|
|
172
|
+
# stay in the local mirror (the producer's audit bundle); only their digests
|
|
173
|
+
# travel, marked client-attested on the certificate.
|
|
174
|
+
STRICT_WITHHELD = (
|
|
175
|
+
("quantum/circuit.qpy", "secqdevops:circuit-sha256"),
|
|
176
|
+
("quantum/transpiled.qpy", "secqdevops:transpiled-circuit-sha256"),
|
|
177
|
+
("quantum/program.qasm", "secqdevops:qasm-program-sha256"),
|
|
178
|
+
)
|
|
179
|
+
# ... and so a producer signature made for a strict upload must not cover them:
|
|
180
|
+
# the service verifies every signed subject against the bytes it received.
|
|
181
|
+
STRICT_WITHHELD_REFS = (
|
|
182
|
+
"secqdevops:circuit-artifact-ref",
|
|
183
|
+
"secqdevops:transpiled-circuit-ref",
|
|
184
|
+
"secqdevops:qasm-program-ref",
|
|
185
|
+
)
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
def _apply_strict_privacy(sbom_path: Path, evidence_dir: Path) -> tuple[str, ...]:
|
|
189
|
+
"""Re-verify the withheld digests locally, then mark the SBOM strict.
|
|
190
|
+
|
|
191
|
+
Attesting requires verifying: each withheld file's bytes are re-hashed
|
|
192
|
+
against the SBOM's claimed digest BEFORE it is withheld. A mismatch is a
|
|
193
|
+
hard error — we never attest digests we could not reproduce.
|
|
194
|
+
"""
|
|
195
|
+
import hashlib
|
|
196
|
+
|
|
197
|
+
sbom = json.loads(sbom_path.read_text(encoding="utf-8"))
|
|
198
|
+
props = {p.get("name"): p.get("value") for p in sbom.get("metadata", {}).get("properties", [])}
|
|
199
|
+
withheld: list[str] = []
|
|
200
|
+
for rel, sha_prop in STRICT_WITHHELD:
|
|
201
|
+
path = evidence_dir / rel
|
|
202
|
+
claimed = props.get(sha_prop)
|
|
203
|
+
if not path.exists() or not claimed:
|
|
204
|
+
continue
|
|
205
|
+
actual = "sha256:" + hashlib.sha256(path.read_bytes()).hexdigest()
|
|
206
|
+
if actual != claimed:
|
|
207
|
+
raise RuntimeError(f"privacy=strict: local re-hash of {rel} does not match the QBOM ({claimed[:20]}… != {actual[:20]}…)")
|
|
208
|
+
withheld.append(rel)
|
|
209
|
+
properties = sbom.setdefault("metadata", {}).setdefault("properties", [])
|
|
210
|
+
properties[:] = [p for p in properties if p.get("name") != "secqdevops:privacy-mode"]
|
|
211
|
+
properties.append({"name": "secqdevops:privacy-mode", "value": "strict"})
|
|
212
|
+
sbom_path.write_text(json.dumps(sbom, indent=2, ensure_ascii=True) + "\n", encoding="utf-8")
|
|
213
|
+
return tuple(withheld)
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
def _post_certify(zip_bytes: bytes, release: str, retain: bool = False, assurance: str = "standard", scheme: str = "eu-cra") -> dict[str, Any]:
|
|
217
|
+
url = _config["api_url"].rstrip("/") + "/v1/certify"
|
|
218
|
+
key = _config["api_key"]
|
|
219
|
+
if not key:
|
|
220
|
+
raise RuntimeError("no API key: set QUANTBOM_API_KEY or call quantbom.configure(api_key=...)")
|
|
221
|
+
headers = {"X-API-Key": key, "X-Release-Id": release, "Content-Type": "application/zip"}
|
|
222
|
+
if retain: # opt-in server-side retention of the QBOM document + results (never artifact bytes)
|
|
223
|
+
headers["X-Retain"] = "1"
|
|
224
|
+
if assurance != "standard": # opt-in server-side re-execution (costs an extra credit)
|
|
225
|
+
headers["X-Assurance"] = assurance
|
|
226
|
+
if scheme != "eu-cra": # certify under an alternative compliance scheme (see /v1/schemes)
|
|
227
|
+
headers["X-Scheme"] = scheme
|
|
228
|
+
req = urllib.request.Request(url, data=zip_bytes, method="POST", headers=headers)
|
|
229
|
+
with urllib.request.urlopen(req, timeout=60) as resp:
|
|
230
|
+
return json.loads(resp.read().decode("utf-8"))
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
def _write_mirror(evidence_dir: Path, bundle: dict[str, Any]) -> None:
|
|
234
|
+
for name, content in (bundle or {}).items():
|
|
235
|
+
if content is None:
|
|
236
|
+
continue
|
|
237
|
+
path = evidence_dir / name
|
|
238
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
239
|
+
if isinstance(content, str):
|
|
240
|
+
path.write_text(content, encoding="utf-8")
|
|
241
|
+
else:
|
|
242
|
+
path.write_text(json.dumps(content, indent=2, ensure_ascii=True) + "\n", encoding="utf-8")
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
def _merge_environment_sbom(sbom_path: Path, *, syft_sbom=None, environment: bool = False) -> None:
|
|
246
|
+
"""Optionally merge a full environment SBOM (syft) into the collected QBOM.
|
|
247
|
+
|
|
248
|
+
environment=True scans the running Python environment with a locally
|
|
249
|
+
installed syft (skipped with a warning when syft is absent); syft_sbom=
|
|
250
|
+
merges a pre-generated CycloneDX file. Both run client-side, before upload.
|
|
251
|
+
"""
|
|
252
|
+
from . import _merge
|
|
253
|
+
|
|
254
|
+
if syft_sbom:
|
|
255
|
+
env = json.loads(Path(syft_sbom).read_text(encoding="utf-8"))
|
|
256
|
+
elif environment:
|
|
257
|
+
if not _merge.syft_available():
|
|
258
|
+
_warn("environment SBOM skipped — syft not found on PATH (quantum evidence still collected)")
|
|
259
|
+
return
|
|
260
|
+
env = _merge.run_syft(f"dir:{sys.prefix}") # the running Python environment
|
|
261
|
+
else:
|
|
262
|
+
return
|
|
263
|
+
sbom = json.loads(sbom_path.read_text(encoding="utf-8"))
|
|
264
|
+
stats = _merge.merge_environment(sbom, env)
|
|
265
|
+
sbom_path.write_text(json.dumps(sbom, indent=2, ensure_ascii=True) + "\n", encoding="utf-8")
|
|
266
|
+
_warn(f"environment SBOM merged: +{stats['added']} components "
|
|
267
|
+
f"({stats['licenses_enriched']} licenses enriched, {stats['missing_license']} still unlicensed)")
|
|
268
|
+
|
|
269
|
+
|
|
270
|
+
def _scan_vulnerabilities(sbom_path: Path, *, vuln_scan: bool = False, grype_output=None) -> None:
|
|
271
|
+
"""Optionally populate the QBOM's vulnerabilities[] from a grype scan.
|
|
272
|
+
|
|
273
|
+
vuln_scan=True runs a locally installed grype over the collected (and
|
|
274
|
+
optionally syft-merged) QBOM — skipped with a warning when grype is
|
|
275
|
+
absent; grype_output= applies a pre-generated grype CycloneDX file.
|
|
276
|
+
"""
|
|
277
|
+
from . import _scan
|
|
278
|
+
|
|
279
|
+
if grype_output:
|
|
280
|
+
scan_doc = json.loads(Path(grype_output).read_text(encoding="utf-8"))
|
|
281
|
+
elif vuln_scan:
|
|
282
|
+
if not _scan.grype_available():
|
|
283
|
+
_warn("vulnerability scan skipped — grype not found on PATH (gate will see an unscanned SBOM)")
|
|
284
|
+
return
|
|
285
|
+
scan_doc = _scan.run_grype(sbom_path)
|
|
286
|
+
else:
|
|
287
|
+
return
|
|
288
|
+
sbom = json.loads(sbom_path.read_text(encoding="utf-8"))
|
|
289
|
+
stats = _scan.apply_findings(sbom, scan_doc)
|
|
290
|
+
sbom_path.write_text(json.dumps(sbom, indent=2, ensure_ascii=True) + "\n", encoding="utf-8")
|
|
291
|
+
_warn(f"vulnerability scan: {stats['findings']} findings {stats.get('by_severity') or ''}")
|
|
292
|
+
|
|
293
|
+
|
|
294
|
+
def certify(circuit=None, release: str = "release", *, backend: str = "basic", qpy=None, qasm3=None,
|
|
295
|
+
mode: str = "report", api_key: str | None = None, api_url: str | None = None,
|
|
296
|
+
syft_sbom=None, environment: bool = False, privacy: str = "standard",
|
|
297
|
+
vuln_scan: bool = False, grype_output=None, retain: bool = False,
|
|
298
|
+
equivalence: bool = False, expected=None, tolerance: float = 0.05,
|
|
299
|
+
assurance: str = "standard", scheme: str = "eu-cra", expected_order: str = "native",
|
|
300
|
+
bundle=None, sign: bool = False, signing_key=None) -> dict[str, Any]:
|
|
301
|
+
"""Collect evidence locally, certify via the QuantBOM service, mirror the result.
|
|
302
|
+
|
|
303
|
+
Returns a result dict. mode="report" (default) never raises on policy outcome;
|
|
304
|
+
mode="gate" raises RuntimeError on a non-compliant or failed/unreachable result.
|
|
305
|
+
environment=True merges a full syft scan of the running Python environment into
|
|
306
|
+
the QBOM; syft_sbom= merges a pre-generated CycloneDX environment SBOM.
|
|
307
|
+
vuln_scan=True runs grype over the QBOM so CRA-ESR-03 and the VEX carry real
|
|
308
|
+
findings; grype_output= applies a pre-generated grype CycloneDX file.
|
|
309
|
+
privacy="strict" keeps the circuit-IR bytes (QPY/OpenQASM) on this machine:
|
|
310
|
+
only their locally re-verified digests travel, and the certificate marks them
|
|
311
|
+
client-attested. The local mirror retains the full bundle for on-site audit.
|
|
312
|
+
retain=True opts into server-side retention of the QBOM document + result
|
|
313
|
+
JSONs (never artifact bytes) — list/download/delete via /v1/releases.
|
|
314
|
+
equivalence=True formally checks source-vs-transpiled semantic equivalence
|
|
315
|
+
(mqt.qcec — pip install "quantbom[verify]"); the verdict ships as
|
|
316
|
+
hash-verified evidence and the gate's QNT-05 judges it.
|
|
317
|
+
expected={"0101": 1.0} declares the distribution the circuit should produce
|
|
318
|
+
(with tolerance= as max total-variation distance); the measured deviation
|
|
319
|
+
ships as hash-verified evidence and the gate's QBOM-BENCH-02 judges it.
|
|
320
|
+
Outcomes are read in YOUR framework's bit order (expected_order="native"):
|
|
321
|
+
Cirq, Braket and PennyLane put qubit 0 first, Qiskit and Qrisp put it last,
|
|
322
|
+
and the SDK translates before comparing. Pass expected_order="qiskit" to
|
|
323
|
+
write outcomes in measured (Qiskit) order instead. Every outcome must span
|
|
324
|
+
the full measured register; a wrong width raises immediately.
|
|
325
|
+
assurance="re-executed" asks the SERVICE to independently re-run the circuit
|
|
326
|
+
and reproduce the claimed results (privacy=standard only; costs an extra
|
|
327
|
+
credit); in gate mode, not achieving the requested assurance raises.
|
|
328
|
+
bundle=DIR certifies a bundle made by collect(), after partner evidence or
|
|
329
|
+
a release signature has been added to it; nothing is re-collected.
|
|
330
|
+
sign=True signs the evidence as its producer before upload (your key from
|
|
331
|
+
`quantbom keygen`, signing_key= or $QUANTBOM_SIGNING_KEY; needs
|
|
332
|
+
"quantbom[signing]") — the authorship claim CRA-SIGN-01 rests on. Without
|
|
333
|
+
it the service only countersigns what it received, and the certificate
|
|
334
|
+
says the producer did not sign.
|
|
335
|
+
"""
|
|
336
|
+
if assurance not in ("standard", "re-executed"):
|
|
337
|
+
raise ValueError("assurance must be 'standard' or 're-executed'")
|
|
338
|
+
if privacy not in ("standard", "strict"):
|
|
339
|
+
raise ValueError("privacy must be 'standard' or 'strict'")
|
|
340
|
+
if bundle is not None and (circuit is not None or qpy is not None or qasm3 is not None):
|
|
341
|
+
raise ValueError("bundle= certifies an already-collected bundle; do not also pass a circuit")
|
|
342
|
+
if bundle is not None and (equivalence or expected):
|
|
343
|
+
raise ValueError("equivalence= and expected= are collection options: pass them to collect(), not certify(bundle=)")
|
|
344
|
+
if api_key or api_url:
|
|
345
|
+
configure(api_url=api_url, api_key=api_key)
|
|
346
|
+
evidence_dir = Path(bundle) if bundle is not None else Path(_config["mirror_dir"]) / _safe(release)
|
|
347
|
+
|
|
348
|
+
try:
|
|
349
|
+
if bundle is not None:
|
|
350
|
+
sbom_path = evidence_dir / "sbom.cdx.json"
|
|
351
|
+
if not sbom_path.is_file():
|
|
352
|
+
raise FileNotFoundError(f"{evidence_dir} is not an evidence bundle (no sbom.cdx.json); create one with quantbom.collect()")
|
|
353
|
+
else:
|
|
354
|
+
sbom_path = _collect_evidence(release, evidence_dir, circuit=circuit, qpy=qpy, qasm3=qasm3, backend=backend, equivalence=equivalence, expected=expected, tolerance=tolerance, expected_order=expected_order)
|
|
355
|
+
if equivalence and "secqdevops:equivalence-ref" not in sbom_path.read_text(encoding="utf-8"):
|
|
356
|
+
_warn("equivalence check skipped — mqt.qcec not installed (pip install \"quantbom[verify]\")")
|
|
357
|
+
_merge_environment_sbom(sbom_path, syft_sbom=syft_sbom, environment=environment)
|
|
358
|
+
_scan_vulnerabilities(sbom_path, vuln_scan=vuln_scan, grype_output=grype_output)
|
|
359
|
+
if sign: # last, so the signature covers everything added above
|
|
360
|
+
from . import _sign
|
|
361
|
+
|
|
362
|
+
info = _sign.sign(evidence_dir, signing_key, omit_refs=STRICT_WITHHELD_REFS if privacy == "strict" else ())
|
|
363
|
+
_warn(f"evidence signed as producer, key {info['key_id'][:19]}… over {info['subjects']} artifacts")
|
|
364
|
+
withheld: tuple[str, ...] = ()
|
|
365
|
+
if privacy == "strict":
|
|
366
|
+
withheld = _apply_strict_privacy(sbom_path, evidence_dir)
|
|
367
|
+
_warn(f"privacy=strict — retained on this machine: {', '.join(withheld) or '(none)'}")
|
|
368
|
+
result = _post_certify(_zip_dir(evidence_dir, exclude=withheld), release, retain=retain, assurance=assurance, scheme=scheme)
|
|
369
|
+
except urllib.error.HTTPError as exc:
|
|
370
|
+
detail = _read_err(exc)
|
|
371
|
+
result = {"ok": False, "error": detail.get("error", f"HTTP {exc.code}"), "http_status": exc.code, "release_id": release}
|
|
372
|
+
if exc.code == 402:
|
|
373
|
+
_warn("out of credits — purchase more at the QuantBOM dashboard.")
|
|
374
|
+
except (urllib.error.URLError, Exception) as exc: # fail-soft: never break the user's quantum run
|
|
375
|
+
result = {"ok": False, "error": str(exc), "release_id": release}
|
|
376
|
+
|
|
377
|
+
if result.get("bundle"):
|
|
378
|
+
_write_mirror(evidence_dir, result.pop("bundle"))
|
|
379
|
+
result["mirror"] = str(evidence_dir)
|
|
380
|
+
result["ok"] = True
|
|
381
|
+
|
|
382
|
+
_summarize(result)
|
|
383
|
+
|
|
384
|
+
if mode == "gate":
|
|
385
|
+
if not result.get("ok"):
|
|
386
|
+
raise RuntimeError(f"QuantBOM: certification could not be completed: {result.get('error')}")
|
|
387
|
+
if result.get("overall_status") != "COMPLIANT":
|
|
388
|
+
raise RuntimeError(f"QuantBOM gate failed: release is {result.get('overall_status')}")
|
|
389
|
+
if assurance == "re-executed" and result.get("assurance") != "service-re-executed":
|
|
390
|
+
note = (result.get("reexecution") or {}).get("note") or (result.get("reexecution") or {}).get("verdict")
|
|
391
|
+
raise RuntimeError(f"QuantBOM: requested re-executed assurance not achieved ({result.get('assurance')}: {note})")
|
|
392
|
+
return result
|
|
393
|
+
|
|
394
|
+
|
|
395
|
+
def _extract_circuits(pubs) -> list:
|
|
396
|
+
"""Pull qiskit circuits out of a primitive's run() argument (PUBs or a bare circuit)."""
|
|
397
|
+
from qiskit import QuantumCircuit
|
|
398
|
+
|
|
399
|
+
if isinstance(pubs, QuantumCircuit):
|
|
400
|
+
return [pubs]
|
|
401
|
+
seq = pubs if isinstance(pubs, (list, tuple)) else [pubs]
|
|
402
|
+
out = []
|
|
403
|
+
for pub in seq:
|
|
404
|
+
if isinstance(pub, QuantumCircuit):
|
|
405
|
+
out.append(pub)
|
|
406
|
+
elif isinstance(pub, (list, tuple)) and pub and isinstance(pub[0], QuantumCircuit):
|
|
407
|
+
out.append(pub[0])
|
|
408
|
+
return out
|
|
409
|
+
|
|
410
|
+
|
|
411
|
+
def wrap(primitive, release: str = "release", **kwargs):
|
|
412
|
+
"""Wrap a Qiskit primitive (SamplerV2/EstimatorV2) so `.run(...)` certifies transparently.
|
|
413
|
+
|
|
414
|
+
from qiskit_ibm_runtime import SamplerV2
|
|
415
|
+
sampler = quantbom.wrap(SamplerV2(mode=backend), release="vqe@2.1")
|
|
416
|
+
job = sampler.run([circuit]) # certifies the circuit, then runs as normal
|
|
417
|
+
|
|
418
|
+
Certification is fire-and-forget and fail-soft — it never blocks or breaks the run.
|
|
419
|
+
"""
|
|
420
|
+
original_run = primitive.run
|
|
421
|
+
|
|
422
|
+
def run(pubs, *a, **k):
|
|
423
|
+
try:
|
|
424
|
+
circuits = _extract_circuits(pubs)
|
|
425
|
+
for i, circ in enumerate(circuits):
|
|
426
|
+
rel = release if len(circuits) == 1 else release + "-" + str(i)
|
|
427
|
+
certify(circ, release=rel, **kwargs)
|
|
428
|
+
except Exception as exc: # never let certification break the user's run
|
|
429
|
+
_warn("wrap: certification skipped — " + str(exc))
|
|
430
|
+
return original_run(pubs, *a, **k)
|
|
431
|
+
|
|
432
|
+
primitive.run = run
|
|
433
|
+
return primitive
|
|
434
|
+
|
|
435
|
+
|
|
436
|
+
def certified(release: str = "release", **kwargs) -> Callable:
|
|
437
|
+
"""Decorator: certify the qiskit/qrisp circuit returned by the wrapped function."""
|
|
438
|
+
def deco(fn: Callable) -> Callable:
|
|
439
|
+
def wrapper(*a, **k):
|
|
440
|
+
out = fn(*a, **k)
|
|
441
|
+
circuit = out[0] if isinstance(out, tuple) else out
|
|
442
|
+
certify(circuit, release=release, **kwargs)
|
|
443
|
+
return out
|
|
444
|
+
return wrapper
|
|
445
|
+
return deco
|
|
446
|
+
|
|
447
|
+
|
|
448
|
+
def keygen(key_dir=None, *, overwrite: bool = False) -> dict[str, str]:
|
|
449
|
+
"""Generate your producer signing key (default ~/.quantbom/signing.key, 0600). See quantbom._sign."""
|
|
450
|
+
from . import _sign
|
|
451
|
+
|
|
452
|
+
return _sign.keygen(key_dir, overwrite=overwrite)
|
|
453
|
+
|
|
454
|
+
|
|
455
|
+
def sign(bundle, key=None, *, strict: bool = False) -> dict[str, Any]:
|
|
456
|
+
"""Sign a collected bundle as its producer; strict=True when it will be uploaded with privacy="strict"."""
|
|
457
|
+
from . import _sign
|
|
458
|
+
|
|
459
|
+
return _sign.sign(bundle, key, omit_refs=STRICT_WITHHELD_REFS if strict else ())
|
|
460
|
+
|
|
461
|
+
|
|
462
|
+
def verify(path, *, certificate=None, keys_file=None, service_key=None) -> dict[str, Any]:
|
|
463
|
+
"""Offline verification of a mirror directory, a collected bundle or a zip of either. See quantbom._verify."""
|
|
464
|
+
from . import _verify
|
|
465
|
+
|
|
466
|
+
return _verify.verify(path, certificate=certificate, keys_file=keys_file, service_key=service_key)
|
|
467
|
+
|
|
468
|
+
|
|
469
|
+
def pin_keys(api_url: str | None = None, out=None) -> dict[str, Any]:
|
|
470
|
+
"""Fetch the service's signing keys once and pin them as the local trust anchor for verify()."""
|
|
471
|
+
from . import _verify
|
|
472
|
+
|
|
473
|
+
return _verify.pin_keys(api_url or _config["api_url"], out)
|
|
474
|
+
|
|
475
|
+
|
|
476
|
+
def _summarize(result: dict[str, Any]) -> None:
|
|
477
|
+
if result.get("ok"):
|
|
478
|
+
cert = result.get("certificate", {})
|
|
479
|
+
ver = result.get("verification", {})
|
|
480
|
+
_warn(
|
|
481
|
+
f"{result.get('release_id')}: {result.get('overall_status')} · "
|
|
482
|
+
f"cert {cert.get('certificate_id')} ({cert.get('overall_status')}) · "
|
|
483
|
+
f"verify {ver.get('verdict')} · credits left {result.get('credits_remaining')} · mirror {result.get('mirror')}"
|
|
484
|
+
)
|
|
485
|
+
else:
|
|
486
|
+
_warn(f"{result.get('release_id')}: not certified — {result.get('error')} (workflow continues)")
|
|
487
|
+
|
|
488
|
+
|
|
489
|
+
def _safe(name: str) -> str:
|
|
490
|
+
return "".join(c if c.isalnum() or c in "._-@" else "_" for c in name)
|
|
491
|
+
|
|
492
|
+
|
|
493
|
+
def _read_err(exc: urllib.error.HTTPError) -> dict[str, Any]:
|
|
494
|
+
try:
|
|
495
|
+
return json.loads(exc.read().decode("utf-8"))
|
|
496
|
+
except Exception:
|
|
497
|
+
return {}
|
quantbom/_bitorder.py
ADDED
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
"""Bit order for declared expected results (QBOM-BENCH-02).
|
|
2
|
+
|
|
3
|
+
Results are measured on the translated Qiskit circuit, so measured bitstrings
|
|
4
|
+
arrive in Qiskit's order: classical bit 0 is the RIGHTMOST character. Cirq,
|
|
5
|
+
Braket and PennyLane print qubit 0 as the LEFTMOST character. Measured with
|
|
6
|
+
each framework's own sampler, an X on qubit 0 of three qubits reads:
|
|
7
|
+
|
|
8
|
+
cirq, braket, pennylane '100'
|
|
9
|
+
qrisp, qiskit '001'
|
|
10
|
+
|
|
11
|
+
A declaration written in the wrong order is not just an inconvenience. It
|
|
12
|
+
fails correct releases — and it can pass wrong ones: '100' meant as "qubit 0
|
|
13
|
+
flipped" matches a buggy circuit that flips qubit 2 instead.
|
|
14
|
+
|
|
15
|
+
So expected results are read in the framework's own order by default and
|
|
16
|
+
translated to the measured order before comparison; the declaration as
|
|
17
|
+
written, and the order it was read in, are recorded in the hash-bound
|
|
18
|
+
expected-distribution document. Qiskit and Qrisp need no translation.
|
|
19
|
+
|
|
20
|
+
Declarations are also checked against the measured register width up front:
|
|
21
|
+
a wrong-width key can never match a measured outcome, so it always signals a
|
|
22
|
+
mistake and is better raised than recorded as a silent `exceeded`.
|
|
23
|
+
"""
|
|
24
|
+
from __future__ import annotations
|
|
25
|
+
|
|
26
|
+
from typing import Any
|
|
27
|
+
|
|
28
|
+
from .frameworks import ADAPTERS
|
|
29
|
+
|
|
30
|
+
# Frameworks whose own bitstrings put qubit 0 first — declared per adapter and
|
|
31
|
+
# verified against each framework's own sampler by the conformance suite.
|
|
32
|
+
QUBIT0_FIRST = {a.name for a in ADAPTERS if a.bit_order == "qubit0_first"}
|
|
33
|
+
ORDERS = ("native", "qiskit")
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def measured_register(qiskit_circuit: Any) -> tuple[int, int]:
|
|
37
|
+
"""(width, number of classical registers) exactly as the collector measures.
|
|
38
|
+
|
|
39
|
+
Mirrors the collector's runner: a circuit with no measurement gets
|
|
40
|
+
measure_all(), and the reported bitstring spans every classical bit.
|
|
41
|
+
"""
|
|
42
|
+
circ = qiskit_circuit.copy()
|
|
43
|
+
if not any(inst.operation.name == "measure" for inst in circ.data):
|
|
44
|
+
circ.measure_all()
|
|
45
|
+
return circ.num_clbits, len(circ.cregs)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def to_measured_order(
|
|
49
|
+
expected: dict[str, float],
|
|
50
|
+
source_format: str,
|
|
51
|
+
qiskit_circuit: Any,
|
|
52
|
+
order: str = "native",
|
|
53
|
+
*,
|
|
54
|
+
native_order: str | None = None,
|
|
55
|
+
measured_order: str = "qubit0_last",
|
|
56
|
+
) -> tuple[dict[str, float], dict[str, Any]]:
|
|
57
|
+
"""Return (distribution in measured order, declaration record).
|
|
58
|
+
|
|
59
|
+
`native_order` is how the framework's own reading of THIS input is ordered
|
|
60
|
+
(per input kind for Qrisp; the adapter's `bit_order_for`), defaulting to the
|
|
61
|
+
adapter's class-level order. `measured_order` is how the lowered circuit's
|
|
62
|
+
measured string is ordered — Qiskit's, unless the lowering allocates
|
|
63
|
+
classical bits in reverse (Jasp). A declaration is translated exactly when
|
|
64
|
+
the two differ.
|
|
65
|
+
|
|
66
|
+
Raises ValueError for a declaration that cannot be interpreted safely:
|
|
67
|
+
unknown order, non-binary or wrong-width keys, keys that collide once
|
|
68
|
+
normalised, or a translation on a multi-register circuit (where "qubit 0
|
|
69
|
+
first" no longer describes a single unambiguous string).
|
|
70
|
+
"""
|
|
71
|
+
if order not in ORDERS:
|
|
72
|
+
raise ValueError(f"expected_order must be one of {ORDERS}, not {order!r}")
|
|
73
|
+
if native_order is None:
|
|
74
|
+
native_order = "qubit0_first" if source_format in QUBIT0_FIRST else "qubit0_last"
|
|
75
|
+
width, registers = measured_register(qiskit_circuit)
|
|
76
|
+
|
|
77
|
+
declared: dict[str, float] = {}
|
|
78
|
+
for raw_key, prob in expected.items():
|
|
79
|
+
key = str(raw_key).replace(" ", "")
|
|
80
|
+
if not key or set(key) - {"0", "1"}:
|
|
81
|
+
raise ValueError(f"expected outcome {raw_key!r} is not a bitstring of 0s and 1s")
|
|
82
|
+
if len(key) != width:
|
|
83
|
+
raise ValueError(
|
|
84
|
+
f"expected outcome {raw_key!r} has {len(key)} bits, but this circuit is measured "
|
|
85
|
+
f"over {width} classical bits — every outcome must be exactly {width} characters"
|
|
86
|
+
)
|
|
87
|
+
if key in declared:
|
|
88
|
+
raise ValueError(f"expected outcome {raw_key!r} duplicates another key once spaces are removed")
|
|
89
|
+
declared[key] = float(prob)
|
|
90
|
+
|
|
91
|
+
translate = order == "native" and native_order != measured_order
|
|
92
|
+
if translate and registers > 1:
|
|
93
|
+
raise ValueError(
|
|
94
|
+
f"this {source_format} circuit measures into {registers} classical registers, so "
|
|
95
|
+
"'qubit 0 first' does not define one unambiguous bitstring. Declare the expected "
|
|
96
|
+
"outcomes in measured (Qiskit) order and pass expected_order='qiskit'."
|
|
97
|
+
)
|
|
98
|
+
|
|
99
|
+
first = "qubit 0 is the leftmost character"
|
|
100
|
+
last = "qubit 0 is the rightmost character"
|
|
101
|
+
if translate:
|
|
102
|
+
measured = {k[::-1]: v for k, v in declared.items()}
|
|
103
|
+
record = {
|
|
104
|
+
"declared_order": f"native ({source_format}): {first if native_order == 'qubit0_first' else last}",
|
|
105
|
+
"declared_distribution": declared,
|
|
106
|
+
"translated": True,
|
|
107
|
+
}
|
|
108
|
+
else:
|
|
109
|
+
convention = (
|
|
110
|
+
"qiskit: classical bit 0 is the rightmost character"
|
|
111
|
+
if order == "qiskit"
|
|
112
|
+
else f"native ({source_format}): same convention as the measured order"
|
|
113
|
+
)
|
|
114
|
+
measured = dict(declared)
|
|
115
|
+
record = {"declared_order": convention, "translated": False}
|
|
116
|
+
record["measured_order"] = (
|
|
117
|
+
"qiskit: classical bit 0 is the rightmost character" if measured_order == "qubit0_last"
|
|
118
|
+
else "lowered circuit: classical bits allocated in reverse, so qubit 0 is the leftmost character"
|
|
119
|
+
)
|
|
120
|
+
record["width"] = width
|
|
121
|
+
return measured, record
|