verify-proof 0.3.0__tar.gz → 0.3.2__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.
- {verify_proof-0.3.0/verify_proof.egg-info → verify_proof-0.3.2}/PKG-INFO +19 -4
- {verify_proof-0.3.0 → verify_proof-0.3.2}/README.md +17 -2
- {verify_proof-0.3.0 → verify_proof-0.3.2}/proofledger_api.py +134 -2
- {verify_proof-0.3.0 → verify_proof-0.3.2}/pyproject.toml +2 -2
- {verify_proof-0.3.0 → verify_proof-0.3.2/verify_proof.egg-info}/PKG-INFO +19 -4
- {verify_proof-0.3.0 → verify_proof-0.3.2}/verify_proof.py +34 -2
- {verify_proof-0.3.0 → verify_proof-0.3.2}/verify_proof_mcp.py +119 -6
- {verify_proof-0.3.0 → verify_proof-0.3.2}/LICENSE +0 -0
- {verify_proof-0.3.0 → verify_proof-0.3.2}/setup.cfg +0 -0
- {verify_proof-0.3.0 → verify_proof-0.3.2}/verify_proof.egg-info/SOURCES.txt +0 -0
- {verify_proof-0.3.0 → verify_proof-0.3.2}/verify_proof.egg-info/dependency_links.txt +0 -0
- {verify_proof-0.3.0 → verify_proof-0.3.2}/verify_proof.egg-info/entry_points.txt +0 -0
- {verify_proof-0.3.0 → verify_proof-0.3.2}/verify_proof.egg-info/requires.txt +0 -0
- {verify_proof-0.3.0 → verify_proof-0.3.2}/verify_proof.egg-info/top_level.txt +0 -0
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: verify-proof
|
|
3
|
-
Version: 0.3.
|
|
4
|
-
Summary: Verify blockchain-anchored timestamp proofs
|
|
3
|
+
Version: 0.3.2
|
|
4
|
+
Summary: Verify blockchain-anchored timestamp proofs offline, and create them through the ProofLedger API — CLI + MCP server
|
|
5
5
|
Author-email: Craig Solomon <craig@fulcrumenterprises.tech>
|
|
6
6
|
License-Expression: MIT
|
|
7
7
|
Project-URL: Homepage, https://github.com/Fulcrum-Enterprises/verify-proof
|
|
@@ -102,6 +102,16 @@ verify-proof verify document.pdf --proof proof.json
|
|
|
102
102
|
# Transaction: abc123... Anchored at: 2026-03-15T10:30:00Z
|
|
103
103
|
```
|
|
104
104
|
|
|
105
|
+
### Quiet by default
|
|
106
|
+
|
|
107
|
+
A successful `verify` prints one line to stderr saying where proofs like it come
|
|
108
|
+
from. It is suppressed whenever the output is piped or redirected, so scripts
|
|
109
|
+
never see it, and stdout is unchanged either way. To silence it everywhere:
|
|
110
|
+
|
|
111
|
+
```bash
|
|
112
|
+
export VERIFY_PROOF_NO_HINT=1
|
|
113
|
+
```
|
|
114
|
+
|
|
105
115
|
### Create a proof (needs a free API key)
|
|
106
116
|
|
|
107
117
|
Everything above works offline. This is the one command that does not: to prove
|
|
@@ -118,7 +128,9 @@ verify-proof create document.pdf --no-filename # send only the hash
|
|
|
118
128
|
Only the 64-character SHA-256 digest leaves your machine, plus the filename
|
|
119
129
|
unless you pass `--no-filename`. The file itself is never uploaded, on any
|
|
120
130
|
command. Free accounts include 25 API proofs a month and unlimited Polygon
|
|
121
|
-
anchoring through the web app:
|
|
131
|
+
anchoring through the web app:
|
|
132
|
+
|
|
133
|
+
[proofledger.io](https://proofledger.io/login.html?utm_source=verify-proof&utm_medium=readme&utm_campaign=create)
|
|
122
134
|
|
|
123
135
|
### Proof file format
|
|
124
136
|
|
|
@@ -159,7 +171,10 @@ The base install stays dependency-free; the `[mcp]` extra adds the MCP SDK and i
|
|
|
159
171
|
| `verify_file` | Verify a local file against a proof JSON file |
|
|
160
172
|
| `verify_hash` | Verify a known hash against inline or file-based proof data |
|
|
161
173
|
| `explain_proof` | Describe, in plain language, what a proof asserts and how to check it on a block explorer |
|
|
162
|
-
| `create_proof` | Anchor a file's hash on ProofLedger to create a new proof.
|
|
174
|
+
| `create_proof` | Anchor a file's hash on ProofLedger to create a new proof. Uses the network and needs an API key |
|
|
175
|
+
| `create_proofs_batch` | Anchor up to 100 files in one API call; file names double as external ids, so re-running returns the existing proofs. Network, API key |
|
|
176
|
+
| `get_proof_status` | Current anchoring status of one of your proofs. Network, API key |
|
|
177
|
+
| `save_proof_bundle` | Download a proof's bundle (this package's proof format) and save it next to the file, so verification needs nothing from ProofLedger later. Network, API key |
|
|
163
178
|
|
|
164
179
|
### Connect it to Claude Desktop
|
|
165
180
|
|
|
@@ -67,6 +67,16 @@ verify-proof verify document.pdf --proof proof.json
|
|
|
67
67
|
# Transaction: abc123... Anchored at: 2026-03-15T10:30:00Z
|
|
68
68
|
```
|
|
69
69
|
|
|
70
|
+
### Quiet by default
|
|
71
|
+
|
|
72
|
+
A successful `verify` prints one line to stderr saying where proofs like it come
|
|
73
|
+
from. It is suppressed whenever the output is piped or redirected, so scripts
|
|
74
|
+
never see it, and stdout is unchanged either way. To silence it everywhere:
|
|
75
|
+
|
|
76
|
+
```bash
|
|
77
|
+
export VERIFY_PROOF_NO_HINT=1
|
|
78
|
+
```
|
|
79
|
+
|
|
70
80
|
### Create a proof (needs a free API key)
|
|
71
81
|
|
|
72
82
|
Everything above works offline. This is the one command that does not: to prove
|
|
@@ -83,7 +93,9 @@ verify-proof create document.pdf --no-filename # send only the hash
|
|
|
83
93
|
Only the 64-character SHA-256 digest leaves your machine, plus the filename
|
|
84
94
|
unless you pass `--no-filename`. The file itself is never uploaded, on any
|
|
85
95
|
command. Free accounts include 25 API proofs a month and unlimited Polygon
|
|
86
|
-
anchoring through the web app:
|
|
96
|
+
anchoring through the web app:
|
|
97
|
+
|
|
98
|
+
[proofledger.io](https://proofledger.io/login.html?utm_source=verify-proof&utm_medium=readme&utm_campaign=create)
|
|
87
99
|
|
|
88
100
|
### Proof file format
|
|
89
101
|
|
|
@@ -124,7 +136,10 @@ The base install stays dependency-free; the `[mcp]` extra adds the MCP SDK and i
|
|
|
124
136
|
| `verify_file` | Verify a local file against a proof JSON file |
|
|
125
137
|
| `verify_hash` | Verify a known hash against inline or file-based proof data |
|
|
126
138
|
| `explain_proof` | Describe, in plain language, what a proof asserts and how to check it on a block explorer |
|
|
127
|
-
| `create_proof` | Anchor a file's hash on ProofLedger to create a new proof.
|
|
139
|
+
| `create_proof` | Anchor a file's hash on ProofLedger to create a new proof. Uses the network and needs an API key |
|
|
140
|
+
| `create_proofs_batch` | Anchor up to 100 files in one API call; file names double as external ids, so re-running returns the existing proofs. Network, API key |
|
|
141
|
+
| `get_proof_status` | Current anchoring status of one of your proofs. Network, API key |
|
|
142
|
+
| `save_proof_bundle` | Download a proof's bundle (this package's proof format) and save it next to the file, so verification needs nothing from ProofLedger later. Network, API key |
|
|
128
143
|
|
|
129
144
|
### Connect it to Claude Desktop
|
|
130
145
|
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
#!/usr/bin/env python3
|
|
2
|
-
"""proofledger_api — minimal client for
|
|
2
|
+
"""proofledger_api — minimal client for the ProofLedger API.
|
|
3
3
|
|
|
4
4
|
This is the one part of verify-proof that talks to a network. Everything else
|
|
5
5
|
in this package (hashing, verification, explanation) runs entirely offline and
|
|
@@ -17,9 +17,14 @@ Standard library only, so installing verify-proof still pulls in nothing.
|
|
|
17
17
|
|
|
18
18
|
from __future__ import annotations
|
|
19
19
|
|
|
20
|
+
import hashlib
|
|
21
|
+
import hmac
|
|
20
22
|
import json
|
|
23
|
+
import re
|
|
21
24
|
import os
|
|
25
|
+
import time
|
|
22
26
|
import urllib.error
|
|
27
|
+
import urllib.parse
|
|
23
28
|
import urllib.request
|
|
24
29
|
|
|
25
30
|
DEFAULT_BASE_URL = "https://proofledger.io"
|
|
@@ -31,6 +36,11 @@ API_KEY_ENV = "PROOFLEDGER_API_KEY"
|
|
|
31
36
|
_UTM = "utm_source=verify-proof&utm_medium=cli"
|
|
32
37
|
SIGNUP_URL = f"{DEFAULT_BASE_URL}/login.html?{_UTM}&utm_campaign=api-key"
|
|
33
38
|
PRICING_URL = f"{DEFAULT_BASE_URL}/pricing.html?{_UTM}&utm_campaign=quota"
|
|
39
|
+
# The one printed after a successful `verify`, which is the command almost every
|
|
40
|
+
# install actually runs. Its own campaign so the scoreboard can tell the people who
|
|
41
|
+
# arrived after verifying somebody else's proof from the ones who went looking for
|
|
42
|
+
# an API key.
|
|
43
|
+
HOME_URL = f"{DEFAULT_BASE_URL}/?{_UTM}&utm_campaign=verified"
|
|
34
44
|
|
|
35
45
|
|
|
36
46
|
class ProofLedgerError(RuntimeError):
|
|
@@ -111,6 +121,121 @@ def submit_proof(
|
|
|
111
121
|
) from exc
|
|
112
122
|
|
|
113
123
|
|
|
124
|
+
def _request(
|
|
125
|
+
method: str,
|
|
126
|
+
path: str,
|
|
127
|
+
payload: dict | None = None,
|
|
128
|
+
key: str | None = None,
|
|
129
|
+
base_url: str = DEFAULT_BASE_URL,
|
|
130
|
+
timeout: int = 30,
|
|
131
|
+
) -> dict:
|
|
132
|
+
"""One authenticated call to the v1 API. Errors become ProofLedgerError."""
|
|
133
|
+
request = urllib.request.Request(
|
|
134
|
+
f"{base_url.rstrip('/')}{path}",
|
|
135
|
+
data=None if payload is None else json.dumps(payload).encode("utf-8"),
|
|
136
|
+
headers={
|
|
137
|
+
"Authorization": f"Bearer {key or api_key()}",
|
|
138
|
+
"Content-Type": "application/json",
|
|
139
|
+
"User-Agent": "verify-proof (https://pypi.org/project/verify-proof/)",
|
|
140
|
+
},
|
|
141
|
+
method=method,
|
|
142
|
+
)
|
|
143
|
+
try:
|
|
144
|
+
with urllib.request.urlopen(request, timeout=timeout) as response:
|
|
145
|
+
body = response.read().decode("utf-8")
|
|
146
|
+
return json.loads(body) if body else {}
|
|
147
|
+
except urllib.error.HTTPError as exc:
|
|
148
|
+
raise ProofLedgerError(_http_message(exc)) from exc
|
|
149
|
+
except urllib.error.URLError as exc:
|
|
150
|
+
raise ProofLedgerError(
|
|
151
|
+
f"Could not reach {base_url}: {exc.reason}\n"
|
|
152
|
+
"Hashing and verification still work offline."
|
|
153
|
+
) from exc
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def _digest(sha256: str) -> str:
|
|
157
|
+
digest = sha256.strip().lower().removeprefix("0x")
|
|
158
|
+
if len(digest) != 64 or any(c not in "0123456789abcdef" for c in digest):
|
|
159
|
+
raise ProofLedgerError(f"Not a SHA-256 hex digest: {sha256!r}")
|
|
160
|
+
return digest
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def submit_batch(
|
|
164
|
+
items: list[dict],
|
|
165
|
+
key: str | None = None,
|
|
166
|
+
base_url: str = DEFAULT_BASE_URL,
|
|
167
|
+
timeout: int = 60,
|
|
168
|
+
) -> dict:
|
|
169
|
+
"""Anchor up to 100 hashes in one call (POST /api/v1/proofs/batch).
|
|
170
|
+
|
|
171
|
+
Each item: {"sha256": ..., "external_id": optional, "filename": optional}.
|
|
172
|
+
Items are checked first and written together; Polygon only. The response
|
|
173
|
+
has a `summary` and one entry per item with `result` created / exists /
|
|
174
|
+
invalid and, for the first two, the `proof`.
|
|
175
|
+
"""
|
|
176
|
+
if not items or len(items) > 100:
|
|
177
|
+
raise ProofLedgerError("submit_batch takes 1 to 100 items.")
|
|
178
|
+
payload = {"items": []}
|
|
179
|
+
for it in items:
|
|
180
|
+
out = {"sha256": _digest(it["sha256"])}
|
|
181
|
+
if it.get("external_id") is not None:
|
|
182
|
+
out["external_id"] = str(it["external_id"])
|
|
183
|
+
if it.get("filename"):
|
|
184
|
+
out["filename"] = str(it["filename"])[:500]
|
|
185
|
+
payload["items"].append(out)
|
|
186
|
+
return _request("POST", "/api/v1/proofs/batch", payload, key, base_url, timeout)
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
def get_proof(proof_id: int | str, key: str | None = None, base_url: str = DEFAULT_BASE_URL) -> dict:
|
|
190
|
+
"""One of your proofs by ProofLedger id (GET /api/v1/proof/:id)."""
|
|
191
|
+
return _request("GET", f"/api/v1/proof/{urllib.parse.quote(str(proof_id))}", None, key, base_url)
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def get_proof_by_external_id(external_id: str, key: str | None = None, base_url: str = DEFAULT_BASE_URL) -> dict | None:
|
|
195
|
+
"""One of your proofs by your own record id, or None when there is none."""
|
|
196
|
+
try:
|
|
197
|
+
r = _request("GET", "/api/v1/proofs?" + urllib.parse.urlencode({"external_id": external_id}), None, key, base_url)
|
|
198
|
+
except ProofLedgerError as exc:
|
|
199
|
+
if "404" in str(exc):
|
|
200
|
+
return None
|
|
201
|
+
raise
|
|
202
|
+
return r.get("proof")
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
def get_bundle(proof_id: int | str, chain: str = "polygon", key: str | None = None, base_url: str = DEFAULT_BASE_URL) -> dict:
|
|
206
|
+
"""The bundle for a proof (GET /api/v1/proof/:id/bundle): everything needed
|
|
207
|
+
to verify it without ProofLedger, in this package's proof-file format.
|
|
208
|
+
Save it next to the file and `verify-proof verify <file> --proof bundle.json`
|
|
209
|
+
works offline, for as long as the chain exists."""
|
|
210
|
+
if chain not in ("polygon", "bitcoin"):
|
|
211
|
+
raise ProofLedgerError("chain must be 'polygon' or 'bitcoin'")
|
|
212
|
+
return _request("GET", f"/api/v1/proof/{urllib.parse.quote(str(proof_id))}/bundle?chain={chain}", None, key, base_url)
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
def verify_webhook_signature(
|
|
216
|
+
secret: str,
|
|
217
|
+
header: str,
|
|
218
|
+
raw_body: bytes | str,
|
|
219
|
+
tolerance_seconds: int = 300,
|
|
220
|
+
now: int | None = None,
|
|
221
|
+
) -> bool:
|
|
222
|
+
"""Check a ProofLedger webhook delivery before trusting it.
|
|
223
|
+
|
|
224
|
+
ProofLedger sends `ProofLedger-Signature: t=<unix>,v1=<hex HMAC-SHA256(secret, t + "." + body)>`.
|
|
225
|
+
Pass the RAW request body exactly as received; re-serialising it breaks the MAC.
|
|
226
|
+
Pure computation, no network.
|
|
227
|
+
"""
|
|
228
|
+
m = re.fullmatch(r"t=(\d+),v1=([0-9a-f]{64})", str(header or ""))
|
|
229
|
+
if not m:
|
|
230
|
+
return False
|
|
231
|
+
t = int(m.group(1))
|
|
232
|
+
if abs((now if now is not None else int(time.time())) - t) > tolerance_seconds:
|
|
233
|
+
return False
|
|
234
|
+
body = raw_body if isinstance(raw_body, bytes) else str(raw_body).encode("utf-8")
|
|
235
|
+
expected = hmac.new(secret.encode("utf-8"), f"{t}.".encode("utf-8") + body, hashlib.sha256).hexdigest()
|
|
236
|
+
return hmac.compare_digest(expected, m.group(2))
|
|
237
|
+
|
|
238
|
+
|
|
114
239
|
def _http_message(exc: urllib.error.HTTPError) -> str:
|
|
115
240
|
"""Turn an API error into something the reader can act on."""
|
|
116
241
|
detail = ""
|
|
@@ -127,6 +252,12 @@ def _http_message(exc: urllib.error.HTTPError) -> str:
|
|
|
127
252
|
+ (f"\n({detail})" if detail else "")
|
|
128
253
|
)
|
|
129
254
|
if exc.code == 429:
|
|
255
|
+
retry_after = exc.headers.get("Retry-After") if exc.headers else None
|
|
256
|
+
if retry_after:
|
|
257
|
+
return (
|
|
258
|
+
f"Rate limit: 120 requests per minute per key (429). Retry after {retry_after} s.\n"
|
|
259
|
+
"One batch call carries up to 100 hashes." + (f"\n({detail})" if detail else "")
|
|
260
|
+
)
|
|
130
261
|
return (
|
|
131
262
|
"Monthly API proof limit reached (429).\n"
|
|
132
263
|
"Free and Standard include 25 proofs a month, Professional 500, "
|
|
@@ -178,7 +309,8 @@ def summarize(proof: dict, base_url: str = DEFAULT_BASE_URL) -> str:
|
|
|
178
309
|
lines += [
|
|
179
310
|
"",
|
|
180
311
|
"Anchoring is not instant. Polygon usually settles within minutes; "
|
|
181
|
-
"Bitcoin is
|
|
312
|
+
"Bitcoin, when requested, is anchored in hand-signed batches with no fixed "
|
|
313
|
+
"timeline. Re-check the proof, or verify the "
|
|
182
314
|
"downloaded proof file offline with: verify-proof verify <file> "
|
|
183
315
|
"--proof <proof.json>",
|
|
184
316
|
]
|
|
@@ -4,8 +4,8 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "verify-proof"
|
|
7
|
-
version = "0.3.
|
|
8
|
-
description = "Verify blockchain-anchored timestamp proofs
|
|
7
|
+
version = "0.3.2"
|
|
8
|
+
description = "Verify blockchain-anchored timestamp proofs offline, and create them through the ProofLedger API — CLI + MCP server"
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
license = "MIT"
|
|
11
11
|
requires-python = ">=3.10"
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: verify-proof
|
|
3
|
-
Version: 0.3.
|
|
4
|
-
Summary: Verify blockchain-anchored timestamp proofs
|
|
3
|
+
Version: 0.3.2
|
|
4
|
+
Summary: Verify blockchain-anchored timestamp proofs offline, and create them through the ProofLedger API — CLI + MCP server
|
|
5
5
|
Author-email: Craig Solomon <craig@fulcrumenterprises.tech>
|
|
6
6
|
License-Expression: MIT
|
|
7
7
|
Project-URL: Homepage, https://github.com/Fulcrum-Enterprises/verify-proof
|
|
@@ -102,6 +102,16 @@ verify-proof verify document.pdf --proof proof.json
|
|
|
102
102
|
# Transaction: abc123... Anchored at: 2026-03-15T10:30:00Z
|
|
103
103
|
```
|
|
104
104
|
|
|
105
|
+
### Quiet by default
|
|
106
|
+
|
|
107
|
+
A successful `verify` prints one line to stderr saying where proofs like it come
|
|
108
|
+
from. It is suppressed whenever the output is piped or redirected, so scripts
|
|
109
|
+
never see it, and stdout is unchanged either way. To silence it everywhere:
|
|
110
|
+
|
|
111
|
+
```bash
|
|
112
|
+
export VERIFY_PROOF_NO_HINT=1
|
|
113
|
+
```
|
|
114
|
+
|
|
105
115
|
### Create a proof (needs a free API key)
|
|
106
116
|
|
|
107
117
|
Everything above works offline. This is the one command that does not: to prove
|
|
@@ -118,7 +128,9 @@ verify-proof create document.pdf --no-filename # send only the hash
|
|
|
118
128
|
Only the 64-character SHA-256 digest leaves your machine, plus the filename
|
|
119
129
|
unless you pass `--no-filename`. The file itself is never uploaded, on any
|
|
120
130
|
command. Free accounts include 25 API proofs a month and unlimited Polygon
|
|
121
|
-
anchoring through the web app:
|
|
131
|
+
anchoring through the web app:
|
|
132
|
+
|
|
133
|
+
[proofledger.io](https://proofledger.io/login.html?utm_source=verify-proof&utm_medium=readme&utm_campaign=create)
|
|
122
134
|
|
|
123
135
|
### Proof file format
|
|
124
136
|
|
|
@@ -159,7 +171,10 @@ The base install stays dependency-free; the `[mcp]` extra adds the MCP SDK and i
|
|
|
159
171
|
| `verify_file` | Verify a local file against a proof JSON file |
|
|
160
172
|
| `verify_hash` | Verify a known hash against inline or file-based proof data |
|
|
161
173
|
| `explain_proof` | Describe, in plain language, what a proof asserts and how to check it on a block explorer |
|
|
162
|
-
| `create_proof` | Anchor a file's hash on ProofLedger to create a new proof.
|
|
174
|
+
| `create_proof` | Anchor a file's hash on ProofLedger to create a new proof. Uses the network and needs an API key |
|
|
175
|
+
| `create_proofs_batch` | Anchor up to 100 files in one API call; file names double as external ids, so re-running returns the existing proofs. Network, API key |
|
|
176
|
+
| `get_proof_status` | Current anchoring status of one of your proofs. Network, API key |
|
|
177
|
+
| `save_proof_bundle` | Download a proof's bundle (this package's proof format) and save it next to the file, so verification needs nothing from ProofLedger later. Network, API key |
|
|
163
178
|
|
|
164
179
|
### Connect it to Claude Desktop
|
|
165
180
|
|
|
@@ -8,7 +8,7 @@ Supports verification of:
|
|
|
8
8
|
- SHA-256 file hashes against Bitcoin and Polygon blockchain anchors
|
|
9
9
|
- Proof of creation timestamps for digital content
|
|
10
10
|
- Pre-loss evidence timestamps for legal/insurance records (ProofLedger)
|
|
11
|
-
-
|
|
11
|
+
- JSON proof files carrying hash, blockchain, tx_id and merkle_path (ProofLedger bundles; binary OpenTimestamps .ots files are a different format and are not parsed here, use the ots tool)
|
|
12
12
|
|
|
13
13
|
Usage:
|
|
14
14
|
python verify_proof.py <file> [--proof <proof.json>] [--algorithm sha256]
|
|
@@ -36,12 +36,14 @@ immutable, independently verifiable timestamps that prove what existed and when.
|
|
|
36
36
|
import argparse
|
|
37
37
|
import hashlib
|
|
38
38
|
import json
|
|
39
|
+
import os
|
|
39
40
|
import sys
|
|
40
41
|
from pathlib import Path
|
|
41
42
|
|
|
42
43
|
from proofledger_api import (
|
|
43
44
|
API_KEY_ENV,
|
|
44
45
|
DEFAULT_BASE_URL,
|
|
46
|
+
HOME_URL,
|
|
45
47
|
ProofLedgerError,
|
|
46
48
|
submit_proof,
|
|
47
49
|
summarize,
|
|
@@ -122,12 +124,41 @@ def load_proof(proof_path: str) -> dict:
|
|
|
122
124
|
return json.load(f)
|
|
123
125
|
|
|
124
126
|
|
|
127
|
+
|
|
128
|
+
def _hint() -> None:
|
|
129
|
+
"""One quiet line after a successful verify, pointing at where proofs come from.
|
|
130
|
+
|
|
131
|
+
Almost everybody who installs this runs `verify`, and `verify` never touches
|
|
132
|
+
the network, which is the product. So the only tagged link the package ever
|
|
133
|
+
printed lived in the missing-key error on `create`, a path most users never
|
|
134
|
+
reach: 417 installs in a month produced 10 tagged arrivals in 120 days. The
|
|
135
|
+
tool was doing its job and saying nothing about who made it.
|
|
136
|
+
|
|
137
|
+
Three manners rules, because a dev tool that nags gets uninstalled and the
|
|
138
|
+
goodwill is the whole asset:
|
|
139
|
+
|
|
140
|
+
* **stderr, never stdout.** Somebody is parsing `VERIFIED: ...` in a script
|
|
141
|
+
and a marketing line in that stream would break them.
|
|
142
|
+
* **Only when a human is watching.** Not a TTY means piped or scripted, and
|
|
143
|
+
a pipeline does not need to hear from us.
|
|
144
|
+
* **One env var turns it off for good** (`VERIFY_PROOF_NO_HINT=1`).
|
|
145
|
+
"""
|
|
146
|
+
if os.environ.get("VERIFY_PROOF_NO_HINT"):
|
|
147
|
+
return
|
|
148
|
+
try:
|
|
149
|
+
if not sys.stdout.isatty():
|
|
150
|
+
return
|
|
151
|
+
except Exception: # pragma: no cover - exotic stdout
|
|
152
|
+
return
|
|
153
|
+
print(f"\n(proofs like this are made with ProofLedger: {HOME_URL})", file=sys.stderr)
|
|
154
|
+
|
|
155
|
+
|
|
125
156
|
def main():
|
|
126
157
|
parser = argparse.ArgumentParser(
|
|
127
158
|
prog="verify-proof",
|
|
128
159
|
description=(
|
|
129
160
|
"Verify blockchain-anchored timestamp proofs. "
|
|
130
|
-
"
|
|
161
|
+
"Reads JSON proofs (ProofLedger bundles and compatible); .ots files are not parsed."
|
|
131
162
|
),
|
|
132
163
|
epilog=(
|
|
133
164
|
"Examples:\n"
|
|
@@ -211,6 +242,7 @@ def main():
|
|
|
211
242
|
|
|
212
243
|
if result["verified"]:
|
|
213
244
|
print(f"VERIFIED: {result['message']}")
|
|
245
|
+
_hint()
|
|
214
246
|
sys.exit(0)
|
|
215
247
|
else:
|
|
216
248
|
print(f"FAILED: {result.get('error', 'Unknown error')}")
|
|
@@ -3,19 +3,22 @@
|
|
|
3
3
|
|
|
4
4
|
A Model Context Protocol (MCP) server that lets MCP-compatible clients
|
|
5
5
|
(Claude Desktop, Cursor, and any other MCP host) verify blockchain-anchored
|
|
6
|
-
timestamp proofs directly. It exposes the `verify-proof` library as
|
|
6
|
+
timestamp proofs directly. It exposes the `verify-proof` library as tools
|
|
7
7
|
so an assistant can, on the user's own machine:
|
|
8
8
|
|
|
9
9
|
- compute the SHA-256 hash of a local file (the file is never uploaded),
|
|
10
10
|
- verify a local file against a blockchain-anchored proof JSON,
|
|
11
11
|
- verify a known hash against inline proof data,
|
|
12
12
|
- explain in plain language what a proof file asserts,
|
|
13
|
-
- create a new proof by anchoring a file's hash on ProofLedger
|
|
13
|
+
- create a new proof by anchoring a file's hash on ProofLedger,
|
|
14
|
+
- create proofs for up to 100 files in one call, check a proof's status,
|
|
15
|
+
and save a proof's bundle so it verifies later without ProofLedger.
|
|
14
16
|
|
|
15
17
|
The first four run entirely offline and always will: verification that depends
|
|
16
|
-
on the issuing service is not verification.
|
|
17
|
-
|
|
18
|
-
|
|
18
|
+
on the issuing service is not verification. The ProofLedger tools (create,
|
|
19
|
+
batch, status, bundle) are the exception and say so in their own descriptions:
|
|
20
|
+
anchoring requires a service, and they send hashes (never files) to
|
|
21
|
+
ProofLedger, with a free API key.
|
|
19
22
|
|
|
20
23
|
Blockchain timestamping proves a file existed at a point in time by anchoring
|
|
21
24
|
its SHA-256 hash to a public blockchain (Polygon or Bitcoin). ProofLedger
|
|
@@ -47,7 +50,15 @@ except ImportError as exc: # pragma: no cover - depends on installed SDK
|
|
|
47
50
|
f"(underlying import error: {exc})"
|
|
48
51
|
)
|
|
49
52
|
|
|
50
|
-
from proofledger_api import
|
|
53
|
+
from proofledger_api import (
|
|
54
|
+
DEFAULT_BASE_URL,
|
|
55
|
+
ProofLedgerError,
|
|
56
|
+
get_bundle,
|
|
57
|
+
get_proof,
|
|
58
|
+
submit_batch,
|
|
59
|
+
submit_proof,
|
|
60
|
+
summarize,
|
|
61
|
+
)
|
|
51
62
|
from verify_proof import hash_file, load_proof
|
|
52
63
|
from verify_proof import verify_proof as _verify_proof
|
|
53
64
|
|
|
@@ -269,6 +280,108 @@ def create_proof(file_path: str, bitcoin: bool = False, send_filename: bool = Tr
|
|
|
269
280
|
return summarize(proof)
|
|
270
281
|
|
|
271
282
|
|
|
283
|
+
@mcp.tool()
|
|
284
|
+
def create_proofs_batch(file_paths: list[str], send_filenames: bool = True) -> str:
|
|
285
|
+
"""Create proofs for several local files in ONE ProofLedger API call (up to 100).
|
|
286
|
+
|
|
287
|
+
Uses the network and needs PROOFLEDGER_API_KEY, like create_proof. Each
|
|
288
|
+
file is hashed locally; only the hashes (and filenames, if allowed) are
|
|
289
|
+
sent. The file name is also used as the external_id, so running this twice
|
|
290
|
+
on the same files returns the existing proofs instead of creating more.
|
|
291
|
+
Polygon anchoring only.
|
|
292
|
+
|
|
293
|
+
Args:
|
|
294
|
+
file_paths: Paths of the files to timestamp, 1 to 100.
|
|
295
|
+
send_filenames: Send each file's name as its label. Set false to send
|
|
296
|
+
only the hashes (external ids are still the names).
|
|
297
|
+
"""
|
|
298
|
+
paths = [Path(p) for p in file_paths]
|
|
299
|
+
missing = [str(p) for p in paths if not p.is_file()]
|
|
300
|
+
if missing:
|
|
301
|
+
return "File not found: " + ", ".join(missing)
|
|
302
|
+
if not paths or len(paths) > 100:
|
|
303
|
+
return "Give between 1 and 100 files."
|
|
304
|
+
items = [
|
|
305
|
+
{"sha256": hash_file(str(p)), "external_id": p.name, "filename": p.name if send_filenames else None}
|
|
306
|
+
for p in paths
|
|
307
|
+
]
|
|
308
|
+
try:
|
|
309
|
+
r = submit_batch(items)
|
|
310
|
+
except ProofLedgerError as exc:
|
|
311
|
+
return str(exc)
|
|
312
|
+
summary = r.get("summary", {})
|
|
313
|
+
lines = [
|
|
314
|
+
f"Batch submitted: {summary.get('created', 0)} created, "
|
|
315
|
+
f"{summary.get('exists', 0)} already existed, {summary.get('invalid', 0)} invalid.",
|
|
316
|
+
"",
|
|
317
|
+
]
|
|
318
|
+
for it, p in zip(r.get("items", []), paths):
|
|
319
|
+
proof = it.get("proof") or {}
|
|
320
|
+
if it.get("result") in ("created", "exists"):
|
|
321
|
+
lines.append(
|
|
322
|
+
f" {p.name}: proof {proof.get('id')} ({it['result']}) "
|
|
323
|
+
f"{DEFAULT_BASE_URL}{proof.get('certificate_url', '')}"
|
|
324
|
+
)
|
|
325
|
+
else:
|
|
326
|
+
lines.append(f" {p.name}: {it.get('result')} - {it.get('error', '')}")
|
|
327
|
+
return "\n".join(lines)
|
|
328
|
+
|
|
329
|
+
|
|
330
|
+
@mcp.tool()
|
|
331
|
+
def get_proof_status(proof_id: int) -> str:
|
|
332
|
+
"""Fetch the current status of one of YOUR ProofLedger proofs by id.
|
|
333
|
+
|
|
334
|
+
Uses the network and needs PROOFLEDGER_API_KEY. Shows whether the Polygon
|
|
335
|
+
anchor has landed (usually within a minute of creation) and the Bitcoin
|
|
336
|
+
status when it was requested. For checking somebody else's proof, use
|
|
337
|
+
verify_file with their bundle instead; that needs no key and no network.
|
|
338
|
+
"""
|
|
339
|
+
try:
|
|
340
|
+
p = get_proof(proof_id)
|
|
341
|
+
except ProofLedgerError as exc:
|
|
342
|
+
return str(exc)
|
|
343
|
+
lines = [
|
|
344
|
+
f"Proof {p.get('id')} ({p.get('filename') or 'no filename'})",
|
|
345
|
+
f" SHA-256 : {p.get('sha256')}",
|
|
346
|
+
f" Polygon : {p.get('polygon_status')}" + (f" tx {p.get('chain_tx')}" if p.get("chain_tx") else ""),
|
|
347
|
+
f" Bitcoin : {p.get('bitcoin_status')}" + (f" tx {p.get('bitcoin_txid')}" if p.get("bitcoin_txid") else ""),
|
|
348
|
+
f" Created : {p.get('created_at')}",
|
|
349
|
+
f" Certificate : {DEFAULT_BASE_URL}{p.get('certificate_url', '')}",
|
|
350
|
+
]
|
|
351
|
+
if p.get("issued_via"):
|
|
352
|
+
lines.append(f" Recorded through {p['issued_via']} \u00b7 Anchored by ProofLedger")
|
|
353
|
+
return "\n".join(lines)
|
|
354
|
+
|
|
355
|
+
|
|
356
|
+
@mcp.tool()
|
|
357
|
+
def save_proof_bundle(proof_id: int, output_path: str, chain: str = "polygon") -> str:
|
|
358
|
+
"""Download the bundle for one of YOUR proofs and save it as a JSON file.
|
|
359
|
+
|
|
360
|
+
Uses the network and needs PROOFLEDGER_API_KEY. The bundle is everything
|
|
361
|
+
needed to verify the proof WITHOUT ProofLedger: hash, chain, transaction
|
|
362
|
+
id, anchor time and, for Bitcoin, the Merkle path. Save it next to the
|
|
363
|
+
file; `verify_file` (or `verify-proof verify`) then works offline, for as
|
|
364
|
+
long as the blockchain exists. Before the anchor lands the bundle says
|
|
365
|
+
anchored: false; fetch it again afterwards.
|
|
366
|
+
|
|
367
|
+
Args:
|
|
368
|
+
proof_id: The ProofLedger proof id.
|
|
369
|
+
output_path: Where to write the JSON (e.g. song.mp3.proof.json).
|
|
370
|
+
chain: "polygon" (default) or "bitcoin" for the top-level fields.
|
|
371
|
+
"""
|
|
372
|
+
try:
|
|
373
|
+
bundle = get_bundle(proof_id, chain=chain)
|
|
374
|
+
except ProofLedgerError as exc:
|
|
375
|
+
return str(exc)
|
|
376
|
+
out = Path(output_path)
|
|
377
|
+
out.write_text(json.dumps(bundle, indent=2), encoding="utf-8")
|
|
378
|
+
state = "anchored" if bundle.get("anchored") else "NOT YET ANCHORED (fetch again later)"
|
|
379
|
+
return (
|
|
380
|
+
f"Saved bundle for proof {proof_id} to {out} ({bundle.get('blockchain')}, {state}). "
|
|
381
|
+
f"Verify offline with: verify-proof verify <file> --proof {out}"
|
|
382
|
+
)
|
|
383
|
+
|
|
384
|
+
|
|
272
385
|
def main() -> None:
|
|
273
386
|
"""Entry point — run the server over stdio (the transport MCP hosts use)."""
|
|
274
387
|
mcp.run()
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|