From da05758f20c8b1d6b4517ecfd96d710879754fa6 Mon Sep 17 00:00:00 2001 From: Ilias Tekeoglou <256083614+ILIASTEKEOGLOU@users.noreply.github.com> Date: Sat, 10 Oct 2026 17:32:41 +0200 Subject: [PATCH] Introduce PDR 0.3 with registry context and RFC 8785 hashing PDR generation now writes contract version 0.3. Document hashes, record hashes and run_id values differ from 0.2 for the same input. - Hashes use the RFC 8785 canonical form (qstriage/canonical_json.py). - registry_context (registry_id, registry_version, registry_hash) is recorded in the document and in each record. registry_hash is the RFC 8785 SHA-256 hash of the parsed algorithm_registry.json data. - run_id is derived from source_hash, policy_pack_hash, registry_hash and pdr_version. - observed_state adds scheme_type, components and validation_component. The current registry has no hybrid entries, so they are "single", [] and null. - Values that RFC 8785 cannot represent, such as integers above 2^53, stop PDR generation; no output file is written. - The registry file is rejected if it contains duplicate JSON keys. pdr verify checks 0.2 documents under the 0.2 rule and 0.3 documents under the 0.3 rule, including registry_context consistency for each record. A PDR 0.2 document generated before this change is kept as a test fixture and must keep verifying. A test binds each registry_version to one registry_hash, so a change to registry content requires a new registry_version. Decision, policy and evidence content is unchanged relative to the 0.2 fixture. --- CHANGELOG.md | 19 + docs/algorithm-identifier-grammar.md | 2 +- docs/pdr-contract.md | 89 ++- docs/standards-and-classification.md | 14 +- docs/usage.md | 16 +- qstriage/algorithm_registry.py | 40 +- qstriage/pdr.py | 54 +- qstriage/pdr_verify.py | 94 ++- qstriage/standards.py | 6 + tests/fixtures/pdr_v0_2_sample.json | 970 ++++++++++++++++++++++++++ tests/test_algorithm_registry.py | 21 +- tests/test_classification_snapshot.py | 12 + tests/test_cli_pdr.py | 2 +- tests/test_pdr.py | 4 +- tests/test_pdr_verify.py | 175 ++++- 15 files changed, 1446 insertions(+), 72 deletions(-) create mode 100644 tests/fixtures/pdr_v0_2_sample.json diff --git a/CHANGELOG.md b/CHANGELOG.md index 51fbf5d..6bcf473 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,25 @@ ## Unreleased +### Changed + +- PDR contract version is now 0.3, and `qstriage pdr generate` writes only + 0.3. Document hashes, record hashes, and `run_id` values differ from 0.2 + for the same input: + - Hashes use the RFC 8785 canonical form instead of Python `json.dumps` + output, so any RFC 8785 implementation can recompute them. + - A `registry_context` (`registry_id`, `registry_version`, + `registry_hash`) is recorded in the document and in each record. + - `run_id` is derived from `source_hash`, `policy_pack_hash`, + `registry_hash`, and `pdr_version`. + - `observed_state` adds `scheme_type`, `components`, and + `validation_component`. + - Values that RFC 8785 cannot represent, such as integers above 2^53, + stop PDR generation. +- `qstriage pdr verify` verifies PDR 0.2 and 0.3 documents, each under its + own rules. +- The algorithm registry file is rejected if it contains duplicate JSON keys. + ### Added - Added `qstriage pdr verify`, which recomputes the hashes and `run_id` of a diff --git a/docs/algorithm-identifier-grammar.md b/docs/algorithm-identifier-grammar.md index 1b118fd..d42df91 100644 --- a/docs/algorithm-identifier-grammar.md +++ b/docs/algorithm-identifier-grammar.md @@ -206,7 +206,7 @@ The grammar does not: `ML-KEM-768+X25519`; the current data model has no hybrid-family result; - infer runtime implementation, key validity, protocol role, or policy approval from an identifier string; -- change the policy-pack version, scoring formula, or PDR 0.2 schema. +- change the policy-pack version, scoring formula, or PDR schema. The deferred curve identifiers remain `unrecognized_identifier` until a versioned, provenance-aware registry adds them. Conservative unknown handling diff --git a/docs/pdr-contract.md b/docs/pdr-contract.md index d44c1d7..ba944e9 100644 --- a/docs/pdr-contract.md +++ b/docs/pdr-contract.md @@ -1,15 +1,16 @@ -# QSTriage PDR 0.2 Contract +# QSTriage PDR 0.3 Contract A PQC Decision Record (PDR) is structured decision state produced from one captured QSTriage inventory or CycloneDX CBOM input. -This document describes PDR contract version `0.2` as implemented by `qstriage/pdr.py`. Code and tests remain authoritative for executable behavior. +This document describes PDR contract version `0.3` as implemented by `qstriage/pdr.py`. Code and tests remain authoritative for executable behavior. ## Contract version and authority -- PDR contract version: `0.2` +- PDR contract version: `0.3` - Engine name: `QSTriage` - Engine version: the installed QSTriage package version - Default policy pack: `nist-pqc-basic` version `0.2` +- Algorithm registry: the registry bundled with the installed package PDR versioning is separate from the QSTriage package version. Removing, renaming, or changing the documented meaning of public PDR fields requires a PDR contract-version change. Additive fields can be introduced in a compatible minor evolution when their meaning does not alter existing fields. @@ -19,10 +20,11 @@ A `PDRDocument` contains: | Field | Contract | |---|---| -| `pdr_version` | PDR contract version, currently `0.2` | -| `run_id` | Deterministic identifier derived from source hash, policy-pack hash, and PDR version | +| `pdr_version` | PDR contract version, currently `0.3` | +| `run_id` | Deterministic identifier derived from source hash, policy-pack hash, registry hash, and PDR version | | `input_snapshot` | Provenance for the captured source | | `policy_context` | Document-level policy-pack provenance | +| `registry_context` | Document-level algorithm-registry provenance | | `records` | One decision record per inventory asset | | `document_hash` | Integrity hash over the document with this field neutralized | @@ -55,7 +57,7 @@ Inventory and CBOM file limits are documented in [Input Contracts](input-contrac ### In-memory generation -When no file-backed snapshot is supplied, QSTriage hashes the canonical JSON representation of the validated inventory object. In that mode, `source_path` is null. This is deterministic but is not a hash of an original file byte stream. +When no file-backed snapshot is supplied, QSTriage hashes the RFC 8785 canonical JSON representation of the validated inventory object. In that mode, `source_path` is null. This is deterministic but is not a hash of an original file byte stream. ## Policy context @@ -70,6 +72,21 @@ It records policy provenance only. Applied rule IDs and policy findings are asse A requested policy-pack version mismatch is rejected rather than silently substituted. +## Registry context + +The document-level `registry_context` identifies the algorithm registry that +supplied classification data: + +| Field | Meaning | +|---|---| +| `registry_id` | Registry identifier, currently `qstriage-algorithms` | +| `registry_version` | Version of the registry content | +| `registry_hash` | `sha256:`-prefixed hash of the RFC 8785 canonical form of the parsed registry file `qstriage/algorithm_registry.json` | + +`registry_hash` covers the registry data, not the file bytes, so a formatting +change to the file does not change it, while any change to an entry does. +The registry file is rejected if it contains duplicate JSON keys. + ## Decision record Each `PQCDecisionRecord` contains: @@ -80,8 +97,9 @@ Each `PQCDecisionRecord` contains: | `engine` | QSTriage name and version | | `input_snapshot` | Copy of the document input provenance | | `policy_context` | Copy of policy-pack provenance | +| `registry_context` | Copy of algorithm-registry provenance | | `policy_evaluation` | Applied rules, standards, thresholds, findings, and blocking state for this asset | -| `observed_state` | Asset identity plus classified algorithm family, primitive, quantum status, and standards status | +| `observed_state` | Asset identity plus classified algorithm family, primitive, quantum status, standards status, and scheme fields | | `evidence_quality` | Numeric quality score, missing evidence, and limitations | | `evidence_review` | Structured findings, evidence score, confidence cap, decision-grade status, and recommended human actions | | `decision_confidence` | Final bounded confidence value and rationale | @@ -92,7 +110,22 @@ Each `PQCDecisionRecord` contains: | `assumptions_made` | Explicit evidence assumptions and blocking conditions | | `record_integrity` | Optional prior-record reference and deterministic record hash | -`record_id` is `pdr:` followed by the QSTriage asset ID. `lineage_id` is derived from the source hash. All records in the same document share the document `run_id`, `input_snapshot`, and `policy_context`. +`record_id` is `pdr:` followed by the QSTriage asset ID. `lineage_id` is derived from the source hash. All records in the same document share the document `run_id`, `input_snapshot`, `policy_context`, and `registry_context`. + +## Observed state scheme fields + +`observed_state` reports how the matched registry entry is composed: + +| Field | Meaning | +|---|---| +| `scheme_type` | `single`, `pq_t_hybrid`, or `pq_pq_hybrid` (RFC 9794 terms) | +| `components` | For hybrids, the component registry entry IDs in the order defined by the source; otherwise an empty list | +| `validation_component` | For hybrids, the component whose implementation must be validated, as stated by the source; otherwise null | + +The current registry contains no hybrid entries, so every record reports +`single`, an empty list, and null. A name that lists separate key-establishment +and authentication algorithms, such as `ECDHE_RSA`, is a cipher-suite +combination, not a hybrid scheme, and is reported as `single`. ## Canonical decision projection @@ -125,26 +158,44 @@ Suggestions do not approve deployment. Each suggestion records standards provena ## Deterministic identifiers and hashes -QSTriage uses canonical JSON with sorted keys, compact separators, and UTF-8 encoding for object hashes. +PDR 0.3 hashes JSON data in the canonical form defined by RFC 8785 (JSON +Canonicalization Scheme), encoded as UTF-8, with SHA-256. Any RFC 8785 +implementation can recompute the hashes from the parsed document. Values that +RFC 8785 cannot represent, such as integers above 2^53, stop PDR generation. -- `run_id` is derived from `source_hash`, `policy_pack_hash`, and `pdr_version`. +- `run_id` is `run:` followed by the first 16 hex characters of the hash of + `{"source_hash", "policy_pack_hash", "registry_hash", "pdr_version"}`. - `lineage_id` is derived from the source hash. - `record_hash` is calculated with its own `record_hash` field neutralized while preserving `previous_record_hash`. - `document_hash` is calculated with its own `document_hash` field neutralized. An optional `previous_record_hash` can link a record to an externally supplied prior record for the same asset. It is not an implicit chain between adjacent records in one document. -For the same QSTriage version, captured input snapshot, policy pack, and generated content, PDR output and hashes are deterministic. Exact hashes are not promised to remain unchanged across QSTriage or PDR contract versions because documented content or serialization inputs can evolve. +For the same QSTriage version, captured input snapshot, policy pack, registry, and generated content, PDR output and hashes are deterministic. Exact hashes are not promised to remain unchanged across QSTriage or PDR contract versions because documented content or serialization inputs can evolve. ## Verification -`qstriage pdr verify` checks a PDR 0.2 document under the rules above: it -recomputes `document_hash` with that field set to null, recomputes each -`record_hash` with that field set to null, recomputes `run_id`, and checks -that every record repeats the document's `pdr_version`, `run_id`, -`input_snapshot`, and `policy_context`. The 0.2 hashing rule is frozen in -`qstriage/pdr_verify.py`, so 0.2 documents remain verifiable after later -contract versions change the serialization. +`qstriage pdr verify` applies the rules of the document's own `pdr_version`: + +- It recomputes `document_hash` with that field set to null. +- It recomputes each `record_hash` with that field set to null. +- It recomputes `run_id`. +- It checks that every record repeats the document's `pdr_version`, `run_id`, + `input_snapshot`, and `policy_context`, and for 0.3 also `registry_context`. + +Supported versions: + +| `pdr_version` | Canonical form | `run_id` inputs | +|---|---|---| +| `0.2` | Python `json.dumps(sort_keys=True, separators=(",", ":"), ensure_ascii=False)` | `source_hash`, `policy_pack_hash`, `pdr_version` | +| `0.3` | RFC 8785 | `source_hash`, `policy_pack_hash`, `registry_hash`, `pdr_version` | + +The 0.2 rule is kept in `qstriage/pdr_verify.py`, so 0.2 documents remain +verifiable. `pdr generate` writes only 0.3. + +Verification shows that a document is internally consistent. It does not +show which registry or policy content produced it; `registry_hash` and +`policy_pack_hash` identify that content. ## Evidence and confidence behavior @@ -165,7 +216,7 @@ A CBOM with no supported explicit cryptographic-asset evidence produces no PDR. ## Public compatibility boundary -PDR 0.2 documents QSTriage decision state. It does not claim: +PDR 0.3 documents QSTriage decision state. It does not claim: - universal CBOM compatibility, - complete cryptographic discovery, diff --git a/docs/standards-and-classification.md b/docs/standards-and-classification.md index 8a7d9e9..846646f 100644 --- a/docs/standards-and-classification.md +++ b/docs/standards-and-classification.md @@ -142,11 +142,15 @@ A registry change must: must remain unknown, - preserve conservative manual review outside the supported match boundary, - update source IDs and this document when public behavior or provenance - changes. - -The registry does not currently have an independent public version or hash. -The PDR records the QSTriage engine version and the policy-pack version and -hash; those policy fields are not an algorithm-registry version. + changes, +- increase `registry_version` in `qstriage/algorithm_registry.json` when entry + content changes. + +The registry has its own identity. Each PDR 0.3 records `registry_id`, +`registry_version`, and `registry_hash`, the RFC 8785 SHA-256 hash of the +parsed registry file, in its `registry_context`. A change to any entry changes +`registry_hash` and therefore the PDR `run_id`. See +[PDR Contract](pdr-contract.md). ## Bounded interpretation diff --git a/docs/usage.md b/docs/usage.md index 84b62ec..72ed779 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -111,12 +111,12 @@ qstriage pdr generate \ --output reports/cbom_pdr.json ``` -PDR 0.2 records input provenance, policy context, evidence, confidence, -canonical decision state, assumptions, and integrity hashes. File-backed -generation parses and hashes one captured byte snapshot. +PDR 0.3 records input provenance, policy and registry context, evidence, +confidence, canonical decision state, assumptions, and integrity hashes. +File-backed generation parses and hashes one captured byte snapshot. The serialized compatibility boundary is defined in -[PDR 0.2 Contract](pdr-contract.md). +[PDR 0.3 Contract](pdr-contract.md). ## Verify a PQC Decision Record @@ -127,9 +127,11 @@ qstriage pdr verify reports/pdr.json --format json Verification recomputes `document_hash`, every `record_hash`, and `run_id`, and checks that each record carries the document's `pdr_version`, `run_id`, -`input_snapshot`, and `policy_context`. It reads the file once within the -64 MiB limit, rejects duplicate JSON keys and non-finite numbers, and does not -write. The exit code is 0 when every check passes and 1 otherwise. +`input_snapshot`, `policy_context`, and, for PDR 0.3, `registry_context`. +PDR 0.2 and 0.3 documents are each checked under their own rules. +Verification reads the file once within the 64 MiB limit, rejects duplicate +JSON keys and non-finite numbers, and does not write. The exit code is 0 when +every check passes and 1 otherwise. ## Inspect algorithm classification diff --git a/qstriage/algorithm_registry.py b/qstriage/algorithm_registry.py index fbac271..d0f04d7 100644 --- a/qstriage/algorithm_registry.py +++ b/qstriage/algorithm_registry.py @@ -14,6 +14,8 @@ from pydantic import BaseModel, ConfigDict, Field, model_validator +from qstriage.canonical_json import canonical_sha256 + REGISTRY_RESOURCE = "algorithm_registry.json" REGISTRY_SCHEMA_VERSION = "1" @@ -155,11 +157,47 @@ def entry(self, entry_id: str) -> RegistryEntry: raise KeyError(entry_id) +class RegistryLoadError(ValueError): + """Raised when the bundled registry file is not well-formed JSON data.""" + + @lru_cache(maxsize=1) def load_registry() -> AlgorithmRegistry: + return AlgorithmRegistry.model_validate(_read_registry_data()) + + +@lru_cache(maxsize=1) +def registry_hash() -> str: + """Return the RFC 8785 SHA-256 hash of the bundled registry data. + + The hash covers the parsed JSON data, not the file bytes, so formatting + changes do not change it. Any RFC 8785 implementation can recompute it + from ``qstriage/algorithm_registry.json``. + """ + + return canonical_sha256(_read_registry_data()) + + +def _read_registry_data() -> dict[str, object]: text = ( resources.files("qstriage") .joinpath(REGISTRY_RESOURCE) .read_text(encoding="utf-8") ) - return AlgorithmRegistry.model_validate(json.loads(text)) + data = json.loads(text, object_pairs_hook=_object_without_duplicate_keys) + if not isinstance(data, dict): + raise RegistryLoadError("Algorithm registry must be a JSON object.") + return data + + +def _object_without_duplicate_keys( + pairs: list[tuple[str, object]], +) -> dict[str, object]: + result: dict[str, object] = {} + for key, value in pairs: + if key in result: + raise RegistryLoadError( + f"Algorithm registry contains duplicate key {key!r}." + ) + result[key] = value + return result diff --git a/qstriage/pdr.py b/qstriage/pdr.py index 6ba5229..87ad768 100644 --- a/qstriage/pdr.py +++ b/qstriage/pdr.py @@ -1,7 +1,6 @@ from __future__ import annotations import hashlib -import json from dataclasses import dataclass from pathlib import Path from typing import Any @@ -9,6 +8,7 @@ from pydantic import BaseModel, ConfigDict, Field from qstriage import __version__ +from qstriage.algorithm_registry import load_registry, registry_hash from qstriage.assessment import ( AssetAssessment, DecisionConfidence, @@ -21,6 +21,7 @@ VerificationPriority, VerificationRequirement, ) +from qstriage.canonical_json import canonical_sha256 from qstriage.cbom import inventory_from_cbom, parse_cbom_json from qstriage.evidence import EvidenceReview from qstriage.limits import ( @@ -44,7 +45,7 @@ ) -PDR_VERSION = "0.2" +PDR_VERSION = "0.3" ENGINE_NAME = "QSTriage" ENGINE_VERSION = __version__ @@ -83,6 +84,14 @@ class PolicyContext(BaseModel): standards_applied: list[str] +class RegistryContext(BaseModel): + model_config = ConfigDict(extra="forbid") + + registry_id: str = Field(min_length=1) + registry_version: str = Field(min_length=1) + registry_hash: str = Field(min_length=1) + + class ObservedState(BaseModel): model_config = ConfigDict(extra="forbid") @@ -97,6 +106,9 @@ class ObservedState(BaseModel): primitive: str quantum_status: str standard_status: str + scheme_type: str + components: list[str] + validation_component: str | None class MissionContext(BaseModel): @@ -159,6 +171,7 @@ class PQCDecisionRecord(BaseModel): engine: PDREngine input_snapshot: InputSnapshot policy_context: PolicyContext + registry_context: RegistryContext policy_evaluation: PolicyEvaluationResult observed_state: ObservedState evidence_quality: EvidenceQuality @@ -179,6 +192,7 @@ class PDRDocument(BaseModel): run_id: str input_snapshot: InputSnapshot policy_context: PolicyContext + registry_context: RegistryContext records: list[PQCDecisionRecord] document_hash: str @@ -290,7 +304,8 @@ def generate_pdr_document( ) policy_pack = _load_policy_pack(policy_pack_id, policy_pack_version) policy_context = _build_policy_context(policy_pack) - run_id = _run_id(snapshot, policy_context) + registry_context = _build_registry_context() + run_id = _run_id(snapshot, policy_context, registry_context) lineage_id = _lineage_id(snapshot) score_by_asset = {result.asset_id: result for result in score_inventory(inventory)} @@ -303,6 +318,7 @@ def generate_pdr_document( lineage_id=lineage_id, input_snapshot=snapshot, policy_context=policy_context, + registry_context=registry_context, policy_pack=policy_pack, previous_record_hash=( previous_record_hashes or {} @@ -315,6 +331,7 @@ def generate_pdr_document( run_id=run_id, input_snapshot=snapshot, policy_context=policy_context, + registry_context=registry_context, records=records, document_hash="pending", ) @@ -331,6 +348,7 @@ def _build_record( lineage_id: str, input_snapshot: InputSnapshot, policy_context: PolicyContext, + registry_context: RegistryContext, policy_pack: PolicyPack, previous_record_hash: str | None, ) -> PQCDecisionRecord: @@ -349,6 +367,7 @@ def _build_record( engine=PDREngine(), input_snapshot=input_snapshot, policy_context=policy_context, + registry_context=registry_context, policy_evaluation=assessment.policy_evaluation, observed_state=_observed_state(asset, assessment.classification), evidence_quality=assessment.evidence_quality, @@ -443,6 +462,15 @@ def _build_policy_context(policy_pack: PolicyPack) -> PolicyContext: ) +def _build_registry_context() -> RegistryContext: + registry = load_registry() + return RegistryContext( + registry_id=registry.registry_id, + registry_version=registry.registry_version, + registry_hash=registry_hash(), + ) + + def _observed_state( asset: CryptographicAsset, classification: AlgorithmClassification, @@ -459,6 +487,9 @@ def _observed_state( primitive=classification.primitive, quantum_status=classification.quantum_status, standard_status=classification.standard_status, + scheme_type=classification.scheme_type, + components=list(classification.components), + validation_component=classification.validation_component, ) @@ -638,11 +669,16 @@ def _assumptions( return assumptions -def _run_id(input_snapshot: InputSnapshot, policy_context: PolicyContext) -> str: +def _run_id( + input_snapshot: InputSnapshot, + policy_context: PolicyContext, + registry_context: RegistryContext, +) -> str: return "run:" + _hash_object( { "source_hash": input_snapshot.source_hash, "policy_pack_hash": policy_context.policy_pack_hash, + "registry_hash": registry_context.registry_hash, "pdr_version": PDR_VERSION, } ).split(":", 1)[1][:16] @@ -669,10 +705,6 @@ def _hash_bytes(value: bytes) -> str: def _hash_object(value: Any) -> str: - canonical = json.dumps( - value, - sort_keys=True, - separators=(",", ":"), - ensure_ascii=False, - ) - return "sha256:" + hashlib.sha256(canonical.encode("utf-8")).hexdigest() + """Hash JSON data under RFC 8785, the PDR 0.3 canonical form.""" + + return canonical_sha256(value) diff --git a/qstriage/pdr_verify.py b/qstriage/pdr_verify.py index 965c1f9..f3867de 100644 --- a/qstriage/pdr_verify.py +++ b/qstriage/pdr_verify.py @@ -4,11 +4,17 @@ document and reports each check. It reads the file once within a size limit, rejects duplicate JSON keys and non-finite numbers, and never writes. -Each PDR contract version is verified under its own rules. PDR 0.2 hashes the -output of Python ``json.dumps(sort_keys=True, separators=(",", ":"), -ensure_ascii=False)`` encoded as UTF-8. That rule is frozen here so that 0.2 -documents stay verifiable after later contract versions change the -serialization. +Each PDR contract version is verified under its own rules: + +- PDR 0.2 hashes the output of Python ``json.dumps(sort_keys=True, + separators=(",", ":"), ensure_ascii=False)`` encoded as UTF-8. That rule is + frozen here so that 0.2 documents stay verifiable. +- PDR 0.3 hashes the RFC 8785 canonical form, adds ``registry_context``, and + derives ``run_id`` from the source, policy pack, and registry hashes. + +Verification proves that a document is internally consistent. It does not +prove which registry or policy content produced it; the recorded hashes +identify that content. """ from __future__ import annotations @@ -18,12 +24,13 @@ import hashlib import json from pathlib import Path -from typing import Any +from typing import Any, Callable +from qstriage.canonical_json import CanonicalizationError, canonical_sha256 from qstriage.limits import MAX_PDR_FILE_BYTES, ResourceLimitError, read_text_limited -SUPPORTED_PDR_VERSIONS = frozenset({"0.2"}) +SUPPORTED_PDR_VERSIONS = frozenset({"0.2", "0.3"}) class PDRVerificationInputError(ValueError): @@ -98,7 +105,9 @@ def verify_pdr_document(document: Any) -> VerificationResult: raise PDRVerificationInputError( f"Unsupported PDR version for verification: {version!r}" ) - return _verify_v0_2(document) + if version == "0.2": + return _verify_v0_2(document) + return _verify_v0_3(document) def pdr_v0_2_hash(value: Any) -> str: @@ -111,22 +120,71 @@ def pdr_v0_2_hash(value: Any) -> str: return "sha256:" + hashlib.sha256(canonical.encode("utf-8")).hexdigest() +def pdr_v0_3_hash(value: Any) -> str: + try: + return canonical_sha256(value) + except CanonicalizationError as error: + raise PDRVerificationInputError( + f"PDR content cannot be canonicalized under RFC 8785: {error}" + ) from error + + def _verify_v0_2(document: dict[str, Any]) -> VerificationResult: + run_id_input = { + "source_hash": document["input_snapshot"].get("source_hash"), + "policy_pack_hash": document["policy_context"].get("policy_pack_hash"), + "pdr_version": document["pdr_version"], + } + return _verify_document( + document, + hash_value=pdr_v0_2_hash, + run_id_input=run_id_input, + shared_keys=("pdr_version", "run_id", "input_snapshot", "policy_context"), + ) + + +def _verify_v0_3(document: dict[str, Any]) -> VerificationResult: + registry_context = document.get("registry_context") + if not isinstance(registry_context, dict): + raise PDRVerificationInputError( + "PDR document field 'registry_context' is missing or has the wrong type." + ) + run_id_input = { + "source_hash": document["input_snapshot"].get("source_hash"), + "policy_pack_hash": document["policy_context"].get("policy_pack_hash"), + "registry_hash": registry_context.get("registry_hash"), + "pdr_version": document["pdr_version"], + } + return _verify_document( + document, + hash_value=pdr_v0_3_hash, + run_id_input=run_id_input, + shared_keys=( + "pdr_version", + "run_id", + "input_snapshot", + "policy_context", + "registry_context", + ), + ) + + +def _verify_document( + document: dict[str, Any], + *, + hash_value: Callable[[Any], str], + run_id_input: dict[str, Any], + shared_keys: tuple[str, ...], +) -> VerificationResult: checks: list[VerificationCheck] = [] neutral = copy.deepcopy(document) neutral["document_hash"] = None checks.append( - _compare("document_hash", "document", document["document_hash"], pdr_v0_2_hash(neutral)) + _compare("document_hash", "document", document["document_hash"], hash_value(neutral)) ) - expected_run_id = "run:" + pdr_v0_2_hash( - { - "source_hash": document["input_snapshot"].get("source_hash"), - "policy_pack_hash": document["policy_context"].get("policy_pack_hash"), - "pdr_version": document["pdr_version"], - } - ).split(":", 1)[1][:16] + expected_run_id = "run:" + hash_value(run_id_input).split(":", 1)[1][:16] checks.append(_compare("run_id", "document", document["run_id"], expected_run_id)) for index, record in enumerate(document["records"]): @@ -143,9 +201,9 @@ def _verify_v0_2(document: dict[str, Any]) -> VerificationResult: neutral_record = copy.deepcopy(record) neutral_record["record_integrity"]["record_hash"] = None checks.append( - _compare("record_hash", subject, stated, pdr_v0_2_hash(neutral_record)) + _compare("record_hash", subject, stated, hash_value(neutral_record)) ) - for key in ("pdr_version", "run_id", "input_snapshot", "policy_context"): + for key in shared_keys: checks.append( VerificationCheck( f"record_{key}_matches_document", diff --git a/qstriage/standards.py b/qstriage/standards.py index 268ae2a..2ae0856 100644 --- a/qstriage/standards.py +++ b/qstriage/standards.py @@ -84,6 +84,9 @@ class AlgorithmClassification: rationale: str source_ids: tuple[str, ...] identifier_resolution: str = IDENTIFIER_UNRECOGNIZED + scheme_type: str = "single" + components: tuple[str, ...] = () + validation_component: str | None = None def classify_algorithm(algorithm: str | None) -> AlgorithmClassification: @@ -148,6 +151,9 @@ def _classification_from_entry(entry_id: str, original: str) -> AlgorithmClassif rationale=entry.rationale, source_ids=entry.source_ids, identifier_resolution=entry.identifier_resolution, + scheme_type=entry.scheme_type.value, + components=entry.components, + validation_component=entry.validation_component, ) diff --git a/tests/fixtures/pdr_v0_2_sample.json b/tests/fixtures/pdr_v0_2_sample.json new file mode 100644 index 0000000..aacc9a3 --- /dev/null +++ b/tests/fixtures/pdr_v0_2_sample.json @@ -0,0 +1,970 @@ +{ + "pdr_version": "0.2", + "run_id": "run:14dc25d0853de305", + "input_snapshot": { + "source_type": "qstriage_inventory", + "source_version": null, + "source_path": "sample_inventory.yaml", + "source_hash": "sha256:b4c431633d0d7c3fe418f5cb639fe1405a91deccf19c5ab1f25d7a6ce4cba9c6" + }, + "policy_context": { + "policy_pack_id": "nist-pqc-basic", + "policy_pack_version": "0.2", + "policy_pack_hash": "sha256:2d3dae46043cf68c63c05ab1ebd4b7d2dd5838323c09cb4211a797ed2a296b91", + "standards_applied": [ + "NIST-FIPS-203", + "NIST-FIPS-204", + "NIST-FIPS-205", + "NIST-SP-800-131A-REV3-IPD", + "NIST-IR-8547-IPD", + "NIST-SP-800-227", + "NIST-CSWP-39-UPDATE-1", + "CISA-QUANTUM-READINESS", + "CISA-PQC-DISCOVERY-INVENTORY", + "QSTRIAGE-SAFETY-POLICY" + ] + }, + "records": [ + { + "pdr_version": "0.2", + "record_id": "pdr:public-api-gateway", + "run_id": "run:14dc25d0853de305", + "lineage_id": "lineage:b4c431633d0d7c3f", + "sequence_number": 1, + "engine": { + "name": "QSTriage", + "version": "1.3.0" + }, + "input_snapshot": { + "source_type": "qstriage_inventory", + "source_version": null, + "source_path": "sample_inventory.yaml", + "source_hash": "sha256:b4c431633d0d7c3fe418f5cb639fe1405a91deccf19c5ab1f25d7a6ce4cba9c6" + }, + "policy_context": { + "policy_pack_id": "nist-pqc-basic", + "policy_pack_version": "0.2", + "policy_pack_hash": "sha256:2d3dae46043cf68c63c05ab1ebd4b7d2dd5838323c09cb4211a797ed2a296b91", + "standards_applied": [ + "NIST-FIPS-203", + "NIST-FIPS-204", + "NIST-FIPS-205", + "NIST-SP-800-131A-REV3-IPD", + "NIST-IR-8547-IPD", + "NIST-SP-800-227", + "NIST-CSWP-39-UPDATE-1", + "CISA-QUANTUM-READINESS", + "CISA-PQC-DISCOVERY-INVENTORY", + "QSTRIAGE-SAFETY-POLICY" + ] + }, + "policy_evaluation": { + "policy_pack_id": "nist-pqc-basic", + "policy_pack_version": "0.2", + "policy_pack_hash": "sha256:2d3dae46043cf68c63c05ab1ebd4b7d2dd5838323c09cb4211a797ed2a296b91", + "applied_rule_ids": [ + "quantum_vulnerable_public_key_requires_pqc_migration_review", + "long_retention_sensitive_data_raises_priority", + "public_or_partner_exposed_quantum_vulnerable_crypto_raises_priority" + ], + "standards_applied": [ + "NIST-IR-8547-IPD", + "NIST-SP-800-131A-REV3-IPD", + "QSTRIAGE-SAFETY-POLICY", + "CISA-QUANTUM-READINESS" + ], + "thresholds_applied": [ + "long_retention_years" + ], + "findings": [ + { + "rule_id": "quantum_vulnerable_public_key_requires_pqc_migration_review", + "severity": "high", + "message": "Quantum-vulnerable public-key crypto requires PQC migration review", + "effects": [ + "raises_priority", + "requires_human_review", + "adds_policy_context" + ], + "asset_id": "public-api-gateway", + "field_path": null, + "rationale": "RSA, finite-field DH, and ECC require migration review in a PQC transition context.", + "recommendation": "Review the asset for hybrid or PQC migration planning.", + "standards_applied": [ + "NIST-IR-8547-IPD", + "NIST-SP-800-131A-REV3-IPD", + "QSTRIAGE-SAFETY-POLICY" + ] + }, + { + "rule_id": "long_retention_sensitive_data_raises_priority", + "severity": "high", + "message": "Long-retention sensitive data raises migration priority", + "effects": [ + "raises_priority", + "requires_human_review", + "adds_policy_context" + ], + "asset_id": "public-api-gateway", + "field_path": null, + "rationale": "Long-lived sensitive data increases harvest-now-decrypt-later migration urgency.", + "recommendation": "Prioritize migration review for long-retention sensitive data.", + "standards_applied": [ + "NIST-IR-8547-IPD", + "QSTRIAGE-SAFETY-POLICY" + ] + }, + { + "rule_id": "public_or_partner_exposed_quantum_vulnerable_crypto_raises_priority", + "severity": "high", + "message": "Public or partner exposure raises migration priority", + "effects": [ + "raises_priority", + "requires_human_review" + ], + "asset_id": "public-api-gateway", + "field_path": null, + "rationale": "Externally exposed cryptographic assets carry higher migration and coordination risk.", + "recommendation": "Review exposed assets for migration sequencing and stakeholder coordination.", + "standards_applied": [ + "NIST-IR-8547-IPD", + "CISA-QUANTUM-READINESS", + "QSTRIAGE-SAFETY-POLICY" + ] + } + ] + }, + "observed_state": { + "asset_id": "public-api-gateway", + "asset_name": "Public API Gateway", + "environment": "production", + "asset_type": "web_gateway", + "protocol": "TLS1.3", + "algorithm": "ECDHE_RSA", + "key_size_bits": 2048, + "algorithm_family": "classical_public_key_composite", + "primitive": "key_establishment_and_signature", + "quantum_status": "quantum_vulnerable", + "standard_status": "classical_public_key" + }, + "evidence_quality": { + "score": 1.0, + "missing_evidence": [], + "limitations": [] + }, + "evidence_review": { + "review_version": "0.1", + "asset_id": "public-api-gateway", + "evidence_score": 1.0, + "confidence_cap": 1.0, + "decision_grade": "decision_grade", + "human_review_required": false, + "findings": [], + "blocking_finding_codes": [], + "recommended_next_actions": [] + }, + "decision_confidence": { + "score": 0.8, + "reason": "Decision confidence is based on available inventory, scoring, and standards context." + }, + "mission_context": { + "business_function": null, + "data_class": "customer_pii", + "retention_years": 10, + "exposure": "public_internet", + "mission_impact_if_wrong": "high", + "mission_impact_if_delayed": "critical" + }, + "tradeoffs": [ + { + "type": "migration_complexity", + "description": "Migration effort is medium; production rollout should be staged if effort is high." + }, + { + "type": "cryptographic_risk", + "description": "Classical public-key cryptography creates harvest-now-decrypt-later or future forgery exposure depending on use." + }, + { + "type": "operational_risk", + "description": "Hybrid or PQC handshake changes may affect interoperability, packet size, middleboxes, or constrained paths." + } + ], + "target_state_suggestion": [ + { + "option": "hybrid_key_establishment_with_ml_kem_768", + "standards": [ + "NIST-FIPS-203" + ], + "rationale": "Use ML-KEM as the PQC target for key establishment, with hybrid rollout where compatibility risk exists.", + "operational_risk": "medium", + "requires_human_review": true + }, + { + "option": "signature_migration_to_ml_dsa_profile", + "standards": [ + "NIST-FIPS-204" + ], + "rationale": "Use ML-DSA as the primary standardized PQC signature target where signature semantics are confirmed.", + "operational_risk": "medium", + "requires_human_review": true + } + ], + "decision": { + "risk_attention_score": 96.0, + "risk_attention_band": "critical", + "execution_state": "justified", + "action_type": "migration_planning", + "verification_priority": "none", + "verification_requirements": [], + "confidence_score": 0.8, + "human_review_required": true, + "reason_codes": [ + "classification:quantum_vulnerable", + "policy:long_retention_sensitive_data_raises_priority", + "policy:public_or_partner_exposed_quantum_vulnerable_crypto_raises_priority", + "policy:quantum_vulnerable_public_key_requires_pqc_migration_review", + "risk:critical_attention" + ] + }, + "assumptions_made": [ + "No material evidence assumptions were added by the current PDR rules." + ], + "record_integrity": { + "previous_record_hash": null, + "record_hash": "sha256:933c9b0aa56b78b45509283138c92c46c73971a9dc99a1b2d4442980a3fc0d72" + } + }, + { + "pdr_version": "0.2", + "record_id": "pdr:auth-service", + "run_id": "run:14dc25d0853de305", + "lineage_id": "lineage:b4c431633d0d7c3f", + "sequence_number": 2, + "engine": { + "name": "QSTriage", + "version": "1.3.0" + }, + "input_snapshot": { + "source_type": "qstriage_inventory", + "source_version": null, + "source_path": "sample_inventory.yaml", + "source_hash": "sha256:b4c431633d0d7c3fe418f5cb639fe1405a91deccf19c5ab1f25d7a6ce4cba9c6" + }, + "policy_context": { + "policy_pack_id": "nist-pqc-basic", + "policy_pack_version": "0.2", + "policy_pack_hash": "sha256:2d3dae46043cf68c63c05ab1ebd4b7d2dd5838323c09cb4211a797ed2a296b91", + "standards_applied": [ + "NIST-FIPS-203", + "NIST-FIPS-204", + "NIST-FIPS-205", + "NIST-SP-800-131A-REV3-IPD", + "NIST-IR-8547-IPD", + "NIST-SP-800-227", + "NIST-CSWP-39-UPDATE-1", + "CISA-QUANTUM-READINESS", + "CISA-PQC-DISCOVERY-INVENTORY", + "QSTRIAGE-SAFETY-POLICY" + ] + }, + "policy_evaluation": { + "policy_pack_id": "nist-pqc-basic", + "policy_pack_version": "0.2", + "policy_pack_hash": "sha256:2d3dae46043cf68c63c05ab1ebd4b7d2dd5838323c09cb4211a797ed2a296b91", + "applied_rule_ids": [ + "quantum_vulnerable_public_key_requires_pqc_migration_review" + ], + "standards_applied": [ + "NIST-IR-8547-IPD", + "NIST-SP-800-131A-REV3-IPD", + "QSTRIAGE-SAFETY-POLICY" + ], + "thresholds_applied": [], + "findings": [ + { + "rule_id": "quantum_vulnerable_public_key_requires_pqc_migration_review", + "severity": "high", + "message": "Quantum-vulnerable public-key crypto requires PQC migration review", + "effects": [ + "raises_priority", + "requires_human_review", + "adds_policy_context" + ], + "asset_id": "auth-service", + "field_path": null, + "rationale": "RSA, finite-field DH, and ECC require migration review in a PQC transition context.", + "recommendation": "Review the asset for hybrid or PQC migration planning.", + "standards_applied": [ + "NIST-IR-8547-IPD", + "NIST-SP-800-131A-REV3-IPD", + "QSTRIAGE-SAFETY-POLICY" + ] + } + ] + }, + "observed_state": { + "asset_id": "auth-service", + "asset_name": "Authentication Service", + "environment": "production", + "asset_type": "identity_service", + "protocol": "HTTPS/TLS1.3", + "algorithm": "ECDSA_P256", + "key_size_bits": 256, + "algorithm_family": "ECC", + "primitive": "key_establishment_or_signature", + "quantum_status": "quantum_vulnerable", + "standard_status": "classical_public_key" + }, + "evidence_quality": { + "score": 1.0, + "missing_evidence": [], + "limitations": [] + }, + "evidence_review": { + "review_version": "0.1", + "asset_id": "auth-service", + "evidence_score": 1.0, + "confidence_cap": 1.0, + "decision_grade": "decision_grade", + "human_review_required": false, + "findings": [], + "blocking_finding_codes": [], + "recommended_next_actions": [] + }, + "decision_confidence": { + "score": 0.8, + "reason": "Decision confidence is based on available inventory, scoring, and standards context." + }, + "mission_context": { + "business_function": null, + "data_class": "identity_tokens", + "retention_years": 5, + "exposure": "internal", + "mission_impact_if_wrong": "critical", + "mission_impact_if_delayed": "high" + }, + "tradeoffs": [ + { + "type": "migration_complexity", + "description": "Migration effort is medium; production rollout should be staged if effort is high." + }, + { + "type": "cryptographic_risk", + "description": "Classical public-key cryptography creates harvest-now-decrypt-later or future forgery exposure depending on use." + }, + { + "type": "operational_risk", + "description": "Hybrid or PQC handshake changes may affect interoperability, packet size, middleboxes, or constrained paths." + } + ], + "target_state_suggestion": [ + { + "option": "hybrid_key_establishment_with_ml_kem_768", + "standards": [ + "NIST-FIPS-203" + ], + "rationale": "Use ML-KEM as the PQC target for key establishment, with hybrid rollout where compatibility risk exists.", + "operational_risk": "medium", + "requires_human_review": true + }, + { + "option": "signature_migration_to_ml_dsa_profile", + "standards": [ + "NIST-FIPS-204" + ], + "rationale": "Use ML-DSA as the primary standardized PQC signature target where signature semantics are confirmed.", + "operational_risk": "medium", + "requires_human_review": true + } + ], + "decision": { + "risk_attention_score": 78.0, + "risk_attention_band": "high", + "execution_state": "justified", + "action_type": "migration_planning", + "verification_priority": "none", + "verification_requirements": [], + "confidence_score": 0.8, + "human_review_required": true, + "reason_codes": [ + "classification:quantum_vulnerable", + "policy:quantum_vulnerable_public_key_requires_pqc_migration_review", + "risk:high_attention" + ] + }, + "assumptions_made": [ + "No material evidence assumptions were added by the current PDR rules." + ], + "record_integrity": { + "previous_record_hash": null, + "record_hash": "sha256:f448789ac25715c53f646ede15eac794da52610b1619358c7b969fad7b1438dc" + } + }, + { + "pdr_version": "0.2", + "record_id": "pdr:customer-db", + "run_id": "run:14dc25d0853de305", + "lineage_id": "lineage:b4c431633d0d7c3f", + "sequence_number": 3, + "engine": { + "name": "QSTriage", + "version": "1.3.0" + }, + "input_snapshot": { + "source_type": "qstriage_inventory", + "source_version": null, + "source_path": "sample_inventory.yaml", + "source_hash": "sha256:b4c431633d0d7c3fe418f5cb639fe1405a91deccf19c5ab1f25d7a6ce4cba9c6" + }, + "policy_context": { + "policy_pack_id": "nist-pqc-basic", + "policy_pack_version": "0.2", + "policy_pack_hash": "sha256:2d3dae46043cf68c63c05ab1ebd4b7d2dd5838323c09cb4211a797ed2a296b91", + "standards_applied": [ + "NIST-FIPS-203", + "NIST-FIPS-204", + "NIST-FIPS-205", + "NIST-SP-800-131A-REV3-IPD", + "NIST-IR-8547-IPD", + "NIST-SP-800-227", + "NIST-CSWP-39-UPDATE-1", + "CISA-QUANTUM-READINESS", + "CISA-PQC-DISCOVERY-INVENTORY", + "QSTRIAGE-SAFETY-POLICY" + ] + }, + "policy_evaluation": { + "policy_pack_id": "nist-pqc-basic", + "policy_pack_version": "0.2", + "policy_pack_hash": "sha256:2d3dae46043cf68c63c05ab1ebd4b7d2dd5838323c09cb4211a797ed2a296b91", + "applied_rule_ids": [ + "quantum_vulnerable_public_key_requires_pqc_migration_review", + "long_retention_sensitive_data_raises_priority" + ], + "standards_applied": [ + "NIST-IR-8547-IPD", + "NIST-SP-800-131A-REV3-IPD", + "QSTRIAGE-SAFETY-POLICY" + ], + "thresholds_applied": [ + "long_retention_years" + ], + "findings": [ + { + "rule_id": "quantum_vulnerable_public_key_requires_pqc_migration_review", + "severity": "high", + "message": "Quantum-vulnerable public-key crypto requires PQC migration review", + "effects": [ + "raises_priority", + "requires_human_review", + "adds_policy_context" + ], + "asset_id": "customer-db", + "field_path": null, + "rationale": "RSA, finite-field DH, and ECC require migration review in a PQC transition context.", + "recommendation": "Review the asset for hybrid or PQC migration planning.", + "standards_applied": [ + "NIST-IR-8547-IPD", + "NIST-SP-800-131A-REV3-IPD", + "QSTRIAGE-SAFETY-POLICY" + ] + }, + { + "rule_id": "long_retention_sensitive_data_raises_priority", + "severity": "high", + "message": "Long-retention sensitive data raises migration priority", + "effects": [ + "raises_priority", + "requires_human_review", + "adds_policy_context" + ], + "asset_id": "customer-db", + "field_path": null, + "rationale": "Long-lived sensitive data increases harvest-now-decrypt-later migration urgency.", + "recommendation": "Prioritize migration review for long-retention sensitive data.", + "standards_applied": [ + "NIST-IR-8547-IPD", + "QSTRIAGE-SAFETY-POLICY" + ] + } + ] + }, + "observed_state": { + "asset_id": "customer-db", + "asset_name": "Customer Database", + "environment": "production", + "asset_type": "database", + "protocol": "mTLS", + "algorithm": "RSA", + "key_size_bits": 2048, + "algorithm_family": "RSA", + "primitive": "public_key_encryption_or_signature", + "quantum_status": "quantum_vulnerable", + "standard_status": "classical_public_key" + }, + "evidence_quality": { + "score": 1.0, + "missing_evidence": [], + "limitations": [] + }, + "evidence_review": { + "review_version": "0.1", + "asset_id": "customer-db", + "evidence_score": 1.0, + "confidence_cap": 1.0, + "decision_grade": "decision_grade", + "human_review_required": false, + "findings": [], + "blocking_finding_codes": [], + "recommended_next_actions": [] + }, + "decision_confidence": { + "score": 0.8, + "reason": "Decision confidence is based on available inventory, scoring, and standards context." + }, + "mission_context": { + "business_function": null, + "data_class": "customer_pii", + "retention_years": 15, + "exposure": "internal", + "mission_impact_if_wrong": "critical", + "mission_impact_if_delayed": "critical" + }, + "tradeoffs": [ + { + "type": "migration_complexity", + "description": "Migration effort is high; production rollout should be staged if effort is high." + }, + { + "type": "cryptographic_risk", + "description": "Classical public-key cryptography creates harvest-now-decrypt-later or future forgery exposure depending on use." + }, + { + "type": "operational_risk", + "description": "Hybrid or PQC handshake changes may affect interoperability, packet size, middleboxes, or constrained paths." + } + ], + "target_state_suggestion": [ + { + "option": "hybrid_key_establishment_with_ml_kem_768", + "standards": [ + "NIST-FIPS-203" + ], + "rationale": "Use ML-KEM as the PQC target for key establishment, with hybrid rollout where compatibility risk exists.", + "operational_risk": "medium", + "requires_human_review": true + }, + { + "option": "signature_migration_to_ml_dsa_profile", + "standards": [ + "NIST-FIPS-204" + ], + "rationale": "Use ML-DSA as the primary standardized PQC signature target where signature semantics are confirmed.", + "operational_risk": "medium", + "requires_human_review": true + } + ], + "decision": { + "risk_attention_score": 88.0, + "risk_attention_band": "critical", + "execution_state": "justified", + "action_type": "simulate_before_migration", + "verification_priority": "none", + "verification_requirements": [], + "confidence_score": 0.8, + "human_review_required": true, + "reason_codes": [ + "classification:quantum_vulnerable", + "policy:long_retention_sensitive_data_raises_priority", + "policy:quantum_vulnerable_public_key_requires_pqc_migration_review", + "risk:critical_attention", + "migration:simulation_required" + ] + }, + "assumptions_made": [ + "No material evidence assumptions were added by the current PDR rules." + ], + "record_integrity": { + "previous_record_hash": null, + "record_hash": "sha256:a13825df124dd1b4480894384e1cb932d52ee60ee65451d1ede832ecbaa7f2c3" + } + }, + { + "pdr_version": "0.2", + "record_id": "pdr:payments-api", + "run_id": "run:14dc25d0853de305", + "lineage_id": "lineage:b4c431633d0d7c3f", + "sequence_number": 4, + "engine": { + "name": "QSTriage", + "version": "1.3.0" + }, + "input_snapshot": { + "source_type": "qstriage_inventory", + "source_version": null, + "source_path": "sample_inventory.yaml", + "source_hash": "sha256:b4c431633d0d7c3fe418f5cb639fe1405a91deccf19c5ab1f25d7a6ce4cba9c6" + }, + "policy_context": { + "policy_pack_id": "nist-pqc-basic", + "policy_pack_version": "0.2", + "policy_pack_hash": "sha256:2d3dae46043cf68c63c05ab1ebd4b7d2dd5838323c09cb4211a797ed2a296b91", + "standards_applied": [ + "NIST-FIPS-203", + "NIST-FIPS-204", + "NIST-FIPS-205", + "NIST-SP-800-131A-REV3-IPD", + "NIST-IR-8547-IPD", + "NIST-SP-800-227", + "NIST-CSWP-39-UPDATE-1", + "CISA-QUANTUM-READINESS", + "CISA-PQC-DISCOVERY-INVENTORY", + "QSTRIAGE-SAFETY-POLICY" + ] + }, + "policy_evaluation": { + "policy_pack_id": "nist-pqc-basic", + "policy_pack_version": "0.2", + "policy_pack_hash": "sha256:2d3dae46043cf68c63c05ab1ebd4b7d2dd5838323c09cb4211a797ed2a296b91", + "applied_rule_ids": [ + "quantum_vulnerable_public_key_requires_pqc_migration_review", + "long_retention_sensitive_data_raises_priority", + "public_or_partner_exposed_quantum_vulnerable_crypto_raises_priority" + ], + "standards_applied": [ + "NIST-IR-8547-IPD", + "NIST-SP-800-131A-REV3-IPD", + "QSTRIAGE-SAFETY-POLICY", + "CISA-QUANTUM-READINESS" + ], + "thresholds_applied": [ + "long_retention_years" + ], + "findings": [ + { + "rule_id": "quantum_vulnerable_public_key_requires_pqc_migration_review", + "severity": "high", + "message": "Quantum-vulnerable public-key crypto requires PQC migration review", + "effects": [ + "raises_priority", + "requires_human_review", + "adds_policy_context" + ], + "asset_id": "payments-api", + "field_path": null, + "rationale": "RSA, finite-field DH, and ECC require migration review in a PQC transition context.", + "recommendation": "Review the asset for hybrid or PQC migration planning.", + "standards_applied": [ + "NIST-IR-8547-IPD", + "NIST-SP-800-131A-REV3-IPD", + "QSTRIAGE-SAFETY-POLICY" + ] + }, + { + "rule_id": "long_retention_sensitive_data_raises_priority", + "severity": "high", + "message": "Long-retention sensitive data raises migration priority", + "effects": [ + "raises_priority", + "requires_human_review", + "adds_policy_context" + ], + "asset_id": "payments-api", + "field_path": null, + "rationale": "Long-lived sensitive data increases harvest-now-decrypt-later migration urgency.", + "recommendation": "Prioritize migration review for long-retention sensitive data.", + "standards_applied": [ + "NIST-IR-8547-IPD", + "QSTRIAGE-SAFETY-POLICY" + ] + }, + { + "rule_id": "public_or_partner_exposed_quantum_vulnerable_crypto_raises_priority", + "severity": "high", + "message": "Public or partner exposure raises migration priority", + "effects": [ + "raises_priority", + "requires_human_review" + ], + "asset_id": "payments-api", + "field_path": null, + "rationale": "Externally exposed cryptographic assets carry higher migration and coordination risk.", + "recommendation": "Review exposed assets for migration sequencing and stakeholder coordination.", + "standards_applied": [ + "NIST-IR-8547-IPD", + "CISA-QUANTUM-READINESS", + "QSTRIAGE-SAFETY-POLICY" + ] + } + ] + }, + "observed_state": { + "asset_id": "payments-api", + "asset_name": "Payments API", + "environment": "production", + "asset_type": "api_service", + "protocol": "TLS1.3", + "algorithm": "ECDHE_RSA", + "key_size_bits": 2048, + "algorithm_family": "classical_public_key_composite", + "primitive": "key_establishment_and_signature", + "quantum_status": "quantum_vulnerable", + "standard_status": "classical_public_key" + }, + "evidence_quality": { + "score": 1.0, + "missing_evidence": [], + "limitations": [] + }, + "evidence_review": { + "review_version": "0.1", + "asset_id": "payments-api", + "evidence_score": 1.0, + "confidence_cap": 1.0, + "decision_grade": "decision_grade", + "human_review_required": false, + "findings": [], + "blocking_finding_codes": [], + "recommended_next_actions": [] + }, + "decision_confidence": { + "score": 0.8, + "reason": "Decision confidence is based on available inventory, scoring, and standards context." + }, + "mission_context": { + "business_function": null, + "data_class": "payment_metadata", + "retention_years": 7, + "exposure": "partner_network", + "mission_impact_if_wrong": "high", + "mission_impact_if_delayed": "high" + }, + "tradeoffs": [ + { + "type": "migration_complexity", + "description": "Migration effort is high; production rollout should be staged if effort is high." + }, + { + "type": "cryptographic_risk", + "description": "Classical public-key cryptography creates harvest-now-decrypt-later or future forgery exposure depending on use." + }, + { + "type": "operational_risk", + "description": "Hybrid or PQC handshake changes may affect interoperability, packet size, middleboxes, or constrained paths." + } + ], + "target_state_suggestion": [ + { + "option": "hybrid_key_establishment_with_ml_kem_768", + "standards": [ + "NIST-FIPS-203" + ], + "rationale": "Use ML-KEM as the PQC target for key establishment, with hybrid rollout where compatibility risk exists.", + "operational_risk": "medium", + "requires_human_review": true + }, + { + "option": "signature_migration_to_ml_dsa_profile", + "standards": [ + "NIST-FIPS-204" + ], + "rationale": "Use ML-DSA as the primary standardized PQC signature target where signature semantics are confirmed.", + "operational_risk": "medium", + "requires_human_review": true + } + ], + "decision": { + "risk_attention_score": 81.0, + "risk_attention_band": "high", + "execution_state": "justified", + "action_type": "simulate_before_migration", + "verification_priority": "none", + "verification_requirements": [], + "confidence_score": 0.8, + "human_review_required": true, + "reason_codes": [ + "classification:quantum_vulnerable", + "policy:long_retention_sensitive_data_raises_priority", + "policy:public_or_partner_exposed_quantum_vulnerable_crypto_raises_priority", + "policy:quantum_vulnerable_public_key_requires_pqc_migration_review", + "risk:high_attention", + "migration:simulation_required" + ] + }, + "assumptions_made": [ + "No material evidence assumptions were added by the current PDR rules." + ], + "record_integrity": { + "previous_record_hash": null, + "record_hash": "sha256:8866f111a72f89b1841a22eaf1459511127e60105075f4daaec43420e1517920" + } + }, + { + "pdr_version": "0.2", + "record_id": "pdr:ot-gateway", + "run_id": "run:14dc25d0853de305", + "lineage_id": "lineage:b4c431633d0d7c3f", + "sequence_number": 5, + "engine": { + "name": "QSTriage", + "version": "1.3.0" + }, + "input_snapshot": { + "source_type": "qstriage_inventory", + "source_version": null, + "source_path": "sample_inventory.yaml", + "source_hash": "sha256:b4c431633d0d7c3fe418f5cb639fe1405a91deccf19c5ab1f25d7a6ce4cba9c6" + }, + "policy_context": { + "policy_pack_id": "nist-pqc-basic", + "policy_pack_version": "0.2", + "policy_pack_hash": "sha256:2d3dae46043cf68c63c05ab1ebd4b7d2dd5838323c09cb4211a797ed2a296b91", + "standards_applied": [ + "NIST-FIPS-203", + "NIST-FIPS-204", + "NIST-FIPS-205", + "NIST-SP-800-131A-REV3-IPD", + "NIST-IR-8547-IPD", + "NIST-SP-800-227", + "NIST-CSWP-39-UPDATE-1", + "CISA-QUANTUM-READINESS", + "CISA-PQC-DISCOVERY-INVENTORY", + "QSTRIAGE-SAFETY-POLICY" + ] + }, + "policy_evaluation": { + "policy_pack_id": "nist-pqc-basic", + "policy_pack_version": "0.2", + "policy_pack_hash": "sha256:2d3dae46043cf68c63c05ab1ebd4b7d2dd5838323c09cb4211a797ed2a296b91", + "applied_rule_ids": [ + "quantum_vulnerable_public_key_requires_pqc_migration_review" + ], + "standards_applied": [ + "NIST-IR-8547-IPD", + "NIST-SP-800-131A-REV3-IPD", + "QSTRIAGE-SAFETY-POLICY" + ], + "thresholds_applied": [], + "findings": [ + { + "rule_id": "quantum_vulnerable_public_key_requires_pqc_migration_review", + "severity": "high", + "message": "Quantum-vulnerable public-key crypto requires PQC migration review", + "effects": [ + "raises_priority", + "requires_human_review", + "adds_policy_context" + ], + "asset_id": "ot-gateway", + "field_path": null, + "rationale": "RSA, finite-field DH, and ECC require migration review in a PQC transition context.", + "recommendation": "Review the asset for hybrid or PQC migration planning.", + "standards_applied": [ + "NIST-IR-8547-IPD", + "NIST-SP-800-131A-REV3-IPD", + "QSTRIAGE-SAFETY-POLICY" + ] + } + ] + }, + "observed_state": { + "asset_id": "ot-gateway", + "asset_name": "OT Gateway", + "environment": "staging", + "asset_type": "industrial_gateway", + "protocol": "MQTT/TLS", + "algorithm": "RSA", + "key_size_bits": 2048, + "algorithm_family": "RSA", + "primitive": "public_key_encryption_or_signature", + "quantum_status": "quantum_vulnerable", + "standard_status": "classical_public_key" + }, + "evidence_quality": { + "score": 1.0, + "missing_evidence": [], + "limitations": [] + }, + "evidence_review": { + "review_version": "0.1", + "asset_id": "ot-gateway", + "evidence_score": 1.0, + "confidence_cap": 1.0, + "decision_grade": "decision_grade", + "human_review_required": false, + "findings": [], + "blocking_finding_codes": [], + "recommended_next_actions": [] + }, + "decision_confidence": { + "score": 0.8, + "reason": "Decision confidence is based on available inventory, scoring, and standards context." + }, + "mission_context": { + "business_function": null, + "data_class": "telemetry", + "retention_years": 3, + "exposure": "restricted_network", + "mission_impact_if_wrong": "high", + "mission_impact_if_delayed": "medium" + }, + "tradeoffs": [ + { + "type": "migration_complexity", + "description": "Migration effort is critical; production rollout should be staged if effort is high." + }, + { + "type": "cryptographic_risk", + "description": "Classical public-key cryptography creates harvest-now-decrypt-later or future forgery exposure depending on use." + }, + { + "type": "operational_risk", + "description": "Hybrid or PQC handshake changes may affect interoperability, packet size, middleboxes, or constrained paths." + } + ], + "target_state_suggestion": [ + { + "option": "hybrid_key_establishment_with_ml_kem_768", + "standards": [ + "NIST-FIPS-203" + ], + "rationale": "Use ML-KEM as the PQC target for key establishment, with hybrid rollout where compatibility risk exists.", + "operational_risk": "medium", + "requires_human_review": true + }, + { + "option": "signature_migration_to_ml_dsa_profile", + "standards": [ + "NIST-FIPS-204" + ], + "rationale": "Use ML-DSA as the primary standardized PQC signature target where signature semantics are confirmed.", + "operational_risk": "medium", + "requires_human_review": true + } + ], + "decision": { + "risk_attention_score": 64.0, + "risk_attention_band": "medium", + "execution_state": "justified", + "action_type": "simulate_before_migration", + "verification_priority": "none", + "verification_requirements": [], + "confidence_score": 0.8, + "human_review_required": true, + "reason_codes": [ + "classification:quantum_vulnerable", + "policy:quantum_vulnerable_public_key_requires_pqc_migration_review", + "migration:simulation_required" + ] + }, + "assumptions_made": [ + "No material evidence assumptions were added by the current PDR rules." + ], + "record_integrity": { + "previous_record_hash": null, + "record_hash": "sha256:175e51e6140da1ef5b0b06148f1a5246b8b28991aebe30f15b5ea7103a874553" + } + } + ], + "document_hash": "sha256:291a2086cf24c90d5bd21eb426b86fe3ee4fbafe0543caabfdb082eb7265d65b" +} diff --git a/tests/test_algorithm_registry.py b/tests/test_algorithm_registry.py index 9c92ac8..34fcec9 100644 --- a/tests/test_algorithm_registry.py +++ b/tests/test_algorithm_registry.py @@ -6,7 +6,7 @@ import pytest from pydantic import ValidationError -from qstriage.algorithm_registry import AlgorithmRegistry, load_registry +from qstriage.algorithm_registry import AlgorithmRegistry, load_registry, registry_hash from qstriage.standards import ( ML_DSA_PARAMETER_SETS, ML_KEM_PARAMETER_SETS, @@ -135,3 +135,22 @@ def test_unsupported_schema_version_is_rejected() -> None: with pytest.raises(ValidationError, match="schema version"): AlgorithmRegistry.model_validate(raw) + + +# Each released registry_version identifies exactly one registry content. +# When entry content changes, increase registry_version in +# qstriage/algorithm_registry.json and add the new pair here. +REGISTRY_HASH_BY_VERSION = { + "1": "sha256:44a93da463b6a9a0888a67bc23c0e915aa671508fba5c86d6af9c8590a629546", +} + + +def test_registry_content_change_requires_a_new_registry_version() -> None: + version = load_registry().registry_version + + assert version in REGISTRY_HASH_BY_VERSION, ( + f"registry_version {version!r} has no recorded hash" + ) + assert registry_hash() == REGISTRY_HASH_BY_VERSION[version], ( + "Registry content changed without a new registry_version" + ) diff --git a/tests/test_classification_snapshot.py b/tests/test_classification_snapshot.py index 7442d8b..fc4ec08 100644 --- a/tests/test_classification_snapshot.py +++ b/tests/test_classification_snapshot.py @@ -12,6 +12,10 @@ SNAPSHOT = Path(__file__).parent / "fixtures" / "classification_snapshot.json" CASES = json.loads(SNAPSHOT.read_text(encoding="utf-8"))["cases"] +# Fields added after the snapshot was pinned. They are checked separately so +# that the pinned fixture stays byte-identical. +SCHEME_FIELDS = ("scheme_type", "components", "validation_component") + def test_snapshot_covers_every_classification_outcome() -> None: families = {case["classification"]["algorithm_family"] for case in CASES} @@ -45,5 +49,13 @@ def test_snapshot_covers_every_classification_outcome() -> None: def test_classification_matches_pinned_snapshot(case: dict[str, object]) -> None: actual = dataclasses.asdict(classify_algorithm(case["input"])) actual["source_ids"] = list(actual["source_ids"]) + scheme = {key: actual.pop(key) for key in SCHEME_FIELDS} assert actual == case["classification"] + # The registry holds no hybrid entries yet, so every pinned identifier + # resolves to a single scheme. + assert scheme == { + "scheme_type": "single", + "components": (), + "validation_component": None, + } diff --git a/tests/test_cli_pdr.py b/tests/test_cli_pdr.py index 98a1ad4..905dbe7 100644 --- a/tests/test_cli_pdr.py +++ b/tests/test_cli_pdr.py @@ -29,7 +29,7 @@ def test_pdr_generate_inventory_writes_json_document(tmp_path: Path) -> None: document = json.loads(output.read_text(encoding="utf-8")) - assert document["pdr_version"] == "0.2" + assert document["pdr_version"] == "0.3" assert document["input_snapshot"]["source_type"] == "qstriage_inventory" expected_source_hash = ( "sha256:" diff --git a/tests/test_pdr.py b/tests/test_pdr.py index a888119..f86c677 100644 --- a/tests/test_pdr.py +++ b/tests/test_pdr.py @@ -409,8 +409,8 @@ def test_pdr_projects_canonical_decision_for_score_action_divergence() -> None: payments = record_by_asset["payments-api"] ot_gateway = record_by_asset["ot-gateway"] - assert document.pdr_version == "0.2" - assert payments.pdr_version == "0.2" + assert document.pdr_version == "0.3" + assert payments.pdr_version == "0.3" assert payments.decision.risk_attention_score == 81.0 assert payments.decision.risk_attention_band == "high" assert payments.decision.execution_state.value == "justified" diff --git a/tests/test_pdr_verify.py b/tests/test_pdr_verify.py index 723b79a..f2865ea 100644 --- a/tests/test_pdr_verify.py +++ b/tests/test_pdr_verify.py @@ -6,10 +6,18 @@ import pytest from typer.testing import CliRunner +import qstriage.pdr as pdr_module +from qstriage.algorithm_registry import ( + RegistryLoadError, + _object_without_duplicate_keys, + load_registry, + registry_hash, +) +from qstriage.canonical_json import CanonicalizationError, canonical_sha256 from qstriage.cli import app from qstriage.limits import MAX_PDR_FILE_BYTES from qstriage.models import load_inventory -from qstriage.pdr import _hash_object, generate_pdr_document +from qstriage.pdr import InputSnapshot, _hash_object, generate_pdr_document from qstriage.pdr_verify import ( PDRVerificationInputError, pdr_v0_2_hash, @@ -19,7 +27,12 @@ ) -SAMPLE = Path(__file__).resolve().parents[1] / "examples" / "sample_inventory.yaml" +ROOT = Path(__file__).resolve().parents[1] +SAMPLE = ROOT / "examples" / "sample_inventory.yaml" +REGISTRY_FILE = ROOT / "qstriage" / "algorithm_registry.json" +# Generated from examples/sample_inventory.yaml by QSTriage 1.3.0 plus the +# pdr verify change, before the PDR 0.3 contract. It must keep verifying. +V0_2_FIXTURE = ROOT / "tests" / "fixtures" / "pdr_v0_2_sample.json" def _document() -> dict[str, object]: @@ -35,15 +48,113 @@ def test_generated_document_passes_every_check() -> None: result = verify_pdr_document(document) assert result.passed - assert result.pdr_version == "0.2" + assert result.pdr_version == "0.3" record_checks = [check for check in result.checks if check.check == "record_hash"] assert len(record_checks) == len(document["records"]) + registry_checks = [ + check + for check in result.checks + if check.check == "record_registry_context_matches_document" + ] + assert len(registry_checks) == len(document["records"]) + + +def test_v0_2_document_from_the_previous_generator_still_verifies() -> None: + document = json.loads(V0_2_FIXTURE.read_text(encoding="utf-8")) + result = verify_pdr_document(document) + + assert document["pdr_version"] == "0.2" + assert "registry_context" not in document + assert result.passed + assert len(result.checks) == 2 + 5 * len(document["records"]) + + +def test_v0_2_document_tampering_is_still_detected() -> None: + document = json.loads(V0_2_FIXTURE.read_text(encoding="utf-8")) + record = document["records"][0] + record["decision"]["action_type"] = "retain_monitor" + + failed = _failed(verify_pdr_document(document)) + assert ("record_hash", record["record_id"]) in failed + assert ("document_hash", "document") in failed + + +def test_generator_hashes_under_rfc8785_not_the_v0_2_rule() -> None: + document = _document() + + assert _hash_object(document) == canonical_sha256(document) + # The document contains floats such as 81.0, which the two rules + # serialize differently, so the hashes must differ. + assert _hash_object(document) != pdr_v0_2_hash(document) -def test_frozen_v0_2_hash_matches_the_generator() -> None: + +def test_registry_context_records_the_bundled_registry() -> None: document = _document() + registry = load_registry() + + assert document["registry_context"] == { + "registry_id": registry.registry_id, + "registry_version": registry.registry_version, + "registry_hash": registry_hash(), + } + assert all( + record["registry_context"] == document["registry_context"] + for record in document["records"] + ) + + +def test_registry_hash_is_rfc8785_hash_of_the_registry_file_data() -> None: + data = json.loads(REGISTRY_FILE.read_text(encoding="utf-8")) + + assert registry_hash() == canonical_sha256(data) + - assert pdr_v0_2_hash(document) == _hash_object(document) +def test_different_registry_hash_gives_different_run_id(monkeypatch) -> None: + original = _document() + monkeypatch.setattr(pdr_module, "registry_hash", lambda: "sha256:" + "1" * 64) + changed = _document() + + assert changed["registry_context"]["registry_hash"] == "sha256:" + "1" * 64 + assert changed["run_id"] != original["run_id"] + assert verify_pdr_document(changed).passed + + +def test_altered_registry_context_fails_hash_run_id_and_record_consistency() -> None: + document = _document() + document["registry_context"]["registry_hash"] = "sha256:" + "0" * 64 + + failed = _failed(verify_pdr_document(document)) + + assert ("document_hash", "document") in failed + assert ("run_id", "document") in failed + first = document["records"][0]["record_id"] + assert ("record_registry_context_matches_document", first) in failed + + +def test_v0_3_document_without_registry_context_is_rejected() -> None: + document = _document() + del document["registry_context"] + + with pytest.raises(PDRVerificationInputError, match="registry_context"): + verify_pdr_document(document) + + +def test_observed_state_reports_scheme_fields() -> None: + observed = _document()["records"][0]["observed_state"] + + assert observed["scheme_type"] == "single" + assert observed["components"] == [] + assert observed["validation_component"] is None + + +def test_value_outside_rfc8785_range_stops_generation() -> None: + inventory = load_inventory(SAMPLE) + asset = inventory.assets[0].model_copy(update={"key_size_bits": 2**60}) + inventory = inventory.model_copy(update={"assets": [asset, *inventory.assets[1:]]}) + + with pytest.raises(CanonicalizationError): + generate_pdr_document(inventory) def test_altered_record_field_fails_record_and_document_hash() -> None: @@ -143,7 +254,7 @@ def test_cli_verify_json_output(tmp_path: Path) -> None: assert result.exit_code == 0 payload = json.loads(result.output) assert payload["passed"] is True - assert payload["pdr_version"] == "0.2" + assert payload["pdr_version"] == "0.3" def test_cli_verify_rejects_unknown_format(tmp_path: Path) -> None: @@ -163,3 +274,55 @@ def test_cli_verify_does_not_modify_the_input(tmp_path: Path) -> None: CliRunner().invoke(app, ["pdr", "verify", str(path)]) assert path.read_bytes() == before + + +def test_cli_generate_stops_on_value_outside_rfc8785_range(tmp_path: Path) -> None: + text = SAMPLE.read_text(encoding="utf-8") + source = tmp_path / "inventory.yaml" + source.write_text( + text.replace("key_size_bits: 2048", f"key_size_bits: {2**60}", 1), + encoding="utf-8", + ) + assert str(2**60) in source.read_text(encoding="utf-8") + output = tmp_path / "pdr.json" + + result = CliRunner().invoke( + app, ["pdr", "generate", str(source), "--output", str(output)] + ) + + assert result.exit_code == 1 + assert "PDR generation failed" in result.output + assert not output.exists() + + +def test_registry_reader_rejects_duplicate_keys() -> None: + with pytest.raises(RegistryLoadError, match="duplicate key"): + json.loads( + '{"registry_id":"a","registry_id":"b"}', + object_pairs_hook=_object_without_duplicate_keys, + ) + + +def test_v0_3_changes_only_contract_fields_relative_to_v0_2() -> None: + before = json.loads(V0_2_FIXTURE.read_text(encoding="utf-8")) + # Reuse the snapshot recorded in the fixture. A file-backed snapshot + # hashes the bytes on disk, which differ when a checkout uses CRLF line + # endings; the parsed inventory does not. + after = generate_pdr_document( + load_inventory(SAMPLE), + input_snapshot=InputSnapshot(**before["input_snapshot"]), + ).model_dump(mode="json") + contract_fields = {"pdr_version", "run_id", "registry_context", "engine"} + scheme_fields = {"scheme_type", "components", "validation_component"} + + assert after["input_snapshot"] == before["input_snapshot"] + assert after["policy_context"] == before["policy_context"] + assert len(after["records"]) == len(before["records"]) + for old, new in zip(before["records"], after["records"]): + old_rest = {k: v for k, v in old.items() if k not in contract_fields} + new_rest = {k: v for k, v in new.items() if k not in contract_fields} + del old_rest["record_integrity"], new_rest["record_integrity"] + new_rest["observed_state"] = { + k: v for k, v in new_rest["observed_state"].items() if k not in scheme_fields + } + assert new_rest == old_rest