pipelock-verify 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,49 @@
1
+ """Pipelock action receipt verifier.
2
+
3
+ Verifies Ed25519-signed action receipts emitted by the Pipelock mediator.
4
+ Matches the Go reference implementation in ``internal/receipt`` byte-for-byte
5
+ so that receipts generated by the Pipelock binary verify identically in Go
6
+ and Python.
7
+
8
+ Typical usage::
9
+
10
+ import pipelock_verify
11
+
12
+ # Single receipt from a JSON string, bytes, or already-parsed dict.
13
+ result = pipelock_verify.verify(receipt_json)
14
+ if not result.valid:
15
+ raise SystemExit(f"bad receipt: {result.error}")
16
+
17
+ # Receipt chain from a flight recorder JSONL file.
18
+ chain = pipelock_verify.verify_chain("evidence-proxy-0.jsonl")
19
+ if not chain.valid:
20
+ raise SystemExit(f"chain broken at seq {chain.broken_at_seq}: {chain.error}")
21
+
22
+ Trust anchors are opt-in. Pass ``public_key_hex`` to pin a specific signer,
23
+ or leave it empty to trust the key embedded in the receipt (chain mode then
24
+ enforces signer consistency across every receipt in the file).
25
+
26
+ Wire format: see https://pipelab.org/learn/action-receipt-spec/ for field
27
+ layout, canonicalization rules, and the exact signing input. The format is
28
+ the Go ``json.Marshal`` output of the ``Receipt`` struct; this library
29
+ mirrors it.
30
+ """
31
+
32
+ from ._verify import (
33
+ ChainResult,
34
+ InvalidReceiptError,
35
+ VerifyResult,
36
+ verify,
37
+ verify_chain,
38
+ )
39
+
40
+ __version__ = "0.1.0"
41
+
42
+ __all__ = [
43
+ "ChainResult",
44
+ "InvalidReceiptError",
45
+ "VerifyResult",
46
+ "__version__",
47
+ "verify",
48
+ "verify_chain",
49
+ ]
@@ -0,0 +1,140 @@
1
+ """Command-line interface for ``python -m pipelock_verify``.
2
+
3
+ Verifies a single receipt JSON file or a flight recorder JSONL chain.
4
+ Exit code 0 on success, 1 on failure — same convention as the Go
5
+ ``pipelock verify-receipt`` command.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import argparse
11
+ import sys
12
+ from collections.abc import Sequence
13
+ from pathlib import Path
14
+
15
+ from . import __version__
16
+ from ._verify import ChainResult, VerifyResult, verify, verify_chain
17
+
18
+
19
+ def _detect_mode(path: Path) -> str:
20
+ """Return ``"chain"`` for JSONL files, ``"single"`` otherwise."""
21
+ if path.suffix.lower() == ".jsonl":
22
+ return "chain"
23
+ return "single"
24
+
25
+
26
+ def _print_single(result: VerifyResult, path: Path) -> None:
27
+ if not result.valid:
28
+ print(f"FAILED: {path}: {result.error}")
29
+ return
30
+ print(f"OK: {path}")
31
+ if result.action_id:
32
+ print(f" Action ID: {result.action_id}")
33
+ if result.action_type:
34
+ print(f" Action Type: {result.action_type}")
35
+ if result.verdict:
36
+ print(f" Verdict: {result.verdict}")
37
+ if result.target:
38
+ print(f" Target: {result.target}")
39
+ if result.transport:
40
+ print(f" Transport: {result.transport}")
41
+ if result.timestamp:
42
+ print(f" Timestamp: {result.timestamp}")
43
+ if result.signer_key:
44
+ print(f" Signer: {result.signer_key}")
45
+ if result.chain_seq is not None:
46
+ print(f" Chain seq: {result.chain_seq}")
47
+ if result.chain_prev_hash:
48
+ print(f" Chain prev: {result.chain_prev_hash}")
49
+
50
+
51
+ def _print_chain(result: ChainResult, path: Path) -> None:
52
+ if not result.valid:
53
+ print(f"CHAIN BROKEN: {path}")
54
+ if result.error:
55
+ print(f" Error: {result.error}")
56
+ if result.broken_at_seq is not None:
57
+ print(f" Broke at: seq {result.broken_at_seq}")
58
+ return
59
+ print(f"CHAIN VALID: {path}")
60
+ print(f" Receipts: {result.receipt_count}")
61
+ if result.final_seq is not None:
62
+ print(f" Final seq: {result.final_seq}")
63
+ if result.root_hash:
64
+ print(f" Root hash: {result.root_hash}")
65
+ if result.start_time:
66
+ print(f" Start: {result.start_time}")
67
+ if result.end_time:
68
+ print(f" End: {result.end_time}")
69
+
70
+
71
+ def main(argv: Sequence[str] | None = None) -> int:
72
+ parser = argparse.ArgumentParser(
73
+ prog="pipelock_verify",
74
+ description=(
75
+ "Verify Pipelock action receipts. "
76
+ "Pass a single receipt JSON file or a JSONL flight recorder "
77
+ "chain. Exit 0 on success, 1 on failure."
78
+ ),
79
+ )
80
+ parser.add_argument(
81
+ "path",
82
+ type=Path,
83
+ help="receipt JSON file (single) or JSONL file (chain)",
84
+ )
85
+ parser.add_argument(
86
+ "--key",
87
+ dest="public_key_hex",
88
+ help=(
89
+ "expected signer public key, hex-encoded. When omitted, the "
90
+ "embedded signer_key is trusted; chain mode pins it to the "
91
+ "first receipt and enforces consistency across the chain."
92
+ ),
93
+ )
94
+ parser.add_argument(
95
+ "--mode",
96
+ choices=("single", "chain", "auto"),
97
+ default="auto",
98
+ help="force single or chain mode; default auto-detects from file suffix",
99
+ )
100
+ parser.add_argument(
101
+ "--version",
102
+ action="version",
103
+ version=f"pipelock-verify {__version__}",
104
+ )
105
+
106
+ args = parser.parse_args(argv)
107
+ path: Path = args.path
108
+
109
+ if not path.exists():
110
+ print(f"FAILED: {path}: file not found", file=sys.stderr)
111
+ return 1
112
+
113
+ mode = args.mode if args.mode != "auto" else _detect_mode(path)
114
+
115
+ if mode == "chain":
116
+ chain_result = verify_chain(path, args.public_key_hex)
117
+ # Empty files are rejected at the CLI layer to match the Go CLI's
118
+ # behavior (internal/cli/signing/receipt.go returns an error when
119
+ # the extracted receipt list is empty). The library function keeps
120
+ # the permissive "empty is vacuously valid" shape that mirrors
121
+ # Go's receipt.VerifyChain.
122
+ if chain_result.valid and chain_result.receipt_count == 0:
123
+ print(f"No receipts found in {path}")
124
+ return 1
125
+ _print_chain(chain_result, path)
126
+ return 0 if chain_result.valid else 1
127
+
128
+ try:
129
+ data = path.read_bytes()
130
+ except OSError as exc:
131
+ print(f"FAILED: {path}: {exc}", file=sys.stderr)
132
+ return 1
133
+
134
+ single_result = verify(data, args.public_key_hex)
135
+ _print_single(single_result, path)
136
+ return 0 if single_result.valid else 1
137
+
138
+
139
+ if __name__ == "__main__":
140
+ raise SystemExit(main())
@@ -0,0 +1,176 @@
1
+ """Go-compatible JSON canonicalization for action records and receipts.
2
+
3
+ The signing input for a Pipelock receipt is the SHA-256 of the canonical
4
+ JSON encoding of the inner ``ActionRecord``. "Canonical" here means
5
+ "byte-identical to what Go's ``encoding/json`` produces when marshalling
6
+ the ``receipt.ActionRecord`` struct." That contract is what this module
7
+ reproduces. The same rules apply when hashing the full ``Receipt`` envelope
8
+ for chain linkage.
9
+
10
+ What Go does that matters:
11
+
12
+ * Fields are emitted in **struct declaration order**, not alphabetical.
13
+ * Fields tagged ``omitempty`` are dropped when the value is the Go zero value
14
+ (``""`` for strings, empty slice, ``0``, ``false``, nil).
15
+ * Compact output: no whitespace between tokens.
16
+ * HTML-safe escaping: ``<``, ``>``, ``&``, U+2028, U+2029 are escaped as
17
+ ``\\u003c``, ``\\u003e``, ``\\u0026``, ``\\u2028``, ``\\u2029`` even inside
18
+ strings that would otherwise be valid JSON.
19
+
20
+ Any deviation from these rules produces different bytes, which produces a
21
+ different SHA-256, which fails signature verification. There is no slack.
22
+ """
23
+
24
+ from __future__ import annotations
25
+
26
+ import json
27
+ from typing import Any
28
+
29
+ # ActionRecord fields in Go struct-tag order. Each tuple is
30
+ # (json_name, has_omitempty). Source of truth:
31
+ # https://github.com/luckyPipewrench/pipelock/blob/main/internal/receipt/action.go
32
+ _ACTION_RECORD_FIELDS: list[tuple[str, bool]] = [
33
+ ("version", False),
34
+ ("action_id", False),
35
+ ("action_type", False),
36
+ ("timestamp", False),
37
+ ("principal", False),
38
+ ("actor", False),
39
+ ("delegation_chain", False),
40
+ ("target", False),
41
+ ("intent", True),
42
+ ("data_classes_in", True),
43
+ ("data_classes_out", True),
44
+ ("side_effect_class", False),
45
+ ("reversibility", False),
46
+ ("policy_hash", False),
47
+ ("verdict", False),
48
+ ("transport", False),
49
+ ("method", True),
50
+ ("layer", True),
51
+ ("pattern", True),
52
+ ("request_id", True),
53
+ ("chain_prev_hash", False),
54
+ ("chain_seq", False),
55
+ ("venue", True),
56
+ ("jurisdiction", True),
57
+ ("rulebook_id", True),
58
+ ("remedy_class", True),
59
+ ("contestation_window", True),
60
+ ("precedent_refs", True),
61
+ ]
62
+
63
+ # Receipt envelope fields in Go struct-tag order. Source of truth:
64
+ # https://github.com/luckyPipewrench/pipelock/blob/main/internal/receipt/receipt.go
65
+ _RECEIPT_FIELDS: list[tuple[str, bool]] = [
66
+ ("version", False),
67
+ ("action_record", False),
68
+ ("signature", False),
69
+ ("signer_key", False),
70
+ ]
71
+
72
+
73
+ def _is_go_zero(value: Any) -> bool:
74
+ """Return True if ``value`` matches Go's ``omitempty`` zero value.
75
+
76
+ Go drops a field tagged ``omitempty`` when the value is the zero value
77
+ for its type: ``""``, empty/nil slice, empty/nil map, ``0``, ``false``,
78
+ or ``nil``. Python equivalents:
79
+
80
+ * ``None`` (nil)
81
+ * ``""`` (string zero)
82
+ * ``[]`` (nil or empty slice)
83
+ * ``{}`` (nil or empty map)
84
+ * ``False``
85
+ * ``0`` / ``0.0``
86
+ """
87
+ if value is None:
88
+ return True
89
+ if isinstance(value, bool):
90
+ return not value
91
+ if isinstance(value, (int, float)):
92
+ return value == 0
93
+ if isinstance(value, str):
94
+ return value == ""
95
+ if isinstance(value, (list, tuple, dict)):
96
+ return len(value) == 0
97
+ return False
98
+
99
+
100
+ def _order_action_record(ar: dict[str, Any]) -> dict[str, Any]:
101
+ """Return a new dict with ActionRecord fields in Go's canonical order.
102
+
103
+ Missing fields are skipped. ``omitempty`` fields with zero values are
104
+ dropped. Unknown fields are ignored: Go's ``json.Unmarshal`` would drop
105
+ them, and the re-serialized canonical form should not include them.
106
+ """
107
+ ordered: dict[str, Any] = {}
108
+ for name, omitempty in _ACTION_RECORD_FIELDS:
109
+ if name not in ar:
110
+ continue
111
+ value = ar[name]
112
+ if omitempty and _is_go_zero(value):
113
+ continue
114
+ ordered[name] = value
115
+ return ordered
116
+
117
+
118
+ def _order_receipt(receipt: dict[str, Any]) -> dict[str, Any]:
119
+ """Return a new dict with Receipt fields in Go's canonical order.
120
+
121
+ The nested ``action_record`` is also reordered, so this function alone
122
+ is enough to produce canonical bytes for the full envelope (used when
123
+ computing chain prev_hash linkage).
124
+ """
125
+ ordered: dict[str, Any] = {}
126
+ for name, omitempty in _RECEIPT_FIELDS:
127
+ if name not in receipt:
128
+ continue
129
+ value = receipt[name]
130
+ if omitempty and _is_go_zero(value):
131
+ continue
132
+ if name == "action_record" and isinstance(value, dict):
133
+ value = _order_action_record(value)
134
+ ordered[name] = value
135
+ return ordered
136
+
137
+
138
+ def _go_html_escape(serialized: str) -> str:
139
+ """Apply Go's default HTML-safe escaping to a JSON string.
140
+
141
+ Go's ``encoding/json`` escapes ``<``, ``>``, ``&`` and the Unicode line
142
+ separators U+2028, U+2029 even inside string values. Python's
143
+ ``json.dumps`` does not. This post-processes the Python output so it
144
+ matches Go byte-for-byte.
145
+ """
146
+ return (
147
+ serialized.replace("<", "\\u003c")
148
+ .replace(">", "\\u003e")
149
+ .replace("&", "\\u0026")
150
+ .replace("\u2028", "\\u2028")
151
+ .replace("\u2029", "\\u2029")
152
+ )
153
+
154
+
155
+ def _to_canonical_bytes(obj: Any) -> bytes:
156
+ """Serialize an ordered dict to canonical UTF-8 bytes."""
157
+ raw = json.dumps(obj, separators=(",", ":"), ensure_ascii=False)
158
+ return _go_html_escape(raw).encode("utf-8")
159
+
160
+
161
+ def canonicalize_action_record(ar: dict[str, Any]) -> bytes:
162
+ """Return the signing input bytes for an action record.
163
+
164
+ The returned bytes are what Go's ``ActionRecord.Canonical()`` produces
165
+ and what gets SHA-256-hashed before Ed25519 signing.
166
+ """
167
+ return _to_canonical_bytes(_order_action_record(ar))
168
+
169
+
170
+ def canonicalize_receipt(receipt: dict[str, Any]) -> bytes:
171
+ """Return the chain-linking bytes for a full receipt envelope.
172
+
173
+ The returned bytes are what Go's ``json.Marshal(receipt)`` produces
174
+ and what gets SHA-256-hashed to form the next receipt's ``chain_prev_hash``.
175
+ """
176
+ return _to_canonical_bytes(_order_receipt(receipt))
@@ -0,0 +1,471 @@
1
+ """Core receipt and chain verification."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import hashlib
6
+ import json
7
+ import re
8
+ from dataclasses import dataclass
9
+ from datetime import datetime
10
+ from pathlib import Path
11
+ from typing import Any
12
+
13
+ from cryptography.exceptions import InvalidSignature
14
+ from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PublicKey
15
+
16
+ from ._canonical import canonicalize_action_record, canonicalize_receipt
17
+
18
+ # Wire format constants — keep in sync with internal/receipt/receipt.go.
19
+ _RECEIPT_VERSION = 1
20
+ _ACTION_RECORD_VERSION = 1
21
+ _SIGNATURE_PREFIX = "ed25519:"
22
+ _GENESIS_HASH = "genesis"
23
+
24
+ # Ed25519 sizes (RFC 8032): 32-byte public key, 64-byte signature.
25
+ _PUBLIC_KEY_LEN = 32
26
+ _SIGNATURE_LEN = 64
27
+
28
+ # Flight-recorder entry type for receipts. Matches
29
+ # internal/receipt/emitter.go recorderEntryType.
30
+ _RECORDER_ENTRY_TYPE = "action_receipt"
31
+
32
+ # Valid action_type enum values. Matches the allActionTypes map in
33
+ # internal/receipt/action.go. A verifier that does not enforce this set
34
+ # will accept Go-rejected receipts (e.g. action_type: "bogus"), which
35
+ # breaks cross-implementation agreement. Order mirrors the Go source.
36
+ _VALID_ACTION_TYPES = frozenset(
37
+ {
38
+ "read",
39
+ "derive",
40
+ "write",
41
+ "delegate",
42
+ "authorize",
43
+ "spend",
44
+ "commit",
45
+ "actuate",
46
+ "unclassified",
47
+ }
48
+ )
49
+
50
+ # RFC 3339 / time.RFC3339Nano shape check. Go's time.Time.UnmarshalJSON
51
+ # accepts:
52
+ #
53
+ # 2006-01-02T15:04:05Z (no fractional seconds)
54
+ # 2006-01-02T15:04:05.999999999Z (up to 9 fractional digits)
55
+ # 2006-01-02T15:04:05.999999999+07:00 (numeric offset)
56
+ #
57
+ # It rejects anything else, including lower-case "t"/"z", missing timezone,
58
+ # or non-numeric content. The regex below enforces the shape; a follow-up
59
+ # datetime.fromisoformat() call catches semantic errors like month 13 or
60
+ # day 32. Both checks must pass or the timestamp is invalid.
61
+ _RFC3339_RE = re.compile(r"^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d{1,9})?(Z|[+-]\d{2}:\d{2})$")
62
+
63
+
64
+ def _is_valid_rfc3339(value: Any) -> bool:
65
+ """Return True if value parses as an RFC 3339 timestamp Go would accept.
66
+
67
+ Python's datetime.fromisoformat accepts the ``Z`` suffix natively only
68
+ on 3.11+, so we substitute it with ``+00:00`` before parsing to support
69
+ 3.9 and 3.10 as well.
70
+ """
71
+ if not isinstance(value, str):
72
+ return False
73
+ if not _RFC3339_RE.match(value):
74
+ return False
75
+ candidate = value[:-1] + "+00:00" if value.endswith("Z") else value
76
+ try:
77
+ datetime.fromisoformat(candidate)
78
+ except ValueError:
79
+ return False
80
+ return True
81
+
82
+
83
+ class InvalidReceiptError(Exception):
84
+ """Raised when a receipt cannot be parsed as JSON at all."""
85
+
86
+
87
+ @dataclass
88
+ class VerifyResult:
89
+ """Outcome of verifying a single receipt.
90
+
91
+ ``valid`` is the only field guaranteed to be set. Descriptive fields
92
+ are populated on success (and may be populated on some failures) for
93
+ diagnostic output. ``error`` holds a short reason string on failure.
94
+ """
95
+
96
+ valid: bool
97
+ error: str | None = None
98
+ action_id: str | None = None
99
+ action_type: str | None = None
100
+ verdict: str | None = None
101
+ target: str | None = None
102
+ transport: str | None = None
103
+ signer_key: str | None = None
104
+ chain_seq: int | None = None
105
+ chain_prev_hash: str | None = None
106
+ timestamp: str | None = None
107
+
108
+
109
+ @dataclass
110
+ class ChainResult:
111
+ """Outcome of verifying a receipt chain.
112
+
113
+ Mirrors ``receipt.ChainResult`` in the Go implementation: on break,
114
+ ``broken_at_seq`` identifies where the chain failed, and ``error``
115
+ describes why. On success, ``root_hash`` is the hash of the final
116
+ receipt, suitable for publishing as a transcript root.
117
+ """
118
+
119
+ valid: bool
120
+ error: str | None = None
121
+ broken_at_seq: int | None = None
122
+ receipt_count: int = 0
123
+ final_seq: int | None = None
124
+ root_hash: str | None = None
125
+ start_time: str | None = None
126
+ end_time: str | None = None
127
+
128
+
129
+ def verify(
130
+ source: str | bytes | dict[str, Any],
131
+ public_key_hex: str | None = None,
132
+ ) -> VerifyResult:
133
+ """Verify a single action receipt.
134
+
135
+ Args:
136
+ source: Receipt as a JSON string, UTF-8 bytes, or a pre-parsed dict.
137
+ public_key_hex: Optional trust anchor. When supplied, the receipt's
138
+ ``signer_key`` field must match this value (hex-encoded 32-byte
139
+ Ed25519 public key). When omitted, the embedded ``signer_key``
140
+ is trusted.
141
+
142
+ Returns:
143
+ A :class:`VerifyResult`. ``result.valid`` is ``True`` only when the
144
+ signature verifies and every structural check passes.
145
+ """
146
+ try:
147
+ parsed = _parse_receipt(source)
148
+ except json.JSONDecodeError as exc:
149
+ return VerifyResult(valid=False, error=f"parsing receipt: {exc}")
150
+ except InvalidReceiptError as exc:
151
+ return VerifyResult(valid=False, error=str(exc))
152
+
153
+ # Transparently unwrap a flight-recorder entry wrapper if present so
154
+ # callers can feed either bare receipt JSON or an entry dump.
155
+ try:
156
+ receipt = _extract_receipt(parsed)
157
+ except InvalidReceiptError as exc:
158
+ return VerifyResult(valid=False, error=str(exc))
159
+ if receipt is None:
160
+ return VerifyResult(
161
+ valid=False, error="flight-recorder entry does not carry an action receipt"
162
+ )
163
+
164
+ return _verify_receipt_dict(receipt, public_key_hex)
165
+
166
+
167
+ def verify_chain(
168
+ jsonl_path: str | Path,
169
+ public_key_hex: str | None = None,
170
+ ) -> ChainResult:
171
+ """Verify a receipt chain from a flight recorder JSONL file.
172
+
173
+ Args:
174
+ jsonl_path: Path to a JSONL file with one receipt per line. Empty
175
+ lines are ignored.
176
+ public_key_hex: Optional trust anchor. When omitted, the
177
+ ``signer_key`` of the first receipt is taken as the expected
178
+ key and every subsequent receipt must share it. This matches
179
+ the Go ``VerifyChain`` signer-consistency check.
180
+
181
+ Returns:
182
+ A :class:`ChainResult`. On failure, ``broken_at_seq`` and ``error``
183
+ locate the first receipt that failed.
184
+ """
185
+ try:
186
+ receipts = _read_jsonl(Path(jsonl_path))
187
+ except FileNotFoundError as exc:
188
+ return ChainResult(valid=False, error=f"reading file: {exc}")
189
+ except json.JSONDecodeError as exc:
190
+ return ChainResult(valid=False, error=f"parsing JSONL: {exc}")
191
+ except InvalidReceiptError as exc:
192
+ # _read_jsonl raises this when a line parses as JSON but doesn't
193
+ # look like a receipt or a flight-recorder entry. Surface it as a
194
+ # normal failure result instead of propagating a raw exception.
195
+ return ChainResult(valid=False, error=f"parsing JSONL: {exc}")
196
+
197
+ return _verify_chain_list(receipts, public_key_hex)
198
+
199
+
200
+ # ---- internals ----
201
+
202
+
203
+ def _parse_receipt(source: str | bytes | dict[str, Any]) -> dict[str, Any]:
204
+ if isinstance(source, dict):
205
+ return source
206
+ if isinstance(source, bytes):
207
+ source = source.decode("utf-8")
208
+ if not isinstance(source, str):
209
+ raise InvalidReceiptError(
210
+ f"unsupported source type {type(source).__name__}; expected str, bytes, or dict"
211
+ )
212
+ parsed = json.loads(source)
213
+ if not isinstance(parsed, dict):
214
+ raise InvalidReceiptError("receipt must be a JSON object")
215
+ return parsed
216
+
217
+
218
+ def _extract_receipt(parsed: dict[str, Any]) -> dict[str, Any] | None:
219
+ """Pull a receipt dict out of a parsed JSONL line.
220
+
221
+ Accepts two formats:
222
+
223
+ 1. **Bare receipt** — the top-level object already has ``action_record``
224
+ and ``signature``. Returned as-is.
225
+ 2. **Flight-recorder entry** — a wrapper with ``type == "action_receipt"``
226
+ and the receipt sitting in ``detail`` (either as an object or as a
227
+ JSON-encoded string, since Go emits it via ``json.RawMessage``).
228
+
229
+ Returns ``None`` for flight-recorder entries whose type is not a
230
+ receipt (checkpoints, other event types) so ``_read_jsonl`` can skip
231
+ them without aborting the chain.
232
+ """
233
+ # Flight-recorder entry.
234
+ if "type" in parsed and "detail" in parsed:
235
+ if parsed.get("type") != _RECORDER_ENTRY_TYPE:
236
+ return None
237
+ detail = parsed["detail"]
238
+ if isinstance(detail, dict):
239
+ return detail
240
+ if isinstance(detail, (str, bytes)):
241
+ decoded = json.loads(detail)
242
+ if not isinstance(decoded, dict):
243
+ raise InvalidReceiptError("flight-recorder detail JSON did not decode to an object")
244
+ return decoded
245
+ raise InvalidReceiptError(
246
+ f"flight-recorder detail has unexpected type {type(detail).__name__}"
247
+ )
248
+
249
+ # Bare receipt.
250
+ if "action_record" in parsed and "signature" in parsed:
251
+ return parsed
252
+
253
+ raise InvalidReceiptError("unrecognized JSONL line: not a receipt or flight-recorder entry")
254
+
255
+
256
+ def _read_jsonl(path: Path) -> list[dict[str, Any]]:
257
+ text = path.read_text(encoding="utf-8")
258
+ receipts: list[dict[str, Any]] = []
259
+ for lineno, raw in enumerate(text.splitlines(), start=1):
260
+ line = raw.strip()
261
+ if not line:
262
+ continue
263
+ try:
264
+ parsed = json.loads(line)
265
+ except json.JSONDecodeError as exc:
266
+ raise json.JSONDecodeError(f"line {lineno}: {exc.msg}", exc.doc, exc.pos) from exc
267
+ if not isinstance(parsed, dict):
268
+ raise json.JSONDecodeError(f"line {lineno}: JSONL entry must be a JSON object", line, 0)
269
+ receipt = _extract_receipt(parsed)
270
+ if receipt is None:
271
+ # Non-receipt recorder entry (checkpoint, other event). Skip
272
+ # instead of failing the whole chain — matches Go ExtractReceipts.
273
+ continue
274
+ receipts.append(receipt)
275
+ return receipts
276
+
277
+
278
+ def _verify_receipt_dict(
279
+ receipt: dict[str, Any],
280
+ expected_key_hex: str | None,
281
+ ) -> VerifyResult:
282
+ version = receipt.get("version")
283
+ if version != _RECEIPT_VERSION:
284
+ return VerifyResult(
285
+ valid=False,
286
+ error=(f"unsupported receipt version {version} (expected {_RECEIPT_VERSION})"),
287
+ )
288
+
289
+ action_record = receipt.get("action_record")
290
+ if not isinstance(action_record, dict):
291
+ return VerifyResult(valid=False, error="missing or invalid action_record")
292
+
293
+ ar_version = action_record.get("version")
294
+ if ar_version != _ACTION_RECORD_VERSION:
295
+ return VerifyResult(
296
+ valid=False,
297
+ error=(
298
+ f"unsupported action_record version {ar_version} "
299
+ f"(expected {_ACTION_RECORD_VERSION})"
300
+ ),
301
+ )
302
+
303
+ # Match internal/receipt/action.go Validate() exactly. Go's error order
304
+ # and messages are part of the cross-implementation contract:
305
+ #
306
+ # 1. action_id presence -> "action_id is required"
307
+ # 2. action_type enum membership -> 'invalid action_type "<value>"'
308
+ # (rejects both empty "" and any non-enum value with the same
309
+ # wording; the required-field sweep does NOT cover action_type)
310
+ # 3. timestamp presence -> "timestamp is required"
311
+ # 4. target presence -> "target is required"
312
+ # 5. verdict presence -> "verdict is required"
313
+ # 6. transport presence -> "transport is required"
314
+ #
315
+ # Timestamp validity is enforced BEFORE the signature check because Go
316
+ # parses the receipt into a typed struct first; a malformed timestamp
317
+ # there fails json.Unmarshal and never reaches signature verification.
318
+ # Python parses into a dict, so we have to replay the same check
319
+ # manually or an attacker can ship "timestamp": "not-a-time" with a
320
+ # valid signature over the canonical garbage and Python will accept it.
321
+ if not action_record.get("action_id"):
322
+ return VerifyResult(valid=False, error="invalid action record: action_id is required")
323
+
324
+ action_type = action_record.get("action_type", "")
325
+ if action_type not in _VALID_ACTION_TYPES:
326
+ return VerifyResult(
327
+ valid=False,
328
+ error=f'invalid action record: invalid action_type "{action_type}"',
329
+ )
330
+
331
+ timestamp = action_record.get("timestamp")
332
+ if not timestamp:
333
+ return VerifyResult(valid=False, error="invalid action record: timestamp is required")
334
+ if not _is_valid_rfc3339(timestamp):
335
+ # Go surfaces this as a json.Unmarshal error; Python surfaces it as
336
+ # its own diagnostic so the fix point is obvious. The net effect —
337
+ # "signed receipt with a bogus timestamp is rejected" — matches.
338
+ return VerifyResult(
339
+ valid=False,
340
+ error=(f'unmarshal receipt: invalid RFC 3339 timestamp "{timestamp}"'),
341
+ )
342
+
343
+ for required in ("target", "verdict", "transport"):
344
+ if not action_record.get(required):
345
+ return VerifyResult(
346
+ valid=False,
347
+ error=f"invalid action record: {required} is required",
348
+ )
349
+
350
+ signature_str = receipt.get("signature", "")
351
+ if not signature_str:
352
+ return VerifyResult(valid=False, error="receipt has no signature")
353
+ if not isinstance(signature_str, str) or not signature_str.startswith(_SIGNATURE_PREFIX):
354
+ return VerifyResult(
355
+ valid=False,
356
+ error=f"invalid signature format: missing {_SIGNATURE_PREFIX} prefix",
357
+ )
358
+
359
+ signer_key_hex = receipt.get("signer_key", "")
360
+ if not signer_key_hex:
361
+ return VerifyResult(valid=False, error="receipt has no signer_key")
362
+
363
+ if expected_key_hex and signer_key_hex != expected_key_hex:
364
+ return VerifyResult(
365
+ valid=False,
366
+ error=(f"signer_key {signer_key_hex} does not match expected key {expected_key_hex}"),
367
+ )
368
+
369
+ sig_hex = signature_str[len(_SIGNATURE_PREFIX) :]
370
+ try:
371
+ sig_bytes = bytes.fromhex(sig_hex)
372
+ except ValueError as exc:
373
+ return VerifyResult(valid=False, error=f"decoding signature: {exc}")
374
+ if len(sig_bytes) != _SIGNATURE_LEN:
375
+ return VerifyResult(
376
+ valid=False,
377
+ error=(f"invalid signature length: got {len(sig_bytes)}, want {_SIGNATURE_LEN}"),
378
+ )
379
+
380
+ try:
381
+ pub_key_bytes = bytes.fromhex(signer_key_hex)
382
+ except ValueError as exc:
383
+ return VerifyResult(valid=False, error=f"decoding signer_key: {exc}")
384
+ if len(pub_key_bytes) != _PUBLIC_KEY_LEN:
385
+ return VerifyResult(
386
+ valid=False,
387
+ error=(f"invalid signer_key length: got {len(pub_key_bytes)}, want {_PUBLIC_KEY_LEN}"),
388
+ )
389
+
390
+ canonical = canonicalize_action_record(action_record)
391
+ signing_hash = hashlib.sha256(canonical).digest()
392
+
393
+ try:
394
+ Ed25519PublicKey.from_public_bytes(pub_key_bytes).verify(sig_bytes, signing_hash)
395
+ except InvalidSignature:
396
+ return VerifyResult(valid=False, error="signature verification failed")
397
+
398
+ return VerifyResult(
399
+ valid=True,
400
+ action_id=action_record.get("action_id"),
401
+ action_type=action_record.get("action_type"),
402
+ verdict=action_record.get("verdict"),
403
+ target=action_record.get("target"),
404
+ transport=action_record.get("transport"),
405
+ signer_key=signer_key_hex,
406
+ chain_seq=action_record.get("chain_seq"),
407
+ chain_prev_hash=action_record.get("chain_prev_hash"),
408
+ timestamp=action_record.get("timestamp"),
409
+ )
410
+
411
+
412
+ def _compute_receipt_hash(receipt: dict[str, Any]) -> str:
413
+ """Chain linkage hash: SHA-256 hex of canonical receipt envelope.
414
+
415
+ Matches ``receipt.ReceiptHash`` in ``internal/receipt/chain.go``.
416
+ """
417
+ canonical = canonicalize_receipt(receipt)
418
+ return hashlib.sha256(canonical).hexdigest()
419
+
420
+
421
+ def _verify_chain_list(
422
+ receipts: list[dict[str, Any]],
423
+ public_key_hex: str | None,
424
+ ) -> ChainResult:
425
+ if not receipts:
426
+ return ChainResult(valid=True, receipt_count=0)
427
+
428
+ # When no key is pinned, lock to the first receipt's signer_key so an
429
+ # attacker can't splice receipts from a second signer into the chain.
430
+ expected_key = public_key_hex or receipts[0].get("signer_key", "")
431
+
432
+ prev_hash = _GENESIS_HASH
433
+ for i, receipt in enumerate(receipts):
434
+ ar = receipt.get("action_record") or {}
435
+ seq = ar.get("chain_seq", i)
436
+
437
+ result = _verify_receipt_dict(receipt, expected_key)
438
+ if not result.valid:
439
+ return ChainResult(
440
+ valid=False,
441
+ broken_at_seq=seq,
442
+ error=f"seq {seq}: signature: {result.error}",
443
+ )
444
+
445
+ if seq != i:
446
+ return ChainResult(
447
+ valid=False,
448
+ broken_at_seq=seq,
449
+ error=f"seq gap: expected {i}, got {seq}",
450
+ )
451
+
452
+ actual_prev = ar.get("chain_prev_hash", "")
453
+ if actual_prev != prev_hash:
454
+ return ChainResult(
455
+ valid=False,
456
+ broken_at_seq=seq,
457
+ error=f"seq {seq}: chain_prev_hash mismatch",
458
+ )
459
+
460
+ prev_hash = _compute_receipt_hash(receipt)
461
+
462
+ first_ar = receipts[0].get("action_record") or {}
463
+ last_ar = receipts[-1].get("action_record") or {}
464
+ return ChainResult(
465
+ valid=True,
466
+ receipt_count=len(receipts),
467
+ final_seq=last_ar.get("chain_seq"),
468
+ root_hash=prev_hash,
469
+ start_time=first_ar.get("timestamp"),
470
+ end_time=last_ar.get("timestamp"),
471
+ )
File without changes
@@ -0,0 +1,205 @@
1
+ Metadata-Version: 2.4
2
+ Name: pipelock-verify
3
+ Version: 0.1.0
4
+ Summary: Verify Pipelock action receipts (Ed25519-signed, chain-linked).
5
+ Author-email: PipeLab <luckypipe@pipelab.org>
6
+ License: Apache-2.0
7
+ Project-URL: Homepage, https://pipelab.org
8
+ Project-URL: Repository, https://github.com/luckyPipewrench/pipelock-verify-python
9
+ Project-URL: Pipelock (Go reference), https://github.com/luckyPipewrench/pipelock
10
+ Project-URL: Receipt Spec, https://pipelab.org/learn/action-receipt-spec/
11
+ Keywords: pipelock,agent-security,receipt,ed25519,verifier,ai-safety
12
+ Classifier: Development Status :: 3 - Alpha
13
+ Classifier: Intended Audience :: Developers
14
+ Classifier: Intended Audience :: System Administrators
15
+ Classifier: License :: OSI Approved :: Apache Software License
16
+ Classifier: Operating System :: OS Independent
17
+ Classifier: Programming Language :: Python :: 3
18
+ Classifier: Programming Language :: Python :: 3.9
19
+ Classifier: Programming Language :: Python :: 3.10
20
+ Classifier: Programming Language :: Python :: 3.11
21
+ Classifier: Programming Language :: Python :: 3.12
22
+ Classifier: Programming Language :: Python :: 3.13
23
+ Classifier: Topic :: Security
24
+ Classifier: Topic :: Security :: Cryptography
25
+ Classifier: Topic :: Software Development :: Libraries :: Python Modules
26
+ Requires-Python: >=3.9
27
+ Description-Content-Type: text/markdown
28
+ License-File: LICENSE
29
+ Requires-Dist: cryptography>=41.0
30
+ Provides-Extra: dev
31
+ Requires-Dist: pytest>=7.0; extra == "dev"
32
+ Requires-Dist: pytest-cov>=4.0; extra == "dev"
33
+ Requires-Dist: ruff>=0.4; extra == "dev"
34
+ Requires-Dist: mypy>=1.8; extra == "dev"
35
+ Dynamic: license-file
36
+
37
+ # pipelock-verify
38
+
39
+ [![PyPI version](https://img.shields.io/pypi/v/pipelock-verify.svg)](https://pypi.org/project/pipelock-verify/)
40
+ [![Python versions](https://img.shields.io/pypi/pyversions/pipelock-verify.svg)](https://pypi.org/project/pipelock-verify/)
41
+ [![CI](https://github.com/luckyPipewrench/pipelock-verify-python/actions/workflows/ci.yml/badge.svg)](https://github.com/luckyPipewrench/pipelock-verify-python/actions/workflows/ci.yml)
42
+ [![License: Apache-2.0](https://img.shields.io/badge/License-Apache_2.0-blue.svg)](LICENSE)
43
+
44
+ Python verifier for [Pipelock](https://github.com/luckyPipewrench/pipelock)
45
+ action receipts. Verifies the Ed25519 signature, chain linkage, and
46
+ flight-recorder wrapping of receipts emitted by the Pipelock mediator.
47
+
48
+ The library mirrors the Go reference implementation byte-for-byte. The
49
+ conformance golden files in `tests/conformance/` are generated by Pipelock's
50
+ Go code and verified identically by both sides.
51
+
52
+ ## Install
53
+
54
+ ```bash
55
+ pip install pipelock-verify
56
+ ```
57
+
58
+ Only one runtime dependency: [`cryptography`](https://cryptography.io) for
59
+ the Ed25519 primitives.
60
+
61
+ ## Usage
62
+
63
+ ### Single receipt
64
+
65
+ ```python
66
+ import pipelock_verify
67
+
68
+ with open("receipt.json", "rb") as f:
69
+ result = pipelock_verify.verify(f.read())
70
+
71
+ if not result.valid:
72
+ raise SystemExit(f"bad receipt: {result.error}")
73
+
74
+ print(f"OK: {result.action_id} {result.verdict} {result.target}")
75
+ ```
76
+
77
+ Pin a specific signing key to reject receipts from any other signer:
78
+
79
+ ```python
80
+ PROD_KEY = "70b991eb77816fc4ef0ae6a54d8a4119ddc5a16c9711c332c39e743079f6c63e"
81
+ result = pipelock_verify.verify(receipt_bytes, public_key_hex=PROD_KEY)
82
+ ```
83
+
84
+ ### Receipt chain
85
+
86
+ Pass a flight-recorder JSONL path:
87
+
88
+ ```python
89
+ chain = pipelock_verify.verify_chain("evidence-proxy-0.jsonl")
90
+
91
+ if not chain.valid:
92
+ raise SystemExit(
93
+ f"chain broken at seq {chain.broken_at_seq}: {chain.error}"
94
+ )
95
+
96
+ print(f"CHAIN VALID: {chain.receipt_count} receipts, root {chain.root_hash}")
97
+ ```
98
+
99
+ When no trust anchor is supplied, the first receipt's `signer_key` becomes
100
+ the expected key for the rest of the chain. This matches the signer-
101
+ consistency check in Go's `receipt.VerifyChain`.
102
+
103
+ ### CLI
104
+
105
+ ```bash
106
+ python -m pipelock_verify receipt.json
107
+ python -m pipelock_verify evidence.jsonl
108
+ python -m pipelock_verify evidence.jsonl --key 70b991eb77816fc4...
109
+ ```
110
+
111
+ Exit codes match `pipelock verify-receipt`: 0 on success, 1 on failure.
112
+
113
+ ## What gets verified
114
+
115
+ On a single receipt:
116
+
117
+ - Envelope version (rejects anything other than v1).
118
+ - Action record version (rejects anything other than v1).
119
+ - Required action record fields (`action_id`, `action_type`, `timestamp`,
120
+ `target`, `verdict`, `transport`).
121
+ - Signature format (`ed25519:<hex>` prefix, 64-byte length).
122
+ - Signer key format (32-byte hex).
123
+ - Optional trust anchor match (`public_key_hex` argument).
124
+ - Ed25519 signature over `SHA-256(canonical action record)`.
125
+
126
+ On a chain:
127
+
128
+ - Every individual receipt above.
129
+ - Signer consistency (every receipt uses the same `signer_key`, or the
130
+ pinned trust anchor if one was supplied).
131
+ - Monotonic `chain_seq` starting at 0.
132
+ - `chain_prev_hash` linkage: each receipt's `chain_prev_hash` equals
133
+ `SHA-256` of the previous receipt's canonical envelope, in hex.
134
+ - First receipt's `chain_prev_hash` equals the literal string `"genesis"`.
135
+
136
+ Failing receipts return the first break point (`broken_at_seq`) and a
137
+ descriptive `error`, the same shape the Go CLI prints.
138
+
139
+ ## Input formats
140
+
141
+ `verify_chain()` accepts JSONL in two shapes:
142
+
143
+ 1. **Flight-recorder entries** — the format Pipelock actually writes to
144
+ disk. Each line is a `recorder.Entry` object with `type ==
145
+ "action_receipt"` and the receipt nested in `detail`. Non-receipt
146
+ entries (checkpoints etc.) are skipped, not rejected.
147
+ 2. **Bare receipts** — one receipt object per line, no wrapping. Used by
148
+ the conformance suite and handy for ad-hoc testing.
149
+
150
+ `verify()` accepts:
151
+
152
+ - A JSON string or UTF-8 bytes.
153
+ - A pre-parsed `dict` (for callers that already have the receipt loaded).
154
+ - A flight-recorder entry dict (transparently unwrapped).
155
+
156
+ ## Canonicalization rules
157
+
158
+ The signing input is the SHA-256 of the Go `json.Marshal` output of the
159
+ `ActionRecord` struct. "Canonical" means matching that exactly:
160
+
161
+ - Fields emitted in Go struct declaration order (not alphabetical).
162
+ - `omitempty` fields dropped when the value is the Go zero value
163
+ (`""`, empty slice, `0`, `false`, `nil`).
164
+ - Compact JSON (no whitespace between tokens).
165
+ - HTML-safe escapes: `<`, `>`, `&`, U+2028, U+2029 encoded as Unicode
166
+ escapes, matching Go's default `encoding/json` behavior.
167
+ - Fields unknown to the v1 schema are dropped (matches Go
168
+ `json.Unmarshal` round-trip behavior).
169
+
170
+ Any deviation produces different bytes, a different hash, and a failed
171
+ signature. See `pipelock_verify/_canonical.py` for the full rule set.
172
+
173
+ ## Relationship to the Go reference
174
+
175
+ * Go reference: https://github.com/luckyPipewrench/pipelock/tree/main/internal/receipt
176
+ * Conformance suite: https://github.com/luckyPipewrench/pipelock/tree/main/sdk/conformance
177
+ * Spec page: https://pipelab.org/learn/action-receipt-spec/
178
+
179
+ Both implementations verify the same `sdk/conformance/testdata/` golden
180
+ files and compute identical root hashes.
181
+
182
+ ## Development
183
+
184
+ ```bash
185
+ git clone https://github.com/luckyPipewrench/pipelock-verify-python
186
+ cd pipelock-verify-python
187
+ python -m venv .venv
188
+ source .venv/bin/activate
189
+ pip install -e ".[dev]"
190
+ pytest
191
+ ```
192
+
193
+ To refresh the conformance fixtures from a local Pipelock checkout:
194
+
195
+ ```bash
196
+ cd /path/to/pipelock
197
+ go test ./sdk/conformance/ -run TestGenerateGoldenFiles -update
198
+ cp sdk/conformance/testdata/*.{json,jsonl} \
199
+ /path/to/pipelock-verify-python/tests/conformance/
200
+ pytest
201
+ ```
202
+
203
+ ## License
204
+
205
+ Apache 2.0. See [LICENSE](LICENSE).
@@ -0,0 +1,11 @@
1
+ pipelock_verify/__init__.py,sha256=Pn184SXU-bJsBo662X3VoZGvWS5GLz22NdBsguif7Rs,1486
2
+ pipelock_verify/__main__.py,sha256=Zz-Qa5geo05zFb7_R3kKWp7wsci-eRX9BjoILXRAPpA,4605
3
+ pipelock_verify/_canonical.py,sha256=Rbeua9GdUWu4P5pXhAWz_DYSgHOUU7Z_aA2VJ-tSteM,6066
4
+ pipelock_verify/_verify.py,sha256=87cZUyfYSQDdcsmEuo91GVkyrpKBpgWhbFO1fCUh2-M,17345
5
+ pipelock_verify/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
6
+ pipelock_verify-0.1.0.dist-info/licenses/LICENSE,sha256=PO99Z4PhxPnBmYkVqYiQkSva2Gl6ssNCWErSAjABpUk,736
7
+ pipelock_verify-0.1.0.dist-info/METADATA,sha256=CKt7s8GTO2YC14jvJ0jdymCN0bcbQDSjizei8LJcVHI,7319
8
+ pipelock_verify-0.1.0.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
9
+ pipelock_verify-0.1.0.dist-info/entry_points.txt,sha256=gfvMGUzm26-g2k4hqoZndoCfJpuZhE83L2UWKd7g_Xw,66
10
+ pipelock_verify-0.1.0.dist-info/top_level.txt,sha256=BCQ_f-JXtPiaHiyXK5UXMRRz6NEsW_PWLuEbTOcIhqg,16
11
+ pipelock_verify-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (82.0.1)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ pipelock-verify = pipelock_verify.__main__:main
@@ -0,0 +1,17 @@
1
+ Apache License
2
+ Version 2.0, January 2004
3
+ http://www.apache.org/licenses/
4
+
5
+ Copyright 2026 PipeLab
6
+
7
+ Licensed under the Apache License, Version 2.0 (the "License");
8
+ you may not use this file except in compliance with the License.
9
+ You may obtain a copy of the License at
10
+
11
+ http://www.apache.org/licenses/LICENSE-2.0
12
+
13
+ Unless required by applicable law or agreed to in writing, software
14
+ distributed under the License is distributed on an "AS IS" BASIS,
15
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
16
+ See the License for the specific language governing permissions and
17
+ limitations under the License.
@@ -0,0 +1 @@
1
+ pipelock_verify