pipelock-verify 0.1.0__tar.gz
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-0.1.0/LICENSE +17 -0
- pipelock_verify-0.1.0/PKG-INFO +205 -0
- pipelock_verify-0.1.0/README.md +169 -0
- pipelock_verify-0.1.0/pipelock_verify/__init__.py +49 -0
- pipelock_verify-0.1.0/pipelock_verify/__main__.py +140 -0
- pipelock_verify-0.1.0/pipelock_verify/_canonical.py +176 -0
- pipelock_verify-0.1.0/pipelock_verify/_verify.py +471 -0
- pipelock_verify-0.1.0/pipelock_verify/py.typed +0 -0
- pipelock_verify-0.1.0/pipelock_verify.egg-info/PKG-INFO +205 -0
- pipelock_verify-0.1.0/pipelock_verify.egg-info/SOURCES.txt +18 -0
- pipelock_verify-0.1.0/pipelock_verify.egg-info/dependency_links.txt +1 -0
- pipelock_verify-0.1.0/pipelock_verify.egg-info/entry_points.txt +2 -0
- pipelock_verify-0.1.0/pipelock_verify.egg-info/requires.txt +7 -0
- pipelock_verify-0.1.0/pipelock_verify.egg-info/top_level.txt +1 -0
- pipelock_verify-0.1.0/pyproject.toml +81 -0
- pipelock_verify-0.1.0/setup.cfg +4 -0
- pipelock_verify-0.1.0/tests/test_canonical.py +141 -0
- pipelock_verify-0.1.0/tests/test_cli.py +77 -0
- pipelock_verify-0.1.0/tests/test_conformance.py +131 -0
- pipelock_verify-0.1.0/tests/test_regressions.py +321 -0
|
@@ -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,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,169 @@
|
|
|
1
|
+
# pipelock-verify
|
|
2
|
+
|
|
3
|
+
[](https://pypi.org/project/pipelock-verify/)
|
|
4
|
+
[](https://pypi.org/project/pipelock-verify/)
|
|
5
|
+
[](https://github.com/luckyPipewrench/pipelock-verify-python/actions/workflows/ci.yml)
|
|
6
|
+
[](LICENSE)
|
|
7
|
+
|
|
8
|
+
Python verifier for [Pipelock](https://github.com/luckyPipewrench/pipelock)
|
|
9
|
+
action receipts. Verifies the Ed25519 signature, chain linkage, and
|
|
10
|
+
flight-recorder wrapping of receipts emitted by the Pipelock mediator.
|
|
11
|
+
|
|
12
|
+
The library mirrors the Go reference implementation byte-for-byte. The
|
|
13
|
+
conformance golden files in `tests/conformance/` are generated by Pipelock's
|
|
14
|
+
Go code and verified identically by both sides.
|
|
15
|
+
|
|
16
|
+
## Install
|
|
17
|
+
|
|
18
|
+
```bash
|
|
19
|
+
pip install pipelock-verify
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
Only one runtime dependency: [`cryptography`](https://cryptography.io) for
|
|
23
|
+
the Ed25519 primitives.
|
|
24
|
+
|
|
25
|
+
## Usage
|
|
26
|
+
|
|
27
|
+
### Single receipt
|
|
28
|
+
|
|
29
|
+
```python
|
|
30
|
+
import pipelock_verify
|
|
31
|
+
|
|
32
|
+
with open("receipt.json", "rb") as f:
|
|
33
|
+
result = pipelock_verify.verify(f.read())
|
|
34
|
+
|
|
35
|
+
if not result.valid:
|
|
36
|
+
raise SystemExit(f"bad receipt: {result.error}")
|
|
37
|
+
|
|
38
|
+
print(f"OK: {result.action_id} {result.verdict} {result.target}")
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
Pin a specific signing key to reject receipts from any other signer:
|
|
42
|
+
|
|
43
|
+
```python
|
|
44
|
+
PROD_KEY = "70b991eb77816fc4ef0ae6a54d8a4119ddc5a16c9711c332c39e743079f6c63e"
|
|
45
|
+
result = pipelock_verify.verify(receipt_bytes, public_key_hex=PROD_KEY)
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
### Receipt chain
|
|
49
|
+
|
|
50
|
+
Pass a flight-recorder JSONL path:
|
|
51
|
+
|
|
52
|
+
```python
|
|
53
|
+
chain = pipelock_verify.verify_chain("evidence-proxy-0.jsonl")
|
|
54
|
+
|
|
55
|
+
if not chain.valid:
|
|
56
|
+
raise SystemExit(
|
|
57
|
+
f"chain broken at seq {chain.broken_at_seq}: {chain.error}"
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
print(f"CHAIN VALID: {chain.receipt_count} receipts, root {chain.root_hash}")
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
When no trust anchor is supplied, the first receipt's `signer_key` becomes
|
|
64
|
+
the expected key for the rest of the chain. This matches the signer-
|
|
65
|
+
consistency check in Go's `receipt.VerifyChain`.
|
|
66
|
+
|
|
67
|
+
### CLI
|
|
68
|
+
|
|
69
|
+
```bash
|
|
70
|
+
python -m pipelock_verify receipt.json
|
|
71
|
+
python -m pipelock_verify evidence.jsonl
|
|
72
|
+
python -m pipelock_verify evidence.jsonl --key 70b991eb77816fc4...
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
Exit codes match `pipelock verify-receipt`: 0 on success, 1 on failure.
|
|
76
|
+
|
|
77
|
+
## What gets verified
|
|
78
|
+
|
|
79
|
+
On a single receipt:
|
|
80
|
+
|
|
81
|
+
- Envelope version (rejects anything other than v1).
|
|
82
|
+
- Action record version (rejects anything other than v1).
|
|
83
|
+
- Required action record fields (`action_id`, `action_type`, `timestamp`,
|
|
84
|
+
`target`, `verdict`, `transport`).
|
|
85
|
+
- Signature format (`ed25519:<hex>` prefix, 64-byte length).
|
|
86
|
+
- Signer key format (32-byte hex).
|
|
87
|
+
- Optional trust anchor match (`public_key_hex` argument).
|
|
88
|
+
- Ed25519 signature over `SHA-256(canonical action record)`.
|
|
89
|
+
|
|
90
|
+
On a chain:
|
|
91
|
+
|
|
92
|
+
- Every individual receipt above.
|
|
93
|
+
- Signer consistency (every receipt uses the same `signer_key`, or the
|
|
94
|
+
pinned trust anchor if one was supplied).
|
|
95
|
+
- Monotonic `chain_seq` starting at 0.
|
|
96
|
+
- `chain_prev_hash` linkage: each receipt's `chain_prev_hash` equals
|
|
97
|
+
`SHA-256` of the previous receipt's canonical envelope, in hex.
|
|
98
|
+
- First receipt's `chain_prev_hash` equals the literal string `"genesis"`.
|
|
99
|
+
|
|
100
|
+
Failing receipts return the first break point (`broken_at_seq`) and a
|
|
101
|
+
descriptive `error`, the same shape the Go CLI prints.
|
|
102
|
+
|
|
103
|
+
## Input formats
|
|
104
|
+
|
|
105
|
+
`verify_chain()` accepts JSONL in two shapes:
|
|
106
|
+
|
|
107
|
+
1. **Flight-recorder entries** — the format Pipelock actually writes to
|
|
108
|
+
disk. Each line is a `recorder.Entry` object with `type ==
|
|
109
|
+
"action_receipt"` and the receipt nested in `detail`. Non-receipt
|
|
110
|
+
entries (checkpoints etc.) are skipped, not rejected.
|
|
111
|
+
2. **Bare receipts** — one receipt object per line, no wrapping. Used by
|
|
112
|
+
the conformance suite and handy for ad-hoc testing.
|
|
113
|
+
|
|
114
|
+
`verify()` accepts:
|
|
115
|
+
|
|
116
|
+
- A JSON string or UTF-8 bytes.
|
|
117
|
+
- A pre-parsed `dict` (for callers that already have the receipt loaded).
|
|
118
|
+
- A flight-recorder entry dict (transparently unwrapped).
|
|
119
|
+
|
|
120
|
+
## Canonicalization rules
|
|
121
|
+
|
|
122
|
+
The signing input is the SHA-256 of the Go `json.Marshal` output of the
|
|
123
|
+
`ActionRecord` struct. "Canonical" means matching that exactly:
|
|
124
|
+
|
|
125
|
+
- Fields emitted in Go struct declaration order (not alphabetical).
|
|
126
|
+
- `omitempty` fields dropped when the value is the Go zero value
|
|
127
|
+
(`""`, empty slice, `0`, `false`, `nil`).
|
|
128
|
+
- Compact JSON (no whitespace between tokens).
|
|
129
|
+
- HTML-safe escapes: `<`, `>`, `&`, U+2028, U+2029 encoded as Unicode
|
|
130
|
+
escapes, matching Go's default `encoding/json` behavior.
|
|
131
|
+
- Fields unknown to the v1 schema are dropped (matches Go
|
|
132
|
+
`json.Unmarshal` round-trip behavior).
|
|
133
|
+
|
|
134
|
+
Any deviation produces different bytes, a different hash, and a failed
|
|
135
|
+
signature. See `pipelock_verify/_canonical.py` for the full rule set.
|
|
136
|
+
|
|
137
|
+
## Relationship to the Go reference
|
|
138
|
+
|
|
139
|
+
* Go reference: https://github.com/luckyPipewrench/pipelock/tree/main/internal/receipt
|
|
140
|
+
* Conformance suite: https://github.com/luckyPipewrench/pipelock/tree/main/sdk/conformance
|
|
141
|
+
* Spec page: https://pipelab.org/learn/action-receipt-spec/
|
|
142
|
+
|
|
143
|
+
Both implementations verify the same `sdk/conformance/testdata/` golden
|
|
144
|
+
files and compute identical root hashes.
|
|
145
|
+
|
|
146
|
+
## Development
|
|
147
|
+
|
|
148
|
+
```bash
|
|
149
|
+
git clone https://github.com/luckyPipewrench/pipelock-verify-python
|
|
150
|
+
cd pipelock-verify-python
|
|
151
|
+
python -m venv .venv
|
|
152
|
+
source .venv/bin/activate
|
|
153
|
+
pip install -e ".[dev]"
|
|
154
|
+
pytest
|
|
155
|
+
```
|
|
156
|
+
|
|
157
|
+
To refresh the conformance fixtures from a local Pipelock checkout:
|
|
158
|
+
|
|
159
|
+
```bash
|
|
160
|
+
cd /path/to/pipelock
|
|
161
|
+
go test ./sdk/conformance/ -run TestGenerateGoldenFiles -update
|
|
162
|
+
cp sdk/conformance/testdata/*.{json,jsonl} \
|
|
163
|
+
/path/to/pipelock-verify-python/tests/conformance/
|
|
164
|
+
pytest
|
|
165
|
+
```
|
|
166
|
+
|
|
167
|
+
## License
|
|
168
|
+
|
|
169
|
+
Apache 2.0. See [LICENSE](LICENSE).
|
|
@@ -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())
|