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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 10 additions & 12 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -51,12 +51,12 @@ jobs:
with:
python-version: ${{ matrix.python-version }}

- name: Install package
run: |
python -m pip install --upgrade pip
pip install -e ".[dev]"
- name: Install locked CI dependencies
run: python -m pip install --require-hashes -r requirements/ci.txt

- name: Run tests
env:
PYTHONPATH: ${{ github.workspace }}
run: pytest --cov=pipelock_verify --cov-report=term-missing

lint:
Expand All @@ -73,14 +73,14 @@ jobs:
with:
python-version: '3.12'

- name: Install ruff
run: pip install ruff
- name: Install locked CI dependencies
run: python -m pip install --require-hashes -r requirements/ci.txt

- name: Ruff lint
run: ruff check pipelock_verify tests
run: ruff check pipelock_verify tests fuzz

- name: Ruff format check
run: ruff format --check pipelock_verify tests
run: ruff format --check pipelock_verify tests fuzz

typecheck:
needs: [security-scan]
Expand All @@ -96,10 +96,8 @@ jobs:
with:
python-version: '3.12'

- name: Install package with dev extras
run: |
python -m pip install --upgrade pip
pip install -e ".[dev]"
- name: Install locked CI dependencies
run: python -m pip install --require-hashes -r requirements/ci.txt

- name: Mypy strict
run: mypy pipelock_verify
52 changes: 52 additions & 0 deletions .github/workflows/fuzz.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
name: Fuzz

on:
push:
branches: [main]
tags-ignore: ['v*']
pull_request:
branches: [main]
schedule:
- cron: '17 4 * * 1'

permissions:
contents: read

jobs:
security-scan:
runs-on: ubuntu-latest
timeout-minutes: 5
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
with:
fetch-depth: 0
persist-credentials: false

- name: Pipelock Scan
uses: luckyPipewrench/pipelock@7a3b7de4a5552b4e756eb930256468b7cbd616b1 # v2.3.0
with:
scan-diff: 'true'
fail-on-findings: 'true'
test-vectors: 'false'
exclude-paths: |
tests/conformance/

atheris:
needs: [security-scan]
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
with:
persist-credentials: false

- name: Set up Python
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
with:
python-version: '3.11'

- name: Install locked fuzz dependencies
run: python -m pip install --require-hashes -r requirements/fuzz.txt

- name: Atheris receipt parser smoke
run: python fuzz/receipt_fuzzer.py -runs=256
9 changes: 5 additions & 4 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -51,16 +51,17 @@ jobs:
raise SystemExit(1)
PY

- name: Install pinned pip
run: python -m pip install --require-hashes -r requirements/pip.txt

- name: Install build tools
# Pin to specific versions so a compromised upstream cannot
# substitute a malicious build backend or upload client during
# the release. Bump these explicitly when upstream releases.
run: |
python -m pip install --upgrade 'pip==26.0.1'
pip install 'build==1.4.3' 'twine==6.2.0'
run: python -m pip install --require-hashes -r requirements/release.txt

- name: Build sdist and wheel
run: python -m build
run: python -m build --no-isolation

- name: Check distribution metadata
run: twine check dist/*
Expand Down
25 changes: 17 additions & 8 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
[![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)
[![License: Apache-2.0](https://img.shields.io/badge/License-Apache_2.0-blue.svg)](LICENSE)

**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.
**Python verifier for [Pipelock](https://github.com/luckyPipewrench/pipelock) receipts.** Supports **ActionReceipt v1** chains and individual **EvidenceReceipt v2** envelopes for contract-aware lifecycle events. Verifies Ed25519 signatures, v1 chain linkage, v2 payload schemas, key-purpose authority, and flight-recorder wrapping.

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.

Expand Down Expand Up @@ -56,7 +56,7 @@ key_hex = directory.public_key_hex()
result = pipelock_verify.verify(receipt_bytes, public_key_hex=key_hex)
```

### Receipt chain
### ActionReceipt v1 chain

Pass a flight-recorder JSONL path:

Expand All @@ -75,6 +75,11 @@ When no trust anchor is supplied, the first receipt's `signer_key` becomes
the expected key for the rest of the chain. This matches the signer-
consistency check in Go's `receipt.VerifyChain`.

`verify_chain()` intentionally fails closed when the chain contains
`EvidenceReceipt v2` entries. v0.2.0 verifies v2 receipts one at a time with
`verify()` or `verify_evidence()`; v2 chain verification is reserved for a
follow-up release once the cross-version chain-linking rules are specified.

### CLI

```bash
Expand Down Expand Up @@ -192,14 +197,17 @@ On a single **EvidenceReceipt v2**:
- Optional trust anchors: `public_key_hex`, `expected_signer_key_id`,
`expected_key_purpose`.

On a **chain**:
On an **ActionReceipt v1 chain**:

- Every individual receipt above (v1 or v2).
- Every individual ActionReceipt v1 above.
- Signer consistency across the chain.
- Monotonic `chain_seq` starting at 0.
- `chain_prev_hash` linkage via SHA-256 of canonical envelopes.
- First receipt's `chain_prev_hash` equals `"genesis"`.

EvidenceReceipt v2 entries are rejected in chain mode with an explicit
unsupported-v2-chain error. Verify them individually in v0.2.0.

## Input formats

`verify_chain()` accepts JSONL in two shapes:
Expand All @@ -209,8 +217,9 @@ On a **chain**:
"action_receipt"` and the receipt nested in `detail`. Non-receipt
entries (checkpoints etc.) are skipped, not rejected.
2. **Bare receipts** -- one receipt object per line, no wrapping. Used by
the conformance suite and handy for ad-hoc testing. Both v1 and v2
bare receipts are recognized.
the conformance suite and handy for ad-hoc testing. ActionReceipt v1 bare
receipts are verified as a chain. EvidenceReceipt v2 bare receipts are
rejected in chain mode and should be verified individually.

`verify()` accepts:

Expand All @@ -224,8 +233,8 @@ On a **chain**:
* Conformance suite: https://github.com/luckyPipewrench/pipelock/tree/main/sdk/conformance
* Spec page: https://pipelab.org/learn/action-receipt-spec/

Both implementations verify the same `sdk/conformance/testdata/` golden
files and compute identical root hashes.
Both implementations verify the same single-receipt v2 golden files. For
chain root hashes, v0.2.0 parity is limited to ActionReceipt v1 chains.

## Development

Expand Down
34 changes: 34 additions & 0 deletions fuzz/receipt_fuzzer.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
"""Atheris fuzz target for receipt parsing and verification paths.

The target intentionally ignores verifier outcomes: invalid receipts are expected.
It only treats uncaught exceptions as findings.
"""

from __future__ import annotations

import sys
from pathlib import Path

import atheris

sys.path.insert(0, str(Path(__file__).resolve().parents[1]))

with atheris.instrument_imports():
import pipelock_verify


def TestOneInput(data: bytes) -> None:
if len(data) > 16384:
data = data[:16384]

text = data.decode("utf-8", errors="ignore")
pipelock_verify.verify(text)


def main() -> None:
atheris.Setup(sys.argv, TestOneInput)
atheris.Fuzz()


if __name__ == "__main__":
main()
8 changes: 5 additions & 3 deletions pipelock_verify/__init__.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
"""Pipelock receipt verifier.

Verifies Ed25519-signed receipts emitted by the Pipelock mediator. Supports
both **ActionReceipt v1** (legacy) and **EvidenceReceipt v2** (contract-aware).
ActionReceipt v1 chains and individual EvidenceReceipt v2 envelopes.

Typical usage::

Expand All @@ -19,7 +19,7 @@
expected_key_purpose="receipt-signing",
)

# Receipt chain from a flight recorder JSONL file.
# ActionReceipt v1 chain from a flight recorder JSONL file.
chain = pipelock_verify.verify_chain("evidence-proxy-0.jsonl")
if not chain.valid:
raise SystemExit(f"chain broken at seq {chain.broken_at_seq}: {chain.error}")
Expand All @@ -30,7 +30,9 @@

Trust anchors are opt-in. Pass ``public_key_hex`` to pin a specific signer,
or leave it empty to trust the key embedded in the receipt (chain mode then
enforces signer consistency across every receipt in the file).
enforces signer consistency across every v1 receipt in the file). v0.2.0
rejects EvidenceReceipt v2 in chain mode; verify v2 receipts individually
with verify() or verify_evidence().

Wire format: see https://pipelab.org/learn/action-receipt-spec/ for field
layout, canonicalization rules, and the exact signing input.
Expand Down
7 changes: 3 additions & 4 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -5,10 +5,10 @@ build-backend = "setuptools.build_meta"
[project]
name = "pipelock-verify"
version = "0.2.0"
description = "Verify Pipelock receipts: ActionReceipt v1 and EvidenceReceipt v2 (Ed25519-signed, chain-linked)."
description = "Verify Pipelock receipts: ActionReceipt v1 chains and individual EvidenceReceipt v2 envelopes."
readme = "README.md"
license = { text = "Apache-2.0" }
requires-python = ">=3.9"
license = "Apache-2.0"
requires-python = ">=3.9.2"
authors = [
{ name = "PipeLab", email = "luckypipe@pipelab.org" },
]
Expand All @@ -24,7 +24,6 @@ classifiers = [
"Development Status :: 3 - Alpha",
"Intended Audience :: Developers",
"Intended Audience :: System Administrators",
"License :: OSI Approved :: Apache Software License",
"Operating System :: OS Independent",
"Programming Language :: Python :: 3",
"Programming Language :: Python :: 3.9",
Expand Down
Loading
Loading