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/__init__.py +19 -0
- underwrit/__main__.py +3 -0
- underwrit/api.py +1763 -0
- underwrit/auth.py +348 -0
- underwrit/backup.py +125 -0
- underwrit/bundle.py +110 -0
- underwrit/cedar.py +150 -0
- underwrit/chain.py +519 -0
- underwrit/checkpoint.py +428 -0
- underwrit/cli.py +155 -0
- underwrit/db.py +377 -0
- underwrit/demo.py +310 -0
- underwrit/ecdsa_p256.py +108 -0
- underwrit/ed25519.py +171 -0
- underwrit/evidence.py +590 -0
- underwrit/mcp.py +147 -0
- underwrit/merkle.py +144 -0
- underwrit/migrate.py +186 -0
- underwrit/obs.py +108 -0
- underwrit/oidc.py +362 -0
- underwrit/opa.py +103 -0
- underwrit/policy.py +933 -0
- underwrit/ratelimit.py +46 -0
- underwrit/recorder.py +139 -0
- underwrit/replay.py +136 -0
- underwrit/retention.py +220 -0
- underwrit/store.py +607 -0
- underwrit/subject.py +57 -0
- underwrit/taskdraft.py +40 -0
- underwrit/tsa.py +180 -0
- underwrit-0.3.0.dist-info/METADATA +174 -0
- underwrit-0.3.0.dist-info/RECORD +37 -0
- underwrit-0.3.0.dist-info/WHEEL +5 -0
- underwrit-0.3.0.dist-info/entry_points.txt +2 -0
- underwrit-0.3.0.dist-info/licenses/LICENSE +202 -0
- underwrit-0.3.0.dist-info/licenses/NOTICE +4 -0
- underwrit-0.3.0.dist-info/top_level.txt +1 -0
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
|