etch-provenance 0.3.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.
etch/__init__.py ADDED
@@ -0,0 +1,19 @@
1
+ """Etch — System of Record provenance on a tamper-evident Merkle chain."""
2
+
3
+ from .chain import (
4
+ AuditChain,
5
+ ChainEntry,
6
+ InclusionProof,
7
+ verify_inclusion_proof,
8
+ )
9
+ from .sdk import EtchClient
10
+
11
+ __all__ = [
12
+ "AuditChain",
13
+ "ChainEntry",
14
+ "EtchClient",
15
+ "InclusionProof",
16
+ "verify_inclusion_proof",
17
+ ]
18
+
19
+ __version__ = "0.3.0"
etch/api.py ADDED
@@ -0,0 +1,497 @@
1
+ """
2
+ Etch — Content Provenance API
3
+
4
+ Timestamps content on a tamper-evident Merkle chain, creating proof receipts
5
+ that prove content existed at a specific point in time.
6
+
7
+ Endpoints:
8
+ POST /v1/proof - Register content -> get proof receipt
9
+ GET /v1/proof/recent - List recent proofs (paginated)
10
+ GET /v1/proof/stats - Chain statistics
11
+ GET /v1/proof/{proof_id} - Retrieve receipt by proof_id (leaf_index)
12
+ GET /v1/proof/hash/{content_hash} - Look up proof by content SHA-256
13
+ POST /v1/proof/{proof_id}/verify - Verify content matches a stored proof
14
+
15
+ Design:
16
+ - Content is NEVER stored — only SHA-256(content) is logged (privacy-preserving).
17
+ - proof_id is the MMR leaf_index (integer, monotonically increasing, globally unique).
18
+ - Chain integrity can be independently verified by any party using the inclusion proof.
19
+ """
20
+ from __future__ import annotations
21
+
22
+ import hashlib
23
+ import json
24
+ import logging
25
+ import time
26
+
27
+ from fastapi import APIRouter, HTTPException, Query
28
+ from pydantic import BaseModel, Field
29
+ from sqlalchemy import func, or_, select
30
+
31
+ from .chain import get_chain, log_event
32
+ from .db import get_session
33
+ from .models import ProofRecord
34
+
35
+ logger = logging.getLogger(__name__)
36
+
37
+ router = APIRouter(prefix="/v1/proof", tags=["Etch"])
38
+
39
+
40
+ # ---------------------------------------------------------------------------
41
+ # Pydantic models
42
+ # ---------------------------------------------------------------------------
43
+
44
+ class ProofRequest(BaseModel):
45
+ content: str | None = Field(
46
+ None,
47
+ description="Raw content to hash and register. Mutually exclusive with content_hash.",
48
+ )
49
+ content_hash: str | None = Field(
50
+ None,
51
+ description="Pre-computed SHA-256 hex of the content (64 chars). Use when you want "
52
+ "to register without transmitting the raw content.",
53
+ )
54
+ label: str | None = Field(
55
+ None,
56
+ max_length=200,
57
+ description="Human-readable label for this proof (e.g. 'Article: My Post Title').",
58
+ )
59
+ owner: str | None = Field(
60
+ None,
61
+ max_length=200,
62
+ description="Owner identifier (e.g. user ID, wallet address, email hash).",
63
+ )
64
+
65
+
66
+ class ProofReceipt(BaseModel):
67
+ proof_id: int = Field(description="MMR leaf index — unique, monotonically increasing")
68
+ content_hash: str = Field(description="SHA-256 hex of the registered content")
69
+ label: str | None
70
+ owner: str | None
71
+ timestamp: float = Field(description="Unix timestamp (seconds, UTC)")
72
+ leaf_hash: str = Field(description="SHA-256 commitment: hash(prev_root:action:payload_hash:ts)")
73
+ mmr_root: str = Field(description="MMR root after this leaf was appended")
74
+ chain_depth: int = Field(description="Total leaves in the chain at registration time")
75
+
76
+
77
+ class BatchProofRequest(BaseModel):
78
+ items: list[ProofRequest] = Field(..., min_length=1, max_length=1000)
79
+
80
+
81
+ class BatchProofItemResult(BaseModel):
82
+ """Result for a single item in a batch — either a receipt or an error."""
83
+ receipt: ProofReceipt | None = None
84
+ error: str | None = None
85
+ index: int = Field(description="Position of this item in the request batch")
86
+
87
+
88
+ class BatchProofResponse(BaseModel):
89
+ receipts: list[BatchProofItemResult]
90
+ count: int = Field(description="Number of successfully registered items")
91
+
92
+
93
+ class VerifyRequest(BaseModel):
94
+ content: str | None = Field(
95
+ None,
96
+ description="Raw content to verify against the stored proof.",
97
+ )
98
+ content_hash: str | None = Field(
99
+ None,
100
+ description="Pre-computed SHA-256 hex to verify.",
101
+ )
102
+
103
+
104
+ class VerifyResponse(BaseModel):
105
+ proof_id: int
106
+ content_hash_matches: bool
107
+ chain_integrity_valid: bool
108
+ verified: bool = Field(description="True only when both content_hash and chain integrity pass")
109
+ receipt: ProofReceipt
110
+
111
+
112
+ # ---------------------------------------------------------------------------
113
+ # Helpers
114
+ # ---------------------------------------------------------------------------
115
+
116
+ def _sha256(data: str) -> str:
117
+ return hashlib.sha256(data.encode()).hexdigest()
118
+
119
+
120
+ async def _persist_to_db(entry, content_hash: str, label: str | None, owner: str | None):
121
+ """Persist a chain entry to the database."""
122
+ try:
123
+ async with get_session() as session:
124
+ record = ProofRecord(
125
+ leaf_index=entry.leaf_index,
126
+ leaf_hash=entry.leaf_hash,
127
+ mmr_root=entry.mmr_root,
128
+ leaf_count=entry.leaf_index + 1,
129
+ payload_hash=entry.payload_hash,
130
+ action_type="content_proof",
131
+ content_hash=content_hash,
132
+ label=label,
133
+ owner=owner,
134
+ proof_json=json.dumps({
135
+ "content_hash": content_hash,
136
+ "label": label or "",
137
+ "owner": owner or "",
138
+ }),
139
+ created_at_exact=entry.created_at,
140
+ payload_json=entry.payload_json,
141
+ )
142
+ session.add(record)
143
+ except Exception as exc:
144
+ logger.warning(f"[Etch] DB persist failed: {exc}")
145
+
146
+
147
+ # ---------------------------------------------------------------------------
148
+ # Routes
149
+ # ---------------------------------------------------------------------------
150
+
151
+ @router.post("", summary="Register content and get a tamper-evident proof receipt")
152
+ async def register_proof(body: ProofRequest) -> ProofReceipt:
153
+ """
154
+ Submit content (or its SHA-256 hash) to be timestamped on the Merkle chain.
155
+
156
+ The raw content is never stored — only its SHA-256 hash. Returns a receipt with
157
+ the proof_id, cryptographic commitments, and timestamp.
158
+ """
159
+ if not body.content and not body.content_hash:
160
+ raise HTTPException(status_code=422, detail="Provide either 'content' or 'content_hash'")
161
+
162
+ if body.content_hash and len(body.content_hash) != 64:
163
+ raise HTTPException(status_code=422, detail="content_hash must be a 64-character SHA-256 hex string")
164
+
165
+ content_hash = body.content_hash or _sha256(body.content)
166
+
167
+ payload = {
168
+ "content_hash": content_hash,
169
+ "label": body.label or "",
170
+ "owner": body.owner or "",
171
+ "registered_at": time.time(),
172
+ }
173
+
174
+ entry = log_event(
175
+ action_type="content_proof",
176
+ payload=payload,
177
+ specialist="etch",
178
+ agent_id=content_hash,
179
+ )
180
+
181
+ await _persist_to_db(entry, content_hash, body.label, body.owner)
182
+
183
+ logger.info(f"[Etch] Registered proof_id={entry.leaf_index} hash={content_hash[:12]}...")
184
+
185
+ return ProofReceipt(
186
+ proof_id=entry.leaf_index,
187
+ content_hash=content_hash,
188
+ label=body.label,
189
+ owner=body.owner,
190
+ timestamp=entry.created_at,
191
+ leaf_hash=entry.leaf_hash,
192
+ mmr_root=entry.mmr_root,
193
+ chain_depth=entry.leaf_index + 1,
194
+ )
195
+
196
+
197
+ @router.get("/recent", summary="List recent content proofs")
198
+ async def list_recent_proofs(
199
+ limit: int = Query(default=50, ge=1, le=500),
200
+ offset: int = Query(default=0, ge=0),
201
+ ) -> dict:
202
+ """List recent content proofs, newest first."""
203
+ try:
204
+ async with get_session() as session:
205
+ count_result = await session.execute(
206
+ select(func.count()).select_from(ProofRecord)
207
+ )
208
+ total = count_result.scalar() or 0
209
+
210
+ result = await session.execute(
211
+ select(ProofRecord)
212
+ .order_by(ProofRecord.leaf_index.desc())
213
+ .offset(offset)
214
+ .limit(limit)
215
+ )
216
+ records = result.scalars().all()
217
+
218
+ proofs = []
219
+ for r in records:
220
+ ts = r.created_at.timestamp() if hasattr(r.created_at, "timestamp") else r.created_at
221
+ proofs.append({
222
+ "proof_id": r.leaf_index,
223
+ "content_hash": r.content_hash,
224
+ "label": r.label,
225
+ "owner": r.owner,
226
+ "timestamp": ts,
227
+ "leaf_hash": r.leaf_hash,
228
+ "mmr_root": r.mmr_root,
229
+ })
230
+
231
+ return {"proofs": proofs, "total": total, "limit": limit, "offset": offset}
232
+ except Exception as exc:
233
+ logger.warning(f"[Etch] List failed: {exc}")
234
+ raise HTTPException(status_code=503, detail="Database unavailable")
235
+
236
+
237
+ @router.get("/stats", summary="Etch chain statistics")
238
+ async def proof_stats() -> dict:
239
+ """Return basic statistics about the Etch chain."""
240
+ chain = get_chain()
241
+
242
+ try:
243
+ async with get_session() as session:
244
+ result = await session.execute(
245
+ select(
246
+ func.count().label("total_proofs"),
247
+ func.min(ProofRecord.created_at).label("first_proof"),
248
+ func.max(ProofRecord.created_at).label("last_proof"),
249
+ )
250
+ )
251
+ row = result.one()
252
+
253
+ first_ts = None
254
+ last_ts = None
255
+ if row.first_proof:
256
+ first_ts = row.first_proof.timestamp() if hasattr(row.first_proof, "timestamp") else row.first_proof
257
+ if row.last_proof:
258
+ last_ts = row.last_proof.timestamp() if hasattr(row.last_proof, "timestamp") else row.last_proof
259
+
260
+ return {
261
+ "total_proofs": row.total_proofs or 0,
262
+ "chain_depth": chain.leaf_count(),
263
+ "mmr_root": chain.current_root(),
264
+ "first_proof_at": first_ts,
265
+ "last_proof_at": last_ts,
266
+ }
267
+ except Exception as exc:
268
+ logger.warning(f"[Etch] Stats failed: {exc}")
269
+ return {
270
+ "total_proofs": 0,
271
+ "chain_depth": chain.leaf_count(),
272
+ "mmr_root": chain.current_root(),
273
+ "first_proof_at": None,
274
+ "last_proof_at": None,
275
+ }
276
+
277
+
278
+ @router.get("/hash/{content_hash}", summary="Look up a proof by content SHA-256")
279
+ async def get_proof_by_hash(content_hash: str) -> ProofReceipt:
280
+ """Retrieve a proof receipt using the SHA-256 hash of the content."""
281
+ if len(content_hash) != 64:
282
+ raise HTTPException(status_code=422, detail="content_hash must be 64 hex characters")
283
+
284
+ try:
285
+ async with get_session() as session:
286
+ result = await session.execute(
287
+ select(ProofRecord)
288
+ .where(ProofRecord.content_hash == content_hash)
289
+ .order_by(ProofRecord.leaf_index.asc())
290
+ .limit(1)
291
+ )
292
+ record = result.scalar_one_or_none()
293
+ except Exception as exc:
294
+ logger.warning(f"[Etch] DB lookup failed: {exc}")
295
+ record = None
296
+
297
+ if not record:
298
+ raise HTTPException(status_code=404, detail=f"No proof found for content_hash={content_hash[:12]}...")
299
+
300
+ return ProofReceipt(
301
+ proof_id=record.leaf_index,
302
+ content_hash=record.content_hash,
303
+ label=record.label,
304
+ owner=record.owner,
305
+ timestamp=record.created_at.timestamp() if hasattr(record.created_at, "timestamp") else record.created_at,
306
+ leaf_hash=record.leaf_hash,
307
+ mmr_root=record.mmr_root,
308
+ chain_depth=record.leaf_count,
309
+ )
310
+
311
+
312
+ @router.post("/batch", summary="Register multiple content proofs in a single request")
313
+ async def register_batch(body: BatchProofRequest) -> BatchProofResponse:
314
+ """
315
+ Register multiple content items on the chain in one request.
316
+
317
+ Each item is validated individually. Valid items get a proof receipt;
318
+ invalid items get an error message. All valid records are persisted
319
+ in a single DB session.
320
+ """
321
+ results: list[BatchProofItemResult] = []
322
+ entries_to_persist: list[tuple] = [] # (entry, content_hash, label, owner)
323
+ success_count = 0
324
+
325
+ for idx, item in enumerate(body.items):
326
+ # Validate
327
+ if not item.content and not item.content_hash:
328
+ results.append(BatchProofItemResult(
329
+ index=idx, error="Provide either 'content' or 'content_hash'",
330
+ ))
331
+ continue
332
+
333
+ if item.content_hash and len(item.content_hash) != 64:
334
+ results.append(BatchProofItemResult(
335
+ index=idx, error="content_hash must be a 64-character SHA-256 hex string",
336
+ ))
337
+ continue
338
+
339
+ content_hash = item.content_hash or _sha256(item.content)
340
+
341
+ payload = {
342
+ "content_hash": content_hash,
343
+ "label": item.label or "",
344
+ "owner": item.owner or "",
345
+ "registered_at": time.time(),
346
+ }
347
+
348
+ entry = log_event(
349
+ action_type="content_proof",
350
+ payload=payload,
351
+ specialist="etch",
352
+ agent_id=content_hash,
353
+ )
354
+
355
+ receipt = ProofReceipt(
356
+ proof_id=entry.leaf_index,
357
+ content_hash=content_hash,
358
+ label=item.label,
359
+ owner=item.owner,
360
+ timestamp=entry.created_at,
361
+ leaf_hash=entry.leaf_hash,
362
+ mmr_root=entry.mmr_root,
363
+ chain_depth=entry.leaf_index + 1,
364
+ )
365
+ results.append(BatchProofItemResult(index=idx, receipt=receipt))
366
+ entries_to_persist.append((entry, content_hash, item.label, item.owner))
367
+ success_count += 1
368
+
369
+ # Persist all valid records in a single DB session
370
+ if entries_to_persist:
371
+ try:
372
+ async with get_session() as session:
373
+ for entry, content_hash, label, owner in entries_to_persist:
374
+ record = ProofRecord(
375
+ leaf_index=entry.leaf_index,
376
+ leaf_hash=entry.leaf_hash,
377
+ mmr_root=entry.mmr_root,
378
+ leaf_count=entry.leaf_index + 1,
379
+ payload_hash=entry.payload_hash,
380
+ action_type="content_proof",
381
+ content_hash=content_hash,
382
+ label=label,
383
+ owner=owner,
384
+ proof_json=json.dumps({
385
+ "content_hash": content_hash,
386
+ "label": label or "",
387
+ "owner": owner or "",
388
+ }),
389
+ created_at_exact=entry.created_at,
390
+ payload_json=entry.payload_json,
391
+ )
392
+ session.add(record)
393
+ except Exception as exc:
394
+ logger.warning(f"[Etch] Batch DB persist failed: {exc}")
395
+
396
+ logger.info(f"[Etch] Batch registered {success_count}/{len(body.items)} proofs")
397
+
398
+ return BatchProofResponse(receipts=results, count=success_count)
399
+
400
+
401
+ @router.get("/{proof_id}", summary="Retrieve a proof receipt by proof_id")
402
+ async def get_proof(proof_id: int) -> ProofReceipt:
403
+ """Retrieve a previously registered proof receipt by its proof_id."""
404
+ try:
405
+ async with get_session() as session:
406
+ result = await session.execute(
407
+ select(ProofRecord)
408
+ .where(ProofRecord.leaf_index == proof_id)
409
+ )
410
+ record = result.scalar_one_or_none()
411
+ except Exception as exc:
412
+ logger.warning(f"[Etch] DB lookup failed: {exc}")
413
+ record = None
414
+
415
+ if not record:
416
+ raise HTTPException(status_code=404, detail=f"Proof {proof_id} not found")
417
+
418
+ return ProofReceipt(
419
+ proof_id=record.leaf_index,
420
+ content_hash=record.content_hash,
421
+ label=record.label,
422
+ owner=record.owner,
423
+ timestamp=record.created_at.timestamp() if hasattr(record.created_at, "timestamp") else record.created_at,
424
+ leaf_hash=record.leaf_hash,
425
+ mmr_root=record.mmr_root,
426
+ chain_depth=record.leaf_count,
427
+ )
428
+
429
+
430
+ @router.post("/{proof_id}/verify", summary="Verify content matches a stored proof")
431
+ async def verify_proof(proof_id: int, body: VerifyRequest) -> VerifyResponse:
432
+ """
433
+ Verify that content matches the proof stored at proof_id, and that the proof
434
+ itself is cryptographically intact on the chain.
435
+ """
436
+ if not body.content and not body.content_hash:
437
+ raise HTTPException(status_code=422, detail="Provide either 'content' or 'content_hash'")
438
+
439
+ provided_hash = body.content_hash or _sha256(body.content)
440
+
441
+ try:
442
+ async with get_session() as session:
443
+ result = await session.execute(
444
+ select(ProofRecord).where(
445
+ or_(
446
+ ProofRecord.leaf_index == proof_id,
447
+ ProofRecord.leaf_index == proof_id - 1,
448
+ )
449
+ ).order_by(ProofRecord.leaf_index.asc())
450
+ )
451
+ records = result.scalars().all()
452
+ except Exception as exc:
453
+ logger.warning(f"[Etch] DB lookup failed: {exc}")
454
+ raise HTTPException(status_code=503, detail="Database unavailable")
455
+
456
+ record = next((r for r in records if r.leaf_index == proof_id), None)
457
+ if not record:
458
+ raise HTTPException(status_code=404, detail=f"Proof {proof_id} not found")
459
+
460
+ prev_record = next((r for r in records if r.leaf_index == proof_id - 1), None)
461
+
462
+ # Check 1: content hash match
463
+ content_hash_matches = record.content_hash == provided_hash
464
+
465
+ # Check 2: chain integrity — recompute leaf_hash from stored fields
466
+ chain_integrity_valid = False
467
+ try:
468
+ prev_root = prev_record.mmr_root if prev_record else "0" * 64
469
+ ts = record.created_at_exact if record.created_at_exact is not None else (
470
+ record.created_at.timestamp() if hasattr(record.created_at, "timestamp") else record.created_at
471
+ )
472
+ expected_leaf_hash = _sha256(f"{prev_root}:{record.action_type}:{record.payload_hash}:{ts}")
473
+ chain_integrity_valid = expected_leaf_hash == record.leaf_hash
474
+ except Exception as exc:
475
+ logger.warning(f"[Etch] Chain integrity check failed: {exc}")
476
+
477
+ ts_float = record.created_at_exact if record.created_at_exact is not None else (
478
+ record.created_at.timestamp() if hasattr(record.created_at, "timestamp") else float(record.created_at)
479
+ )
480
+ receipt = ProofReceipt(
481
+ proof_id=record.leaf_index,
482
+ content_hash=record.content_hash,
483
+ label=record.label,
484
+ owner=record.owner,
485
+ timestamp=ts_float,
486
+ leaf_hash=record.leaf_hash,
487
+ mmr_root=record.mmr_root,
488
+ chain_depth=record.leaf_count,
489
+ )
490
+
491
+ return VerifyResponse(
492
+ proof_id=proof_id,
493
+ content_hash_matches=content_hash_matches,
494
+ chain_integrity_valid=chain_integrity_valid,
495
+ verified=content_hash_matches and chain_integrity_valid,
496
+ receipt=receipt,
497
+ )