verify-proof 0.3.1__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.
@@ -1,7 +1,7 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: verify-proof
3
- Version: 0.3.1
4
- Summary: Verify blockchain-anchored timestamp proofs (ProofLedger, OpenTimestamps) — CLI + MCP server
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
@@ -128,7 +128,9 @@ verify-proof create document.pdf --no-filename # send only the hash
128
128
  Only the 64-character SHA-256 digest leaves your machine, plus the filename
129
129
  unless you pass `--no-filename`. The file itself is never uploaded, on any
130
130
  command. Free accounts include 25 API proofs a month and unlimited Polygon
131
- anchoring through the web app: [proofledger.io](https://proofledger.io/login.html?utm_source=verify-proof&utm_medium=readme&utm_campaign=create).
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)
132
134
 
133
135
  ### Proof file format
134
136
 
@@ -169,7 +171,10 @@ The base install stays dependency-free; the `[mcp]` extra adds the MCP SDK and i
169
171
  | `verify_file` | Verify a local file against a proof JSON file |
170
172
  | `verify_hash` | Verify a known hash against inline or file-based proof data |
171
173
  | `explain_proof` | Describe, in plain language, what a proof asserts and how to check it on a block explorer |
172
- | `create_proof` | Anchor a file's hash on ProofLedger to create a new proof. The only tool that uses the network, and the only one needing an API key |
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 |
173
178
 
174
179
  ### Connect it to Claude Desktop
175
180
 
@@ -93,7 +93,9 @@ verify-proof create document.pdf --no-filename # send only the hash
93
93
  Only the 64-character SHA-256 digest leaves your machine, plus the filename
94
94
  unless you pass `--no-filename`. The file itself is never uploaded, on any
95
95
  command. Free accounts include 25 API proofs a month and unlimited Polygon
96
- anchoring through the web app: [proofledger.io](https://proofledger.io/login.html?utm_source=verify-proof&utm_medium=readme&utm_campaign=create).
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)
97
99
 
98
100
  ### Proof file format
99
101
 
@@ -134,7 +136,10 @@ The base install stays dependency-free; the `[mcp]` extra adds the MCP SDK and i
134
136
  | `verify_file` | Verify a local file against a proof JSON file |
135
137
  | `verify_hash` | Verify a known hash against inline or file-based proof data |
136
138
  | `explain_proof` | Describe, in plain language, what a proof asserts and how to check it on a block explorer |
137
- | `create_proof` | Anchor a file's hash on ProofLedger to create a new proof. The only tool that uses the network, and the only one needing an API key |
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 |
138
143
 
139
144
  ### Connect it to Claude Desktop
140
145
 
@@ -1,5 +1,5 @@
1
1
  #!/usr/bin/env python3
2
- """proofledger_api — minimal client for creating a ProofLedger timestamp proof.
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"
@@ -116,6 +121,121 @@ def submit_proof(
116
121
  ) from exc
117
122
 
118
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
+
119
239
  def _http_message(exc: urllib.error.HTTPError) -> str:
120
240
  """Turn an API error into something the reader can act on."""
121
241
  detail = ""
@@ -132,6 +252,12 @@ def _http_message(exc: urllib.error.HTTPError) -> str:
132
252
  + (f"\n({detail})" if detail else "")
133
253
  )
134
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
+ )
135
261
  return (
136
262
  "Monthly API proof limit reached (429).\n"
137
263
  "Free and Standard include 25 proofs a month, Professional 500, "
@@ -183,7 +309,8 @@ def summarize(proof: dict, base_url: str = DEFAULT_BASE_URL) -> str:
183
309
  lines += [
184
310
  "",
185
311
  "Anchoring is not instant. Polygon usually settles within minutes; "
186
- "Bitcoin is Merkle-batched daily. Re-check the proof, or verify the "
312
+ "Bitcoin, when requested, is anchored in hand-signed batches with no fixed "
313
+ "timeline. Re-check the proof, or verify the "
187
314
  "downloaded proof file offline with: verify-proof verify <file> "
188
315
  "--proof <proof.json>",
189
316
  ]
@@ -4,8 +4,8 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "verify-proof"
7
- version = "0.3.1"
8
- description = "Verify blockchain-anchored timestamp proofs (ProofLedger, OpenTimestamps) — CLI + MCP server"
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.1
4
- Summary: Verify blockchain-anchored timestamp proofs (ProofLedger, OpenTimestamps) — CLI + MCP server
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
@@ -128,7 +128,9 @@ verify-proof create document.pdf --no-filename # send only the hash
128
128
  Only the 64-character SHA-256 digest leaves your machine, plus the filename
129
129
  unless you pass `--no-filename`. The file itself is never uploaded, on any
130
130
  command. Free accounts include 25 API proofs a month and unlimited Polygon
131
- anchoring through the web app: [proofledger.io](https://proofledger.io/login.html?utm_source=verify-proof&utm_medium=readme&utm_campaign=create).
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)
132
134
 
133
135
  ### Proof file format
134
136
 
@@ -169,7 +171,10 @@ The base install stays dependency-free; the `[mcp]` extra adds the MCP SDK and i
169
171
  | `verify_file` | Verify a local file against a proof JSON file |
170
172
  | `verify_hash` | Verify a known hash against inline or file-based proof data |
171
173
  | `explain_proof` | Describe, in plain language, what a proof asserts and how to check it on a block explorer |
172
- | `create_proof` | Anchor a file's hash on ProofLedger to create a new proof. The only tool that uses the network, and the only one needing an API key |
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 |
173
178
 
174
179
  ### Connect it to Claude Desktop
175
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
- - Any OpenTimestamps (.ots) compatible proof file
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]
@@ -158,7 +158,7 @@ def main():
158
158
  prog="verify-proof",
159
159
  description=(
160
160
  "Verify blockchain-anchored timestamp proofs. "
161
- "Works with ProofLedger, OpenTimestamps, and compatible proofs."
161
+ "Reads JSON proofs (ProofLedger bundles and compatible); .ots files are not parsed."
162
162
  ),
163
163
  epilog=(
164
164
  "Examples:\n"
@@ -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 four tools
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. `create_proof` is the exception and
17
- says so in its own description - anchoring requires a service, and it sends the
18
- hash (never the file) to ProofLedger, with a free API key.
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 ProofLedgerError, submit_proof, summarize
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