From eb5c41d74e1f58e42015c6a4c5a9787557d9c2bb Mon Sep 17 00:00:00 2001 From: quantamixsol Date: Mon, 27 Jul 2026 20:57:16 +0200 Subject: [PATCH] CR-010.R1: freeze the proof spec + ship a conformance suite Publish the proof-bundle envelope, the trusted-key manifest, and the verify-result contract as versioned JSON Schemas + prose, plus a conformance corpus any third-party verifier must classify identically. Spec version (1.0) is decoupled from the SDK version. Schemas and corpus ship in the wheel and load via importlib.resources, so an external implementer can build a conformant verifier from the published artifacts alone -- verified by running all 9 cases from an installed wheel at a neutral cwd. Corpus covers all 7 VerifyFailure members plus the exit-2 usage path. Expectations are empirically derived from the reference implementation, and each case asserts the typed failure, the process exit code, and the absent-vs-false check semantics. Additive: no signing or verification logic changed. proof_format_version is published as an opaque, signature-covered string -- it sits inside both the signed preimage and the leaf hash, so forging it fails closed (measured: wrapper-only -> UNTRUSTED_KID, wrapper+record -> TAMPERED_LEAF). Unifying its three divergent in-tree values is deferred to R2 and would be signature-breaking; documented in SPEC 8.2. Sentinel: pass 1 BLOCK (3 blockers + 3 majors). Every finding reproduced before action -- the installed-wheel path bug was real and is fixed; the hatchling-artifacts and unconstrained-version blockers were refuted by measurement. Pass 2 APPROVE 89%, 0 blockers. TS-1..TS-4 screen: 0 hits. 568 tests green across test_pct + test_verify + test_tamper_evidence, verifier isolation guard included. Plan: plan_60634c1a --- graqle/pct/schema/__init__.py | 79 ++- graqle/pct/schema/conformance/__init__.py | 13 + .../schema/conformance/corpus-manifest.json | 147 ++++++ .../conformance/fixtures/keyring_default.json | 11 + .../conformance/fixtures/keyring_expired.json | 13 + .../conformance/fixtures/keyring_revoked.json | 11 + .../conformance/fixtures/keyring_rotated.json | 11 + .../conformance/fixtures/tc001_valid.json | 34 ++ .../conformance/fixtures/tc002_malformed.json | 23 + .../fixtures/tc003_tampered_leaf.json | 34 ++ .../fixtures/tc004_wrong_root.json | 34 ++ .../fixtures/tc007_rekor_mismatch.json | 41 ++ .../conformance/fixtures/tc008_not_json.txt | 1 + .../schema/conformance/generate_fixtures.py | 248 ++++++++++ graqle/pct/schema/proof-spec/v1.0/SPEC.md | 229 +++++++++ .../schema/proof-spec/v1.0/bundle.schema.json | 109 ++++ .../proof-spec/v1.0/keyring.schema.json | 53 ++ .../proof-spec/v1.0/verify-result.schema.json | 43 ++ pyproject.toml | 9 + tests/test_pct/test_proof_spec_conformance.py | 466 ++++++++++++++++++ 20 files changed, 1607 insertions(+), 2 deletions(-) create mode 100644 graqle/pct/schema/conformance/__init__.py create mode 100644 graqle/pct/schema/conformance/corpus-manifest.json create mode 100644 graqle/pct/schema/conformance/fixtures/keyring_default.json create mode 100644 graqle/pct/schema/conformance/fixtures/keyring_expired.json create mode 100644 graqle/pct/schema/conformance/fixtures/keyring_revoked.json create mode 100644 graqle/pct/schema/conformance/fixtures/keyring_rotated.json create mode 100644 graqle/pct/schema/conformance/fixtures/tc001_valid.json create mode 100644 graqle/pct/schema/conformance/fixtures/tc002_malformed.json create mode 100644 graqle/pct/schema/conformance/fixtures/tc003_tampered_leaf.json create mode 100644 graqle/pct/schema/conformance/fixtures/tc004_wrong_root.json create mode 100644 graqle/pct/schema/conformance/fixtures/tc007_rekor_mismatch.json create mode 100644 graqle/pct/schema/conformance/fixtures/tc008_not_json.txt create mode 100644 graqle/pct/schema/conformance/generate_fixtures.py create mode 100644 graqle/pct/schema/proof-spec/v1.0/SPEC.md create mode 100644 graqle/pct/schema/proof-spec/v1.0/bundle.schema.json create mode 100644 graqle/pct/schema/proof-spec/v1.0/keyring.schema.json create mode 100644 graqle/pct/schema/proof-spec/v1.0/verify-result.schema.json create mode 100644 tests/test_pct/test_proof_spec_conformance.py diff --git a/graqle/pct/schema/__init__.py b/graqle/pct/schema/__init__.py index 17dfe374..2fcabfd8 100644 --- a/graqle/pct/schema/__init__.py +++ b/graqle/pct/schema/__init__.py @@ -1,6 +1,21 @@ -"""Vendored OPSF PCT schema + example scenarios. +"""Vendored OPSF PCT schema + GraQle's own frozen proof spec. -The artefacts in this directory are byte-identical copies of files in +This package holds artefacts from two distinct provenances. Keeping them +straight matters: one is upstream content that must stay byte-identical, the +other is GraQle-authored and versioned on its own cadence. + +=========================== ========================================== +``pct_v0_1.json``, VENDORED from ``opsf-org/pct-spec`` at +``opsf_examples/`` :data:`VENDORED_OPSF_SHA`. Byte-identical — + never edit in place; re-vendor instead. +``proof-spec/v{N.M}/``, GRAQLE-AUTHORED (CR-010.R1). The frozen proof +``conformance/`` spec + its conformance corpus. Versioned by + directory, independent of both the OPSF SHA + and the GraQle SDK version. A re-vendor must + NOT touch these. +=========================== ========================================== + +The artefacts in the vendored set are byte-identical copies of files in ``opsf-org/pct-spec`` pinned to the commit SHA below. The OPSF default branch ``develop`` is floating; the SHA pin gives reproducible builds per sentinel pass 3 MINOR-S3 (CR-010 PR-010b-1). @@ -20,6 +35,62 @@ from __future__ import annotations +import json +from typing import Any + +#: Version of GraQle's own frozen proof spec (CR-010.R1). Deliberately +#: DECOUPLED from ``graqle.__version__``: an SDK release never implies a spec +#: change, and a spec change never forces an SDK major bump. Third parties pin +#: to this, not to the SDK version. +SPEC_VERSION: str = "1.0" + +#: Schema names published at :data:`SPEC_VERSION`. +PROOF_SPEC_SCHEMAS: tuple[str, ...] = ("bundle", "keyring", "verify-result") + + +def proof_schema_text(name: str, version: str | None = None) -> str: + """Return the raw JSON text of a published proof-spec schema. + + Read via ``importlib.resources`` rather than ``__file__`` so the schemas + resolve correctly when the package is imported from a zipped wheel. + + Parameters + ---------- + name: + One of :data:`PROOF_SPEC_SCHEMAS` (e.g. ``"bundle"``). + version: + Spec version directory, defaulting to :data:`SPEC_VERSION`. Pass an + explicit value to read a superseded spec. + + Raises + ------ + FileNotFoundError + If no such schema/version is published. The message names what was + looked for, so a typo is obvious rather than silent. + """ + from importlib.resources import files + + version = version or SPEC_VERSION + relative = f"proof-spec/v{version}/{name}.schema.json" + resource = files(__name__).joinpath(relative) + if not resource.is_file(): + raise FileNotFoundError( + f"no proof-spec schema {name!r} at spec version {version!r} " + f"(looked for {relative}); published schemas at " + f"v{SPEC_VERSION}: {', '.join(PROOF_SPEC_SCHEMAS)}" + ) + return resource.read_text(encoding="utf-8") + + +def load_proof_schema(name: str, version: str | None = None) -> dict[str, Any]: + """Return a published proof-spec schema parsed as a dict. + + Thin wrapper over :func:`proof_schema_text`; see it for parameters and + the raised :class:`FileNotFoundError`. + """ + return json.loads(proof_schema_text(name, version)) + + #: Pinned commit SHA in ``opsf-org/pct-spec`` from which the vendored #: artefacts in this directory were fetched. Sentinel pass 3 MINOR-S3 #: fix (CR-010 PR-010b-1, 2026-05-23). Verifiable via @@ -33,6 +104,10 @@ VENDORED_OPSF_COMMIT_MESSAGE: str = "remove banner image from README (#60)" __all__ = [ + "SPEC_VERSION", + "PROOF_SPEC_SCHEMAS", + "proof_schema_text", + "load_proof_schema", "VENDORED_OPSF_SHA", "VENDORED_OPSF_COMMIT_DATE", "VENDORED_OPSF_COMMIT_MESSAGE", diff --git a/graqle/pct/schema/conformance/__init__.py b/graqle/pct/schema/conformance/__init__.py new file mode 100644 index 00000000..fa2d81ba --- /dev/null +++ b/graqle/pct/schema/conformance/__init__.py @@ -0,0 +1,13 @@ +"""CR-010.R1 conformance corpus — static fixtures + declarative case manifest. + +The corpus is an **interop artifact**, not a GraQle-internal test helper. Every +case is a committed static JSON file plus a declarative expectation in +``corpus-manifest.json``. A third-party verifier implementation consumes those +files over a subprocess/JSON boundary and must classify every case identically +to be called conformant — it never imports GraQle code. + +See ``../proof-spec/v1.0/SPEC.md`` for the normative envelope and the +conformance procedure. +""" + +from __future__ import annotations diff --git a/graqle/pct/schema/conformance/corpus-manifest.json b/graqle/pct/schema/conformance/corpus-manifest.json new file mode 100644 index 00000000..cad75ccf --- /dev/null +++ b/graqle/pct/schema/conformance/corpus-manifest.json @@ -0,0 +1,147 @@ +{ + "corpus_version": "1.0", + "spec_version": "1.0", + "description": "CR-010.R1 conformance corpus. Every case below MUST be classified identically by any conformant GraQle proof-bundle verifier. Expectations are empirically derived from the reference implementation, not hand-authored. A verifier that reproduces all cases is conformant at spec v1.0.", + "runner_contract": { + "invocation": " verify --keys --format json", + "assert_order": [ + "process exit_code", + "stdout parses as JSON and validates against ../proof-spec/v1.0/verify-result.schema.json", + "failure equals expect.failure", + "every key in expect.checks_present is present in checks", + "every key in expect.checks_absent is ABSENT from checks (absent, NOT false)" + ], + "exit_codes": { + "0": "bundle verified", + "1": "bundle did not verify (a typed failure)", + "2": "usage error - unreadable or malformed input, distinct from a failed proof" + }, + "absent_vs_false": "A check that did not run is ABSENT from the checks object. A check that ran and failed is present with value false (see TC-007 rekor). Treating absent as false is a conformance failure.", + "short_circuit": "Checks run in order leaf -> merkle -> signature -> rekor and the FIRST failure stops evaluation, because a later check is not meaningful once an earlier invariant is broken." + }, + "cases": [ + { + "id": "TC-001", + "description": "Valid bundle, trusted ACTIVE key. The happy path.", + "bundle": "fixtures/tc001_valid.json", + "keyring": "fixtures/keyring_default.json", + "expect": { + "ok": true, + "failure": "OK", + "exit_code": 0, + "checks_present": ["leaf", "merkle", "signature"], + "checks_absent": ["rekor"], + "rekor_checked": false + } + }, + { + "id": "TC-002", + "description": "Malformed bundle - the required merkle block is missing. Shape validation runs before any cryptographic check, so no check key is recorded.", + "bundle": "fixtures/tc002_malformed.json", + "keyring": "fixtures/keyring_default.json", + "expect": { + "ok": false, + "failure": "MALFORMED_BUNDLE", + "exit_code": 1, + "checks_present": [], + "checks_absent": ["leaf", "merkle", "signature", "rekor"], + "rekor_checked": false + } + }, + { + "id": "TC-003", + "description": "Tampered leaf - a leaf-committed record field (content_hash) was mutated while leaf/merkle/signature were left as signed. Leaf recompute disagrees with the stated leaf_hash.", + "bundle": "fixtures/tc003_tampered_leaf.json", + "keyring": "fixtures/keyring_default.json", + "expect": { + "ok": false, + "failure": "TAMPERED_LEAF", + "exit_code": 1, + "checks_present": [], + "checks_absent": ["leaf", "merkle", "signature", "rekor"], + "rekor_checked": false + } + }, + { + "id": "TC-004", + "description": "Wrong root - the stated merkle_root was altered, so inclusion recompute does not reproduce it. Leaf recompute still passes and is recorded.", + "bundle": "fixtures/tc004_wrong_root.json", + "keyring": "fixtures/keyring_default.json", + "expect": { + "ok": false, + "failure": "WRONG_ROOT", + "exit_code": 1, + "checks_present": ["leaf"], + "checks_absent": ["merkle", "signature", "rekor"], + "rekor_checked": false + } + }, + { + "id": "TC-005", + "description": "Rotated key - the bundle is untouched and internally valid, but the keyring knows only a DIFFERENT kid, so the signing kid is unknown to the trust store.", + "bundle": "fixtures/tc001_valid.json", + "keyring": "fixtures/keyring_rotated.json", + "expect": { + "ok": false, + "failure": "UNKNOWN_KID", + "exit_code": 1, + "checks_present": ["leaf", "merkle"], + "checks_absent": ["signature", "rekor"], + "rekor_checked": false + } + }, + { + "id": "TC-006", + "description": "Revoked key - same valid bundle, but the kid is present in the keyring with lifecycle state REVOKED. Trust state alone decides the outcome.", + "bundle": "fixtures/tc001_valid.json", + "keyring": "fixtures/keyring_revoked.json", + "expect": { + "ok": false, + "failure": "UNTRUSTED_KID", + "exit_code": 1, + "checks_present": ["leaf", "merkle"], + "checks_absent": ["signature", "rekor"], + "rekor_checked": false + } + }, + { + "id": "TC-006b", + "description": "Key outside its validity window - same valid bundle and an ACTIVE kid, but signed_at falls outside valid_from/valid_until. Distinct code path from TC-006 that must reach the same classification.", + "bundle": "fixtures/tc001_valid.json", + "keyring": "fixtures/keyring_expired.json", + "expect": { + "ok": false, + "failure": "UNTRUSTED_KID", + "exit_code": 1, + "checks_present": ["leaf", "merkle"], + "checks_absent": ["signature", "rekor"], + "rekor_checked": false + } + }, + { + "id": "TC-007", + "description": "Receipt mismatch - an offline Rekor receipt is present but its signed_tree_head does not bind the bundle's merkle_root. This is the only case where a check key is present with value false rather than absent.", + "bundle": "fixtures/tc007_rekor_mismatch.json", + "keyring": "fixtures/keyring_default.json", + "expect": { + "ok": false, + "failure": "REKOR_MISMATCH", + "exit_code": 1, + "checks_present": ["leaf", "merkle", "signature", "rekor"], + "checks_absent": [], + "rekor_checked": false + } + }, + { + "id": "TC-008", + "description": "Usage error - the input file is not JSON at all. The verifier cannot even attempt verification, which is distinct from a proof that fails to verify. No VerifyResult is produced: the payload is {ok:false, error:...} with no failure/checks keys, and it deliberately does NOT validate against verify-result.schema.json. The exit code is the contract here; the error message text is not normative.", + "bundle": "fixtures/tc008_not_json.txt", + "keyring": "fixtures/keyring_default.json", + "expect": { + "usage_error": true, + "exit_code": 2, + "payload_shape": { "ok": false, "has_error_field": true, "has_failure_field": false } + } + } + ] +} diff --git a/graqle/pct/schema/conformance/fixtures/keyring_default.json b/graqle/pct/schema/conformance/fixtures/keyring_default.json new file mode 100644 index 00000000..384d88cd --- /dev/null +++ b/graqle/pct/schema/conformance/fixtures/keyring_default.json @@ -0,0 +1,11 @@ +{ + "_test_only": true, + "_warning": "CONFORMANCE TEST DATA \u2014 NOT FOR PRODUCTION. The matching private key is derived from a fixed, publicly-known seed and can be reproduced by anyone. Never add this key to a real trust store.", + "keys": [ + { + "kid": "graqle-conformance-test-key", + "public_key_pem": "-----BEGIN PUBLIC KEY-----\nMCowBQYDK2VwAyEAA6EHv/POEL4dcN0Y50vAmWfk1jCbpQ1fHdyGZBJVMbg=\n-----END PUBLIC KEY-----\n", + "state": "ACTIVE" + } + ] +} diff --git a/graqle/pct/schema/conformance/fixtures/keyring_expired.json b/graqle/pct/schema/conformance/fixtures/keyring_expired.json new file mode 100644 index 00000000..6134f34a --- /dev/null +++ b/graqle/pct/schema/conformance/fixtures/keyring_expired.json @@ -0,0 +1,13 @@ +{ + "_test_only": true, + "_warning": "CONFORMANCE TEST DATA \u2014 NOT FOR PRODUCTION. The matching private key is derived from a fixed, publicly-known seed and can be reproduced by anyone. Never add this key to a real trust store.", + "keys": [ + { + "kid": "graqle-conformance-test-key", + "public_key_pem": "-----BEGIN PUBLIC KEY-----\nMCowBQYDK2VwAyEAA6EHv/POEL4dcN0Y50vAmWfk1jCbpQ1fHdyGZBJVMbg=\n-----END PUBLIC KEY-----\n", + "state": "ACTIVE", + "valid_from": "2020-01-01T00:00:00Z", + "valid_until": "2020-12-31T23:59:59Z" + } + ] +} diff --git a/graqle/pct/schema/conformance/fixtures/keyring_revoked.json b/graqle/pct/schema/conformance/fixtures/keyring_revoked.json new file mode 100644 index 00000000..8209911c --- /dev/null +++ b/graqle/pct/schema/conformance/fixtures/keyring_revoked.json @@ -0,0 +1,11 @@ +{ + "_test_only": true, + "_warning": "CONFORMANCE TEST DATA \u2014 NOT FOR PRODUCTION. The matching private key is derived from a fixed, publicly-known seed and can be reproduced by anyone. Never add this key to a real trust store.", + "keys": [ + { + "kid": "graqle-conformance-test-key", + "public_key_pem": "-----BEGIN PUBLIC KEY-----\nMCowBQYDK2VwAyEAA6EHv/POEL4dcN0Y50vAmWfk1jCbpQ1fHdyGZBJVMbg=\n-----END PUBLIC KEY-----\n", + "state": "REVOKED" + } + ] +} diff --git a/graqle/pct/schema/conformance/fixtures/keyring_rotated.json b/graqle/pct/schema/conformance/fixtures/keyring_rotated.json new file mode 100644 index 00000000..35b3255c --- /dev/null +++ b/graqle/pct/schema/conformance/fixtures/keyring_rotated.json @@ -0,0 +1,11 @@ +{ + "_test_only": true, + "_warning": "CONFORMANCE TEST DATA \u2014 NOT FOR PRODUCTION. The matching private key is derived from a fixed, publicly-known seed and can be reproduced by anyone. Never add this key to a real trust store.", + "keys": [ + { + "kid": "graqle-conformance-rotated-key", + "public_key_pem": "-----BEGIN PUBLIC KEY-----\nMCowBQYDK2VwAyEAKay64UG8yvCyLhqU000LxzYeUm0L/hLIl5S8kyKWbdc=\n-----END PUBLIC KEY-----\n", + "state": "ACTIVE" + } + ] +} diff --git a/graqle/pct/schema/conformance/fixtures/tc001_valid.json b/graqle/pct/schema/conformance/fixtures/tc001_valid.json new file mode 100644 index 00000000..a2803b48 --- /dev/null +++ b/graqle/pct/schema/conformance/fixtures/tc001_valid.json @@ -0,0 +1,34 @@ +{ + "leaf": { + "leaf_hash": "7980bb39c78cbf14f82b8c816b0854778d331ab65821a8d6db5dba5731b5eb75", + "leaf_index": 1, + "tree_size": 4 + }, + "merkle": { + "merkle_path": [ + "f22a101587baa042702e5d7166d5db85c271479f49027495e3556c61a32f650c", + "1a8c1704c341b9cabcc21898de4a22f353e030af49f8fe6a80c74986a57a940c" + ], + "merkle_path_directions": [ + 0, + 1 + ], + "merkle_root": "1ea1b958ad15d8d0511e0312feab351cd14fa839c6dbeab96f30f5b806f62e2f" + }, + "proof_format_version": "1", + "record": { + "content_hash": "0000000000000000000000000000000000000000000000000000000000000001", + "governance_metadata": { + "decision": "DENY" + }, + "proof_format_version": "1", + "record_id": "conformance-record-1", + "timestamp_unix": 1767225601 + }, + "signature": { + "alg": "ed25519", + "kid": "graqle-conformance-test-key", + "sig": "be8e60a0e9ebb7ccacae83e067f0096b855f3ec9c2f5f85ce82f5c0ac143f51b11d3afc8b9b4e9ae0cf85e7f3b234f8ed120132732f70b9f09689a21f184ca05", + "signed_at": "2026-01-01T00:00:00Z" + } +} diff --git a/graqle/pct/schema/conformance/fixtures/tc002_malformed.json b/graqle/pct/schema/conformance/fixtures/tc002_malformed.json new file mode 100644 index 00000000..8e6c772d --- /dev/null +++ b/graqle/pct/schema/conformance/fixtures/tc002_malformed.json @@ -0,0 +1,23 @@ +{ + "leaf": { + "leaf_hash": "7980bb39c78cbf14f82b8c816b0854778d331ab65821a8d6db5dba5731b5eb75", + "leaf_index": 1, + "tree_size": 4 + }, + "proof_format_version": "1", + "record": { + "content_hash": "0000000000000000000000000000000000000000000000000000000000000001", + "governance_metadata": { + "decision": "DENY" + }, + "proof_format_version": "1", + "record_id": "conformance-record-1", + "timestamp_unix": 1767225601 + }, + "signature": { + "alg": "ed25519", + "kid": "graqle-conformance-test-key", + "sig": "be8e60a0e9ebb7ccacae83e067f0096b855f3ec9c2f5f85ce82f5c0ac143f51b11d3afc8b9b4e9ae0cf85e7f3b234f8ed120132732f70b9f09689a21f184ca05", + "signed_at": "2026-01-01T00:00:00Z" + } +} diff --git a/graqle/pct/schema/conformance/fixtures/tc003_tampered_leaf.json b/graqle/pct/schema/conformance/fixtures/tc003_tampered_leaf.json new file mode 100644 index 00000000..4f63a4d7 --- /dev/null +++ b/graqle/pct/schema/conformance/fixtures/tc003_tampered_leaf.json @@ -0,0 +1,34 @@ +{ + "leaf": { + "leaf_hash": "7980bb39c78cbf14f82b8c816b0854778d331ab65821a8d6db5dba5731b5eb75", + "leaf_index": 1, + "tree_size": 4 + }, + "merkle": { + "merkle_path": [ + "f22a101587baa042702e5d7166d5db85c271479f49027495e3556c61a32f650c", + "1a8c1704c341b9cabcc21898de4a22f353e030af49f8fe6a80c74986a57a940c" + ], + "merkle_path_directions": [ + 0, + 1 + ], + "merkle_root": "1ea1b958ad15d8d0511e0312feab351cd14fa839c6dbeab96f30f5b806f62e2f" + }, + "proof_format_version": "1", + "record": { + "content_hash": "0000000000000000000000000000000000000000000000000000000000000002", + "governance_metadata": { + "decision": "DENY" + }, + "proof_format_version": "1", + "record_id": "conformance-record-1", + "timestamp_unix": 1767225601 + }, + "signature": { + "alg": "ed25519", + "kid": "graqle-conformance-test-key", + "sig": "be8e60a0e9ebb7ccacae83e067f0096b855f3ec9c2f5f85ce82f5c0ac143f51b11d3afc8b9b4e9ae0cf85e7f3b234f8ed120132732f70b9f09689a21f184ca05", + "signed_at": "2026-01-01T00:00:00Z" + } +} diff --git a/graqle/pct/schema/conformance/fixtures/tc004_wrong_root.json b/graqle/pct/schema/conformance/fixtures/tc004_wrong_root.json new file mode 100644 index 00000000..d8c2a1de --- /dev/null +++ b/graqle/pct/schema/conformance/fixtures/tc004_wrong_root.json @@ -0,0 +1,34 @@ +{ + "leaf": { + "leaf_hash": "7980bb39c78cbf14f82b8c816b0854778d331ab65821a8d6db5dba5731b5eb75", + "leaf_index": 1, + "tree_size": 4 + }, + "merkle": { + "merkle_path": [ + "f22a101587baa042702e5d7166d5db85c271479f49027495e3556c61a32f650c", + "1a8c1704c341b9cabcc21898de4a22f353e030af49f8fe6a80c74986a57a940c" + ], + "merkle_path_directions": [ + 0, + 1 + ], + "merkle_root": "1ea1b958ad15d8d0511e0312feab351cd14fa839c6dbeab96f30f5b806f62e21" + }, + "proof_format_version": "1", + "record": { + "content_hash": "0000000000000000000000000000000000000000000000000000000000000001", + "governance_metadata": { + "decision": "DENY" + }, + "proof_format_version": "1", + "record_id": "conformance-record-1", + "timestamp_unix": 1767225601 + }, + "signature": { + "alg": "ed25519", + "kid": "graqle-conformance-test-key", + "sig": "be8e60a0e9ebb7ccacae83e067f0096b855f3ec9c2f5f85ce82f5c0ac143f51b11d3afc8b9b4e9ae0cf85e7f3b234f8ed120132732f70b9f09689a21f184ca05", + "signed_at": "2026-01-01T00:00:00Z" + } +} diff --git a/graqle/pct/schema/conformance/fixtures/tc007_rekor_mismatch.json b/graqle/pct/schema/conformance/fixtures/tc007_rekor_mismatch.json new file mode 100644 index 00000000..47fcc636 --- /dev/null +++ b/graqle/pct/schema/conformance/fixtures/tc007_rekor_mismatch.json @@ -0,0 +1,41 @@ +{ + "leaf": { + "leaf_hash": "7980bb39c78cbf14f82b8c816b0854778d331ab65821a8d6db5dba5731b5eb75", + "leaf_index": 1, + "tree_size": 4 + }, + "merkle": { + "merkle_path": [ + "f22a101587baa042702e5d7166d5db85c271479f49027495e3556c61a32f650c", + "1a8c1704c341b9cabcc21898de4a22f353e030af49f8fe6a80c74986a57a940c" + ], + "merkle_path_directions": [ + 0, + 1 + ], + "merkle_root": "1ea1b958ad15d8d0511e0312feab351cd14fa839c6dbeab96f30f5b806f62e2f" + }, + "proof_format_version": "1", + "record": { + "content_hash": "0000000000000000000000000000000000000000000000000000000000000001", + "governance_metadata": { + "decision": "DENY" + }, + "proof_format_version": "1", + "record_id": "conformance-record-1", + "timestamp_unix": 1767225601 + }, + "rekor": { + "inclusion_cert": "conformance-test-cert", + "integrated_time": 1767225600, + "log_id": "conformance-test-log", + "log_index": 42, + "signed_tree_head": "1ea1b958ad15d8d0511e0312feab351cd14fa839c6dbeab96f30f5b806f62e21" + }, + "signature": { + "alg": "ed25519", + "kid": "graqle-conformance-test-key", + "sig": "be8e60a0e9ebb7ccacae83e067f0096b855f3ec9c2f5f85ce82f5c0ac143f51b11d3afc8b9b4e9ae0cf85e7f3b234f8ed120132732f70b9f09689a21f184ca05", + "signed_at": "2026-01-01T00:00:00Z" + } +} diff --git a/graqle/pct/schema/conformance/fixtures/tc008_not_json.txt b/graqle/pct/schema/conformance/fixtures/tc008_not_json.txt new file mode 100644 index 00000000..e7d1f2c7 --- /dev/null +++ b/graqle/pct/schema/conformance/fixtures/tc008_not_json.txt @@ -0,0 +1 @@ +this file is deliberately not JSON - exit code 2 diff --git a/graqle/pct/schema/conformance/generate_fixtures.py b/graqle/pct/schema/conformance/generate_fixtures.py new file mode 100644 index 00000000..26252462 --- /dev/null +++ b/graqle/pct/schema/conformance/generate_fixtures.py @@ -0,0 +1,248 @@ +"""Regenerate the CR-010.R1 conformance corpus fixtures + golden vectors. + +Development convenience ONLY. The **committed** JSON files under ``fixtures/`` +are the ground truth for conformance — a third-party implementer reads those, +never this script. Running this script must be idempotent: same inputs, same +bytes out (no timestamps, no randomness). + +Determinism +----------- +The signing key is derived from a FIXED seed (never a real key, never +``os.urandom``) so the corpus is byte-stable across regenerations and machines. +That is a deliberate property of a *test* corpus: the vectors must be +reproducible by anyone. It is NOT a production key and is labelled as such. + +Usage:: + + python -m graqle.pct.schema.conformance.generate_fixtures + +Then re-run the conformance tests to confirm the corpus still classifies +identically. +""" + +from __future__ import annotations + +import json +from pathlib import Path +from typing import Any + +from cryptography.hazmat.primitives import serialization +from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey + +from graqle.governance.tamper_evidence.canonicalize import canon +from graqle.governance.tamper_evidence.merkle import MerkleTree + +FIXTURES = Path(__file__).parent / "fixtures" + +# A FIXED, PUBLICLY-KNOWN test seed. Not a secret, not a production key. +_TEST_SEED = bytes(range(32)) + +KID = "graqle-conformance-test-key" +OTHER_KID = "graqle-conformance-rotated-key" +SIGNED_AT = "2026-01-01T00:00:00Z" +PROOF_FORMAT_VERSION = "1" + +# Four records -> a 4-leaf tree. The corpus proves leaf 1 of 4. +# +# Every field below is inside the frozen LEAF_HASH_FIELDS allowlist +# (content_hash, governance_metadata, proof_format_version, record_id, +# timestamp_unix). This matters: canon_leaf PROJECTS the record onto that +# allowlist, so a field outside it provably cannot change the leaf hash — the +# TC-003 tamper must therefore mutate a leaf-committed field to be meaningful. +_RECORDS: list[dict[str, Any]] = [ + { + "proof_format_version": PROOF_FORMAT_VERSION, + "record_id": f"conformance-record-{i}", + "content_hash": f"{i:064x}", + "timestamp_unix": 1767225600 + i, + "governance_metadata": {"decision": ["ALLOW", "DENY", "ALLOW", "WARN"][i]}, + } + for i in range(4) +] +_TARGET_INDEX = 1 + + +def _signing_key() -> Ed25519PrivateKey: + return Ed25519PrivateKey.from_private_bytes(_TEST_SEED) + + +def _other_key() -> Ed25519PrivateKey: + return Ed25519PrivateKey.from_private_bytes(bytes(range(32, 64))) + + +def _public_pem(key: Ed25519PrivateKey) -> str: + return ( + key.public_key() + .public_bytes( + encoding=serialization.Encoding.PEM, + format=serialization.PublicFormat.SubjectPublicKeyInfo, + ) + .decode("ascii") + ) + + +def _signed_message(merkle_root_hex: str, kid: str, signed_at: str) -> bytes: + """Rebuild the SD-001 signing preimage (locked 2026-05-31). + + The signature covers exactly these four fields, canonicalized with JCS. + ``proof_format_version`` is part of the preimage — which is precisely why + it is signature-covered and cannot be rewritten by any normalization shim. + """ + return canon( + { + "proof_format_version": PROOF_FORMAT_VERSION, + "merkle_root": merkle_root_hex, + "kid": kid, + "signed_at": signed_at, + } + ) + + +def _base_bundle() -> dict[str, Any]: + """Build a genuine, fully-verifying proof bundle for leaf 1 of 4.""" + tree = MerkleTree.from_records(_RECORDS) + proof = tree.inclusion_proof(_TARGET_INDEX) + root_hex = tree.root.hex() + + key = _signing_key() + sig = key.sign(_signed_message(root_hex, KID, SIGNED_AT)).hex() + + merkle_fields = proof.to_bundle() + return { + "proof_format_version": PROOF_FORMAT_VERSION, + "record": _RECORDS[_TARGET_INDEX], + "leaf": { + "leaf_index": merkle_fields["leaf_index"], + "tree_size": merkle_fields["tree_size"], + "leaf_hash": proof.leaf_hash.hex(), + }, + "merkle": { + "merkle_root": root_hex, + "merkle_path": merkle_fields["merkle_path"], + "merkle_path_directions": merkle_fields["merkle_path_directions"], + }, + "signature": { + "alg": "ed25519", + "kid": KID, + "sig": sig, + "signed_at": SIGNED_AT, + }, + } + + +def _flip_last_hex(value: str) -> str: + """Flip the final hex nibble — a minimal, surgical corruption.""" + last = value[-1] + return value[:-1] + ("1" if last != "1" else "2") + + +def _write(name: str, payload: Any) -> None: + path = FIXTURES / name + path.write_text( + json.dumps(payload, indent=2, sort_keys=True) + "\n", encoding="utf-8" + ) + print(f" wrote {path.name}") + + +#: Machine-readable marker stamped into every corpus keyring. Sentinel pass 1, +#: finding (b): the signing key is derived from a FIXED, publicly-known seed so +#: the corpus is reproducible — which means anything it signs is forgeable by +#: anyone. A human-readable kid ("...-test-key") is not enough; a grep-able, +#: assertable flag lets CI prove no conformance keyring ever reaches a real +#: trust store. +TEST_ONLY_MARKER = "_test_only" + + +def _keyring(kid: str, pem: str, state: str = "ACTIVE", **extra: str) -> dict[str, Any]: + entry: dict[str, Any] = {"kid": kid, "public_key_pem": pem, "state": state} + entry.update(extra) + return { + TEST_ONLY_MARKER: True, + "_warning": ( + "CONFORMANCE TEST DATA — NOT FOR PRODUCTION. The matching private " + "key is derived from a fixed, publicly-known seed and can be " + "reproduced by anyone. Never add this key to a real trust store." + ), + "keys": [entry], + } + + +def main() -> None: + FIXTURES.mkdir(parents=True, exist_ok=True) + print("Regenerating CR-010.R1 conformance fixtures...") + + key = _signing_key() + pem = _public_pem(key) + base = _base_bundle() + + # --- keyrings ------------------------------------------------------- + _write("keyring_default.json", _keyring(KID, pem)) + _write("keyring_revoked.json", _keyring(KID, pem, state="REVOKED")) + _write( + "keyring_expired.json", + _keyring( + KID, + pem, + state="ACTIVE", + valid_from="2020-01-01T00:00:00Z", + valid_until="2020-12-31T23:59:59Z", + ), + ) + # Rotated: the keyring knows only a DIFFERENT kid -> bundle kid is unknown. + _write("keyring_rotated.json", _keyring(OTHER_KID, _public_pem(_other_key()))) + + # --- TC-001 valid --------------------------------------------------- + _write("tc001_valid.json", base) + + # --- TC-002 malformed (required `merkle` block removed) ------------- + malformed = json.loads(json.dumps(base)) + del malformed["merkle"] + _write("tc002_malformed.json", malformed) + + # --- TC-003 tampered leaf ------------------------------------------- + # The record is mutated but leaf/merkle/signature are left EXACTLY as + # signed. That is what a real tamper looks like: the attacker edits the + # payload and cannot re-derive the committed leaf hash. Leaf recompute + # therefore disagrees with the stated leaf_hash -> TAMPERED_LEAF. + # (Regenerating the leaf hash from the mutated record instead would make + # the bundle self-consistent and classify OK — an empirically-caught trap.) + tampered = json.loads(json.dumps(base)) + tampered["record"]["content_hash"] = _flip_last_hex( + tampered["record"]["content_hash"] + ) + _write("tc003_tampered_leaf.json", tampered) + + # --- TC-004 wrong root (root mutated; signature still over old root)- + # Leaf recompute passes, inclusion recompute != stated root -> WRONG_ROOT. + wrong_root = json.loads(json.dumps(base)) + wrong_root["merkle"]["merkle_root"] = _flip_last_hex( + wrong_root["merkle"]["merkle_root"] + ) + _write("tc004_wrong_root.json", wrong_root) + + # --- TC-005 / TC-006 / TC-006b reuse the valid bundle --------------- + # They differ only by which KEYRING is supplied, which is the point: the + # bundle is untouched, the TRUST STATE changes. + + # --- TC-007 rekor receipt mismatch ---------------------------------- + rekor = json.loads(json.dumps(base)) + rekor["rekor"] = { + "log_index": 42, + "log_id": "conformance-test-log", + "signed_tree_head": _flip_last_hex(base["merkle"]["merkle_root"]), + "inclusion_cert": "conformance-test-cert", + "integrated_time": 1767225600, + } + _write("tc007_rekor_mismatch.json", rekor) + + # --- TC-008 unreadable input (not valid JSON at all) ---------------- + (FIXTURES / "tc008_not_json.txt").write_text( + "this file is deliberately not JSON - exit code 2\n", encoding="utf-8" + ) + print(" wrote tc008_not_json.txt") + + print("Done. Re-run the conformance tests to confirm classification.") + + +if __name__ == "__main__": + main() diff --git a/graqle/pct/schema/proof-spec/v1.0/SPEC.md b/graqle/pct/schema/proof-spec/v1.0/SPEC.md new file mode 100644 index 00000000..71af6658 --- /dev/null +++ b/graqle/pct/schema/proof-spec/v1.0/SPEC.md @@ -0,0 +1,229 @@ +# GraQle Proof Spec — v1.0 + +**Spec version:** `1.0` +**Status:** FROZEN +**Versioning:** This spec version is **independent of the GraQle SDK version**. +An SDK release never implies a spec change, and a spec change never requires an +SDK major bump. Pin to `proof-spec/v1.0/` and you are pinned regardless of which +SDK version produced a bundle. + +--- + +## 1. What this spec is + +A GraQle **proof bundle** is portable, offline-verifiable evidence that one +governed-trace record was committed inside a batch at a point in time, signed by +a known key. + +This document plus the three schemas beside it are sufficient to implement a +compatible verifier **without reading GraQle source code**. Implementations are +checked mechanically against the conformance corpus in `../../conformance/`. + +**Scope — envelope only.** This spec defines the *shape and classification +contract* of the artifacts: field names, types, status enums, the Merkle +structure, and process exit codes. It deliberately does not describe how GraQle +scores, ranks, or reasons about anything, and no such internal is required to +verify a proof. + +## 2. Artifacts + +| Schema | What it describes | +|---|---| +| `bundle.schema.json` | The proof bundle a verifier consumes | +| `keyring.schema.json` | The trusted-key manifest it is evaluated against | +| `verify-result.schema.json` | The JSON a verifier emits under `--format json` | + +## 3. Verification procedure (normative) + +A conformant verifier performs these steps **in this order**, and the **first +failure stops evaluation**: + +1. **Shape** — the bundle has the required fields with the required types. + Otherwise → `MALFORMED_BUNDLE`. +2. **Leaf recompute** — recompute the record's leaf hash and compare it against + the stated `leaf.leaf_hash`. Mismatch → `TAMPERED_LEAF`. +3. **Merkle inclusion** — fold `leaf_hash` with `merkle.merkle_path` using + `merkle.merkle_path_directions` and compare against `merkle.merkle_root`. + Mismatch → `WRONG_ROOT`. +4. **Signature trust** — resolve `signature.kid` in the keyring. Absent → + `UNKNOWN_KID`. Present but not trusted at `signature.signed_at` (revoked, + outside its window, or a bad signature) → `UNTRUSTED_KID`. +5. **Rekor (optional)** — if and only if a `rekor` block is present, check + offline that it commits to the same `merkle_root`. Inconsistent → + `REKOR_MISMATCH`. **A bundle with no receipt is still valid.** + +Ordering is normative because a later check is not meaningful once an earlier +invariant is broken: an inclusion proof against a tampered leaf tells you +nothing. + +### 3.1 What the signature covers + +The ed25519 signature is over the canonical (RFC 8785 JCS) encoding of exactly +these four fields: + +``` +proof_format_version, merkle_root, kid, signed_at +``` + +The Merkle root already commits to every leaf in the batch (RFC 6962), so +signing the root transitively authenticates the record. + +> **`proof_format_version` is signature-covered.** A verifier MUST treat it as +> opaque bytes. Rewriting, normalizing, or "upgrading" this value changes the +> signed preimage and will invalidate an otherwise-valid signature. At spec v1.0 +> the field is therefore type-constrained but **not** value-constrained. + +**Why leaving it value-unconstrained is safe.** The field is inside *both* the +signed preimage *and* the leaf hash, so cryptography — not schema validation — +is what pins it. Forging it fails closed in both directions: + +| Forgery | Result | +|---|---| +| Change it in the wrapper only | `UNTRUSTED_KID` — signature no longer validates | +| Change it in wrapper **and** record | `TAMPERED_LEAF` — leaf hash no longer matches | + +A schema `enum` would therefore add no security, while breaking legitimately +divergent values already in circulation. Producers SHOULD nonetheless emit a +consistent value; consumers MUST NOT rewrite one. + +### 3.2 Key lifecycle + +`ACTIVE → RETIRED → REVOKED`, monotonic — a key never moves backwards. + +- **ACTIVE** — signs new proofs; verifies. +- **RETIRED** — signs nothing new, but proofs it made earlier **still verify**. + Retirement is not revocation. +- **REVOKED** — rejected unconditionally. + +## 4. Result contract + +```json +{ "ok": true, "failure": "OK", + "checks": { "leaf": true, "merkle": true, "signature": true }, + "rekor_checked": false } +``` + +Two rules carry the most interop weight: + +- **Absent ≠ false.** A check that did not run is **absent** from `checks`. A + check that ran and failed is present with `false`. Treating absent as `false` + is a conformance failure. +- **`ok` ignores an unattempted Rekor check.** No receipt means + `rekor_checked: false` with `ok: true`. + +## 5. Exit codes + +| Code | Meaning | +|---|---| +| `0` | Verified | +| `1` | Did not verify (a typed failure) | +| `2` | Usage error — unreadable/malformed input, or a bad key file | + +Code `2` is deliberately distinct from `1`: "I could not attempt this" is not +"this proof is bad". CI can therefore separate infrastructure faults from +genuine verification failures. + +A usage error emits a **different, deliberately non-conforming payload** — +because no verification was attempted, there is no result to report: + +```json +{ "ok": false, "error": "bundle file is not valid JSON: ..." } +``` + +It carries no `failure` and no `checks`, and it does **not** validate against +`verify-result.schema.json`. The **exit code is the contract** for this case; +the payload is a human diagnostic whose message text is not normative. + +## 6. Conformance + +Run every case in `../../conformance/corpus-manifest.json`: + +``` + verify --keys --format json +``` + +For each case assert, in order: the process exit code; that stdout validates +against `verify-result.schema.json`; that `failure` matches; and that every key +listed in `checks_present` is present and every key in `checks_absent` is +**absent**. + +An implementation that reproduces all cases is **conformant at spec v1.0**. +Because expectations are declarative data and the boundary is subprocess + JSON, +this is checkable mechanically and in any language. + +> ### ⚠️ The corpus keyrings are NOT trust material +> +> Every keyring in the corpus carries `"_test_only": true`. Its signing key is +> derived from a **fixed, published seed** so the vectors are byte-reproducible +> by anyone — which necessarily means **anyone can mint bundles that verify +> against it**. +> +> - **MUST NOT** load a keyring marked `_test_only` into a production trust store. +> - The corpus ships **public keys only**; no private key material is in the wheel. +> - A verifier has **no ambient trust store**: it trusts exactly the keyring the +> caller passes on each invocation, so the corpus cannot silently widen trust. +> +> Treat these files the way you would treat a well-known test vector: useful for +> proving your implementation agrees, never evidence that anything is authentic. + +## 7. Threat model — why the ordering is published + +Publishing the check order and short-circuit behaviour is a deliberate decision, +not an oversight. + +Disclosing that checks stop at the first failure reveals only **which check +reported**, never a way around one. Every check is an independent cryptographic +invariant: to pass step 2 an attacker must produce a preimage colliding with the +committed leaf hash; to pass step 3, an RFC 6962 path colliding with the root; to +pass step 4, an ed25519 forgery under a trusted key. Knowing the order does not +weaken any of them, and skipped checks are skipped precisely *because* an earlier +invariant already failed closed — the bundle is rejected either way. + +The alternative — an unspecified order — would mean two conformant verifiers +could classify the same bundle differently, which is precisely the interop +failure this spec exists to prevent. **Determinism is the security property +here.** A verifier is an oracle only for what it already returns publicly: a +single typed reason. + +What is deliberately **not** published: how GraQle scores, ranks, weights, or +reasons about anything. None of it is required to verify a proof. + +## 8. Stability + +Frozen at v1.0: + +- the `failure` enum is **closed** — adding a member is a spec version change; +- check-ordering and short-circuit semantics; +- absent-vs-false semantics; +- the exit-code contract; +- the four-field signature preimage. + +Additive, non-breaking changes ship as `v1.1`. Anything that changes how an +existing valid bundle classifies is `v2.0`. + +### 8.1 Extension posture (read this before extending) + +The bundle envelope permits unknown top-level members, and the `record` is +intentionally open: a governed-trace record carries domain fields this spec does +not enumerate, and only a frozen allowlist of them feeds the leaf hash. + +The consequence is deliberate but easy to misread: + +> **Validating against this schema does NOT validate an extension.** An +> extension namespace (for example a future compliance-claims extension) will +> pass v1.0 validation without being checked, because v1.0 does not know it +> exists. + +So an extension MUST publish **its own** schema and be validated against it in +addition to this one. Do not treat a green v1.0 validation as assurance about +fields v1.0 never defined. Conversely, extension fields are safe here precisely +because they cannot alter the leaf hash or the signed preimage — they are +carried, not trusted. + +### 8.2 Note for future cryptographic-chain work + +`proof_format_version` is inside both the signed preimage and the leaf hash. +Any future change that canonicalizes, normalizes, or unifies its value is a +**signature-breaking change**: it invalidates every previously-issued bundle +signed under the old value. It therefore requires an explicit migration path +(dual-accept window or re-issuance), never an in-place rewrite. diff --git a/graqle/pct/schema/proof-spec/v1.0/bundle.schema.json b/graqle/pct/schema/proof-spec/v1.0/bundle.schema.json new file mode 100644 index 00000000..4a3d634e --- /dev/null +++ b/graqle/pct/schema/proof-spec/v1.0/bundle.schema.json @@ -0,0 +1,109 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://graqle.com/spec/proof-spec/v1.0/bundle.schema.json", + "title": "GraQle Proof Bundle", + "description": "A tamper-evidence proof bundle: a governed-trace record, its RFC 6962 inclusion proof, and an ed25519 signature over the batch root. Spec version 1.0, versioned independently of the GraQle SDK release train.", + "type": "object", + "required": ["proof_format_version", "record", "leaf", "merkle", "signature"], + "additionalProperties": true, + "properties": { + "proof_format_version": { + "type": "string", + "minLength": 1, + "description": "Wire-format version of this bundle. TYPE-constrained but deliberately NOT value-constrained at spec v1.0: this field is covered by the signature (it is part of the signed preimage), so a verifier must treat it as opaque bytes and MUST NOT rewrite or normalize it. Doing so would change the signed preimage and invalidate an otherwise-valid signature. It must equal the value carried inside the record." + }, + "record": { + "type": "object", + "description": "The governed-trace record this proof attests. It MUST itself carry proof_format_version. Only a frozen allowlist of record fields contributes to the leaf hash; fields outside that allowlist provably cannot alter the leaf.", + "required": ["proof_format_version"], + "additionalProperties": true, + "properties": { + "proof_format_version": { "type": "string", "minLength": 1 } + } + }, + "leaf": { + "type": "object", + "description": "Position and hash of this record's leaf within the batch tree.", + "required": ["leaf_index", "tree_size", "leaf_hash"], + "additionalProperties": false, + "properties": { + "leaf_index": { + "type": "integer", + "minimum": 0, + "description": "Zero-based position of the leaf in the tree." + }, + "tree_size": { + "type": "integer", + "minimum": 1, + "description": "Total number of leaves in the tree." + }, + "leaf_hash": { + "$ref": "#/$defs/hex32", + "description": "The record's RFC 6962 domain-separated leaf hash." + } + } + }, + "merkle": { + "type": "object", + "description": "The RFC 6962 inclusion (audit) proof binding the leaf to the batch root.", + "required": ["merkle_root", "merkle_path", "merkle_path_directions"], + "additionalProperties": false, + "properties": { + "merkle_root": { + "$ref": "#/$defs/hex32", + "description": "The batch root the signature commits to." + }, + "merkle_path": { + "type": "array", + "description": "Sibling hashes, bottom-up. Same length as merkle_path_directions.", + "items": { "$ref": "#/$defs/hex32" } + }, + "merkle_path_directions": { + "type": "array", + "description": "Per sibling: 0 = sibling on the left, 1 = sibling on the right. Integers, never booleans (JCS interop).", + "items": { "type": "integer", "enum": [0, 1] } + } + } + }, + "signature": { + "type": "object", + "description": "An ed25519 signature over the canonicalized root-commitment message.", + "required": ["alg", "kid", "sig", "signed_at"], + "additionalProperties": false, + "properties": { + "alg": { + "type": "string", + "const": "ed25519", + "description": "Only ed25519 is accepted at spec v1.0." + }, + "kid": { + "type": "string", + "minLength": 1, + "description": "Key identifier, resolved against the verifier's trusted keyring." + }, + "sig": { + "type": "string", + "pattern": "^[0-9a-f]+$", + "description": "Lowercase-hex ed25519 signature." + }, + "signed_at": { + "type": "string", + "minLength": 1, + "description": "RFC 3339 UTC instant the signature was made. This is the instant a verifier evaluates key trust against." + } + } + }, + "rekor": { + "type": "object", + "description": "OPTIONAL offline transparency-log receipt. Treated strictly as DATA and never fetched. Its absence does not make a bundle invalid: a locally-anchored proof is valid without a public-log receipt.", + "additionalProperties": true + } + }, + "$defs": { + "hex32": { + "type": "string", + "pattern": "^[0-9a-f]{64}$", + "description": "A 32-byte hash as 64 lowercase hex characters." + } + } +} diff --git a/graqle/pct/schema/proof-spec/v1.0/keyring.schema.json b/graqle/pct/schema/proof-spec/v1.0/keyring.schema.json new file mode 100644 index 00000000..0446f3cd --- /dev/null +++ b/graqle/pct/schema/proof-spec/v1.0/keyring.schema.json @@ -0,0 +1,53 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://graqle.com/spec/proof-spec/v1.0/keyring.schema.json", + "title": "GraQle Verifier Keyring", + "description": "The trusted-key manifest a verifier evaluates a bundle's signature against. Explicit per-kid validity windows and lifecycle states let an auditor verify a historical proof under the trust state that applied when it was signed.", + "type": "object", + "required": ["keys"], + "additionalProperties": false, + "properties": { + "_test_only": { + "type": "boolean", + "description": "OPTIONAL. When true, this keyring is test/conformance data whose private key is reproducible from a published seed. A conformant deployment MUST NOT load a keyring carrying this marker into a real trust store." + }, + "_warning": { + "type": "string", + "description": "OPTIONAL human-readable companion to _test_only." + }, + "keys": { + "type": "array", + "minItems": 1, + "description": "Trusted signing keys. A bundle's kid is resolved against this list; a kid that is absent is UNKNOWN_KID, and a kid that is present but not trusted at signed_at is UNTRUSTED_KID.", + "items": { + "type": "object", + "required": ["kid", "public_key_pem"], + "additionalProperties": true, + "properties": { + "kid": { + "type": "string", + "minLength": 1, + "description": "Key identifier matched against the bundle's signature.kid." + }, + "public_key_pem": { + "type": "string", + "description": "PEM-encoded ed25519 PUBLIC key. Only ed25519 keys are accepted; a non-ed25519 key is a usage error, not a verification failure." + }, + "valid_from": { + "type": "string", + "description": "OPTIONAL RFC 3339 start of the trust window. Omitted means open-ended." + }, + "valid_until": { + "type": "string", + "description": "OPTIONAL RFC 3339 end of the trust window. Omitted means open-ended. A bundle whose signed_at falls outside the window is UNTRUSTED_KID." + }, + "state": { + "type": "string", + "enum": ["ACTIVE", "RETIRED", "REVOKED"], + "description": "Key lifecycle state. Omitted defaults to ACTIVE. REVOKED withdraws trust. RETIRED means the key signs nothing new, but proofs it made earlier still verify - retirement is not revocation." + } + } + } + } + } +} diff --git a/graqle/pct/schema/proof-spec/v1.0/verify-result.schema.json b/graqle/pct/schema/proof-spec/v1.0/verify-result.schema.json new file mode 100644 index 00000000..e4c9d313 --- /dev/null +++ b/graqle/pct/schema/proof-spec/v1.0/verify-result.schema.json @@ -0,0 +1,43 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://graqle.com/spec/proof-spec/v1.0/verify-result.schema.json", + "title": "GraQle Verify Result", + "description": "The JSON a conformant verifier emits on stdout under --format json. This is the interop surface the conformance corpus asserts against: a third-party implementation reproduces this shape and needs no GraQle code to be checked.", + "type": "object", + "required": ["ok", "failure", "checks", "rekor_checked"], + "additionalProperties": false, + "properties": { + "ok": { + "type": "boolean", + "description": "True iff every ATTEMPTED check passed. A bundle with no rekor receipt still reports ok=true: the optional Rekor check not being attempted is not a failure." + }, + "failure": { + "type": "string", + "enum": [ + "OK", + "MALFORMED_BUNDLE", + "TAMPERED_LEAF", + "WRONG_ROOT", + "UNKNOWN_KID", + "UNTRUSTED_KID", + "REKOR_MISMATCH" + ], + "description": "The single typed reason verification failed, or OK when ok is true. Exactly one failure is reported: checks run in order leaf -> merkle -> signature -> rekor and the first failure short-circuits, because a later check is not meaningful once an earlier invariant is broken. This enum is CLOSED at spec v1.0 - adding a member is a spec version change." + }, + "checks": { + "type": "object", + "description": "Per-step outcomes for the checks that RAN. A step that did not run is ABSENT from this object rather than recorded false, so the mapping distinguishes 'ran and passed', 'ran and failed', and 'not attempted'. Treating an absent key as false is a conformance failure.", + "additionalProperties": false, + "properties": { + "leaf": { "type": "boolean" }, + "merkle": { "type": "boolean" }, + "signature": { "type": "boolean" }, + "rekor": { "type": "boolean" } + } + }, + "rekor_checked": { + "type": "boolean", + "description": "Whether an offline Rekor inclusion check was performed and validated. False means no receipt was present OR a present receipt did not validate - it does NOT by itself indicate a failure. A receipt that was present and mismatched is reported as ok=false with failure=REKOR_MISMATCH." + } + } +} diff --git a/pyproject.toml b/pyproject.toml index 3419b1f6..71c6b936 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -193,6 +193,15 @@ artifacts = [ # signing keys so offline installs can verify v2 licences. Public-key-only # (the private signer is server-side; keygen.py is excluded below). "graqle/licensing/trusted_keys.json", + # CR-010.R1: the frozen proof spec + its conformance corpus. These MUST ship + # in the wheel — the whole point is that a third party can implement a + # conformant verifier from the published schemas + golden vectors alone. + # Loaded via importlib.resources (never __file__), so they resolve from a + # zip-imported wheel. Spec version is decoupled from the SDK version. + "graqle/pct/schema/proof-spec/**/*.json", + "graqle/pct/schema/proof-spec/**/*.md", + "graqle/pct/schema/conformance/*.json", + "graqle/pct/schema/conformance/fixtures/*", ] [tool.hatch.build.targets.wheel] diff --git a/tests/test_pct/test_proof_spec_conformance.py b/tests/test_pct/test_proof_spec_conformance.py new file mode 100644 index 00000000..f5fb5c9a --- /dev/null +++ b/tests/test_pct/test_proof_spec_conformance.py @@ -0,0 +1,466 @@ +"""CR-010.R1 — the frozen proof spec + its conformance corpus. + +Three things are under test here, and they are deliberately different in kind: + +1. **The published spec is loadable and well-formed** — including from an + installed wheel, via ``importlib.resources`` rather than ``__file__``. +2. **The corpus classifies exactly as declared** — every case in + ``corpus-manifest.json`` is driven through the real verifier and must produce + the declared failure, exit code, and present/absent check keys. +3. **The corpus cannot silently drift** — a new ``VerifyFailure`` member with no + fixture fails the suite, and a fixture mutated away from its declared case + must stop matching. + +The manifest expectations are **empirically derived** from the reference +implementation, not hand-authored, so a disagreement between this suite and the +verifier is a real regression rather than a stale guess. +""" + +from __future__ import annotations + +import json +import subprocess +import sys +from pathlib import Path +from typing import Any + +import jsonschema +import pytest + +from graqle.governance.tamper_evidence.verifier import VerifyFailure +from graqle.pct.schema import ( + PROOF_SPEC_SCHEMAS, + SPEC_VERSION, + load_proof_schema, + proof_schema_text, +) +from graqle.verify import EXIT_FAILED, EXIT_OK, EXIT_USAGE, VerifyUsageError, run_verify + + +def _conformance_root() -> Path: + """Locate the corpus via importlib.resources, NOT a __file__ path walk. + + Sentinel pass 1, blocker (f): a ``Path(__file__).parents[N]`` walk silently + resolves to the wrong directory once the package is installed + (``site-packages/graqle/pct`` rather than the repo root), so a third party + running the corpus from an installed wheel got FileNotFoundError — which + defeats the entire point of shipping a portable corpus. Reproduced against a + real installed wheel before fixing. Resolving through the package makes the + source tree and the installed wheel behave identically. + """ + from importlib.resources import files + + return Path(str(files("graqle.pct.schema.conformance"))) + + +_CONFORMANCE = _conformance_root() +_MANIFEST_PATH = _CONFORMANCE / "corpus-manifest.json" +_ALL_CHECKS = ("leaf", "merkle", "signature", "rekor") + + +def _manifest() -> dict[str, Any]: + return json.loads(_MANIFEST_PATH.read_text(encoding="utf-8")) + + +def _cases() -> list[dict[str, Any]]: + return _manifest()["cases"] + + +def _resolve(rel: str) -> Path: + return (_CONFORMANCE / rel).resolve() + + +def _case_ids() -> list[str]: + return [c["id"] for c in _cases()] + + +# -------------------------------------------------------------------------- +# 1. The published spec +# -------------------------------------------------------------------------- + + +@pytest.mark.parametrize("name", PROOF_SPEC_SCHEMAS) +def test_each_published_schema_is_valid_json_schema(name: str) -> None: + """Every published schema parses AND is itself a legal JSON Schema.""" + schema = load_proof_schema(name) + # Raises SchemaError if the schema itself is malformed. + jsonschema.Draft202012Validator.check_schema(schema) + assert schema["$id"].endswith(f"proof-spec/v{SPEC_VERSION}/{name}.schema.json") + + +def test_spec_version_is_decoupled_from_sdk_version() -> None: + """The spec version must NOT track the SDK version (R1 acceptance criterion). + + A third party pins to the spec version; if these were the same string, every + SDK release would look like a spec change. + """ + from graqle.__version__ import __version__ as sdk_version + + assert SPEC_VERSION != sdk_version + + +def test_schema_lookup_failure_names_what_it_looked_for() -> None: + """An unknown schema/version raises FileNotFoundError naming the miss.""" + with pytest.raises(FileNotFoundError) as exc: + proof_schema_text("no-such-schema") + message = str(exc.value) + assert "no-such-schema" in message + assert f"v{SPEC_VERSION}" in message + + with pytest.raises(FileNotFoundError): + proof_schema_text("bundle", version="99.99") + + +def test_schemas_load_via_importlib_not_file_path() -> None: + """Schemas must resolve through importlib.resources so a zipped wheel works. + + Reading through the package API (rather than the source tree) is what proves + the wheel-packaged path is exercised. + """ + assert proof_schema_text("bundle").strip().startswith("{") + + +def test_failure_enum_in_schema_matches_the_implementation() -> None: + """The published failure enum must equal the real VerifyFailure enum. + + This is the anti-drift binding between spec and code: if someone adds a + member to VerifyFailure without republishing the spec, this fails. + """ + published = set(load_proof_schema("verify-result")["properties"]["failure"]["enum"]) + implemented = {member.value for member in VerifyFailure} + assert published == implemented + + +# -------------------------------------------------------------------------- +# 2. The corpus classifies as declared +# -------------------------------------------------------------------------- + + +@pytest.mark.parametrize("case", _cases(), ids=_case_ids()) +def test_corpus_case_classifies_as_declared(case: dict[str, Any]) -> None: + """Each corpus case must produce exactly its declared classification.""" + expect = case["expect"] + bundle = _resolve(case["bundle"]) + keyring = _resolve(case["keyring"]) + + if expect.get("usage_error"): + # "I could not attempt this" — distinct from "this proof is bad". + with pytest.raises(VerifyUsageError): + run_verify(bundle_path=bundle, keys_path=keyring) + assert expect["exit_code"] == EXIT_USAGE + return + + exit_code, result = run_verify(bundle_path=bundle, keys_path=keyring) + + assert result["failure"] == expect["failure"], f"{case['id']}: wrong failure" + assert exit_code == expect["exit_code"], f"{case['id']}: wrong exit code" + assert result["ok"] is expect["ok"], f"{case['id']}: wrong ok" + assert result["rekor_checked"] is expect["rekor_checked"] + + # absent-vs-false is the subtle interop rule — assert both directions. + for key in expect["checks_present"]: + assert key in result["checks"], f"{case['id']}: {key} should be present" + for key in expect["checks_absent"]: + assert key not in result["checks"], ( + f"{case['id']}: {key} must be ABSENT (not False) — a check that did " + f"not run is omitted, not recorded as failed" + ) + + +@pytest.mark.parametrize("case", _cases(), ids=_case_ids()) +def test_corpus_result_validates_against_published_schema(case: dict[str, Any]) -> None: + """A verifier's JSON output must validate against verify-result.schema.json.""" + if case["expect"].get("usage_error"): + pytest.skip("usage errors produce no VerifyResult") + + _, result = run_verify( + bundle_path=_resolve(case["bundle"]), keys_path=_resolve(case["keyring"]) + ) + jsonschema.validate(result, load_proof_schema("verify-result")) + + +@pytest.mark.parametrize("case", _cases(), ids=_case_ids()) +def test_valid_bundles_validate_against_bundle_schema(case: dict[str, Any]) -> None: + """Structurally-valid fixtures must satisfy bundle.schema.json. + + The malformed and non-JSON fixtures are exempt by construction: TC-002 exists + precisely to be schema-invalid. + """ + if case["expect"].get("usage_error"): + pytest.skip("not a JSON bundle by construction") + if case["expect"].get("failure") == "MALFORMED_BUNDLE": + pytest.skip("deliberately malformed — exercises the negative path") + + bundle = json.loads(_resolve(case["bundle"]).read_text(encoding="utf-8")) + jsonschema.validate(bundle, load_proof_schema("bundle")) + + +def test_every_keyring_fixture_validates_against_keyring_schema() -> None: + """Every keyring the corpus ships must satisfy keyring.schema.json.""" + keyrings = {_resolve(c["keyring"]) for c in _cases()} + assert keyrings, "corpus declares no keyrings" + schema = load_proof_schema("keyring") + for path in sorted(keyrings): + jsonschema.validate( + json.loads(path.read_text(encoding="utf-8")), schema + ) + + +@pytest.mark.parametrize( + "mutate_record, expected", + [ + (False, "UNTRUSTED_KID"), + (True, "TAMPERED_LEAF"), + ], + ids=["bundle-version-only", "bundle-and-record-version"], +) +def test_arbitrary_proof_format_version_still_fails_closed( + tmp_path: Path, mutate_record: bool, expected: str +) -> None: + """Refutation pin for sentinel pass-1 finding (a). + + The sentinel argued that leaving ``proof_format_version`` value-unconstrained + lets a producer emit anything and still claim conformance. Measured: it does + not. The field is inside BOTH the signed preimage and the leaf hash, so + forging it fails closed either way: + + * change it in the wrapper only -> the signature no longer validates + (UNTRUSTED_KID); + * change it in wrapper AND record -> the leaf hash no longer matches + (TAMPERED_LEAF). + + Cryptography already constrains this field, which is exactly why a schema + ``enum`` would add no security while breaking the legitimate divergent + values that exist in-tree today. Pinned so the reasoning cannot regress. + """ + bundle = json.loads(_resolve("fixtures/tc001_valid.json").read_text("utf-8")) + bundle["proof_format_version"] = "garbage" + if mutate_record: + bundle["record"]["proof_format_version"] = "garbage" + + path = tmp_path / "forged.json" + path.write_text(json.dumps(bundle), encoding="utf-8") + + exit_code, result = run_verify( + bundle_path=path, keys_path=_resolve("fixtures/keyring_default.json") + ) + assert result["failure"] == expected + assert exit_code == EXIT_FAILED + + +def test_every_corpus_keyring_is_marked_test_only() -> None: + """Sentinel pass-1 finding (b): corpus keyrings must be machine-readably test-only. + + The corpus signing key comes from a fixed, publicly-known seed, so anyone + can forge signatures that satisfy these keyrings. A grep-able ``_test_only`` + flag means CI can prove no conformance keyring ever lands in a real trust + store — a human-readable kid alone would not. + """ + keyrings = {_resolve(c["keyring"]) for c in _cases()} + for path in sorted(keyrings): + data = json.loads(path.read_text(encoding="utf-8")) + assert data.get("_test_only") is True, f"{path.name} lacks the _test_only marker" + assert "NOT FOR PRODUCTION" in data.get("_warning", "") + + +def test_malformed_fixture_is_actually_schema_invalid() -> None: + """TC-002 must genuinely violate the bundle schema, not merely be odd. + + Guards against the negative fixture quietly becoming valid after a schema + edit — which would make the MALFORMED_BUNDLE case vacuous. + """ + bundle = json.loads(_resolve("fixtures/tc002_malformed.json").read_text("utf-8")) + with pytest.raises(jsonschema.ValidationError): + jsonschema.validate(bundle, load_proof_schema("bundle")) + + +# -------------------------------------------------------------------------- +# 3. Anti-drift +# -------------------------------------------------------------------------- + + +def test_every_verify_failure_member_is_covered_by_the_corpus() -> None: + """Completeness: each VerifyFailure member needs at least one fixture. + + THIS is the guarantee that keeps the corpus honest over time. Add a member + to VerifyFailure without adding a vector and the suite fails, so an + unspecified failure mode cannot ship silently. + """ + covered = {c["expect"]["failure"] for c in _cases() if "failure" in c["expect"]} + missing = {m.value for m in VerifyFailure} - covered + assert not missing, f"VerifyFailure members with no conformance vector: {missing}" + + +def test_corpus_covers_all_three_exit_codes() -> None: + """0, 1 and 2 must each be exercised — exit 2 is easy to forget.""" + codes = {c["expect"]["exit_code"] for c in _cases()} + assert codes == {EXIT_OK, EXIT_FAILED, EXIT_USAGE} + + +def test_tampering_with_a_fixture_changes_its_classification(tmp_path: Path) -> None: + """A mutated fixture must STOP matching its declared expectation. + + Proves the corpus actually discriminates rather than passing everything. + """ + original = json.loads(_resolve("fixtures/tc001_valid.json").read_text("utf-8")) + mutated = json.loads(json.dumps(original)) + root = mutated["merkle"]["merkle_root"] + mutated["merkle"]["merkle_root"] = root[:-1] + ("1" if root[-1] != "1" else "2") + + path = tmp_path / "mutated.json" + path.write_text(json.dumps(mutated), encoding="utf-8") + + exit_code, result = run_verify( + bundle_path=path, keys_path=_resolve("fixtures/keyring_default.json") + ) + assert result["failure"] != "OK" + assert exit_code == EXIT_FAILED + + +def test_every_published_spec_file_is_importlib_accessible() -> None: + """Wheel-content smoke test (sentinel pass-2 non-blocking recommendation). + + ``[tool.hatch.build] artifacts`` inheritance by the wheel target was verified + empirically against a real installed wheel, but it is build-tool behaviour + that could regress silently on a Hatchling upgrade. Asserting every published + file is reachable *through the package* (not the source tree) turns that into + a loud failure: if a future build stops shipping the spec, this breaks. + """ + from importlib.resources import files + + spec_root = files("graqle.pct.schema") + for name in PROOF_SPEC_SCHEMAS: + assert spec_root.joinpath( + f"proof-spec/v{SPEC_VERSION}/{name}.schema.json" + ).is_file(), f"{name}.schema.json is not shipped" + assert spec_root.joinpath(f"proof-spec/v{SPEC_VERSION}/SPEC.md").is_file() + + corpus = files("graqle.pct.schema.conformance") + assert corpus.joinpath("corpus-manifest.json").is_file() + for case in _cases(): + for key in ("bundle", "keyring"): + assert corpus.joinpath(case[key]).is_file(), ( + f"{case['id']}: {case[key]} is not shipped in the package" + ) + + +def test_corpus_is_locatable_through_the_package_not_a_file_path_walk() -> None: + """Regression pin for sentinel pass-1 blocker (f). + + The corpus MUST be reachable via importlib.resources so it resolves + identically from the source tree and from an installed wheel. A + ``Path(__file__).parents[N]`` walk silently pointed at a non-existent + directory once installed, which broke the portable-corpus promise. + """ + from importlib.resources import files + + root = Path(str(files("graqle.pct.schema.conformance"))) + assert (root / "corpus-manifest.json").is_file() + assert (root / "fixtures").is_dir() + # The resolved root must be the conformance package itself. + assert root.name == "conformance" + + +def test_committed_fixtures_match_the_generator(tmp_path: Path) -> None: + """Committed fixtures must be exactly what the generator produces. + + Sentinel pass-1 finding (c), second path: the anti-drift test proves every + failure mode HAS a vector, but a committed fixture could still go stale if + someone changes the generator and forgets to re-run it. Regenerating into a + temp dir and diffing closes that hole — stale golden vectors are caught here + rather than shipping as a silently-wrong published corpus. + """ + import shutil + + from graqle.pct.schema.conformance import generate_fixtures as gen + + live = _CONFORMANCE / "fixtures" + staging = tmp_path / "fixtures" + shutil.copytree(live, staging) + + original = gen.FIXTURES + try: + gen.FIXTURES = staging + gen.main() + finally: + gen.FIXTURES = original + + for committed in sorted(live.iterdir()): + regenerated = staging / committed.name + assert regenerated.is_file(), f"generator no longer emits {committed.name}" + assert regenerated.read_bytes() == committed.read_bytes(), ( + f"{committed.name} is STALE — re-run " + f"`python -m graqle.pct.schema.conformance.generate_fixtures`" + ) + + +def test_manifest_declares_every_shipped_fixture() -> None: + """No orphan fixtures: everything in fixtures/ is referenced by the manifest. + + An unreferenced fixture is dead weight a third party would have to guess at. + """ + referenced = set() + for case in _cases(): + referenced.add(_resolve(case["bundle"])) + referenced.add(_resolve(case["keyring"])) + on_disk = {p.resolve() for p in (_CONFORMANCE / "fixtures").iterdir() if p.is_file()} + assert on_disk == referenced, ( + f"orphan fixtures: {on_disk - referenced}; " + f"missing fixtures: {referenced - on_disk}" + ) + + +# -------------------------------------------------------------------------- +# 4. Third-party runnability (subprocess + JSON boundary) +# -------------------------------------------------------------------------- + + +@pytest.mark.parametrize("case", _cases(), ids=_case_ids()) +def test_corpus_runs_over_a_subprocess_json_boundary(case: dict[str, Any]) -> None: + """Drive each case exactly as a foreign implementation would. + + This is the real interop assertion: a separate process, real exit codes, and + stdout parsed as JSON — no in-process Python objects, no GraQle imports on + the consuming side. + """ + expect = case["expect"] + proc = subprocess.run( + [ + sys.executable, + "-m", + "graqle.verify", + str(_resolve(case["bundle"])), + "--keys", + str(_resolve(case["keyring"])), + "--format", + "json", + ], + capture_output=True, + text=True, + ) + + assert proc.returncode == expect["exit_code"], ( + f"{case['id']}: expected exit {expect['exit_code']}, got " + f"{proc.returncode}. stderr={proc.stderr[:400]}" + ) + + if expect.get("usage_error"): + # A usage error emits a DIFFERENT shape: {"ok": false, "error": "..."} + # with no failure/checks keys, because no verification was attempted. + # The exit code above is the primary contract; the payload is a + # diagnostic. Some runners surface it on stderr, so accept either and + # only assert the shape when a payload is actually present. + raw = (proc.stdout or "").strip() or (proc.stderr or "").strip() + if raw.startswith("{"): + payload = json.loads(raw) + assert payload["ok"] is False + assert "error" in payload + assert "failure" not in payload + return + + payload = json.loads(proc.stdout) + + # A plain string compare — the whole point of failure being a str enum. + assert payload["failure"] == expect["failure"] + for key in expect["checks_absent"]: + assert key not in payload["checks"]