diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a0dfb08..a0f372d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -4,7 +4,7 @@ on: push: branches: [master] pull_request: - branches: [master, upgrade/qec-validation-security-20261009] + branches: [master] permissions: contents: read @@ -41,6 +41,23 @@ jobs: assert report["code_revision"] print(f"Resource sweep passed: {report['result_count']} cases, {report['runtime_seconds']} seconds") PY + - name: Run PQC evidence-to-QEC bridge smoke test + run: | + python -m qec.scenario_bridge tests/fixtures/quantum_collapse_scenario.json --n-logical 100 --t-gates 1000000 --output /tmp/qec-bridge.json + python - <<'PY' + import json + with open("/tmp/qec-bridge.json", encoding="utf-8") as handle: + report = json.load(handle) + assert report["measurement_type"] == "conditional_analytical_resource_estimate_not_hardware_measurement" + assert report["summary"]["supported_rsa_node_count"] == 1 + assert report["summary"]["unsupported_node_count"] == 1 + rows = {row["crypto"]: row for row in report["findings"]} + assert rows["RSA"]["status"] == "estimated" + assert rows["RSA"]["resource_estimate"]["n_logical"] == 100 + assert rows["ECC-P256"]["status"] == "unsupported_crypto_model" + assert rows["ECC-P256"]["resource_estimate"] is None + print("PQC-to-QEC bridge passed: RSA estimate scoped; ECC retained as unsupported evidence") + PY - name: Compile Python sources run: python -m compileall -q . diff --git a/README.md b/README.md index 1404827..569507a 100644 --- a/README.md +++ b/README.md @@ -73,6 +73,32 @@ docs/ --- +## PQC evidence → QEC resource scenario bridge + +QEC Lab can consume the versioned scenario JSON produced by Quantum Collapse's +PQC Scanner bridge. It retains the input finding references and runs the existing +resource estimator **only for RSA findings**, because the current estimator models +RSA-2048 factoring. ECC, DH, and DSA observations are preserved with an explicit +`unsupported_crypto_model` status rather than being treated as RSA equivalents. + +From `backend/`: + +```bash +python -m qec.scenario_bridge /path/to/scenario.json \ + --p-physical 0.001 --n-logical 3000 --t-gates 3000000000 \ + --p-total-failure 0.5 --output qec-resource-scenario.json +``` + +Each output labels itself `conditional_analytical_resource_estimate_not_hardware_measurement`. +It carries the estimator's model-domain and distance-cap flags and documents that +the result is assumption-sensitive, omits architecture-specific overhead, and does +not prove source-code reachability, deployed use, or an attack timeline. Input +scenario risk scores and dependency assumptions are not recomputed or treated as +physical evidence. + +CI exercises this bridge using a small fixture containing one RSA and one ECC +node; the fixture is a contract/regression test, not a benchmark result. + ## Scenario uncertainty analysis The API endpoint `GET /api/resource/uncertainty` runs a seeded Monte Carlo sweep over explicitly supplied ranges for physical error rate, logical-qubit count, T-gate count, and total failure budget. It reports p05/p50/p95, mean, sample standard deviation, and rates for cap-exceeded, model-domain-valid, and feasible scenarios. diff --git a/backend/qec/scenario_bridge.py b/backend/qec/scenario_bridge.py new file mode 100644 index 0000000..8b5249d --- /dev/null +++ b/backend/qec/scenario_bridge.py @@ -0,0 +1,140 @@ +"""Consume Quantum Collapse bridge output and add scoped QEC resource estimates. + +Only RSA findings are supported by the current RSA-2048 resource model. ECC/DH/DSA +findings are retained as observed evidence but never coerced into RSA estimates. +All generated values are analytical scenario outputs, not hardware measurements. +""" +from __future__ import annotations + +import argparse +import json +from pathlib import Path +from typing import Any + +from .resource import estimate + + +def build_scenario_report( + bridge: dict[str, Any], + *, + p_physical: float = 0.001, + n_logical: int = 3000, + t_gates: int = 3_000_000_000, + p_total_failure: float = 0.5, +) -> dict[str, Any]: + """Add an RSA-2048 estimate for supported nodes in a bridge scenario.""" + if not isinstance(bridge, dict) or bridge.get("schema_version") != "1.0": + raise ValueError("input must be a Quantum Collapse bridge output with schema_version 1.0") + source = bridge.get("source") + scenario = bridge.get("modeled_scenario") + evidence = bridge.get("observed_evidence") + if not isinstance(source, dict) or not isinstance(scenario, dict) or not isinstance(evidence, dict): + raise ValueError("input must contain source, observed_evidence, and modeled_scenario objects") + nodes = scenario.get("nodes") + if not isinstance(nodes, list): + raise ValueError("modeled_scenario.nodes must be a list") + + rows = [] + for node in nodes: + if not isinstance(node, dict): + raise ValueError("each modeled scenario node must be an object") + crypto = node.get("crypto") + if not isinstance(crypto, str): + raise ValueError("each scenario node must include a crypto label") + evidence_ref = { + "node_id": node.get("id"), + "file": node.get("evidence_file", ""), + "line": node.get("evidence_line"), + "algorithm": node.get("evidence_algorithm", crypto), + } + if crypto == "RSA": + model_result = estimate( + p_physical=p_physical, + n_logical=n_logical, + t_gates=t_gates, + p_total_failure=p_total_failure, + ) + rows.append({ + **evidence_ref, + "crypto": crypto, + "status": "estimated", + "resource_estimate": model_result, + "limitation": "Estimate is scoped to RSA-2048 factoring; it does not establish runtime reachability or an attack timeline.", + }) + else: + rows.append({ + **evidence_ref, + "crypto": crypto, + "status": "unsupported_crypto_model", + "resource_estimate": None, + "limitation": "Current QEC resource model is scoped to RSA-2048 factoring; no RSA estimate is assigned to this finding.", + }) + + supported = sum(row["status"] == "estimated" for row in rows) + return { + "schema_version": "1.0", + "experiment": "qec-lab-quantum-collapse-scenario-bridge", + "measurement_type": "conditional_analytical_resource_estimate_not_hardware_measurement", + "input": { + "format": source.get("format"), + "format_version": source.get("version"), + "source_tool": source.get("tool"), + "bridge_schema_version": bridge.get("schema_version"), + "observed_finding_count": evidence.get("finding_count"), + }, + "resource_model": { + "name": "simplified_surface_code_rsa2048_estimator", + "scope": "RSA-2048 factoring only", + "parameters": { + "p_physical": p_physical, + "n_logical": n_logical, + "t_gates": t_gates, + "p_total_failure": p_total_failure, + }, + "limitations": [ + "analytical approximation, not a hardware measurement or calibrated forecast", + "omits architecture-specific routing, magic-state factory, and layout overhead", + "reported distance 61 is a search-cap sentinel, not a validated minimum-distance solution", + "source-code findings do not prove runtime reachability or deployed use", + "ECC, DH, and DSA findings are preserved but unsupported by this RSA-2048 model", + ], + }, + "summary": { + "scenario_node_count": len(nodes), + "supported_rsa_node_count": supported, + "unsupported_node_count": len(rows) - supported, + "edge_count": scenario.get("edge_count", 0), + }, + "findings": rows, + } + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("scenario", type=Path, help="Quantum Collapse SARIF bridge output JSON") + parser.add_argument("--p-physical", type=float, default=0.001) + parser.add_argument("--n-logical", type=int, default=3000) + parser.add_argument("--t-gates", type=int, default=3_000_000_000) + parser.add_argument("--p-total-failure", type=float, default=0.5) + parser.add_argument("--output", type=Path) + args = parser.parse_args() + try: + bridge = json.loads(args.scenario.read_text(encoding="utf-8")) + report = build_scenario_report( + bridge, + p_physical=args.p_physical, + n_logical=args.n_logical, + t_gates=args.t_gates, + p_total_failure=args.p_total_failure, + ) + except (OSError, json.JSONDecodeError, ValueError) as exc: + parser.error(str(exc)) + rendered = json.dumps(report, indent=2, sort_keys=True) + "\n" + if args.output: + args.output.write_text(rendered, encoding="utf-8") + print(rendered, end="") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/backend/tests/fixtures/quantum_collapse_scenario.json b/backend/tests/fixtures/quantum_collapse_scenario.json new file mode 100644 index 0000000..ad1bc70 --- /dev/null +++ b/backend/tests/fixtures/quantum_collapse_scenario.json @@ -0,0 +1,60 @@ +{ + "schema_version": "1.0", + "source": { + "format": "SARIF", + "version": "2.1.0", + "tool": "pqc-scanner" + }, + "observed_evidence": { + "finding_count": 2, + "by_bucket": { + "quantum_broken": 2, + "classically_broken": 0, + "quantum_weakened": 0, + "other": 0 + }, + "findings": [] + }, + "modeled_scenario": { + "node_count": 2, + "edge_count": 0, + "finding_weighted_risk_score": 0.5, + "nodes": [ + { + "id": "finding-0001", + "sector": "Scanned source finding", + "crypto": "RSA", + "criticality": 5, + "migration_pct": 0, + "evidence_file": "src/auth.py", + "evidence_line": 12, + "evidence_algorithm": "RSA", + "modeled_risk": 0.8 + }, + { + "id": "finding-0002", + "sector": "Scanned source finding", + "crypto": "ECC-P256", + "criticality": 5, + "migration_pct": 0, + "evidence_file": "src/exchange.py", + "evidence_line": 30, + "evidence_algorithm": "ECC/ECDSA/ECDH", + "modeled_risk": 0.8 + } + ], + "unmapped_quantum_findings": [], + "assumptions": { + "migration_pct": 0, + "criticality_by_sarif_level": { + "error": 5, + "warning": 3, + "note": 1 + }, + "dependency_edges": "none inferred; scanner output does not establish dependency topology", + "risk_score": "mean of modeled risk for mapped quantum-broken findings only", + "risk_coefficients": "illustrative values from quantum_collapse.risk", + "limitations": "static findings do not prove runtime reachability or deployed usage" + } + } +} diff --git a/backend/tests/test_core.py b/backend/tests/test_core.py index 8348e09..d21bafc 100644 --- a/backend/tests/test_core.py +++ b/backend/tests/test_core.py @@ -149,40 +149,6 @@ def test_monte_carlo_resource_scenarios_reject_invalid_ranges(kwargs): monte_carlo_resource_scenarios(**kwargs) - - -def test_monte_carlo_separates_cap_sentinels_from_valid_resource_quantiles(monkeypatch): - import qec.uncertainty as uncertainty - - def capped_estimate(**kwargs): - return { - "total_physical": 7442, - "d": 61, - "distance_cap_exceeded": True, - "model_domain_valid": True, - "feasible": False, - } - - monkeypatch.setattr(uncertainty, "estimate", capped_estimate) - report = uncertainty.monte_carlo_resource_scenarios( - samples=3, - seed=11, - p_min=0.001, - p_max=0.002, - n_logical_min=100, - n_logical_max=100, - t_gates_min=1000, - t_gates_max=1000, - p_total_failure_min=0.5, - p_total_failure_max=0.5, - ) - assert report["schema_version"] == "1.1" - assert report["results"]["full_distribution_includes_distance_cap_reference_values"] is True - assert report["results_within_search_cap"]["sample_count"] == 0 - assert report["results_within_search_cap"]["total_physical"] is None - assert report["results_within_search_cap"]["reported_distance"] is None - assert report["rates"]["distance_cap_exceeded"] == 1.0 - def test_resource_uncertainty_api_is_registered(): from server import app @@ -238,6 +204,106 @@ def test_runtime_propagates_resource_model_domain_flags(): assert report["within_factor"] is None assert all("model_domain_valid" in row for row in report["sensitivity"]) +def _sample_quantum_collapse_bridge(): + return { + "schema_version": "1.0", + "source": {"format": "SARIF", "version": "2.1.0", "tool": "pqc-scanner"}, + "observed_evidence": {"finding_count": 2}, + "modeled_scenario": { + "node_count": 2, + "edge_count": 0, + "nodes": [ + { + "id": "finding-0001", + "crypto": "RSA", + "evidence_file": "src/auth.py", + "evidence_line": 12, + "evidence_algorithm": "RSA", + }, + { + "id": "finding-0002", + "crypto": "ECC-P256", + "evidence_file": "src/exchange.py", + "evidence_line": 30, + "evidence_algorithm": "ECC/ECDSA/ECDH", + }, + ], + }, + } + + +def test_scenario_bridge_estimates_only_rsa_nodes(): + from qec.scenario_bridge import build_scenario_report + + report = build_scenario_report( + _sample_quantum_collapse_bridge(), + p_physical=0.001, + n_logical=100, + t_gates=1_000_000, + ) + assert report["measurement_type"] == "conditional_analytical_resource_estimate_not_hardware_measurement" + assert report["summary"]["supported_rsa_node_count"] == 1 + assert report["summary"]["unsupported_node_count"] == 1 + assert report["findings"][0]["status"] == "estimated" + assert report["findings"][0]["resource_estimate"]["n_logical"] == 100 + assert report["findings"][1]["status"] == "unsupported_crypto_model" + assert report["findings"][1]["resource_estimate"] is None + + +def test_scenario_bridge_rejects_unrecognized_contract(): + from qec.scenario_bridge import build_scenario_report + + with pytest.raises(ValueError, match="schema_version 1.0"): + build_scenario_report({"schema_version": "2.0"}) + + +def test_scenario_bridge_propagates_resource_model_domain_flags(): + from qec.scenario_bridge import build_scenario_report + + report = build_scenario_report( + _sample_quantum_collapse_bridge(), + p_physical=0.05, + n_logical=100, + t_gates=1_000_000, + ) + rsa = report["findings"][0]["resource_estimate"] + assert rsa["model_domain_valid"] is False + assert rsa["distance_cap_exceeded"] is True + assert rsa["feasible"] is False + + +def test_monte_carlo_separates_cap_sentinels_from_valid_resource_quantiles(monkeypatch): + import qec.uncertainty as uncertainty + + def capped_estimate(**kwargs): + return { + "total_physical": 7442, + "d": 61, + "distance_cap_exceeded": True, + "model_domain_valid": True, + "feasible": False, + } + + monkeypatch.setattr(uncertainty, "estimate", capped_estimate) + report = uncertainty.monte_carlo_resource_scenarios( + samples=3, + seed=11, + p_min=0.001, + p_max=0.002, + n_logical_min=100, + n_logical_max=100, + t_gates_min=1000, + t_gates_max=1000, + p_total_failure_min=0.5, + p_total_failure_max=0.5, + ) + assert report["schema_version"] == "1.1" + assert report["results"]["full_distribution_includes_distance_cap_reference_values"] is True + assert report["results_within_search_cap"]["sample_count"] == 0 + assert report["results_within_search_cap"]["total_physical"] is None + assert report["results_within_search_cap"]["reported_distance"] is None + assert report["rates"]["distance_cap_exceeded"] == 1.0 + def test_resource_cap_uses_supported_distance_for_reference_counts(): result = estimate(p_physical=0.05, n_logical=100, t_gates=1_000_000)