ledgdex 1.0.20__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.
ledgdex/__init__.py ADDED
@@ -0,0 +1,2 @@
1
+ """Ledgdex: an agent-centric ledger with triple-entry accounting, kept in a dex. See architecture/SPEC.md."""
2
+ __version__ = '1.0.20'
ledgdex/__main__.py ADDED
@@ -0,0 +1,3 @@
1
+ from .cli import main
2
+
3
+ main()
ledgdex/canon.py ADDED
@@ -0,0 +1,99 @@
1
+ """Canonical JSON (spec 1.2): the exact bytes that are hashed and signed."""
2
+ import hashlib, json, re
3
+
4
+ KEY = re.compile(r'[a-z][a-z0-9_]*\Z')
5
+ ID_KEY = re.compile(r'[\x21-\x7e]+\Z') # state output (spec 6.3) is keyed by ids: printable ASCII
6
+ MAX_INT = 2 ** 53 - 1
7
+ MAX_DEPTH = 32 # spec 1.2 rule 9: arrays and objects nest at most this deep
8
+
9
+
10
+ class CanonError(ValueError):
11
+ pass
12
+
13
+
14
+ def check(v, keys=KEY, depth=1):
15
+ """Raise CanonError unless v follows rules 1-3 and 9."""
16
+ if v is None or isinstance(v, bool):
17
+ return
18
+ if isinstance(v, int):
19
+ if not -MAX_INT <= v <= MAX_INT:
20
+ raise CanonError('integer out of range')
21
+ return
22
+ if isinstance(v, float):
23
+ raise CanonError('floats are not allowed')
24
+ if isinstance(v, str):
25
+ try:
26
+ v.encode('utf-8')
27
+ except UnicodeEncodeError:
28
+ raise CanonError('string is not valid Unicode')
29
+ return
30
+ if isinstance(v, (list, dict)) and depth > MAX_DEPTH:
31
+ raise CanonError('too deeply nested')
32
+ if isinstance(v, list):
33
+ for x in v:
34
+ check(x, keys, depth + 1)
35
+ return
36
+ if isinstance(v, dict):
37
+ for k, x in v.items():
38
+ if not isinstance(k, str) or not keys.match(k):
39
+ raise CanonError('bad key: ' + str(k))
40
+ check(x, keys, depth + 1)
41
+ return
42
+ raise CanonError('value of type ' + type(v).__name__ + ' is not allowed')
43
+
44
+
45
+ def canon(v, keys=KEY):
46
+ """canon(x): canonical JSON bytes. keys=ID_KEY only for the state object, whose keys are ids."""
47
+ check(v, keys)
48
+ return json.dumps(v, sort_keys=True, separators=(',', ':'), ensure_ascii=False).encode('utf-8')
49
+
50
+
51
+ def sha256(data):
52
+ return 'sha256:' + hashlib.sha256(data).hexdigest()
53
+
54
+
55
+ def hash_(v):
56
+ """hash(x) = "sha256:" + hex(SHA-256(canon(x)))."""
57
+ return sha256(canon(v))
58
+
59
+
60
+ def _pairs(pairs):
61
+ d = {}
62
+ for k, v in pairs:
63
+ if k in d:
64
+ raise CanonError('duplicate key: ' + k)
65
+ d[k] = v
66
+ return d
67
+
68
+
69
+ def _no_float(s):
70
+ raise CanonError('floats are not allowed')
71
+
72
+
73
+ def _int(s):
74
+ # refuse a long integer before converting it: converting a huge one takes quadratic time on older Pythons
75
+ if len(s.lstrip('-')) > 16:
76
+ raise CanonError('integer out of range')
77
+ return int(s)
78
+
79
+
80
+ def _no_constant(s):
81
+ raise CanonError(s + ' is not allowed')
82
+
83
+
84
+ def parse(data):
85
+ """Parse bytes that MUST already be canonical JSON. Returns the value or raises CanonError."""
86
+ if isinstance(data, str):
87
+ data = data.encode('utf-8')
88
+ try:
89
+ v = json.loads(data.decode('utf-8'), object_pairs_hook=_pairs,
90
+ parse_float=_no_float, parse_int=_int, parse_constant=_no_constant)
91
+ except CanonError:
92
+ raise
93
+ except RecursionError:
94
+ raise CanonError('too deeply nested')
95
+ except ValueError as e:
96
+ raise CanonError('not JSON: ' + str(e))
97
+ if canon(v) != data:
98
+ raise CanonError('not canonical JSON')
99
+ return v
ledgdex/check.py ADDED
@@ -0,0 +1,352 @@
1
+ """Tamper checks over time (spec 3.4, 9): run by hand, from cron, as a worker, or in a GitHub workflow.
2
+
3
+ A ledger that verifies is internally consistent and signed by its owner. These checks add what one verification
4
+ cannot see: whether a ledger still extends the copy seen last time, whether every receipt still matches the ledger
5
+ it came from, whether the same address still serves the same ledger, and (optionally) whether offer media still match
6
+ their hashes. Errors mean tampering or equivocation and come with signed proof where there is one. Warnings mean
7
+ something could not be checked (a site is down)."""
8
+ import datetime, hashlib, json, os, re
9
+ from .canon import hash_
10
+ from .core import KEY_TYPES, SKEW, Invalid, Keys, Ledger, now, seconds
11
+ from .dex import LEDGER, fetch, http_get, inside
12
+
13
+ STATE = '.ledgdex-check'
14
+
15
+
16
+ class Store:
17
+ """Last-seen copies of ledgers and which ledger each address served. Lives outside every dex's gen/."""
18
+
19
+ def __init__(self, root):
20
+ self.root = root
21
+ os.makedirs(os.path.join(root, 'seen'), exist_ok=True)
22
+ os.makedirs(os.path.join(root, 'proofs'), exist_ok=True)
23
+ self.urls_path = os.path.join(root, 'urls.json')
24
+ self.urls = {}
25
+ if os.path.exists(self.urls_path):
26
+ with open(self.urls_path, encoding='utf-8') as f:
27
+ self.urls = json.load(f)
28
+
29
+ def _seen(self, ledger_id):
30
+ return os.path.join(self.root, 'seen', ledger_id[7:] + '.jsonl')
31
+
32
+ def seen(self, ledger_id):
33
+ p = self._seen(ledger_id)
34
+ if not os.path.exists(p):
35
+ return None
36
+ with open(p, 'rb') as f:
37
+ return f.read()
38
+
39
+ def remember(self, led):
40
+ data = led.header_line + b'\n' + b''.join(line + b'\n' for line in led.lines)
41
+ old = self.seen(led.id)
42
+ if old is None or len(data) > len(old):
43
+ with open(self._seen(led.id), 'wb') as f:
44
+ f.write(data)
45
+
46
+ def proof(self, name, obj):
47
+ p = os.path.join(self.root, 'proofs', name + '.json')
48
+ with open(p, 'w', encoding='utf-8', newline='\n') as f:
49
+ f.write(json.dumps(obj, indent=2, ensure_ascii=False))
50
+ return p
51
+
52
+ def save(self):
53
+ with open(self.urls_path, 'w', encoding='utf-8', newline='\n') as f:
54
+ f.write(json.dumps(self.urls, indent=2, sort_keys=True))
55
+
56
+
57
+ class Checker:
58
+ def __init__(self, state_dir=STATE, media=False, log=print, root=None):
59
+ self.store = Store(state_dir)
60
+ self.media = media
61
+ self.log = log
62
+ self.problems = []
63
+ self.root, self.root_error = None, None # the root: needed for ledgers with a "recovered" entry
64
+ if root:
65
+ try:
66
+ self.root = fetch(root, cache=False)[0]
67
+ except Exception as e: # the root is down: say so, and do not call ledgers that need it broken
68
+ self.root_error = str(e)
69
+ self.problem('warning', 'unreachable', root, 'the root: ' + str(e))
70
+ self.checked = []
71
+ self.copies = {} # ledger id -> [(address, Ledger)] checked in this run
72
+
73
+ def problem(self, level, code, url, detail, ledger=None, proof=None):
74
+ p = {'level': level, 'code': code, 'url': url, 'detail': detail}
75
+ if ledger:
76
+ p['ledger'] = ledger
77
+ if proof:
78
+ p['proof'] = proof
79
+ self.problems.append(p)
80
+ self.log(level.upper() + ' ' + code + ': ' + url + ': ' + detail + (' (proof: ' + proof + ')' if proof else ''))
81
+
82
+ def load(self, src, level='error', remote=False):
83
+ # never the verification cache: a check verifies every entry, every time
84
+ try:
85
+ led, _ = fetch(src, cache=False, root=self.root, remote=remote)
86
+ return led
87
+ except Exception as e: # unreachable, missing, not a ledger
88
+ self.problem(level, 'unreachable', src, str(e))
89
+ return None
90
+
91
+ def ledger(self, led, url):
92
+ """Checks for one ledger at one address."""
93
+ if led.header is None:
94
+ self.problem('error', 'broken', url, str(led.error))
95
+ return False
96
+ ok = True
97
+ if not led.whole and (self.root is None) and 'can only be verified with the root' in str(led.error):
98
+ # not tampering: this ledger was recovered through the root, and the root could not be read (or none was
99
+ # given); check it again with the root
100
+ self.problem('warning', 'needs_root', url, 'entry ' + str(led.broken_at) + ' needs the root to verify' +
101
+ (' (the root could not be read)' if self.root_error else ': run with --root'), led.id)
102
+ return True
103
+ if not led.whole:
104
+ self.problem('error', 'broken', url, 'broken at seq ' + str(led.broken_at) + ': ' + str(led.error), led.id)
105
+ ok = False
106
+ if led.entries and seconds(led.entries[-1]['time']) > seconds(now()) + SKEW:
107
+ # an owner stamping entries ahead of time: every later entry must follow it, and it plays with any rule
108
+ # that compares times across ledgers
109
+ self.problem('warning', 'future_time', url, 'entries are timed up to ' + led.entries[-1]['time'] +
110
+ ', ahead of now', led.id)
111
+ before = self.store.urls.get(url)
112
+ if before and before != led.id:
113
+ self.problem('error', 'ledger_changed', url, 'this address served ledger ' + before + ' before and now '
114
+ 'serves ' + led.id, led.id)
115
+ ok = False
116
+ old = self.store.seen(led.id)
117
+ if old is not None and not led.data.startswith(old):
118
+ ok = self.rewritten(led, url, old) and ok
119
+ elif ok:
120
+ self.store.remember(led)
121
+ if not before:
122
+ self.store.urls[url] = led.id
123
+ if self.media:
124
+ self.check_media(led, url)
125
+ self.checked.append({'url': url, 'ledger': led.id, 'entries': len(led.entries), 'whole': led.whole})
126
+ self.copies.setdefault(led.id, []).append((url, led))
127
+ return ok
128
+
129
+ def rewritten(self, led, url, old):
130
+ prev = Ledger(old, cache=False)
131
+ n = 0
132
+ while n < len(prev.lines) and n < len(led.lines) and prev.lines[n] == led.lines[n]:
133
+ n += 1
134
+ if n < len(prev.lines) and n < len(led.lines):
135
+ # two entries at one seq, both signed by the owner (spec 3.4)
136
+ proof = self.store.proof('equivocation-' + led.id[7:19] + '-' + str(n), {
137
+ 'kind': 'equivocation', 'ledger': led.id, 'header': led.header, 'seq': n,
138
+ 'seen': json.loads(prev.lines[n]), 'now': json.loads(led.lines[n]),
139
+ 'note': 'Two entries at the same seq of one ledger, both signed by its owner key.'})
140
+ self.problem('error', 'equivocation', url, 'seq ' + str(n) + ' differs from the copy seen before', led.id,
141
+ proof)
142
+ return False
143
+ # an exact start of the copy seen before: an old snapshot, a cache, or entries withheld. Not a rewrite: every
144
+ # entry served is one seen before, so nothing signed has changed.
145
+ self.problem('warning', 'stale', url, 'serves ' + str(n) + ' of the ' + str(len(prev.lines)) +
146
+ ' entries seen before', led.id)
147
+ return True
148
+
149
+ def check_media(self, led, url):
150
+ for e in led.entries:
151
+ if e['msg']['type'] != 'offer':
152
+ continue
153
+ for m in e['msg']['body']['item']['media']:
154
+ try:
155
+ got = 'sha256:' + hashlib.sha256(http_get(m['url'])).hexdigest()
156
+ except Exception as err:
157
+ self.problem('warning', 'media_unavailable', m['url'], str(err), led.id)
158
+ continue
159
+ if got != m['hash']:
160
+ self.problem('error', 'media_changed', m['url'], 'hash is ' + got + ', the offer signed ' +
161
+ m['hash'], led.id)
162
+
163
+ def receipts(self, led, url):
164
+ """Every receipt in led must match every copy of the ledger it came from: the copies at the receipt's address
165
+ and the ledger's own dex address, copies already checked in this run, and the copy seen before."""
166
+ groups = {}
167
+ for e in led.entries:
168
+ if e['msg']['type'] == 'receipt':
169
+ b = e['msg']['body']
170
+ g = groups.setdefault(b['ledger'], {'urls': [], 'receipts': []})
171
+ if b['url'] not in g['urls']:
172
+ g['urls'].append(b['url'])
173
+ g['receipts'].append(b)
174
+ for ledger_id, g in groups.items():
175
+ # one address per place: a local folder or file named relatively in a receipt is the same as its full path
176
+ urls = []
177
+ for u in g['urls']:
178
+ u = u if re.match(r'https?://', u) or ':' in u.split('/')[0] else os.path.abspath(u)
179
+ if u not in urls:
180
+ urls.append(u)
181
+ for u in urls:
182
+ if any(u == c_url for c_url, _ in self.copies.get(ledger_id, [])):
183
+ continue
184
+ other = self.load(u, level='warning', remote=True)
185
+ if other is None or other.header is None:
186
+ continue
187
+ dex = other.dex
188
+ if re.match(r'https?://', dex) and dex not in urls:
189
+ urls.append(dex) # also check the ledger at its own dex address
190
+ if other.id != ledger_id:
191
+ # the address in a receipt is written by the receipt's holder, not by the ledger's owner: it is
192
+ # evidence against that address only if the address served this ledger before
193
+ if self.store.urls.get(u) == ledger_id:
194
+ self.problem('error', 'ledger_changed', u, 'this address served ledger ' + ledger_id +
195
+ ' before and now serves ' + other.id, ledger_id)
196
+ else:
197
+ self.problem('warning', 'receipt_url_mismatch', url, 'receipts from ledger ' + ledger_id +
198
+ ' name ' + u + ', which serves ' + other.id, ledger_id)
199
+ continue
200
+ self.ledger(other, u)
201
+ copies = list(self.copies.get(ledger_id, []))
202
+ seen = self.store.seen(ledger_id)
203
+ if seen is not None:
204
+ copies.append(('last seen copy', Ledger(seen, cache=False)))
205
+ if not copies:
206
+ self.problem('warning', 'receipts_unchecked', url, 'could not reach ledger ' + ledger_id)
207
+ continue
208
+ reported = set()
209
+ for r in g['receipts']:
210
+ e, s = r['entry'], r['entry']['seq']
211
+ found = False
212
+ for c_url, c in copies:
213
+ if s >= len(c.ids):
214
+ continue
215
+ found = True
216
+ # every entry the receipt holds (its key chain too) is compared: a chain entry signed by the owner
217
+ # that differs from this copy proves a rewrite as surely as the receipt entry itself
218
+ for x in r.get('keys', []) + [e]:
219
+ xs = x['seq']
220
+ if xs >= len(c.ids) or c.ids[xs] == hash_(x) or (c_url, xs) in reported:
221
+ continue
222
+ reported.add((c_url, xs))
223
+ if not self.signed_then(c, x):
224
+ # signed by a key this ledger did not have at that seq (a revoked device, a forged
225
+ # chain): not the owner's word, so not proof against the ledger
226
+ self.problem('warning', 'receipt_bad_signer', url, 'the receipt\'s entry ' + str(xs) +
227
+ ' of ledger ' + ledger_id + ' is not signed by a key that ledger had then')
228
+ continue
229
+ proof = self.store.proof('receipt-' + ledger_id[7:19] + '-' + str(xs), {
230
+ 'kind': 'receipt_mismatch', 'ledger': ledger_id, 'url': c_url, 'receipt': r,
231
+ 'entry': x, 'now': json.loads(c.lines[xs]),
232
+ 'note': 'The receipt holds an entry at this seq signed by a key the ledger had then; this '
233
+ 'copy has another.'})
234
+ self.problem('error', 'receipt_mismatch', c_url, 'entry ' + str(xs) + ' differs from the '
235
+ 'receipt held by ' + url, ledger_id, proof)
236
+ if not found:
237
+ self.problem('warning', 'receipt_not_visible', url, 'no copy of ledger ' + ledger_id +
238
+ ' has entry ' + str(s) + ' yet')
239
+
240
+ @staticmethod
241
+ def signed_then(c, e):
242
+ """True if entry e is signed by a key ledger copy c had at e's seq (its full key history, not a receipt's)."""
243
+ keys = Keys(c.header['owner'])
244
+ for x in c.entries[:e['seq']]:
245
+ if x['msg']['type'] in KEY_TYPES:
246
+ keys.apply(x['msg'])
247
+ return keys.signed_by(e) is not None
248
+
249
+ def published(self, dex):
250
+ """For a dex folder with "publish" in config.json: the published ledger must be the start of the local one."""
251
+ cfg = os.path.join(dex, 'config.json')
252
+ if not os.path.exists(cfg):
253
+ return
254
+ with open(cfg, encoding='utf-8') as f:
255
+ if not isinstance(json.load(f).get('publish'), dict):
256
+ return
257
+ from dexweb import dexweb
258
+ from .render import dexweb_utf8
259
+ dexweb_utf8()
260
+ with inside(os.path.abspath(dex)):
261
+ pub = dexweb.Dexweb().published(LEDGER)
262
+ if pub is None:
263
+ return
264
+ with open(os.path.join(dex, LEDGER), 'rb') as f:
265
+ local = f.read()
266
+ pub = pub.encode('utf-8')
267
+ if local.startswith(pub):
268
+ return
269
+ if pub.startswith(local):
270
+ self.problem('warning', 'behind', dex, 'the published ledger has entries this copy does not: publish to '
271
+ 'catch up')
272
+ else:
273
+ self.problem('error', 'published_differs', dex, 'the published ledger is not the start of this one')
274
+
275
+ def run(self, sources, published=False):
276
+ loaded = []
277
+ for src in sources:
278
+ led = self.load(src)
279
+ if led is None:
280
+ continue
281
+ url = src if re.match(r'https?://', src) else os.path.abspath(src)
282
+ self.ledger(led, url)
283
+ loaded.append((led, url))
284
+ if published and os.path.isdir(src):
285
+ self.published(src)
286
+ for led, url in loaded: # after every source is in, so receipts meet every copy
287
+ if led.header is not None:
288
+ self.receipts(led, url)
289
+ self.store.save()
290
+ errors = [p for p in self.problems if p['level'] == 'error']
291
+ return {'time': datetime.datetime.now(datetime.timezone.utc).strftime('%Y-%m-%dT%H:%M:%SZ'),
292
+ 'ok': not errors, 'errors': len(errors), 'warnings': len(self.problems) - len(errors),
293
+ 'checked': self.checked, 'problems': self.problems}
294
+
295
+
296
+ def check(sources, state_dir=STATE, media=False, published=False, log=print, root=None):
297
+ return Checker(state_dir, media, log, root).run(sources, published)
298
+
299
+
300
+ WORKFLOW = '''# ledgdex check: made by "ledgdex workflow". Runs on every push, daily, and by hand.
301
+ # A failed run means a ledger was tampered with or rewritten; the report and proofs are attached to the run.
302
+ name: ledgdex check
303
+ on:
304
+ push:
305
+ schedule:
306
+ - cron: "{cron}"
307
+ workflow_dispatch:
308
+ permissions:
309
+ contents: read
310
+ jobs:
311
+ check:
312
+ runs-on: ubuntu-latest
313
+ steps:
314
+ - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
315
+ - uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
316
+ with:
317
+ python-version: "3.12"
318
+ - run: pip install "ledgdex=={version}"
319
+ - uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
320
+ with:
321
+ path: .ledgdex-check
322
+ key: ledgdex-check-${{{{ github.run_id }}}}
323
+ restore-keys: ledgdex-check-
324
+ - run: ledgdex check {sources} --json ledgdex-check.json{media}
325
+ - uses: actions/cache/save@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
326
+ if: always()
327
+ with:
328
+ path: .ledgdex-check
329
+ key: ledgdex-check-${{{{ github.run_id }}}}
330
+ - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
331
+ if: always()
332
+ with:
333
+ name: ledgdex-check
334
+ path: |
335
+ ledgdex-check.json
336
+ .ledgdex-check/proofs/
337
+ if-no-files-found: ignore
338
+ '''
339
+
340
+
341
+ def workflow(sources, cron='17 6 * * *', media=False, version=None):
342
+ """The workflow file. Sources are shell-quoted and the cron is checked, so neither can add steps; ledgdex is
343
+ installed from PyPI at this exact version, not from a moving branch."""
344
+ import shlex
345
+ from . import __version__
346
+ if not re.match(r'[0-9*/,-]+( [0-9*/,A-Za-z-]+){4}\Z', cron):
347
+ raise Invalid('not a cron schedule: ' + cron)
348
+ for src in sources:
349
+ if not src or src.startswith('-') or any(c in src for c in '\n\r'):
350
+ raise Invalid('not a ledger source: ' + repr(src))
351
+ return WORKFLOW.format(cron=cron, sources=' '.join(shlex.quote(x) for x in sources), media=' --media' if media else '',
352
+ version=version or __version__)