ememdev 2.0.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.
- {ememdev-2.0.0 → ememdev-2.2.0}/PKG-INFO +18 -6
- {ememdev-2.0.0 → ememdev-2.2.0}/README.md +16 -4
- {ememdev-2.0.0 → ememdev-2.2.0}/pyproject.toml +1 -1
- {ememdev-2.0.0 → ememdev-2.2.0}/src/ememdev/__init__.py +3 -0
- {ememdev-2.0.0 → ememdev-2.2.0}/src/ememdev/__main__.py +1 -1
- {ememdev-2.0.0 → ememdev-2.2.0}/src/ememdev/client.py +47 -2
- ememdev-2.2.0/src/ememdev/verify.py +408 -0
- {ememdev-2.0.0 → ememdev-2.2.0}/.gitignore +0 -0
- {ememdev-2.0.0 → ememdev-2.2.0}/LICENSE +0 -0
- {ememdev-2.0.0 → ememdev-2.2.0}/src/ememdev/identity.py +0 -0
- {ememdev-2.0.0 → ememdev-2.2.0}/src/ememdev/signing.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
Metadata-Version: 2.
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
2
|
Name: ememdev
|
|
3
|
-
Version: 2.
|
|
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
|
|
@@ -137,7 +137,7 @@ signer = load_or_create_signer() # persistent identity
|
|
|
137
137
|
path = f"{signer.namespace_root}/note.md"
|
|
138
138
|
body = b"a durable, signed, citeable note"
|
|
139
139
|
attester = signer.attester_block("create", path, body)
|
|
140
|
-
# POST {path, file_text, kind, attester} to /mcp
|
|
140
|
+
# POST {path, file_text, kind, attester} to /mcp emem_memory_create.
|
|
141
141
|
```
|
|
142
142
|
|
|
143
143
|
`str_replace` and `insert` sign the whole file *after* the edit, and `rename`
|
|
@@ -150,9 +150,10 @@ first.
|
|
|
150
150
|
|
|
151
151
|
Every non-introspection response carries a `receipt` block with:
|
|
152
152
|
|
|
153
|
-
- `
|
|
154
|
-
- `signature_b32` (ed25519 over the
|
|
155
|
-
- `
|
|
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.
|
|
@@ -107,7 +107,7 @@ signer = load_or_create_signer() # persistent identity
|
|
|
107
107
|
path = f"{signer.namespace_root}/note.md"
|
|
108
108
|
body = b"a durable, signed, citeable note"
|
|
109
109
|
attester = signer.attester_block("create", path, body)
|
|
110
|
-
# POST {path, file_text, kind, attester} to /mcp
|
|
110
|
+
# POST {path, file_text, kind, attester} to /mcp emem_memory_create.
|
|
111
111
|
```
|
|
112
112
|
|
|
113
113
|
`str_replace` and `insert` sign the whole file *after* the edit, and `rename`
|
|
@@ -120,9 +120,10 @@ first.
|
|
|
120
120
|
|
|
121
121
|
Every non-introspection response carries a `receipt` block with:
|
|
122
122
|
|
|
123
|
-
- `
|
|
124
|
-
- `signature_b32` (ed25519 over the
|
|
125
|
-
- `
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
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
|