Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion docs/algorithm-identifier-grammar.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
89 changes: 70 additions & 19 deletions docs/pdr-contract.md
Original file line number Diff line number Diff line change
@@ -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.

Expand All @@ -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 |

Expand Down Expand Up @@ -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

Expand All @@ -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:
Expand All @@ -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 |
Expand All @@ -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

Expand Down Expand Up @@ -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

Expand All @@ -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,
Expand Down
14 changes: 9 additions & 5 deletions docs/standards-and-classification.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
16 changes: 9 additions & 7 deletions docs/usage.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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

Expand Down
40 changes: 39 additions & 1 deletion qstriage/algorithm_registry.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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
Loading
Loading