aer1-haystack 0.1.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.
@@ -0,0 +1,39 @@
1
+ """aer1-haystack: AER-1 verifiable workflow receipts for Haystack."""
2
+
3
+ from .anchor import (
4
+ AnchorError,
5
+ NostrBackend,
6
+ OTSBackend,
7
+ anchor_chain_head,
8
+ bip340_verify,
9
+ proof_from_json,
10
+ proof_to_json,
11
+ verify_anchor,
12
+ )
13
+ from .collector import (
14
+ AER1ReceiptCollector,
15
+ DEFAULT_VERIFY_URL,
16
+ WORKFLOW_TYPE,
17
+ WORKFLOW_VERSION,
18
+ merkle_root,
19
+ verify_workflow_receipt,
20
+ )
21
+
22
+ __all__ = [
23
+ "AER1ReceiptCollector",
24
+ "DEFAULT_VERIFY_URL",
25
+ "WORKFLOW_TYPE",
26
+ "WORKFLOW_VERSION",
27
+ "AnchorError",
28
+ "NostrBackend",
29
+ "OTSBackend",
30
+ "anchor_chain_head",
31
+ "bip340_verify",
32
+ "merkle_root",
33
+ "proof_from_json",
34
+ "proof_to_json",
35
+ "verify_anchor",
36
+ "verify_workflow_receipt",
37
+ ]
38
+
39
+ __version__ = "0.1.0"
@@ -0,0 +1,660 @@
1
+ """AER-1 chain-head anchoring: closing the truncation gap.
2
+
3
+ The truncation attack
4
+ ---------------------
5
+ A hash chain catches tampering (a modified step) and reordering (a moved
6
+ step), because every step hash commits to its predecessor. But a hash
7
+ chain CANNOT catch truncation on its own: delete the last k steps and the
8
+ remaining chain still verifies cleanly. A dropped tail looks exactly like
9
+ a run that ended earlier. If a buyer pays on receipts, the operator can
10
+ silently drop the expensive tail of a run and present a clean, shorter
11
+ receipt.
12
+
13
+ The fix: anchor the chain head (the Merkle root over all step
14
+ receipt_ids, AER-1 Section 8.1) somewhere external that the operator
15
+ cannot rewrite. Verification then has three parts:
16
+
17
+ 1. Recompute the Merkle root from the receipt's own steps.
18
+ 2. Check the recomputed root matches the anchored root. This catches
19
+ truncation AND fake-root anchoring: an operator who anchors a root
20
+ for fewer steps fails here, because the receipt's own steps
21
+ recompute to the full root.
22
+ 3. Check the anchor exists on the external backend(s) and commits to
23
+ the same root. This catches anchor forgery.
24
+
25
+ Privacy by design: only the Merkle root (64 hex chars) and the receipt
26
+ UUID ever touch a public backend. No step content, no inputs, no
27
+ outputs, no tool names. A root is a commitment, not a leak.
28
+
29
+ Censorship resistance
30
+ ---------------------
31
+ Backend Resists Notes
32
+ nostr (multi-relay) single-relay censorship, published to N
33
+ relay rewrites history relays; the event
34
+ is signed and public
35
+ opentimestamps relay/calendar censorship, calendar attestation
36
+ (Bitcoin) operator timestamp lies matures into a
37
+ Bitcoin commitment
38
+ (~1h via ots upgrade)
39
+
40
+ No single backend is a single point of failure: if Nostr relays censor,
41
+ the Bitcoin anchor still stands.
42
+
43
+ Failure semantics
44
+ -----------------
45
+ Anchoring NEVER blocks receipt creation. If every backend is down, the
46
+ receipt is still valid locally (hash chain intact) but marked
47
+ "unanchored". Explicit anchoring (anchor_now) fails LOUDLY with
48
+ AnchorError listing every backend failure. anchor_status() always
49
+ reports per-backend state: pending / confirmed / failed. There are no
50
+ silent drops.
51
+ """
52
+
53
+ from __future__ import annotations
54
+
55
+ import base64
56
+ import hashlib
57
+ import json
58
+ import os
59
+ import re
60
+ import time
61
+ import urllib.error
62
+ import urllib.parse
63
+ import urllib.request
64
+ from datetime import datetime, timezone
65
+
66
+ from .collector import _canonical, _rfc3339, merkle_root
67
+
68
+ PROOF_FORMAT = "aer1-anchor-proof/1"
69
+
70
+ DEFAULT_NOSTR_RELAYS = [
71
+ "wss://nos.lol",
72
+ "wss://relay.primal.net",
73
+ "wss://relay.ditto.pub",
74
+ ]
75
+ DEFAULT_OTS_CALENDARS = [
76
+ "https://alice.btc.calendar.opentimestamps.org",
77
+ "https://bob.btc.calendar.opentimestamps.org",
78
+ "https://ots.btc.catallaxy.com",
79
+ ]
80
+
81
+ OTS_MAGIC = b"\x00OpenTimestamps\x00\x00Proof\x00\xbf\x89\xe2\xe8\x84\xe8\x92\x94"
82
+ OTS_VERSION = 1
83
+ # OTS crypto-op tag -> digest length in bytes (v1 supports these four).
84
+ _OTS_OP_LEN = {0x08: 32, 0x09: 20, 0x0A: 20, 0x0B: 32}
85
+
86
+ _NOSTR_KIND = 1
87
+ _URI_RE = re.compile(rb"https?://[A-Za-z0-9._~:/?#\[\]@!$&'()*+,;=%-]{8,120}")
88
+
89
+
90
+ class AnchorError(Exception):
91
+ """Raised when explicit anchoring fails. Never raised by finalize()."""
92
+
93
+
94
+ # ---------------------------------------------------------------------------
95
+ # small helpers
96
+ # ---------------------------------------------------------------------------
97
+
98
+ def _now_rfc3339() -> str:
99
+ return datetime.fromtimestamp(time.time(), tz=timezone.utc).strftime(
100
+ "%Y-%m-%dT%H:%M:%SZ"
101
+ )
102
+
103
+
104
+ def _read_varuint(data: bytes, pos: int):
105
+ """Read an OTS-style LEB128 varuint. Returns (value, new_pos)."""
106
+ value = 0
107
+ shift = 0
108
+ while True:
109
+ if pos >= len(data):
110
+ raise AnchorError("truncated varuint in OTS data")
111
+ b = data[pos]
112
+ pos += 1
113
+ value |= (b & 0x7F) << shift
114
+ if not (b & 0x80):
115
+ return value, pos
116
+ shift += 7
117
+ if shift > 63:
118
+ raise AnchorError("varuint overflow in OTS data")
119
+
120
+
121
+ def _http_post_bytes(url: str, payload: bytes, timeout: int) -> bytes:
122
+ req = urllib.request.Request(
123
+ url, data=payload, headers={"Content-Type": "application/octet-stream"}
124
+ )
125
+ try:
126
+ with urllib.request.urlopen(req, timeout=timeout) as resp:
127
+ return resp.read()
128
+ except urllib.error.HTTPError as e:
129
+ raise AnchorError(f"HTTP {e.code} from {url}")
130
+ except urllib.error.URLError as e:
131
+ raise AnchorError(f"unreachable {url}: {e.reason}")
132
+ except TimeoutError:
133
+ raise AnchorError(f"timeout posting to {url}")
134
+
135
+
136
+ def _proxy_from_env():
137
+ """(host, port, (user, password)) for websocket-client, or None."""
138
+ for var in ("https_proxy", "HTTPS_PROXY", "http_proxy", "HTTP_PROXY"):
139
+ val = os.environ.get(var)
140
+ if val:
141
+ u = urllib.parse.urlparse(val)
142
+ if u.hostname:
143
+ auth = (u.username, u.password) if u.username else None
144
+ return u.hostname, u.port or 8080, auth
145
+ return None
146
+
147
+
148
+ # ---------------------------------------------------------------------------
149
+ # BIP-340 Schnorr verification (pure python; no extra dependency)
150
+ # ---------------------------------------------------------------------------
151
+
152
+ _BIP340_P = 0xFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFEFFFFFC2F
153
+ _BIP340_N = 0xFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFEBAAEDCE6AF48A03BBFD25E8CD0364141
154
+ _BIP340_G = (
155
+ 0x79BE667EF9DCBBAC55A06295CE870B07029BFCDB2DCE28D959F2815B16F81798,
156
+ 0x483ADA7726A3C4655DA4FBFC0E1108A8FD17B448A68554199C47D08FFB10D4B8,
157
+ )
158
+
159
+
160
+ def _bip340_tagged_hash(tag: bytes, msg: bytes) -> bytes:
161
+ th = hashlib.sha256(tag).digest()
162
+ return hashlib.sha256(th + th + msg).digest()
163
+
164
+
165
+ def _bip340_lift_x(x: int):
166
+ p = _BIP340_P
167
+ y_sq = (pow(x, 3, p) + 7) % p
168
+ y = pow(y_sq, (p + 1) // 4, p)
169
+ if pow(y, 2, p) != y_sq:
170
+ return None
171
+ return (x, y if y % 2 == 0 else p - y)
172
+
173
+
174
+ def _bip340_add(p1, p2):
175
+ if p1 is None:
176
+ return p2
177
+ if p2 is None:
178
+ return p1
179
+ p = _BIP340_P
180
+ x1, y1 = p1
181
+ x2, y2 = p2
182
+ if x1 == x2:
183
+ if y1 != y2:
184
+ return None
185
+ lam = (3 * x1 * x1 * pow(2 * y1, p - 2, p)) % p
186
+ else:
187
+ lam = ((y2 - y1) * pow(x2 - x1, p - 2, p)) % p
188
+ x3 = (lam * lam - x1 - x2) % p
189
+ return (x3, (lam * (x1 - x3) - y1) % p)
190
+
191
+
192
+ def _bip340_mul(point, n: int):
193
+ r = None
194
+ addend = point
195
+ while n:
196
+ if n & 1:
197
+ r = _bip340_add(r, addend)
198
+ addend = _bip340_add(addend, addend)
199
+ n >>= 1
200
+ return r
201
+
202
+
203
+ def bip340_verify(pubkey: bytes, msg: bytes, sig: bytes) -> bool:
204
+ """BIP-340 Schnorr verification. pubkey: 32B x-only; msg: 32B; sig: 64B."""
205
+ p, n = _BIP340_P, _BIP340_N
206
+ if len(pubkey) != 32 or len(msg) != 32 or len(sig) != 64:
207
+ return False
208
+ r = int.from_bytes(sig[:32], "big")
209
+ s = int.from_bytes(sig[32:], "big")
210
+ if r >= p or s >= n:
211
+ return False
212
+ P = _bip340_lift_x(int.from_bytes(pubkey, "big"))
213
+ if P is None:
214
+ return False
215
+ e = int.from_bytes(
216
+ _bip340_tagged_hash(b"BIP0340/challenge", sig[:32] + pubkey + msg), "big"
217
+ ) % n
218
+ R = _bip340_add(_bip340_mul(_BIP340_G, s), _bip340_mul(P, n - e))
219
+ if R is None or R[1] % 2 != 0 or R[0] != r:
220
+ return False
221
+ return True
222
+
223
+
224
+ # ---------------------------------------------------------------------------
225
+ # Nostr backend
226
+ # ---------------------------------------------------------------------------
227
+
228
+ def _require_nostr_deps():
229
+ try:
230
+ import coincurve # noqa: F401
231
+ import websocket # noqa: F401
232
+ except ImportError:
233
+ raise AnchorError(
234
+ "nostr anchoring needs the 'anchor' extra: "
235
+ "pip install aer1-haystack[anchor]"
236
+ )
237
+
238
+
239
+ class NostrBackend:
240
+ """Anchor a Merkle root as a signed Nostr kind-1 event.
241
+
242
+ The event content is canonical JSON carrying ONLY the receipt_id,
243
+ the merkle_root, and the anchor timestamp. Signing uses an ephemeral
244
+ keypair by default (pass private_key_hex to use your own); the
245
+ security property is the public, multi-relay log, not key identity.
246
+ Verification is fully offline: recompute the event id and check the
247
+ BIP-340 signature with pure-python code (no coincurve needed).
248
+ """
249
+
250
+ name = "nostr"
251
+
252
+ def __init__(self, relays=None, private_key_hex=None, timeout=25,
253
+ min_relays_ok=1):
254
+ self.relays = list(relays) if relays else list(DEFAULT_NOSTR_RELAYS)
255
+ self.private_key_hex = private_key_hex
256
+ self.timeout = timeout
257
+ self.min_relays_ok = min_relays_ok
258
+
259
+ # -- signing (needs coincurve) ------------------------------------------
260
+ def _keypair(self):
261
+ _require_nostr_deps()
262
+ from coincurve import PrivateKey
263
+
264
+ if self.private_key_hex:
265
+ sk = PrivateKey(bytes.fromhex(self.private_key_hex))
266
+ priv_hex = self.private_key_hex.lower()
267
+ else:
268
+ sk = PrivateKey(os.urandom(32))
269
+ priv_hex = sk.to_hex()
270
+ pubkey_hex = sk.public_key.format(compressed=True)[1:].hex()
271
+ return sk, priv_hex, pubkey_hex
272
+
273
+ def _build_event(self, merkle_root_hex, receipt_id, pubkey_hex):
274
+ content = _canonical(
275
+ {
276
+ "aer1_anchor": 1,
277
+ "receipt_id": receipt_id,
278
+ "merkle_root": merkle_root_hex,
279
+ "anchored_at": _now_rfc3339(),
280
+ }
281
+ ).decode("utf-8")
282
+ return {
283
+ "kind": _NOSTR_KIND,
284
+ "content": content,
285
+ "tags": [["t", "aer1-anchor"]],
286
+ "created_at": int(time.time()),
287
+ "pubkey": pubkey_hex,
288
+ }
289
+
290
+ @staticmethod
291
+ def event_id(event) -> str:
292
+ # NIP-01: sha256 of ["0", pubkey, created_at, kind, tags, content]
293
+ # with integer 0, no whitespace.
294
+ preimage = json.dumps(
295
+ [
296
+ 0,
297
+ event["pubkey"],
298
+ event["created_at"],
299
+ event["kind"],
300
+ event["tags"],
301
+ event["content"],
302
+ ],
303
+ separators=(",", ":"),
304
+ ensure_ascii=False,
305
+ ).encode("utf-8")
306
+ return hashlib.sha256(preimage).hexdigest()
307
+
308
+ def _sign_event(self, event, sk) -> dict:
309
+ eid = self.event_id(event)
310
+ sig = sk.sign_schnorr(bytes.fromhex(eid)).hex()
311
+ return {**event, "id": eid, "sig": sig}
312
+
313
+ @staticmethod
314
+ def verify_event_signature(event) -> bool:
315
+ """Offline check: event id recomputes and the Schnorr sig is valid."""
316
+ try:
317
+ eid = NostrBackend.event_id(event)
318
+ if eid != event.get("id"):
319
+ return False
320
+ return bip340_verify(
321
+ bytes.fromhex(event["pubkey"]),
322
+ bytes.fromhex(eid),
323
+ bytes.fromhex(event["sig"]),
324
+ )
325
+ except (KeyError, ValueError, TypeError):
326
+ return False
327
+
328
+ # -- publish --------------------------------------------------------------
329
+ def _publish_one(self, relay, event) -> None:
330
+ import websocket
331
+
332
+ kwargs = {"timeout": self.timeout}
333
+ proxy = _proxy_from_env()
334
+ if proxy:
335
+ host, port, auth = proxy
336
+ kwargs["http_proxy_host"] = host
337
+ kwargs["http_proxy_port"] = port
338
+ if auth:
339
+ kwargs["http_proxy_auth"] = auth
340
+ ws = websocket.create_connection(relay, **kwargs)
341
+ try:
342
+ ws.send(json.dumps(["EVENT", event]))
343
+ raw = ws.recv()
344
+ msg = json.loads(raw)
345
+ if (
346
+ not isinstance(msg, list)
347
+ or len(msg) < 3
348
+ or msg[0] != "OK"
349
+ or msg[1] != event["id"]
350
+ or msg[2] is not True
351
+ ):
352
+ raise AnchorError(f"relay {relay} rejected event: {raw[:160]}")
353
+ finally:
354
+ try:
355
+ ws.close()
356
+ except Exception:
357
+ pass
358
+
359
+ def anchor(self, merkle_root_hex, receipt_id) -> dict:
360
+ _require_nostr_deps()
361
+ sk, _priv_hex, pubkey_hex = self._keypair()
362
+ event = self._sign_event(
363
+ self._build_event(merkle_root_hex, receipt_id, pubkey_hex), sk
364
+ )
365
+ ok_relays = []
366
+ errors = []
367
+ for relay in self.relays:
368
+ try:
369
+ self._publish_one(relay, event)
370
+ ok_relays.append(relay)
371
+ except Exception as e: # noqa: BLE001 - collected, reported loudly
372
+ errors.append(f"{relay}: {e}")
373
+ if len(ok_relays) < self.min_relays_ok:
374
+ raise AnchorError(
375
+ f"nostr publish failed ({len(ok_relays)}/{self.min_relays_ok} "
376
+ f"relays ok): " + "; ".join(errors)
377
+ )
378
+ return {
379
+ "event_id": event["id"],
380
+ "pubkey": pubkey_hex,
381
+ "relays_ok": ok_relays,
382
+ "relays_tried": list(self.relays),
383
+ "event": event,
384
+ "anchored_at": _now_rfc3339(),
385
+ }
386
+
387
+ def verify(self, proof, merkle_root_hex, receipt_id) -> list:
388
+ failures = []
389
+ event = proof.get("event")
390
+ if not isinstance(event, dict):
391
+ return ["nostr proof has no event"]
392
+ if not self.verify_event_signature(event):
393
+ failures.append("nostr event signature invalid (or event id mismatch)")
394
+ try:
395
+ content = json.loads(event.get("content", ""))
396
+ except (ValueError, TypeError):
397
+ content = {}
398
+ if content.get("aer1_anchor") != 1:
399
+ failures.append("nostr event is not an aer1 anchor event")
400
+ if content.get("receipt_id") != receipt_id:
401
+ failures.append("nostr event receipt_id does not match")
402
+ if content.get("merkle_root") != merkle_root_hex:
403
+ failures.append("nostr event merkle_root does not match anchored root")
404
+ if proof.get("event_id") != event.get("id"):
405
+ failures.append("nostr proof event_id does not match event")
406
+ return failures
407
+
408
+
409
+ # ---------------------------------------------------------------------------
410
+ # OpenTimestamps backend (Bitcoin)
411
+ # ---------------------------------------------------------------------------
412
+
413
+ class OTSBackend:
414
+ """Anchor a Merkle root digest with OpenTimestamps calendar servers.
415
+
416
+ The 32-byte root is POSTed to each calendar's /digest endpoint; the
417
+ calendar returns a timestamp token attesting it saw the digest. The
418
+ tokens are assembled into standard .ots files (openable and
419
+ upgradeable with the reference opentimestamps-client). After roughly
420
+ an hour the pending calendar attestation can be upgraded to a
421
+ Bitcoin block attestation, which is what makes the "when" provable,
422
+ not just the "what".
423
+
424
+ v1 verification is offline: the .ots container must be well-formed,
425
+ its message must equal our digest (binds the proof to OUR root and
426
+ defeats proof substitution), and the attestation URI recorded at
427
+ anchor time must be present (binds the proof to the calendar that
428
+ attested). Full Bitcoin attestation verification is the documented
429
+ job of the reference client after `ots upgrade`.
430
+ """
431
+
432
+ name = "opentimestamps"
433
+
434
+ def __init__(self, calendars=None, timeout=30, min_calendars_ok=1):
435
+ self.calendars = list(calendars) if calendars else list(DEFAULT_OTS_CALENDARS)
436
+ self.timeout = timeout
437
+ self.min_calendars_ok = min_calendars_ok
438
+
439
+ @staticmethod
440
+ def build_ots_file(digest: bytes, timestamp: bytes) -> bytes:
441
+ out = bytearray(OTS_MAGIC)
442
+ out += b"\x01" # version varuint
443
+ out += b"\x08" # OpSHA256
444
+ out += digest
445
+ out += timestamp
446
+ return bytes(out)
447
+
448
+ @staticmethod
449
+ def parse_ots_file(data: bytes):
450
+ """Returns (digest, timestamp_bytes). Raises AnchorError if malformed."""
451
+ if not data.startswith(OTS_MAGIC):
452
+ raise AnchorError("ots file has bad magic")
453
+ pos = len(OTS_MAGIC)
454
+ version, pos = _read_varuint(data, pos)
455
+ if version != OTS_VERSION:
456
+ raise AnchorError(f"unsupported ots version {version}")
457
+ if pos >= len(data):
458
+ raise AnchorError("ots file truncated after version")
459
+ op = data[pos]
460
+ pos += 1
461
+ if op not in _OTS_OP_LEN:
462
+ raise AnchorError(f"unsupported ots digest op 0x{op:02x}")
463
+ digest_len = _OTS_OP_LEN[op]
464
+ digest = data[pos:pos + digest_len]
465
+ if len(digest) != digest_len:
466
+ raise AnchorError("ots file truncated in digest")
467
+ timestamp = data[pos + digest_len:]
468
+ if not timestamp:
469
+ raise AnchorError("ots file has empty timestamp")
470
+ return digest, timestamp
471
+
472
+ @staticmethod
473
+ def attestation_uris(timestamp: bytes) -> list:
474
+ return sorted({m.group().decode("utf-8") for m in _URI_RE.finditer(timestamp)})
475
+
476
+ def _submit(self, calendar: str, digest: bytes):
477
+ raw = _http_post_bytes(calendar.rstrip("/") + "/digest", digest, self.timeout)
478
+ if not raw:
479
+ raise AnchorError(f"empty timestamp from {calendar}")
480
+ return raw
481
+
482
+ def anchor(self, merkle_root_hex, receipt_id) -> dict:
483
+ digest = bytes.fromhex(merkle_root_hex)
484
+ files = []
485
+ errors = []
486
+ for calendar in self.calendars:
487
+ try:
488
+ ts = self._submit(calendar, digest)
489
+ ots = self.build_ots_file(digest, ts)
490
+ files.append(
491
+ {
492
+ "calendar": calendar,
493
+ "ots_b64": base64.b64encode(ots).decode("ascii"),
494
+ "attestation_uris": self.attestation_uris(ts),
495
+ }
496
+ )
497
+ except Exception as e: # noqa: BLE001 - collected, reported loudly
498
+ errors.append(f"{calendar}: {e}")
499
+ if len(files) < self.min_calendars_ok:
500
+ raise AnchorError(
501
+ f"opentimestamps failed ({len(files)}/{self.min_calendars_ok} "
502
+ f"calendars ok): " + "; ".join(errors)
503
+ )
504
+ return {
505
+ "digest_hex": merkle_root_hex,
506
+ "ots_files": files,
507
+ "calendars_tried": list(self.calendars),
508
+ "note": "pending calendar attestation; run `ots upgrade` after ~1h "
509
+ "for the Bitcoin block attestation",
510
+ "anchored_at": _now_rfc3339(),
511
+ }
512
+
513
+ def verify(self, proof, merkle_root_hex, receipt_id) -> list:
514
+ failures = []
515
+ want = bytes.fromhex(merkle_root_hex)
516
+ files = proof.get("ots_files")
517
+ if not isinstance(files, list) or not files:
518
+ return ["opentimestamps proof has no ots files"]
519
+ for i, f in enumerate(files):
520
+ try:
521
+ raw = base64.b64decode(f["ots_b64"])
522
+ digest, ts = self.parse_ots_file(raw)
523
+ except Exception as e: # noqa: BLE001
524
+ failures.append(f"ots file {i} malformed: {e}")
525
+ continue
526
+ if digest != want:
527
+ failures.append(
528
+ f"ots file {i} commits to a different digest "
529
+ "(proof substitution or forgery)"
530
+ )
531
+ recorded = f.get("attestation_uris") or []
532
+ found = self.attestation_uris(ts)
533
+ if recorded and not any(u in found for u in recorded):
534
+ failures.append(
535
+ f"ots file {i} attestation uri mismatch "
536
+ f"(recorded {recorded}, found {found})"
537
+ )
538
+ return failures
539
+
540
+
541
+ _BACKENDS = {
542
+ NostrBackend.name: NostrBackend,
543
+ OTSBackend.name: OTSBackend,
544
+ }
545
+
546
+
547
+ # ---------------------------------------------------------------------------
548
+ # one-line anchoring + verification
549
+ # ---------------------------------------------------------------------------
550
+
551
+ def _resolve_backends(backends, backend_kwargs):
552
+ """backends: tuple of names or dict name -> instance."""
553
+ if isinstance(backends, dict):
554
+ return list(backends.items())
555
+ resolved = []
556
+ for name in backends:
557
+ cls = _BACKENDS.get(name)
558
+ if cls is None:
559
+ raise ValueError(
560
+ f"unknown anchor backend {name!r}; known: {sorted(_BACKENDS)}"
561
+ )
562
+ resolved.append((name, cls(**backend_kwargs.get(name, {}))))
563
+ return resolved
564
+
565
+
566
+ def anchor_chain_head(workflow, backends=("nostr", "opentimestamps"),
567
+ backend_kwargs=None) -> dict:
568
+ """Anchor a workflow receipt's chain head. Returns the anchor proof.
569
+
570
+ The Merkle root is RECOMPUTED from the workflow's steps (never taken
571
+ from workflow["merkle_root"]), so an operator cannot anchor a fake
572
+ root for a receipt whose steps say otherwise. Raises AnchorError if
573
+ every backend fails; partial success returns a proof with per-backend
574
+ status. backends may be a tuple of names or a dict of name -> instance
575
+ (handy for tests).
576
+ """
577
+ steps = workflow.get("steps") or []
578
+ root = merkle_root([s["receipt_id"] for s in steps])
579
+ receipt_id = workflow.get("receipt_id")
580
+ entries = []
581
+ errors = []
582
+ for name, backend in _resolve_backends(backends, backend_kwargs or {}):
583
+ try:
584
+ proof = backend.anchor(root, receipt_id)
585
+ entries.append(
586
+ {
587
+ "backend": name,
588
+ "status": "confirmed",
589
+ "proof": proof,
590
+ "anchored_at": _now_rfc3339(),
591
+ }
592
+ )
593
+ except Exception as e: # noqa: BLE001 - loud, listed, never silent
594
+ errors.append(f"{name}: {e}")
595
+ entries.append(
596
+ {
597
+ "backend": name,
598
+ "status": "failed",
599
+ "error": str(e),
600
+ "anchored_at": _now_rfc3339(),
601
+ }
602
+ )
603
+ if not any(e["status"] == "confirmed" for e in entries):
604
+ raise AnchorError("all anchor backends failed: " + "; ".join(errors))
605
+ return {
606
+ "format": PROOF_FORMAT,
607
+ "receipt_id": receipt_id,
608
+ "merkle_root": root,
609
+ "mode": "immediate",
610
+ "anchored_at": _now_rfc3339(),
611
+ "backends": entries,
612
+ }
613
+
614
+
615
+ def verify_anchor(proof, workflow) -> list:
616
+ """Independently verify an anchor proof against a workflow receipt.
617
+
618
+ Returns a list of failure reasons; [] means the anchor is valid.
619
+ Needs no network for the core checks: the root is recomputed from
620
+ the receipt's steps, so truncation and fake-root anchoring are
621
+ caught offline. Anyone can run this with the proof JSON alone.
622
+ """
623
+ failures = []
624
+ if not isinstance(proof, dict):
625
+ return ["anchor proof is not a JSON object"]
626
+ if proof.get("format") != PROOF_FORMAT:
627
+ failures.append(f"unknown anchor proof format {proof.get('format')!r}")
628
+ if proof.get("receipt_id") != workflow.get("receipt_id"):
629
+ failures.append("anchor proof receipt_id does not match the receipt")
630
+ steps = workflow.get("steps") or []
631
+ recomputed = merkle_root([s["receipt_id"] for s in steps])
632
+ if proof.get("merkle_root") != recomputed:
633
+ failures.append(
634
+ "anchored merkle_root does not match the root recomputed from "
635
+ "the receipt steps (possible truncation or tampering: a dropped "
636
+ "tail changes the root)"
637
+ )
638
+ return failures # root mismatch poisons every backend check
639
+ for entry in proof.get("backends") or []:
640
+ if entry.get("status") != "confirmed":
641
+ continue
642
+ name = entry.get("backend")
643
+ cls = _BACKENDS.get(name)
644
+ if cls is None:
645
+ failures.append(f"unknown backend {name!r} in proof")
646
+ continue
647
+ failures.extend(
648
+ f"{name}: {f}"
649
+ for f in cls().verify(entry.get("proof") or {}, recomputed,
650
+ workflow.get("receipt_id"))
651
+ )
652
+ return failures
653
+
654
+
655
+ def proof_to_json(proof) -> str:
656
+ return json.dumps(proof, indent=2, ensure_ascii=False)
657
+
658
+
659
+ def proof_from_json(text: str) -> dict:
660
+ return json.loads(text)