underwrit 0.3.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.
underwrit/evidence.py ADDED
@@ -0,0 +1,590 @@
1
+ """Build an evidence pack: what happened, with proof it has not been altered since.
2
+
3
+ A pack is a directory. It carries the audit entries for one subject with their chain links intact, a
4
+ manifest of SHA-256 digests for every file, and `verify.py` — which needs no account, no network and
5
+ no trust in whoever produced the pack.
6
+
7
+ Version 2 adds a signed checkpoint (the whole log's size and Merkle root, signed by the data
8
+ plane's Ed25519 log key), an inclusion proof per entry against that root, and an Ed25519 signature
9
+ over the manifest. What each adds, precisely:
10
+
11
+ - **Inclusion proofs** show that every entry present sits at its stated position in a tree of the
12
+ stated size. An entry moved, or a position forged, fails its proof even when its own chain hash
13
+ still matches its contents.
14
+ - **The checkpoint signature** shows the tree was the data plane's, not the bundle-maker's.
15
+ - **The manifest signature** shows the bundle was assembled by the holder of the log key.
16
+
17
+ What none of this proves is completeness of a *subset*, or origin to somebody who has only the
18
+ bundle's copy of the public key. Completeness needs a witness holding the same checkpoint; origin
19
+ needs the key obtained out of band, which is why `verify.py` shows the key id and accepts a pinned
20
+ one. Every pack says this in its own README and the verifier says it again.
21
+
22
+ Three properties the format has committed to since version 1, each of which cost something to learn:
23
+
24
+ **A verifier that overstates itself is worse than none.** **"No entries" is its own state.**
25
+ **A subset is verified per-entry, never walked from genesis.** See §1 and §3 of the specification.
26
+
27
+ This module never queries anything but the chain tables. The tree over the chain is computed in
28
+ `checkpoint.py` and handed in; `test_gate.py` asserts that at source level.
29
+ """
30
+
31
+ from __future__ import annotations
32
+
33
+ import hashlib
34
+ import hmac
35
+ import json
36
+ import os
37
+ import time
38
+ from pathlib import Path
39
+
40
+ from . import chain
41
+ from .subject import Subject
42
+
43
+ FORMAT_VERSION = 2
44
+
45
+
46
+ def signing_key() -> str:
47
+ """The version-1 HMAC secret, if an operator still sets one."""
48
+ return os.environ.get("EVIDENCE_SIGNING_KEY", "").strip()
49
+
50
+
51
+ def canonical(payload) -> bytes:
52
+ return json.dumps(payload, sort_keys=True, separators=(",", ":")).encode("utf-8")
53
+
54
+
55
+ def _digest(data: bytes) -> str:
56
+ return hashlib.sha256(data).hexdigest()
57
+
58
+
59
+ def entries_for(conn, subject: Subject) -> list[dict]:
60
+ """Every audit entry naming this subject, with its chain links kept verbatim.
61
+
62
+ `prevHash` and `hash` are carried unchanged because they are the whole point: an entry without
63
+ them is a line of text somebody typed.
64
+ """
65
+ keys = set(subject.keys())
66
+ entries: list[dict] = []
67
+
68
+ rows = list(conn.execute("SELECT * FROM audit_log ORDER BY seq ASC"))
69
+ try:
70
+ # A subject older than the last seal has its entries in the archive. A pack that searched
71
+ # only the live log would report "no entries" for exactly the changes far enough in the past
72
+ # to be under audit — which is when somebody asks.
73
+ rows = list(conn.execute("SELECT * FROM audit_log_archive ORDER BY seq ASC")) + rows
74
+ except Exception:
75
+ pass # Archive absent on a database that has never sealed.
76
+
77
+ for row in rows:
78
+ raw = row["detail"] or "{}"
79
+ try:
80
+ detail = json.loads(raw) if isinstance(raw, str) else (raw or {})
81
+ except ValueError:
82
+ detail = {}
83
+ target = row["target"] or ""
84
+ named = target in keys or any(
85
+ isinstance(v, str) and v in keys
86
+ for k, v in (detail.items() if isinstance(detail, dict) else [])
87
+ if k in ("runId", "subjectId", "sessionId", "requestId")
88
+ )
89
+ if not named:
90
+ continue
91
+ entries.append({
92
+ "seq": row["seq"], "at": row["at"], "actor": row["actor"], "actorId": row["actor_id"],
93
+ "action": row["action"], "environment": row["environment"], "target": target,
94
+ "outcome": row["outcome"], "reason": row["reason"], "detail": detail,
95
+ "sourceIp": row["source_ip"], "prevHash": row["prev_hash"], "hash": row["hash"],
96
+ })
97
+ return entries
98
+
99
+
100
+ _entries_for = entries_for # the name older callers used
101
+
102
+
103
+ def _continuity(entries: list[dict]) -> dict:
104
+ """Whether the entries are consecutive in the source log, said plainly."""
105
+ if not entries:
106
+ return {"consecutive": None, "gaps": [], "note": "No entries to assess."}
107
+ seqs = [e["seq"] for e in entries]
108
+ gaps = [(a, b) for a, b in zip(seqs, seqs[1:]) if b != a + 1]
109
+ return {
110
+ "consecutive": not gaps,
111
+ "gaps": [{"after": a, "before": b, "missing": b - a - 1} for a, b in gaps],
112
+ "note": (
113
+ "Gaps are expected and are not evidence of tampering: this subject's entries are "
114
+ "interleaved with every other actor's in one shared log. A gap means other entries sit "
115
+ "between these, not that any are missing."
116
+ ),
117
+ }
118
+
119
+
120
+ def build(conn, subject: Subject, *, tree: dict | None = None, entries: list[dict] | None = None) -> dict:
121
+ """Assemble a pack for `subject`. Never returns None — an unknown subject is an empty record.
122
+
123
+ `tree`, when given, is the checkpoint view from `bundle.tree_for`: size, root, signed note, key,
124
+ and an inclusion proof per sequence number. Each entry then carries `leafIndex` and
125
+ `inclusionProof`; an entry with no place in the live tree (sealed into the archive) carries
126
+ `null` for both, and the pack says so rather than omitting the fields.
127
+ """
128
+ entries = entries_for(conn, subject) if entries is None else entries
129
+
130
+ if entries:
131
+ segment = chain.verify_segment([
132
+ {"seq": e["seq"], "at": e["at"], "actor": e["actor"], "actor_id": e["actorId"],
133
+ "action": e["action"], "environment": e["environment"], "target": e["target"],
134
+ "outcome": e["outcome"], "reason": e["reason"],
135
+ "detail": json.dumps(e["detail"], sort_keys=True),
136
+ "source_ip": e["sourceIp"], "prev_hash": e["prevHash"], "hash": e["hash"]}
137
+ for e in entries
138
+ ])
139
+ segment["status"] = "verified" if segment["ok"] else "broken"
140
+ else:
141
+ segment = {
142
+ "status": "no-entries", "ok": None, "checked": 0, "brokenAt": None,
143
+ "detail": (
144
+ "No audit entry names this subject. That is not a clean verification: there is "
145
+ "nothing here to verify."
146
+ ),
147
+ }
148
+
149
+ merkle_section = None
150
+ if tree:
151
+ proofs = tree.get("proofs") or {}
152
+ for e in entries:
153
+ p = proofs.get(e["seq"])
154
+ e["leafIndex"] = p["leafIndex"] if p else None
155
+ e["inclusionProof"] = p["proof"] if p else None
156
+ merkle_section = {
157
+ "size": tree["size"], "root": tree["root"], "keyId": tree["keyId"],
158
+ "key": tree.get("key"), "issuedAt": tree.get("issuedAt"),
159
+ "witnessed": tree.get("witnessed"),
160
+ # External clocks over the checkpoint body (token bytes ship as files, not here).
161
+ "timestamps": [{k: v for k, v in t.items() if k != "token"} for t in (tree.get("timestamps") or [])],
162
+ "proven": sum(1 for e in entries if e.get("inclusionProof") is not None),
163
+ "unplaced": sum(1 for e in entries if e.get("inclusionProof") is None),
164
+ "note": (
165
+ "Each proof shows the entry sits at its stated leaf index in a tree of the stated "
166
+ "size whose root the checkpoint signs. `unplaced` entries were sealed into the "
167
+ "archive before this tree and are verified by chain hash only."
168
+ ),
169
+ }
170
+
171
+ pack = {
172
+ "format": FORMAT_VERSION,
173
+ "generatedAt": time.time(),
174
+ "subject": {"id": subject.id, "kind": subject.kind, **subject.descriptor},
175
+ "auditEntries": entries,
176
+ "auditChain": {
177
+ "segment": segment,
178
+ # The whole-log verdict too. A segment can be internally consistent while the log it came
179
+ # from was altered elsewhere, and a pack reporting only the former would be reassuring
180
+ # about the wrong thing.
181
+ "wholeLog": chain.verify_chain(conn),
182
+ "segmentEntries": len(entries),
183
+ "note": (
184
+ "Entries carry prevHash and hash verbatim. `verify.py` recomputes them; it cannot "
185
+ "prove the segment is complete, only that what is here is unaltered and consecutive "
186
+ "where the sequence numbers are consecutive."
187
+ ),
188
+ },
189
+ "chainContinuity": _continuity(entries),
190
+ "merkle": merkle_section,
191
+ }
192
+ # Host sections last, and they may not shadow a structural key — a host that named a section
193
+ # "auditChain" would otherwise replace the verification with its own claim about it.
194
+ reserved = set(pack)
195
+ for name, value in (subject.sections or {}).items():
196
+ if name not in reserved:
197
+ pack[name] = value
198
+ return pack
199
+
200
+
201
+ # --------------------------------------------------------------------------------------------
202
+ # The shipped verifier
203
+ #
204
+ # Assembled from this package's own Ed25519 and Merkle code so the verifier and the signer cannot
205
+ # disagree: there is one implementation of each, and the bundle carries a copy of it.
206
+
207
+ _VERIFIER_HEAD = '''#!/usr/bin/env python3
208
+ """Verify this evidence pack. Standard library only; needs nothing from the system that made it.
209
+
210
+ python3 verify.py # from inside the bundle directory
211
+ UNDERWRIT_PUBLIC_KEY=<base64> python3 verify.py # and check origin against a key you obtained elsewhere
212
+
213
+ Checks, each independent of the others:
214
+
215
+ 1. Every file matches the SHA-256 recorded in manifest.json, so nothing has been edited.
216
+ 2. Every audit entry's hash is the SHA-256 of its predecessor's hash plus its own contents, so no
217
+ entry has been altered and none has been reordered.
218
+ 3. (format 2) The checkpoint's Ed25519 signature verifies under the key named in the manifest,
219
+ and every entry's inclusion proof leads to the checkpoint's root — so each entry sits where
220
+ the log said it did, in a tree of the size the log signed.
221
+ 4. (format 2) The manifest's Ed25519 signature verifies under the same key.
222
+
223
+ What this cannot prove: that the record is complete (a subset cannot show what was left out), and
224
+ origin, unless you obtained the public key from the issuer yourself — the copy in this bundle only
225
+ shows the bundle agrees with itself. Set UNDERWRIT_PUBLIC_KEY to the issuer's published key to check that.
226
+ """
227
+ import base64, hashlib, hmac, json, os, sys
228
+ from pathlib import Path
229
+
230
+ HERE = Path(__file__).parent
231
+ GENESIS = "0" * 64
232
+
233
+ '''
234
+
235
+ _VERIFIER_TAIL = '''
236
+
237
+ def entry_hash(previous, payload):
238
+ body = json.dumps(payload, sort_keys=True, separators=(",", ":")).encode("utf-8")
239
+ return hashlib.sha256(previous.encode("utf-8") + body).hexdigest()
240
+
241
+
242
+ def canonical(payload):
243
+ return json.dumps(payload, sort_keys=True, separators=(",", ":")).encode("utf-8")
244
+
245
+
246
+ def parse_note(note):
247
+ body, _, sigs = note.partition("\\n\\n")
248
+ lines = body.split("\\n")
249
+ out = {"origin": lines[0], "size": int(lines[1]), "root": base64.b64decode(lines[2]), "signatures": []}
250
+ for line in sigs.splitlines():
251
+ if line.startswith("\\u2014 "):
252
+ _, name, blob = line.split(" ", 2)
253
+ raw = base64.b64decode(blob)
254
+ out["signatures"].append((name, raw[:4].hex(), raw[4:]))
255
+ return out
256
+
257
+
258
+ def _require() -> set:
259
+ """--require a,b or UNDERWRIT_REQUIRE: artefacts whose absence is a failure, not a notice."""
260
+ want = set()
261
+ argv = sys.argv[1:]
262
+ for i, a in enumerate(argv):
263
+ if a.startswith("--require="):
264
+ want.update(x.strip() for x in a.split("=", 1)[1].split(",") if x.strip())
265
+ elif a == "--require" and i + 1 < len(argv):
266
+ want.update(x.strip() for x in argv[i + 1].split(",") if x.strip())
267
+ want.update(x.strip() for x in os.environ.get("UNDERWRIT_REQUIRE", "").split(",") if x.strip())
268
+ bad = want - {"signature", "checkpoint", "witness", "timestamp"}
269
+ if bad:
270
+ raise SystemExit(f"unknown --require item(s): {', '.join(sorted(bad))} "
271
+ f"(choose from signature, checkpoint, witness, timestamp)")
272
+ return want
273
+
274
+
275
+ def main():
276
+ require = _require()
277
+ pinned = os.environ.get("UNDERWRIT_PUBLIC_KEY", "").strip()
278
+ wkeys = [k.strip() for k in os.environ.get("UNDERWRIT_WITNESS_KEY", "").split(",") if k.strip()]
279
+ # A pinned key is a demand, not a hint: pinning the log key requires a signed, checkpointed
280
+ # bundle, and pinning a witness key requires a cosignature. Otherwise a bundle stripped back to
281
+ # format 1 would "verify" with every pin silently ignored.
282
+ if pinned:
283
+ require |= {"signature", "checkpoint"}
284
+ if wkeys:
285
+ require |= {"witness"}
286
+
287
+ try:
288
+ manifest = json.loads((HERE / "manifest.json").read_text())
289
+ except FileNotFoundError:
290
+ print("FAILED: manifest.json is missing — this is not a complete bundle")
291
+ return 1
292
+ except ValueError:
293
+ print("FAILED: manifest.json is not valid JSON")
294
+ return 1
295
+ if manifest.get("format", 1) not in (1, 2):
296
+ print(f"FAILED: this verifier understands formats 1 and 2, not {manifest.get('format')}")
297
+ return 1
298
+ problems = []
299
+
300
+ for name, expected in sorted((manifest.get("files") or {}).items()):
301
+ try:
302
+ actual = hashlib.sha256((HERE / name).read_bytes()).hexdigest()
303
+ except FileNotFoundError:
304
+ problems.append(f"{name}: listed in the manifest but missing from the bundle")
305
+ continue
306
+ if actual != expected:
307
+ problems.append(f"{name}: content does not match its recorded digest")
308
+ for name in ("evidence.json", "verify.py"):
309
+ if name not in (manifest.get("files") or {}):
310
+ problems.append(f"{name}: not covered by the manifest")
311
+
312
+ try:
313
+ pack = json.loads((HERE / "evidence.json").read_text())
314
+ entries = pack["auditEntries"]
315
+ assert isinstance(entries, list)
316
+ except (FileNotFoundError, ValueError, KeyError, AssertionError):
317
+ print("FAILED: evidence.json is missing or malformed")
318
+ return 1
319
+ for entry in entries:
320
+ try:
321
+ payload = {
322
+ "at": entry["at"], "actor": entry["actor"], "actor_id": entry["actorId"],
323
+ "action": entry["action"], "environment": entry["environment"],
324
+ "target": entry["target"], "outcome": entry["outcome"], "reason": entry["reason"],
325
+ # `detail` is hashed as the OBJECT, not as a JSON string of it.
326
+ "detail": entry["detail"], "source_ip": entry["sourceIp"],
327
+ }
328
+ if entry_hash(entry["prevHash"] or GENESIS, payload) != entry["hash"]:
329
+ problems.append(f"audit entry seq {entry['seq']}: contents do not match its hash")
330
+ except (KeyError, TypeError) as exc:
331
+ problems.append(f"audit entry {entry.get('seq', '?') if isinstance(entry, dict) else '?'}: malformed ({type(exc).__name__})")
332
+
333
+ # --- format 2: checkpoint, proofs, signatures ----------------------------------------------
334
+ sigs = manifest.get("signatures") or []
335
+ ed = next((s for s in sigs if isinstance(s, dict) and s.get("alg") == "ed25519"), None)
336
+ if "signature" in require and not ed:
337
+ problems.append("origin: no Ed25519 manifest signature, and one is required (a key is pinned)")
338
+ pub = None
339
+ if ed:
340
+ pub = base64.b64decode(ed["publicKey"])
341
+ if pinned:
342
+ pins = [base64.b64decode(k.strip()) for k in pinned.split(",") if k.strip()]
343
+ if pub in pins:
344
+ print(f"origin: verified against the pinned key ({ed['keyId']})")
345
+ else:
346
+ problems.append("origin: no pinned key matches the key that signed this bundle")
347
+ else:
348
+ print(f"origin: key {ed['keyId']} ({ed.get('origin', '')}) — shown, not verified. "
349
+ "Obtain the issuer's key and set UNDERWRIT_PUBLIC_KEY to check origin.")
350
+ if ed25519_verify(pub, canonical(manifest["files"]), base64.b64decode(ed["signature"])):
351
+ print("manifest: signature verified")
352
+ else:
353
+ problems.append("manifest: Ed25519 signature does not verify")
354
+ else:
355
+ print("origin: no Ed25519 signature (format 1 bundle, or an unsigned data plane)")
356
+
357
+ proven = 0
358
+ has_checkpoint = (HERE / "checkpoint").exists() and bool(pack.get("merkle"))
359
+ if "checkpoint" in require and not has_checkpoint:
360
+ problems.append("checkpoint: none in this bundle, and one is required (a key is pinned)")
361
+ if "witness" in require and not has_checkpoint:
362
+ problems.append("witness: no checkpoint to carry a cosignature, and one is required")
363
+ if "timestamp" in require and not (HERE / "timestamps" / "checkpoint.tsr").exists():
364
+ problems.append("timestamp: no RFC 3161 token in this bundle, and one is required")
365
+ if has_checkpoint:
366
+ note = (HERE / "checkpoint").read_text()
367
+ try:
368
+ cp = parse_note(note)
369
+ except (ValueError, IndexError, TypeError) as exc:
370
+ print(f"FAILED: the checkpoint note is malformed ({type(exc).__name__})")
371
+ return 1
372
+ body = f"{cp['origin']}\\n{cp['size']}\\n{base64.b64encode(cp['root']).decode()}\\n".encode()
373
+ ok_sig = False
374
+ if pub is not None:
375
+ want = hashlib.sha256(cp["origin"].encode() + b"\\n\\x01" + pub).digest()[:4].hex()
376
+ ok_sig = any(n == cp["origin"] and kh == want and ed25519_verify(pub, body, s)
377
+ for n, kh, s in cp["signatures"])
378
+ if ok_sig:
379
+ print(f"checkpoint: signature verified (size {cp['size']})")
380
+ else:
381
+ problems.append("checkpoint: signature does not verify under the bundle's key")
382
+ if cp["root"].hex() != pack["merkle"]["root"] or cp["size"] != pack["merkle"]["size"]:
383
+ problems.append("checkpoint: does not match the tree the pack describes")
384
+ # Witness cosignatures, if any: each is a second party's statement that this is the
385
+ # history it saw. Pin one key per witness in UNDERWRIT_WITNESS_KEY (comma-separated); every
386
+ # pinned key must verify a cosignature on this checkpoint.
387
+ cosigs = [(n, kh, rest) for n, kh, rest in cp["signatures"] if n != cp["origin"] and len(rest) == 72]
388
+ if "witness" in require and not cosigs:
389
+ problems.append("witness: no cosignature on this checkpoint, and one is required (a witness key is pinned)")
390
+ if cosigs and wkeys:
391
+ import datetime
392
+ for wkey in wkeys:
393
+ wpub = base64.b64decode(wkey)
394
+ ok_w = None
395
+ for n, kh, rest in cosigs:
396
+ want = hashlib.sha256(n.encode() + b"\\n\\x04" + wpub).digest()[:4].hex()
397
+ t = int.from_bytes(rest[:8], "big")
398
+ msg = f"cosignature/v1\\ntime {t}\\n".encode() + body
399
+ if kh == want and ed25519_verify(wpub, msg, rest[8:]):
400
+ ok_w = (n, t)
401
+ break
402
+ if ok_w:
403
+ print(f"witness: {ok_w[0]} cosigned this checkpoint at "
404
+ f"{datetime.datetime.utcfromtimestamp(ok_w[1]).isoformat()}Z — the history up to size "
405
+ f"{cp['size']} is the one it saw")
406
+ else:
407
+ problems.append(f"witness: the pinned witness key {wkey[:8]}… does not verify any cosignature on this checkpoint")
408
+ if len(wkeys) > 1 and not problems:
409
+ print(f"witness: {len(wkeys)} independent witnesses agree on this history")
410
+ elif cosigs:
411
+ print(f"witness: cosigned by {', '.join(n for n, _, _ in cosigs)} — set UNDERWRIT_WITNESS_KEY to check it")
412
+ else:
413
+ print("witness: none — completeness rests on the log alone; a witness cosignature is what "
414
+ "would show this history is the one a second party saw")
415
+ # External clocks. An RFC 3161 token binds SHA-256(checkpoint body) to a time a third party
416
+ # signed; we check the imprint here and hand the CMS signature to OpenSSL when it is present.
417
+ imprint_hex = hashlib.sha256(body).hexdigest()
418
+ tsr = HERE / "timestamps" / "checkpoint.tsr"
419
+ if tsr.exists():
420
+ try:
421
+ info = parse_tsr(tsr.read_bytes())
422
+ except (IndexError, ValueError, TypeError):
423
+ info = {"ok": False}
424
+ if not info.get("ok") or info.get("imprint") != imprint_hex or info.get("hashAlg") not in (None, "sha256"):
425
+ problems.append("timestamp: the RFC 3161 token does not cover this checkpoint's body (imprint or hash algorithm differs)")
426
+ else:
427
+ import shutil, subprocess
428
+ ca = os.environ.get("UNDERWRIT_TSA_CA", "").strip()
429
+ if ca and shutil.which("openssl"):
430
+ r = subprocess.run(["openssl", "ts", "-verify", "-in", str(tsr), "-digest", imprint_hex,
431
+ "-CAfile", ca], capture_output=True, text=True)
432
+ if r.returncode == 0 and "Verification: OK" in r.stdout:
433
+ print(f"timestamp: TSA signature and chain verified by openssl; this checkpoint existed at "
434
+ f"{info['genTime']} (serial {info['serial']})")
435
+ else:
436
+ why = ((r.stderr or r.stdout).strip().splitlines() or ["no output"])[-1]
437
+ problems.append(f"timestamp: openssl could not verify the TSA signature against UNDERWRIT_TSA_CA: {why}")
438
+ elif ca:
439
+ problems.append("timestamp: UNDERWRIT_TSA_CA is set but openssl is not on PATH, so the TSA signature "
440
+ "could not be checked")
441
+ else:
442
+ print(f"timestamp: RFC 3161 token present, imprint matches; its claimed time {info['genTime']} "
443
+ f"(serial {info['serial']}) is UNVERIFIED until the TSA signature is checked — set UNDERWRIT_TSA_CA "
444
+ "to the TSA's CA bundle with openssl on PATH, or run: openssl ts -verify -in "
445
+ f"timestamps/checkpoint.tsr -digest {imprint_hex} -CAfile <ca.pem>")
446
+ ots = HERE / "timestamps" / "checkpoint.ots"
447
+ if ots.exists():
448
+ meta = next((t for t in (pack["merkle"].get("timestamps") or []) if t.get("kind") == "ots"), {})
449
+ print(f"timestamp: OpenTimestamps proof present ({meta.get('status', 'unknown')}, calendar "
450
+ f"{meta.get('calendar', '?')}) — upgrade and verify with the ots client; a pending proof is not an anchor")
451
+ if not tsr.exists() and not ots.exists():
452
+ print("timestamp: no external clock — the checkpoint's time rests on the log and its witnesses")
453
+ for entry in entries:
454
+ proof = entry.get("inclusionProof")
455
+ idx = entry.get("leafIndex")
456
+ if proof is None or idx is None:
457
+ continue
458
+ leaf = leaf_hash(entry["hash"].encode("ascii"))
459
+ try:
460
+ got = root_from_inclusion(idx, cp["size"], leaf, [bytes.fromhex(p) for p in proof])
461
+ except ValueError as exc:
462
+ problems.append(f"audit entry seq {entry['seq']}: inclusion proof malformed ({exc})")
463
+ continue
464
+ if got != cp["root"]:
465
+ problems.append(f"audit entry seq {entry['seq']}: inclusion proof does not reach the checkpoint root")
466
+ else:
467
+ proven += 1
468
+ print(f"merkle: {proven} of {len(entries)} entries proven at their positions under checkpoint size {cp['size']}")
469
+ elif manifest.get("format", 1) == 2:
470
+ print("merkle: no checkpoint in this bundle — positions not proven, chain hashes only")
471
+
472
+ key = os.environ.get("EVIDENCE_SIGNING_KEY", "").strip()
473
+ signature = manifest.get("signature")
474
+ if signature and key:
475
+ expected = hmac.new(key.encode("utf-8"), canonical(manifest["files"]), hashlib.sha256).hexdigest()
476
+ if not hmac.compare_digest(expected, signature):
477
+ problems.append("signature does not match: this bundle was not produced with that key")
478
+ else:
479
+ print("signature: verified")
480
+ elif signature:
481
+ print("signature: present, not checked (set EVIDENCE_SIGNING_KEY to check it)")
482
+ elif not ed:
483
+ print("signature: none (this bundle proves it is internally consistent, not who made it)")
484
+
485
+ if not entries:
486
+ print("NOTE: this pack contains no audit entries. There is nothing here to verify —")
487
+ print(" that is not the same as a clean result.")
488
+
489
+ if problems:
490
+ print(f"\\nFAILED — {len(problems)} problem(s):")
491
+ for p in problems:
492
+ print(f" - {p}")
493
+ return 1
494
+ print(f"\\nOK — {len(manifest['files'])} file(s) intact, {len(entries)} audit entr(ies) verified.")
495
+ return 0
496
+
497
+
498
+ if __name__ == "__main__":
499
+ try:
500
+ raise SystemExit(main())
501
+ except SystemExit:
502
+ raise
503
+ except Exception as exc: # noqa: BLE001 — a verifier that crashes has said nothing
504
+ print(f"\\nFAILED — verifier error: {type(exc).__name__}: {exc}")
505
+ raise SystemExit(1)
506
+ '''
507
+
508
+
509
+ def _embedded_crypto() -> str:
510
+ """The package's own Ed25519 verifier and Merkle proof code, as text, for the shipped script."""
511
+ here = Path(__file__).parent
512
+ ed = (here / "ed25519.py").read_text()
513
+ ed = ed.split("# --- optional acceleration")[0]
514
+ ed = ed[ed.index("import hashlib"):] # drop the module docstring; keep the code
515
+ ed = ed.replace("def verify(pub: bytes, msg: bytes, sig: bytes) -> bool:", "def ed25519_verify(pub, msg, sig):")
516
+ mk = (here / "merkle.py").read_text()
517
+ mk = mk[mk.index("import hashlib"):]
518
+ ts = (here / "tsa.py").read_text()
519
+ ts = ts[ts.index("SHA256_OID"):ts.index("def fetch_token")]
520
+ ts = ts.replace("def parse_response(", "def parse_tsr(")
521
+ return "# ---- Ed25519 (RFC 8032), from underwrit/ed25519.py ----\n" + ed + \
522
+ "\n# ---- Merkle proofs (RFC 9162), from underwrit/merkle.py ----\n" + mk + \
523
+ "\n# ---- RFC 3161 response reader, from underwrit/tsa.py ----\n" + ts
524
+
525
+
526
+ _VERIFIER_CACHE: str | None = None
527
+
528
+
529
+ def verifier() -> str:
530
+ global _VERIFIER_CACHE
531
+ if _VERIFIER_CACHE is None:
532
+ _VERIFIER_CACHE = _VERIFIER_HEAD + _embedded_crypto() + _VERIFIER_TAIL
533
+ return _VERIFIER_CACHE
534
+
535
+
536
+ VERIFIER = verifier()
537
+
538
+
539
+ def readme(pack: dict) -> str:
540
+ s = pack["subject"]
541
+ seg = pack["auditChain"]["segment"]
542
+ mk = pack.get("merkle")
543
+ return f"""# Evidence pack
544
+
545
+ Subject: {s.get('kind')} `{s.get('id')}`
546
+ Generated: {time.strftime('%Y-%m-%d %H:%M:%SZ', time.gmtime(pack['generatedAt']))}
547
+ Audit entries: {len(pack['auditEntries'])}
548
+ Chain segment: {seg.get('status')}
549
+ Format: {pack.get('format')}{f" · checkpoint size {mk['size']}, key {mk['keyId']}" if mk else ""}
550
+
551
+ ## Verify it
552
+
553
+ python3 verify.py
554
+
555
+ Needs Python 3 and nothing else. No account, no network, no trust in whoever gave you this.
556
+
557
+ ## What this does and does not prove
558
+
559
+ It proves the files have not been edited since the pack was made, and that each audit entry's
560
+ contents match the hash recorded for it — so no entry has been altered or reordered.
561
+ {"It proves each entry sits at its stated position in a log of the stated size, signed by the data plane's key, so an entry cannot be moved or its position forged." if mk else ""}
562
+
563
+ It does **not** prove the record is complete. A pack can only show what it contains. Where the
564
+ sequence numbers have gaps, other actors' entries sit between these — see `chainContinuity`.
565
+
566
+ It does **not** prove origin from the bundle alone. The public key inside it shows the bundle
567
+ agrees with itself. Obtain the issuer's key separately (`GET /v1/keys` on the data plane) and run
568
+ `UNDERWRIT_PUBLIC_KEY=<key> python3 verify.py` to check the bundle was signed by that key.
569
+ """
570
+
571
+
572
+ _readme = readme
573
+
574
+
575
+ def write(conn, subject: Subject, dest: str | Path, key=None) -> dict:
576
+ """Write a verifiable bundle directory. Returns the manifest.
577
+
578
+ `key` is a `checkpoint.Key`; without one the bundle is format 2 without a checkpoint, proofs
579
+ or Ed25519 signature — the version-1 guarantees, stated as such.
580
+ """
581
+ from . import bundle as bundle_mod
582
+
583
+ dest = Path(dest)
584
+ dest.mkdir(parents=True, exist_ok=True)
585
+ _, files = bundle_mod.files_for(conn, subject, key)
586
+ for name, data in files.items():
587
+ (dest / name).write_bytes(data)
588
+ manifest = bundle_mod.manifest_for(files, key)
589
+ (dest / "manifest.json").write_text(json.dumps(manifest, indent=2, sort_keys=True))
590
+ return manifest