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"
|
aer1_haystack/anchor.py
ADDED
|
@@ -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)
|