ememdev 2.1.0__tar.gz → 2.2.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,6 +1,6 @@
1
- Metadata-Version: 2.4
1
+ Metadata-Version: 2.5
2
2
  Name: ememdev
3
- Version: 2.1.0
3
+ Version: 2.2.0
4
4
  Summary: Python client for emem.dev, the agent-native Earth memory protocol
5
5
  Project-URL: Homepage, https://emem.dev
6
6
  Project-URL: Repository, https://github.com/Vortx-AI/emem
@@ -150,9 +150,10 @@ first.
150
150
 
151
151
  Every non-introspection response carries a `receipt` block with:
152
152
 
153
- - `responder_pubkey` (ed25519 base32-nopad-lowercase)
154
- - `signature_b32` (ed25519 over the canonical CBOR preimage)
155
- - `merkle_root` (BLAKE3 over the fact CIDs)
153
+ - `responder_pubkey_b32` (ed25519 base32-nopad-lowercase)
154
+ - `signature_b32` (ed25519 over the BLAKE3 preimage digest)
155
+ - `preimage_version` (which rule signed it; read it, do not assume)
156
+ - `merkle_proof` (inclusion proof for `fact_cids[0]`, when one was recorded)
156
157
  - `fact_cids[]` (the BLAKE3 CIDs of every fact returned)
157
158
 
158
159
  To cite an answer: quote `receipt.fact_cids[0]` and the responder pubkey.
@@ -160,6 +161,17 @@ The signature can be verified offline against the public key at
160
161
  `https://emem.dev/.well-known/emem.json`; no callback to the responder is
161
162
  required.
162
163
 
164
+ **Pass the receipt on whole.** `ememdev` returns it verbatim and you should
165
+ store and forward it the same way. From `preimage_version: 2` the signature
166
+ covers the inclusion proof as well as every other field it binds, which is
167
+ what stops an intermediary stripping the proof in transit. The cost is that
168
+ a receipt is byte-for-byte or nothing: drop `merkle_proof` because it looks
169
+ redundant, re-key a field, or summarise the envelope, and it comes back
170
+ `signature_valid: false` on data nobody tampered with — indistinguishable
171
+ from forgery. `POST /v1/verify_receipt` reports `reason:
172
+ receipt_reshaped_after_signing` instead of `signature_invalid` when the
173
+ responder can prove that is what happened, but it never accepts the receipt.
174
+
163
175
  ## License
164
176
 
165
177
  Apache-2.0. Same as the upstream protocol.
@@ -120,9 +120,10 @@ first.
120
120
 
121
121
  Every non-introspection response carries a `receipt` block with:
122
122
 
123
- - `responder_pubkey` (ed25519 base32-nopad-lowercase)
124
- - `signature_b32` (ed25519 over the canonical CBOR preimage)
125
- - `merkle_root` (BLAKE3 over the fact CIDs)
123
+ - `responder_pubkey_b32` (ed25519 base32-nopad-lowercase)
124
+ - `signature_b32` (ed25519 over the BLAKE3 preimage digest)
125
+ - `preimage_version` (which rule signed it; read it, do not assume)
126
+ - `merkle_proof` (inclusion proof for `fact_cids[0]`, when one was recorded)
126
127
  - `fact_cids[]` (the BLAKE3 CIDs of every fact returned)
127
128
 
128
129
  To cite an answer: quote `receipt.fact_cids[0]` and the responder pubkey.
@@ -130,6 +131,17 @@ The signature can be verified offline against the public key at
130
131
  `https://emem.dev/.well-known/emem.json`; no callback to the responder is
131
132
  required.
132
133
 
134
+ **Pass the receipt on whole.** `ememdev` returns it verbatim and you should
135
+ store and forward it the same way. From `preimage_version: 2` the signature
136
+ covers the inclusion proof as well as every other field it binds, which is
137
+ what stops an intermediary stripping the proof in transit. The cost is that
138
+ a receipt is byte-for-byte or nothing: drop `merkle_proof` because it looks
139
+ redundant, re-key a field, or summarise the envelope, and it comes back
140
+ `signature_valid: false` on data nobody tampered with — indistinguishable
141
+ from forgery. `POST /v1/verify_receipt` reports `reason:
142
+ receipt_reshaped_after_signing` instead of `signature_invalid` when the
143
+ responder can prove that is what happened, but it never accepts the receipt.
144
+
133
145
  ## License
134
146
 
135
147
  Apache-2.0. Same as the upstream protocol.
@@ -9,7 +9,7 @@ name = "ememdev"
9
9
  # without a framework. 1.0.1 first superseded the empty 1.0.0 on PyPI (it
10
10
  # shipped dist-info and no modules, so `import emem` raised ModuleNotFoundError;
11
11
  # PyPI versions are immutable, so the only fix was to supersede it).
12
- version = "2.1.0"
12
+ version = "2.2.0"
13
13
  description = "Python client for emem.dev, the agent-native Earth memory protocol"
14
14
  readme = "README.md"
15
15
  requires-python = ">=3.9"
@@ -29,8 +29,11 @@ Quick start:
29
29
  from importlib.metadata import PackageNotFoundError, version as _pkg_version
30
30
 
31
31
  from .client import AsyncClient, Client, EmemError, EmemHTTPError
32
+ from .verify import Verdict, verify_receipt_offline
32
33
 
33
34
  __all__ = [
35
+ "verify_receipt_offline",
36
+ "Verdict",
34
37
  "AsyncClient",
35
38
  "Client",
36
39
  "EmemError",
@@ -377,7 +377,37 @@ class Client:
377
377
  receipt: Mapping[str, Any],
378
378
  *,
379
379
  pubkey_b32: str | None = None,
380
- ) -> Any:
380
+ offline: bool = True,
381
+ ) -> Any:
382
+ """Check a receipt.
383
+
384
+ Offline by default, because the alternative is asking the responder
385
+ whether the responder is honest. This method used to do only that: a
386
+ POST to `/v1/verify_receipt`, which is a useful endpoint and is not
387
+ verification. The crypto to do it here was already a dependency, used
388
+ to sign writes, and nothing pointed it at reads.
389
+
390
+ `offline=False` keeps the old behaviour for callers who want the
391
+ responder's opinion as a second signal, or who installed without the
392
+ `signing` extra.
393
+ """
394
+ if offline:
395
+ from .verify import verify_receipt_offline
396
+
397
+ v = verify_receipt_offline(receipt)
398
+ return {
399
+ "valid": v.ok,
400
+ "state": v.state,
401
+ "why": v.why,
402
+ "digest_hex": v.digest_hex,
403
+ "signer_pubkey_b32": v.signer_b32,
404
+ "preimage_version": v.preimage_version,
405
+ # Set HERE, after this process did the arithmetic. A responder
406
+ # can never send this field: a server asserting that its own
407
+ # signature is good is the thing offline verification exists to
408
+ # remove.
409
+ "verified_locally": v.ok,
410
+ }
381
411
  return self._post("/v1/verify_receipt", _verify_receipt_body(receipt, pubkey_b32))
382
412
 
383
413
  def hunt(
@@ -1227,7 +1257,22 @@ class AsyncClient:
1227
1257
  receipt: Mapping[str, Any],
1228
1258
  *,
1229
1259
  pubkey_b32: str | None = None,
1230
- ) -> Any:
1260
+ offline: bool = True,
1261
+ ) -> Any:
1262
+ """Check a receipt. Offline by default; see the sync twin."""
1263
+ if offline:
1264
+ from .verify import verify_receipt_offline
1265
+
1266
+ v = verify_receipt_offline(receipt)
1267
+ return {
1268
+ "valid": v.ok,
1269
+ "state": v.state,
1270
+ "why": v.why,
1271
+ "digest_hex": v.digest_hex,
1272
+ "signer_pubkey_b32": v.signer_b32,
1273
+ "preimage_version": v.preimage_version,
1274
+ "verified_locally": v.ok,
1275
+ }
1231
1276
  return await self._post("/v1/verify_receipt", _verify_receipt_body(receipt, pubkey_b32))
1232
1277
 
1233
1278
  async def hunt(
@@ -0,0 +1,408 @@
1
+ """Verify a receipt here, in this process, without asking the responder.
2
+
3
+ Why this module exists
4
+ ----------------------
5
+ `Client.verify_receipt()` posts to `/v1/verify_receipt`. That endpoint is
6
+ useful and it is not verification: it asks emem.dev whether emem.dev's own
7
+ signature is good. A caller who wanted to know that the responder is honest
8
+ has just asked the responder. The whole argument of the protocol is that you
9
+ do not have to.
10
+
11
+ The ingredients were already installed. `blake3` and `cryptography` ship as
12
+ the `signing` extra and are used to SIGN writes; nothing pointed them at
13
+ reading. This module points them the other way.
14
+
15
+ What it does
16
+ ------------
17
+ Rebuilds the canonical preimage under the rule the receipt's own
18
+ `preimage_version` names, hashes it with blake3, and checks the ed25519
19
+ signature against the responder key carried in the receipt. No network. The
20
+ same algorithm the browser verifier runs at emem.dev/verify, ported from
21
+ `web/emem-verify-core.js` rather than derived a second time.
22
+
23
+ The self-test is not optional
24
+ -----------------------------
25
+ `self_test()` replays vectors emitted by the Rust signer, and
26
+ `verify_receipt_offline` refuses to return a verdict when it fails. A drifted
27
+ encoder can only compute a wrong digest, and a wrong digest reads as a forged
28
+ receipt against one that is perfectly sound. Telling a caller their genuine
29
+ data was tampered with is a worse failure than declining to check it.
30
+
31
+ from ememdev.verify import verify_receipt_offline
32
+ v = verify_receipt_offline(resp["receipt"])
33
+ if not v.ok:
34
+ raise RuntimeError(v.why)
35
+ """
36
+
37
+ from __future__ import annotations
38
+
39
+ import base64
40
+ from dataclasses import dataclass
41
+ from typing import Any, Mapping, Sequence
42
+
43
+ __all__ = ["Verdict", "verify_receipt_offline", "self_test", "CRYPTO_AVAILABLE"]
44
+
45
+ try: # the `signing` extra; absent in a bare install
46
+ import blake3 as _blake3_mod
47
+ from cryptography.exceptions import InvalidSignature
48
+ from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PublicKey
49
+
50
+ CRYPTO_AVAILABLE = True
51
+ except Exception: # pragma: no cover - exercised by installs without the extra
52
+ CRYPTO_AVAILABLE = False
53
+
54
+
55
+ def _b3(data: bytes) -> bytes:
56
+ return _blake3_mod.blake3(data).digest()
57
+
58
+
59
+ def _hex(b: bytes) -> str:
60
+ return b.hex()
61
+
62
+
63
+ def _b32decode(s: str) -> bytes:
64
+ s = s.strip().rstrip("=").upper()
65
+ return base64.b32decode(s + "=" * ((8 - len(s) % 8) % 8))
66
+
67
+
68
+ # ---------------------------------------------------------------- CBOR bits
69
+ # Only the four shapes a preimage needs, reproducing ciborium's canonical
70
+ # output: definite-length text, unsigned ints in shortest form, a definite
71
+ # array of text, and a definite map of string->string in a given key order.
72
+
73
+
74
+ def _cbor_head(major: int, n: int, out: bytearray) -> None:
75
+ if n < 24:
76
+ out.append((major << 5) | n)
77
+ elif n < 0x100:
78
+ out.append((major << 5) | 24)
79
+ out.append(n)
80
+ elif n < 0x10000:
81
+ out.append((major << 5) | 25)
82
+ out += n.to_bytes(2, "big")
83
+ elif n < 0x100000000:
84
+ out.append((major << 5) | 26)
85
+ out += n.to_bytes(4, "big")
86
+ else:
87
+ out.append((major << 5) | 27)
88
+ out += n.to_bytes(8, "big")
89
+
90
+
91
+ def _cbor_text(s: str, out: bytearray) -> None:
92
+ b = s.encode()
93
+ _cbor_head(3, len(b), out)
94
+ out += b
95
+
96
+
97
+ def _cbor_uint(n: int, out: bytearray) -> None:
98
+ _cbor_head(0, int(n), out)
99
+
100
+
101
+ def _cbor_array_text(items: Sequence[str], out: bytearray) -> None:
102
+ _cbor_head(4, len(items), out)
103
+ for s in items:
104
+ _cbor_text(s, out)
105
+
106
+
107
+ def _cbor_map_str(pairs: Sequence[tuple], out: bytearray) -> None:
108
+ _cbor_head(5, len(pairs), out)
109
+ for k, v in pairs:
110
+ _cbor_text(k, out)
111
+ _cbor_text(v, out)
112
+
113
+
114
+ # ------------------------------------------------------- v1 segment layout
115
+ # Tags mirror emem-attest::receipt_tag / merkle_tag. Order is load-bearing:
116
+ # FIELD and MERKLE are appended AFTER fact_cids so a receipt without them
117
+ # hashes identically to one signed before those tags existed.
118
+
119
+ _RT = dict(
120
+ REQUEST_ID=1, SERVED_AT=2, SCOPE=3, AS_OF=4, EDGES=5, MANIFEST=6,
121
+ PRIMITIVE=7, CELLS=8, FACT_CIDS=9, FIELD=10, MERKLE=11,
122
+ )
123
+ _MT = dict(ROOT=1, LEAF_INDEX=2, PATH=3, RULE_VERSION=4, ABSENT=5)
124
+ _DOMAIN = b"emem.preimage.v1\x00"
125
+
126
+
127
+ def _u32le(n: int, out: bytearray) -> None:
128
+ out += int(n).to_bytes(4, "little")
129
+
130
+
131
+ def _seg(tag: int, s: str, out: bytearray) -> None:
132
+ b = s.encode()
133
+ out.append(tag)
134
+ _u32le(len(b), out)
135
+ out += b
136
+
137
+
138
+ def _seg_list(tag: int, items: Sequence[str], out: bytearray) -> None:
139
+ out.append(tag)
140
+ _u32le(len(items), out)
141
+ for s in items:
142
+ b = s.encode()
143
+ _u32le(len(b), out)
144
+ out += b
145
+
146
+
147
+ def _seg_bytes(tag: int, data: bytes, out: bytearray) -> None:
148
+ out.append(tag)
149
+ _u32le(len(data), out)
150
+ out += data
151
+
152
+
153
+ def _start(kind: str) -> bytearray:
154
+ out = bytearray(_DOMAIN)
155
+ d = kind.encode()
156
+ _u32le(len(d), out)
157
+ out += d
158
+ return out
159
+
160
+
161
+ def _bytes_of(v: Any) -> bytes:
162
+ if isinstance(v, (bytes, bytearray)):
163
+ return bytes(v)
164
+ if isinstance(v, list):
165
+ return bytes(int(x) & 0xFF for x in v)
166
+ if isinstance(v, str):
167
+ try:
168
+ return bytes.fromhex(v)
169
+ except ValueError:
170
+ return _b32decode(v)
171
+ return b""
172
+
173
+
174
+ # ------------------------------------------------------------ sub-digests
175
+
176
+
177
+ def _manifest_hex(r: Mapping[str, Any]) -> str | None:
178
+ sv = r.get("source_versions") or {}
179
+ keys = sorted(sv)
180
+ if not keys:
181
+ return None
182
+ out = bytearray()
183
+ _cbor_map_str([(k, str(sv[k])) for k in keys], out)
184
+ return _hex(_b3(bytes(out)))
185
+
186
+
187
+ def _edges_hex(r: Mapping[str, Any]) -> str | None:
188
+ eds = sorted(str(x) for x in (r.get("edge_cids") or []))
189
+ if not eds:
190
+ return None
191
+ out = bytearray()
192
+ _cbor_array_text(eds, out)
193
+ return _hex(_b3(bytes(out)))
194
+
195
+
196
+ def _scope_hex(r: Mapping[str, Any]) -> str | None:
197
+ sc = r.get("scope")
198
+ if not sc:
199
+ return None
200
+ pairs = [(k, str(sc[k])) for k in ("user_id", "agent_id", "run_id", "org_id")
201
+ if sc.get(k) is not None]
202
+ if not pairs:
203
+ return None
204
+ out = bytearray()
205
+ _cbor_map_str(pairs, out)
206
+ return _hex(_b3(bytes(out)))
207
+
208
+
209
+ def _as_of_hex(r: Mapping[str, Any]) -> str | None:
210
+ a = r.get("as_of")
211
+ if not a:
212
+ return None
213
+ n = (a.get("valid_time") is not None) + (a.get("transaction_time") is not None)
214
+ if n == 0:
215
+ return None
216
+ out = bytearray()
217
+ _cbor_head(5, n, out)
218
+ if a.get("valid_time") is not None:
219
+ _cbor_text("valid_time", out)
220
+ _cbor_uint(a["valid_time"], out)
221
+ if a.get("transaction_time") is not None:
222
+ _cbor_text("transaction_time", out)
223
+ _cbor_text(str(a["transaction_time"]), out)
224
+ return _hex(_b3(bytes(out)))
225
+
226
+
227
+ def _field_hex(r: Mapping[str, Any]) -> str | None:
228
+ f = r.get("field")
229
+ if not f:
230
+ return None
231
+ out = _start("field")
232
+ _seg(1, f.get("aoi_cid") or "", out)
233
+ _seg(2, f.get("derivation_cid") or "", out)
234
+ return _hex(_b3(bytes(out)))
235
+
236
+
237
+ def _merkle_binding_v2_hex(r: Mapping[str, Any]) -> str:
238
+ out = _start("merkle")
239
+ p = r.get("merkle_proof")
240
+ if not p:
241
+ # An explicit absence marker, not a skipped segment. If absence were
242
+ # encoded by omission, stripping a proof would produce the same digest
243
+ # as a receipt that never had one, and stripping would stay invisible.
244
+ _seg_bytes(_MT["ABSENT"], b"", out)
245
+ else:
246
+ _seg_bytes(_MT["ROOT"], _bytes_of(p.get("root")), out)
247
+ _seg_bytes(_MT["LEAF_INDEX"], int(p.get("leaf_index") or 0).to_bytes(4, "little"), out)
248
+ path = b"".join(_bytes_of(h) for h in (p.get("path") or []))
249
+ _seg_bytes(_MT["PATH"], path, out)
250
+ _seg_bytes(_MT["RULE_VERSION"], bytes([int(p.get("version") or 0)]), out)
251
+ return _hex(_b3(bytes(out)))
252
+
253
+
254
+ def _preimage_v1(r: Mapping[str, Any]) -> bytes:
255
+ out = _start("receipt")
256
+ _seg(_RT["REQUEST_ID"], r.get("request_id") or "", out)
257
+ _seg(_RT["SERVED_AT"], r.get("served_at") or "", out)
258
+ for tag, h in (
259
+ (_RT["SCOPE"], _scope_hex(r)),
260
+ (_RT["AS_OF"], _as_of_hex(r)),
261
+ (_RT["EDGES"], _edges_hex(r)),
262
+ (_RT["MANIFEST"], _manifest_hex(r)),
263
+ ):
264
+ if h:
265
+ _seg(tag, h, out)
266
+ _seg(_RT["PRIMITIVE"], r.get("primitive") or "", out)
267
+ _seg_list(_RT["CELLS"], [str(c) for c in (r.get("cells") or [])], out)
268
+ _seg_list(_RT["FACT_CIDS"], [str(c) for c in (r.get("fact_cids") or [])], out)
269
+ fh = _field_hex(r)
270
+ if fh:
271
+ _seg(_RT["FIELD"], fh, out)
272
+ if int(r.get("preimage_version") or 0) >= 2:
273
+ _seg(_RT["MERKLE"], _merkle_binding_v2_hex(r), out)
274
+ return bytes(out)
275
+
276
+
277
+ def _preimage_v0(r: Mapping[str, Any]) -> bytes:
278
+ parts: list[bytes] = []
279
+ parts.append((r.get("request_id") or "").encode())
280
+ parts.append(b"|")
281
+ parts.append((r.get("served_at") or "").encode())
282
+ parts.append(b"|")
283
+ for h in (_scope_hex(r), _as_of_hex(r), _edges_hex(r), _manifest_hex(r)):
284
+ if h:
285
+ parts.append(h.encode())
286
+ parts.append(b"|")
287
+ parts.append((r.get("primitive") or "").encode())
288
+ parts.append(b"|")
289
+ for c in (r.get("cells") or []):
290
+ parts.append(str(c).encode())
291
+ parts.append(b",")
292
+ parts.append(b"|")
293
+ for c in (r.get("fact_cids") or []):
294
+ parts.append(str(c).encode())
295
+ parts.append(b",")
296
+ return b"".join(parts)
297
+
298
+
299
+ def _preimage(r: Mapping[str, Any]) -> bytes:
300
+ return _preimage_v1(r) if int(r.get("preimage_version") or 0) >= 1 else _preimage_v0(r)
301
+
302
+
303
+ def _sig_bytes(r: Mapping[str, Any]) -> bytes | None:
304
+ for key in ("signature", "sig_b32", "signature_b32"):
305
+ v = r.get(key)
306
+ if v is None:
307
+ continue
308
+ b = _bytes_of(v)
309
+ if b:
310
+ return b
311
+ return None
312
+
313
+
314
+ def _pub_bytes(r: Mapping[str, Any]) -> bytes | None:
315
+ if r.get("responder_pubkey_b32"):
316
+ return _b32decode(r["responder_pubkey_b32"])
317
+ v = r.get("responder")
318
+ return _bytes_of(v) if v is not None else None
319
+
320
+
321
+ # ------------------------------------------------------------- self-test
322
+ # Ground truth emitted by the Rust signer. Identical to the vectors the
323
+ # browser verifier replays; if these move, both must move together.
324
+
325
+ _VECTORS = {
326
+ "manifest": "de14467c03ed214d08ad536ba2923fed93dfd1d2af63c74d1b08131b00ea3915",
327
+ "edges": "5a27c406ddc3b90b10c07edd1c0902a4901ede4c0efc48d6d792c92f85d8cca6",
328
+ "scope": "6306e69a1f2e0df252a01f76785440c4f3d0704b3872a01bda85446256e8d45c",
329
+ "as_of": "89593e75f23de38f21e520b65c8b86a032cd83f48335bbf95cb7b2b7debbc994",
330
+ }
331
+
332
+
333
+ def self_test() -> bool:
334
+ """Do these encoders still agree with the Rust signer?"""
335
+ if not CRYPTO_AVAILABLE:
336
+ return False
337
+ try:
338
+ checks = (
339
+ _manifest_hex({"source_versions": {"bands_cid": "bbb", "registry_cid": "rrr",
340
+ "schema_cid": "sss", "sources_cid": "ooo"}})
341
+ == _VECTORS["manifest"],
342
+ _edges_hex({"edge_cids": ["e2", "e1"]}) == _VECTORS["edges"],
343
+ _scope_hex({"scope": {"user_id": "u1", "run_id": "r9"}}) == _VECTORS["scope"],
344
+ _as_of_hex({"as_of": {"valid_time": 1767225600,
345
+ "transaction_time": "2026-05-29T00:00:00Z"}})
346
+ == _VECTORS["as_of"],
347
+ )
348
+ return all(checks)
349
+ except Exception:
350
+ return False
351
+
352
+
353
+ @dataclass(frozen=True)
354
+ class Verdict:
355
+ """The result of checking, and never a claim that checking happened."""
356
+
357
+ ok: bool
358
+ state: str
359
+ why: str
360
+ digest_hex: str | None = None
361
+ signer_b32: str | None = None
362
+ preimage_version: int | None = None
363
+
364
+ def __bool__(self) -> bool:
365
+ return self.ok
366
+
367
+
368
+ def verify_receipt_offline(receipt: Mapping[str, Any]) -> Verdict:
369
+ """Check a receipt in this process. Never raises; never asks the server.
370
+
371
+ `ok` is True only when this code rebuilt the digest and ed25519 accepted
372
+ the signature over it. Every other outcome names itself, so a caller can
373
+ never render a pass for a state where no cryptography ran.
374
+ """
375
+ if not CRYPTO_AVAILABLE:
376
+ return Verdict(False, "crypto_unavailable",
377
+ "install the signing extra (pip install 'ememdev[signing]') "
378
+ "to verify locally; nothing was checked")
379
+ if not self_test():
380
+ return Verdict(False, "self_test_failed",
381
+ "these encoders disagree with the vectors the Rust signer "
382
+ "emitted, so any digest computed here would be wrong. "
383
+ "Nothing was checked.")
384
+ if not isinstance(receipt, Mapping):
385
+ return Verdict(False, "no_receipt", "no receipt to check")
386
+ try:
387
+ digest = _b3(_preimage(receipt))
388
+ sig = _sig_bytes(receipt)
389
+ pub = _pub_bytes(receipt)
390
+ if not sig or not pub:
391
+ return Verdict(False, "incomplete",
392
+ "the receipt carries no signature or no responder key")
393
+ try:
394
+ Ed25519PublicKey.from_public_bytes(pub).verify(sig, digest)
395
+ except InvalidSignature:
396
+ return Verdict(False, "signature_rejected",
397
+ "the signature does not match the digest computed from "
398
+ "the receipt's own fields",
399
+ _hex(digest), receipt.get("responder_pubkey_b32"),
400
+ int(receipt.get("preimage_version") or 0))
401
+ return Verdict(True, "verified",
402
+ "rebuilt the canonical preimage, hashed it with blake3, and "
403
+ "ed25519 accepted the signature against the responder key in "
404
+ "the receipt",
405
+ _hex(digest), receipt.get("responder_pubkey_b32"),
406
+ int(receipt.get("preimage_version") or 0))
407
+ except Exception as e: # a malformed receipt is a refusal, not a crash
408
+ return Verdict(False, "error", f"{type(e).__name__}: {e}")
File without changes
File without changes
File without changes
File without changes
File without changes