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.
- pipelock_verify/__init__.py +49 -0
- pipelock_verify/__main__.py +140 -0
- pipelock_verify/_canonical.py +176 -0
- pipelock_verify/_verify.py +471 -0
- pipelock_verify/py.typed +0 -0
- pipelock_verify-0.1.0.dist-info/METADATA +205 -0
- pipelock_verify-0.1.0.dist-info/RECORD +11 -0
- pipelock_verify-0.1.0.dist-info/WHEEL +5 -0
- pipelock_verify-0.1.0.dist-info/entry_points.txt +2 -0
- pipelock_verify-0.1.0.dist-info/licenses/LICENSE +17 -0
- pipelock_verify-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -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
|
+
)
|
pipelock_verify/py.typed
ADDED
|
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
|
+
[](https://pypi.org/project/pipelock-verify/)
|
|
40
|
+
[](https://pypi.org/project/pipelock-verify/)
|
|
41
|
+
[](https://github.com/luckyPipewrench/pipelock-verify-python/actions/workflows/ci.yml)
|
|
42
|
+
[](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,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
|