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 +19 -0
- etch/api.py +497 -0
- etch/assent_agent.py +606 -0
- etch/assent_api.py +601 -0
- etch/assent_docs_api.py +287 -0
- etch/assent_mcp.py +150 -0
- etch/auth.py +113 -0
- etch/backfill_payloads.py +168 -0
- etch/c2pa.py +463 -0
- etch/chain.py +267 -0
- etch/chain_manager.py +94 -0
- etch/db.py +67 -0
- etch/dooh/README.md +163 -0
- etch/dooh/__init__.py +38 -0
- etch/dooh/canonical.py +17 -0
- etch/dooh/keys.py +87 -0
- etch/dooh/manifest.py +45 -0
- etch/dooh/receipt.py +134 -0
- etch/dooh/submitter.py +106 -0
- etch/dooh/verifier.py +138 -0
- etch/dooh_api.py +320 -0
- etch/glyph.py +177 -0
- etch/glyph_api.py +261 -0
- etch/models.py +146 -0
- etch/records_api.py +591 -0
- etch/sdk.py +658 -0
- etch/server.py +88 -0
- etch/watermark/__init__.py +33 -0
- etch/watermark/io.py +111 -0
- etch/watermark/payload.py +127 -0
- etch/watermark/pipeline.py +173 -0
- etch/watermark/spread.py +149 -0
- etch/watermark/sync.py +93 -0
- etch/watermark_api.py +220 -0
- etch_provenance-0.3.0.dist-info/METADATA +357 -0
- etch_provenance-0.3.0.dist-info/RECORD +39 -0
- etch_provenance-0.3.0.dist-info/WHEEL +4 -0
- etch_provenance-0.3.0.dist-info/entry_points.txt +2 -0
- etch_provenance-0.3.0.dist-info/licenses/LICENSE.md +76 -0
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
|
+
)
|