Skip to content

Commit 7649576

Browse files
feat: v0.2.0 EvidenceReceipt v2 + well-known directory (#8)
* feat: v0.2.0 — EvidenceReceipt v2 + well-known directory Adds EvidenceReceipt v2 verification alongside the existing ActionReceipt v1 path. v1 callers see no behavior change. New v2 surface is required by Pipelock v2.4 release-spec criteria 2 and 5: every contract-aware proxy decision and contract-lifecycle event ships in the new envelope, and external auditors must be able to verify them. New surface: - Version routing in `verify()`: dispatches on the top-level `record_type` discriminator. `"action_receipt_v1"` (or absent) routes to v1. `"evidence_receipt_v2"` routes to v2. Unknown types are rejected with a clear error. - `verify_evidence()` for direct v2 verification with full payload detail. - 13 EvidenceReceipt v2 payload kinds: `proxy_decision`, `contract_ratified`, `contract_promote_intent`, `contract_promote_committed`, `contract_rollback_authorized`, `contract_rollback_committed`, `contract_demoted`, `contract_expired`, `contract_drift`, `shadow_delta`, `opportunity_missing`, `key_rotation`, `contract_redaction_request`. Each has its own schema validator with strict unknown-field rejection. - Key-purpose authority matrix: receipts must carry the correct `key_purpose` for their payload kind. Mismatches reject. - RFC 8785 JCS canonicalization (`_jcs.py`) for signable preimage computation. Mirrors the Go reference: parse strict, reject duplicate keys, reject floats, NFC-normalize strings, sort keys. - Well-known directory fetch (`_directory.py`): `fetch_directory()` and `parse_directory()` per RFC 9421. The README example switches from hardcoded SHA hex to directory-based key discovery. Backward compatibility: confirmed across all 62 original tests. Public v1 API surface unchanged. v1 conformance golden files verify byte-identically. Tests: 172 pass (62 v1 unchanged + 110 new). Build: clean wheel + sdist. Lint: ruff check + ruff format both pass. Follow-ups (tracked): - Cross-implementation Go-generated v2 conformance golden vectors not yet in `tests/conformance/`. v2 signature round-trip is currently proven via Python-generated keys only. To prove byte-for-byte parity with the Go reference, generate v2 fixtures from `internal/contract/receipt` and copy them in. - `verify_chain()` still validates v1 chain linkage only. Mixed v1/v2 chains require a chain-verifier upgrade in a follow-up. * tests: cross-implementation conformance for EvidenceReceipt v2 Adds three Go-emitted v2 receipt fixtures and a conformance test suite that loads each via the Python verifier. Proves byte-for-byte JCS preimage parity between the Go reference (internal/contract/receipt) and the Python implementation for the proxy_decision, contract_promote_ committed, and shadow_delta payload kinds — the load-bearing audit kinds for the v2.4 release spec's external verification claim. Fixtures (under tests/conformance/): valid-evidence-proxy-decision.json valid-evidence-promote-committed.json valid-evidence-shadow-delta.json Each is signed with the RFC 8032 section 7.1 test-1 private seed, so the corresponding public key (also in v2-test-keys.json) is the same across both implementations. A divergence in either side's canonicalisation logic surfaces here as a signature mismatch. tests/test_v2_conformance.py covers: - parametrised happy-path verification for each fixture - tampered-payload rejection (single-byte verdict flip on proxy_decision must invalidate the signature) - wrong-key rejection (all-zero public key must fail) 172 v2 tests in test_evidence.py + 18 in test_directory.py + 5 here + the v0.1.x suite = 178 total. All pass. Follow-up: the remaining 10 v2 payload kinds (contract_ratified, contract_promote_intent, contract_rollback_authorized/committed, contract_demoted, contract_expired, contract_drift, opportunity_missing, key_rotation, contract_redaction_request) are still synthesised internally by test_evidence.py and not yet covered by Go-emitted goldens. Adding them follows the same pattern: emit from internal/contract/receipt/golden_vectors_test.go with UPDATE_GOLDEN=1, copy into tests/conformance/, append to V2_FIXTURES. * fix: break import cycle between _verify and _evidence Extract InvalidReceiptError, _is_valid_rfc3339, and _RFC3339_RE into a new leaf module _common.py. Both _verify (v1) and _evidence (v2) now import from _common, which has no intra-package imports of its own. This removes the static cycle CodeQL flagged at _evidence.py:19 and _verify.py:184. The class object identity is preserved (re-exported via `import X as X` from _verify), so existing `except InvalidReceiptError` callers and the public pipelock_verify.InvalidReceiptError API continue to work. * fix: report declared chain_seq in v2 fail-closed chain branch The v2-in-chain fail-closed branch reported the list index for broken_at_seq instead of the receipt's declared chain_seq, which diverges from the v1 branch (which reads action_record.chain_seq with index fallback). Auditors now see the same sequence number the emitter wrote. Defensive: only accept int values (rejecting bool, which Python treats as int). Missing or non-int chain_seq falls back to the list index so broken_at_seq is never None on the fail-closed path. Adds three regression tests covering declared, missing, and non-int chain_seq cases. * chore: fix lint + typecheck CI (pre-existing, surfaced after b64fa7d) CI on the branch has been failing both lint (ruff format) and typecheck (mypy strict no-any-return) since b64fa7d. Fixing both together so the branch goes green. mypy: _PAYLOAD_VALIDATORS was typed `dict[str, Any]`, which lost the validators' `(payload: dict[str, Any]) -> str | None` signature and made `_validate_payload` return Any from a `str | None` slot. Replace with `dict[str, Callable[[dict[str, Any]], str | None]]` so the return type checks through the dispatch. ruff format: blank-line and string-wrap normalisations across _verify.py and the new test_regressions.py block. Auto-applied.
1 parent ca2e635 commit 7649576

19 files changed

Lines changed: 2521 additions & 106 deletions

.github/workflows/codeql.yml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ jobs:
1616
runs-on: ubuntu-latest
1717
timeout-minutes: 15
1818
permissions:
19+
contents: read
1920
security-events: write
2021
packages: read
2122

.github/workflows/scorecard.yml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ jobs:
1414
analysis:
1515
runs-on: ubuntu-latest
1616
permissions:
17+
contents: read
1718
security-events: write
1819
id-token: write
1920
steps:

CHANGELOG.md

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
# Changelog
2+
3+
All notable changes to this project will be documented in this file.
4+
5+
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6+
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7+
8+
## [0.2.0] - 2026-05-01
9+
10+
### Added
11+
12+
- **EvidenceReceipt v2 support.** Full schema parsing and verification for
13+
the contract-aware receipt envelope introduced in Pipelock v2.4. Includes:
14+
- All 13 payload kinds: `proxy_decision`, `contract_ratified`,
15+
`contract_promote_intent`, `contract_promote_committed`,
16+
`contract_rollback_authorized`, `contract_rollback_committed`,
17+
`contract_demoted`, `contract_expired`, `contract_drift`,
18+
`shadow_delta`, `opportunity_missing`, `key_rotation`,
19+
`contract_redaction_request`.
20+
- Strict unknown-field rejection at envelope, signature proof, and payload
21+
levels.
22+
- Key purpose authority matrix enforcement (rejects valid signatures from
23+
the wrong purpose).
24+
- Detached Ed25519 PureEdDSA signature verification with JCS (RFC 8785)
25+
canonicalization over typed structures.
26+
- `verify_evidence()` function for direct v2 verification with
27+
`expected_signer_key_id` and `expected_key_purpose` parameters.
28+
- `EvidenceVerifyResult` dataclass with v2-specific diagnostic fields.
29+
- `evidence_receipt_hash()` for v2 chain linkage computation.
30+
31+
- **Version routing in `verify()`.** The existing `verify()` function now
32+
auto-detects v1 vs v2 receipts by the `record_type` field and dispatches
33+
to the correct verification path. Unknown record types are rejected with
34+
a clear error.
35+
36+
- **Well-known directory fetch helper.** `fetch_directory(host)` retrieves
37+
the signing keyset from `/.well-known/http-message-signatures-directory`
38+
(RFC 9421). `parse_directory()` parses the keyset from raw JSON.
39+
`Directory` dataclass with `get_key()` and `public_key_hex()` lookup
40+
methods.
41+
42+
- **RFC 8785 JCS canonicalization module** (`_jcs.py`). Strict JSON parser
43+
with duplicate-key rejection, float rejection, trailing-token rejection,
44+
and NFC normalization. Used by EvidenceReceipt v2 preimage computation.
45+
46+
### Changed
47+
48+
- README updated with v2 documentation, well-known directory example, and
49+
13-payload-kind authority matrix table. The key-pinning example now uses
50+
the well-known directory fetch instead of a hardcoded SHA digest.
51+
52+
### Fixed
53+
54+
- Nothing. This is a backward-compatible feature release.
55+
56+
### Backward compatibility
57+
58+
- **No breaking changes.** All v0.1.x callers verifying ActionReceipt v1
59+
continue to work without modification.
60+
- `verify()` returns `VerifyResult` for both v1 and v2 (v2 fields are
61+
mapped: `event_id` to `action_id`, `payload_kind` to `action_type`).
62+
- `verify_chain()` is unchanged.
63+
- The `PAYLOAD_KINDS` and `PAYLOAD_AUTHORITY` constants are exported for
64+
callers that need to inspect the v2 schema programmatically.
65+
66+
## [0.1.1] - 2026-04-25
67+
68+
### Fixed
69+
70+
- Internal version metadata sync.
71+
72+
## [0.1.0] - 2026-04-09
73+
74+
### Added
75+
76+
- Initial release. ActionReceipt v1 verification with Ed25519 signatures,
77+
chain linkage, flight-recorder unwrapping, and CLI.

README.md

Lines changed: 123 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -7,11 +7,11 @@
77
[![OpenSSF Scorecard](https://api.scorecard.dev/projects/github.com/luckyPipewrench/pipelock-verify-python/badge)](https://scorecard.dev/viewer/?uri=github.com/luckyPipewrench/pipelock-verify-python)
88
[![License: Apache-2.0](https://img.shields.io/badge/License-Apache_2.0-blue.svg)](LICENSE)
99

10-
**Python verifier for [Pipelock](https://github.com/luckyPipewrench/pipelock) action receipts.** Verifies the Ed25519 signature, chain linkage, and flight-recorder wrapping of receipts emitted by the Pipelock mediator.
10+
**Python verifier for [Pipelock](https://github.com/luckyPipewrench/pipelock) receipts.** Supports both **ActionReceipt v1** (legacy proxy decisions) and **EvidenceReceipt v2** (contract-aware lifecycle events). Verifies Ed25519 signatures, chain linkage, payload schemas, key-purpose authority, and flight-recorder wrapping.
1111

1212
Mirrors the Go reference implementation byte-for-byte. The conformance golden files in `tests/conformance/` are generated by Pipelock's Go code and verified identically by both sides.
1313

14-
[Install](#install) · [Usage](#usage) · [What gets verified](#what-gets-verified) · [Canonicalization](#canonicalization-rules) · [Spec](https://pipelab.org/learn/action-receipt-spec/) · [Go reference](https://github.com/luckyPipewrench/pipelock)
14+
[Install](#install) · [Usage](#usage) · [EvidenceReceipt v2](#evidencereceipt-v2) · [Well-known directory](#well-known-directory) · [What gets verified](#what-gets-verified) · [Spec](https://pipelab.org/learn/action-receipt-spec/) · [Go reference](https://github.com/luckyPipewrench/pipelock)
1515

1616
## Install
1717

@@ -23,7 +23,7 @@ Only one runtime dependency: [`cryptography`](https://cryptography.io) for the E
2323

2424
## Usage
2525

26-
### Single receipt
26+
### Single receipt (auto-detects v1 vs v2)
2727

2828
```python
2929
import pipelock_verify
@@ -37,11 +37,23 @@ if not result.valid:
3737
print(f"OK: {result.action_id} {result.verdict} {result.target}")
3838
```
3939

40-
Pin a specific signing key to reject receipts from any other signer:
40+
The `verify()` function automatically routes to the correct verification
41+
path based on the `record_type` field:
42+
43+
- **No `record_type` or `"action_receipt_v1"`** routes to ActionReceipt v1.
44+
- **`"evidence_receipt_v2"`** routes to EvidenceReceipt v2.
45+
- **Unknown `record_type`** is rejected with a clear error.
46+
47+
### Pin a signing key via the well-known directory
4148

4249
```python
43-
PROD_KEY = "70b991eb77816fc4ef0ae6a54d8a4119ddc5a16c9711c332c39e743079f6c63e"
44-
result = pipelock_verify.verify(receipt_bytes, public_key_hex=PROD_KEY)
50+
import pipelock_verify
51+
52+
# Fetch the signing keyset from the Pipelock instance.
53+
directory = pipelock_verify.fetch_directory("pipelab.org")
54+
key_hex = directory.public_key_hex()
55+
56+
result = pipelock_verify.verify(receipt_bytes, public_key_hex=key_hex)
4557
```
4658

4759
### Receipt chain
@@ -73,9 +85,91 @@ python -m pipelock_verify evidence.jsonl --key 70b991eb77816fc4...
7385

7486
Exit codes match `pipelock verify-receipt`: 0 on success, 1 on failure.
7587

88+
## EvidenceReceipt v2
89+
90+
EvidenceReceipt v2 is the contract-aware receipt envelope introduced in
91+
Pipelock v2.4. It sits alongside ActionReceipt v1 (which remains unchanged
92+
for backward compatibility).
93+
94+
### Direct v2 verification
95+
96+
For fine-grained control over v2-specific checks (key purpose enforcement,
97+
signer key ID pinning):
98+
99+
```python
100+
from pipelock_verify import verify_evidence
101+
102+
result = verify_evidence(
103+
receipt_dict,
104+
public_key_hex="...",
105+
expected_signer_key_id="receipt-key-prod",
106+
expected_key_purpose="receipt-signing",
107+
)
108+
109+
if not result.valid:
110+
raise SystemExit(f"v2 receipt failed: {result.error}")
111+
112+
print(f"Event: {result.event_id}, Kind: {result.payload_kind}")
113+
```
114+
115+
### 13 payload kinds
116+
117+
| Payload kind | Signing purpose |
118+
|---|---|
119+
| `proxy_decision` | `receipt-signing` |
120+
| `contract_ratified` | `receipt-signing` |
121+
| `contract_promote_intent` | `contract-activation-signing` |
122+
| `contract_promote_committed` | `receipt-signing` |
123+
| `contract_rollback_authorized` | `contract-activation-signing` |
124+
| `contract_rollback_committed` | `receipt-signing` |
125+
| `contract_demoted` | `receipt-signing` |
126+
| `contract_expired` | `receipt-signing` |
127+
| `contract_drift` | `receipt-signing` |
128+
| `shadow_delta` | `receipt-signing` |
129+
| `opportunity_missing` | `receipt-signing` |
130+
| `key_rotation` | `contract-activation-signing` |
131+
| `contract_redaction_request` | `contract-activation-signing` |
132+
133+
The authority matrix is enforced automatically. A valid signature from the
134+
wrong key purpose is rejected.
135+
136+
### v2 canonicalization
137+
138+
EvidenceReceipt v2 uses RFC 8785 JSON Canonicalization Scheme (JCS) for
139+
signable preimages, not Go's `encoding/json` byte order (which is what
140+
ActionReceipt v1 uses). JCS rules:
141+
142+
- Object keys sorted lexicographically by Unicode codepoint.
143+
- Strings NFC-normalized.
144+
- Floats rejected (use decimal strings).
145+
- No whitespace between tokens.
146+
147+
The `signature` field is zeroed before computing the preimage.
148+
149+
## Well-known directory
150+
151+
Pipelock instances serve their signing keys at
152+
`/.well-known/http-message-signatures-directory` (RFC 9421). Use the
153+
built-in fetch helper to retrieve and parse the keyset:
154+
155+
```python
156+
from pipelock_verify import fetch_directory, parse_directory
157+
158+
# Fetch from a live instance.
159+
directory = fetch_directory("pipelab.org")
160+
161+
# Or parse from a pre-fetched JSON blob.
162+
directory = parse_directory(json_bytes)
163+
164+
# Look up a specific key.
165+
key = directory.get_key("pipelock-mediation-prod")
166+
if key:
167+
print(f"Key: {key.public_key}, Use: {key.use}")
168+
```
169+
76170
## What gets verified
77171

78-
On a single receipt:
172+
On a single **ActionReceipt v1**:
79173

80174
- Envelope version (rejects anything other than v1).
81175
- Action record version (rejects anything other than v1).
@@ -86,56 +180,47 @@ On a single receipt:
86180
- Optional trust anchor match (`public_key_hex` argument).
87181
- Ed25519 signature over `SHA-256(canonical action record)`.
88182

89-
On a chain:
183+
On a single **EvidenceReceipt v2**:
90184

91-
- Every individual receipt above.
92-
- Signer consistency (every receipt uses the same `signer_key`, or the
93-
pinned trust anchor if one was supplied).
94-
- Monotonic `chain_seq` starting at 0.
95-
- `chain_prev_hash` linkage: each receipt's `chain_prev_hash` equals
96-
`SHA-256` of the previous receipt's canonical envelope, in hex.
97-
- First receipt's `chain_prev_hash` equals the literal string `"genesis"`.
185+
- Envelope `record_type` and `receipt_version`.
186+
- Strict unknown-field rejection (envelope, signature proof, and payload).
187+
- Required envelope fields (`event_id`, `timestamp`, `payload_kind`).
188+
- Payload schema validation for all 13 payload kinds.
189+
- Key purpose authority matrix enforcement.
190+
- Signature proof structure (`signer_key_id`, `key_purpose`, `algorithm`).
191+
- Ed25519 PureEdDSA signature over `JCS(receipt_without_signature)`.
192+
- Optional trust anchors: `public_key_hex`, `expected_signer_key_id`,
193+
`expected_key_purpose`.
98194

99-
Failing receipts return the first break point (`broken_at_seq`) and a
100-
descriptive `error`, the same shape the Go CLI prints.
195+
On a **chain**:
196+
197+
- Every individual receipt above (v1 or v2).
198+
- Signer consistency across the chain.
199+
- Monotonic `chain_seq` starting at 0.
200+
- `chain_prev_hash` linkage via SHA-256 of canonical envelopes.
201+
- First receipt's `chain_prev_hash` equals `"genesis"`.
101202

102203
## Input formats
103204

104205
`verify_chain()` accepts JSONL in two shapes:
105206

106-
1. **Flight-recorder entries** the format Pipelock actually writes to
207+
1. **Flight-recorder entries** -- the format Pipelock actually writes to
107208
disk. Each line is a `recorder.Entry` object with `type ==
108209
"action_receipt"` and the receipt nested in `detail`. Non-receipt
109210
entries (checkpoints etc.) are skipped, not rejected.
110-
2. **Bare receipts** — one receipt object per line, no wrapping. Used by
111-
the conformance suite and handy for ad-hoc testing.
211+
2. **Bare receipts** -- one receipt object per line, no wrapping. Used by
212+
the conformance suite and handy for ad-hoc testing. Both v1 and v2
213+
bare receipts are recognized.
112214

113215
`verify()` accepts:
114216

115217
- A JSON string or UTF-8 bytes.
116218
- A pre-parsed `dict` (for callers that already have the receipt loaded).
117219
- A flight-recorder entry dict (transparently unwrapped).
118220

119-
## Canonicalization rules
120-
121-
The signing input is the SHA-256 of the Go `json.Marshal` output of the
122-
`ActionRecord` struct. "Canonical" means matching that exactly:
123-
124-
- Fields emitted in Go struct declaration order (not alphabetical).
125-
- `omitempty` fields dropped when the value is the Go zero value
126-
(`""`, empty slice, `0`, `false`, `nil`).
127-
- Compact JSON (no whitespace between tokens).
128-
- HTML-safe escapes: `<`, `>`, `&`, U+2028, U+2029 encoded as Unicode
129-
escapes, matching Go's default `encoding/json` behavior.
130-
- Fields unknown to the v1 schema are dropped (matches Go
131-
`json.Unmarshal` round-trip behavior).
132-
133-
Any deviation produces different bytes, a different hash, and a failed
134-
signature. See `pipelock_verify/_canonical.py` for the full rule set.
135-
136221
## Relationship to the Go reference
137222

138-
* Go reference: https://github.com/luckyPipewrench/pipelock/tree/main/internal/receipt
223+
* Go reference: https://github.com/luckyPipewrench/pipelock/tree/main/internal/receipt (v1), https://github.com/luckyPipewrench/pipelock/tree/main/internal/contract/receipt (v2)
139224
* Conformance suite: https://github.com/luckyPipewrench/pipelock/tree/main/sdk/conformance
140225
* Spec page: https://pipelab.org/learn/action-receipt-spec/
141226

0 commit comments

Comments
 (0)