diff options
| author | log <log@invalid> | 2026-10-03 20:31:28 +0000 |
|---|---|---|
| committer | log <log@invalid> | 2026-10-03 20:31:28 +0000 |
| commit | 75aeda8d2167a29e26c17bc76cf6b094187f0c53 (patch) | |
| tree | df4ca665df281daee7aa85e52609dc7632880bad | |
| parent | 20d6dcb461c763036c75bb2b1c0c6e1c94171c37 (diff) | |
size 45286
| -rw-r--r-- | reveal/v5-000001/check.py | 92 | ||||
| -rw-r--r-- | reveal/v5-000001/manifest.txt.ots | bin | 689 -> 2621 bytes | |||
| -rw-r--r-- | reveal/v5-000001/seal.json | 2 |
3 files changed, 80 insertions, 14 deletions
diff --git a/reveal/v5-000001/check.py b/reveal/v5-000001/check.py index e0f55c2..7f86b8d 100644 --- a/reveal/v5-000001/check.py +++ b/reveal/v5-000001/check.py | |||
| @@ -10,8 +10,10 @@ checks is not a checker, which is why it is copied, whole, into every seal direc | |||
| 10 | 10 | ||
| 11 | Each SEAL_DIR is one seal: `manifest.txt` (the exact text the seal's digest is the sha256 of), `seal.json`, and per | 11 | Each SEAL_DIR is one seal: `manifest.txt` (the exact text the seal's digest is the sha256 of), `seal.json`, and per |
| 12 | reveal group `r<ROUND>.tlock` (the group's key, time-locked to that drand round) and `r<ROUND>.ct` (the group, | 12 | reveal group `r<ROUND>.tlock` (the group's key, time-locked to that drand round) and `r<ROUND>.ct` (the group, |
| 13 | encrypted under that key). `manifest.txt.ots`, when present, is an OpenTimestamps proof over the manifest: | 13 | encrypted under that key), and, once the group has been revealed, `r<ROUND>.held.json` (how many of its withheld |
| 14 | `ots verify manifest.txt.ots` checks it against Bitcoin. | 14 | lines Echo publishes no salt for, and why) and any number of `r<ROUND>.salts.<N>.json`: each the salts of withheld |
| 15 | lines Echo decided to open and had not erased, each with its line. `manifest.txt.ots`, when present, is an | ||
| 16 | OpenTimestamps proof over the manifest: `ots verify manifest.txt.ots` checks it against Bitcoin. | ||
| 15 | 17 | ||
| 16 | What it checks, from the files alone and the beacon: | 18 | What it checks, from the files alone and the beacon: |
| 17 | 1. the manifest hashes to the seal's digest, and each seal names the one before it (when several are given); | 19 | 1. the manifest hashes to the seal's digest, and each seal names the one before it (when several are given); |
| @@ -22,11 +24,17 @@ What it checks, from the files alone and the beacon: | |||
| 22 | counted against the committed counts, its lines in id order, and every line's reveal day recomputed from its | 24 | counted against the committed counts, its lines in id order, and every line's reveal day recomputed from its |
| 23 | own settling instant under the seal's named rule (`utc-day-after-close`: the first 00:00 UTC after | 25 | own settling instant under the seal's named rule (`utc-day-after-close`: the first 00:00 UTC after |
| 24 | `settles_at`, else after the deadline); | 26 | `settles_at`, else after the deadline); |
| 25 | 4. once every group of every seal from the first is open and nothing is withheld: the union of their v4 lines, | 27 | 4. for every opened group with releases: each names this seal, round and group and its own number; each released |
| 26 | sorted by id, rehashes to the v4 digest the last seal follows, so no sealed forecast was left out of a reveal. | 28 | salt and line hash to the withheld line's committed hash; no line is settled twice; and a hold, where there is |
| 29 | one, names the group and counts no more lines than are withheld; | ||
| 30 | 5. once every group of every seal from the first is open and every withheld line released: the union of their v4 | ||
| 31 | lines, sorted by id, rehashes to the v4 digest the last seal follows, so no sealed forecast was left out of a | ||
| 32 | reveal. Where a withheld line has no released salt (held, erased, or not yet released) that digest cannot be | ||
| 33 | rebuilt, and this says so and why: what is checked then is only that every forecast the v4 seal covers is in a | ||
| 34 | group, counted. A withheld line is never called verified. | ||
| 27 | 35 | ||
| 28 | A group whose round is not yet signed is reported as pending, not as a failure. Exit 0 when everything checkable | 36 | A group whose round is not yet signed, or whose withheld lines have no release yet, is reported as pending or held, |
| 29 | passed, 1 when anything did not match. | 37 | not as a failure. Exit 0 when everything checkable passed, 1 when anything did not match. |
| 30 | """ | 38 | """ |
| 31 | from __future__ import annotations | 39 | from __future__ import annotations |
| 32 | 40 | ||
| @@ -146,10 +154,56 @@ def unlock(tle: str, beacon: dict, locked: str) -> bytes | None: | |||
| 146 | raise RuntimeError("; ".join(errors)) | 154 | raise RuntimeError("; ".join(errors)) |
| 147 | 155 | ||
| 148 | 156 | ||
| 157 | def release(d: Path, seal_digest: str, g: dict, lines: list[dict], say) -> tuple[dict[int, str], int, str | None]: | ||
| 158 | """An opened group's releases and hold, checked against the group: (id -> the v4 line each released salt opens, | ||
| 159 | how many withheld lines were opened and found erased, and the hold's reason where Echo published one).""" | ||
| 160 | r = g["round"] | ||
| 161 | shut = {x["id"]: x["withheld"] for x in lines if "withheld" in x} | ||
| 162 | out, erased, seen, bad = {}, set(), set(), [] | ||
| 163 | files = sorted(d.glob(f"r{r}.salts.*.json"), key=lambda p: int(p.name.split(".")[-2])) | ||
| 164 | for k, path in enumerate(files): | ||
| 165 | rel = json.loads(path.read_text()) | ||
| 166 | say(rel.get("format") == "echo-reveal-salts/1" and rel.get("seal") == seal_digest | ||
| 167 | and rel.get("round") == r and rel.get("group") == g["digest"] and rel.get("n") == k | ||
| 168 | and path.name == f"r{r}.salts.{k}.json", | ||
| 169 | f"round {r}: release {k} names this seal, round and group, and follows the one before") | ||
| 170 | for x in rel.get("released", []): | ||
| 171 | i, line = x["id"], x["line"] | ||
| 172 | opens = (i in shut and i not in seen and line.startswith(f"{i}|") | ||
| 173 | and sha(bytes.fromhex(x["salt"]) + line.encode()) == shut[i]) | ||
| 174 | if opens: | ||
| 175 | out[i] = line | ||
| 176 | else: | ||
| 177 | bad.append(i) | ||
| 178 | seen.add(i) | ||
| 179 | for i in rel.get("erased", []): | ||
| 180 | if i not in shut or i in seen: | ||
| 181 | bad.append(i) | ||
| 182 | erased.add(i) | ||
| 183 | seen.add(i) | ||
| 184 | if files: | ||
| 185 | say(not bad, f"round {r}: {len(out)} released lines open their committed hashes, {len(erased)} opened lines " | ||
| 186 | f"found erased, none settled twice" + (f" ({len(bad)} do not, e.g. {bad[:3]})" if bad else "")) | ||
| 187 | why = None | ||
| 188 | hold = d / f"r{r}.held.json" | ||
| 189 | if hold.exists(): | ||
| 190 | h = json.loads(hold.read_text()) | ||
| 191 | say(h.get("format") == "echo-reveal-held/1" and h.get("seal") == seal_digest and h.get("round") == r | ||
| 192 | and h.get("group") == g["digest"] and 0 <= h.get("held", -1) <= len(shut), | ||
| 193 | f"round {r}: the hold names this seal, round and group, and counts at most its withheld lines") | ||
| 194 | why = str(h.get("why") or "no reason given") | ||
| 195 | left = len(shut) - len(out) - len(erased) | ||
| 196 | if left: | ||
| 197 | print(f" .... round {r}: {left} withheld lines have no released salt: counted, not rehashed" | ||
| 198 | + (f" (Echo publishes none: {why})" if why else " (no release or hold published yet)")) | ||
| 199 | return out, len(erased), why | ||
| 200 | |||
| 201 | |||
| 149 | def check(dirs: list[Path], tle: str = "tle", offline: bool = False) -> int: | 202 | def check(dirs: list[Path], tle: str = "tle", offline: bool = False) -> int: |
| 150 | from cryptography.hazmat.primitives.ciphers.aead import ChaCha20Poly1305 | 203 | from cryptography.hazmat.primitives.ciphers.aead import ChaCha20Poly1305 |
| 151 | 204 | ||
| 152 | failed, pending, withheld, union = 0, 0, 0, {} | 205 | failed, pending, withheld, held, erased, union = 0, 0, 0, 0, 0, {} |
| 206 | reasons: set[str] = set() | ||
| 153 | first_prev = "unset" | 207 | first_prev = "unset" |
| 154 | 208 | ||
| 155 | def say(ok: bool, what: str) -> None: | 209 | def say(ok: bool, what: str) -> None: |
| @@ -216,20 +270,32 @@ def check(dirs: list[Path], tle: str = "tle", offline: bool = False) -> int: | |||
| 216 | else: | 270 | else: |
| 217 | print(f" .... round {r}: reveal rule {m['variant'].get('reveal')!r} is not one this checker " | 271 | print(f" .... round {r}: reveal rule {m['variant'].get('reveal')!r} is not one this checker " |
| 218 | "recomputes; which lines belong to the day is unchecked") | 272 | "recomputes; which lines belong to the day is unchecked") |
| 273 | opened, gone, why = release(d, row["digest"], g, lines, say) | ||
| 219 | for x in lines: | 274 | for x in lines: |
| 220 | if x["id"] in union: | 275 | if x["id"] in union: |
| 221 | say(False, f"forecast {x['id']} is in two groups") | 276 | say(False, f"forecast {x['id']} is in two groups") |
| 222 | union[x["id"]] = x.get("v4") | 277 | union[x["id"]] = x.get("v4") or opened.get(x["id"]) |
| 223 | withheld += "withheld" in x | 278 | shut = sum(1 for x in lines if "withheld" in x and x["id"] not in opened) - gone |
| 279 | erased += gone | ||
| 280 | if why is None: | ||
| 281 | withheld += shut | ||
| 282 | else: | ||
| 283 | held += shut | ||
| 284 | reasons.add(why) | ||
| 224 | if last is not None: | 285 | if last is not None: |
| 225 | if first_prev is not None: | 286 | if first_prev is not None: |
| 226 | print(" .... completeness: the first seal given is not the chain's first; give every seal from it") | 287 | print(" .... completeness: the first seal given is not the chain's first; give every seal from it") |
| 227 | elif pending: | 288 | elif pending: |
| 228 | print(f" .... completeness: {pending} groups still locked; it is checkable once all have opened") | 289 | print(f" .... completeness: {pending} groups still locked; it is checkable once all have opened") |
| 229 | elif withheld: | 290 | elif withheld or held or erased: |
| 230 | print(f" .... completeness: {withheld} text layers are withheld, so the v4 lines cannot all be rebuilt;" | 291 | told = [f"{withheld} withheld lines not yet released"] if withheld else [] |
| 231 | f" {len(union)} forecasts against the v4 seal's {last['follows']['n']}") | 292 | told += ([f"{held} withheld lines held, for which Echo publishes no salt ({'; '.join(sorted(reasons))})"] |
| 232 | say(len(union) == last["follows"]["n"], "every forecast the v4 seal covers is in a group") | 293 | if held else []) |
| 294 | told += [f"{erased} erased stay hashes"] if erased else [] | ||
| 295 | print(f" .... completeness: {', '.join(told)}: those lines can be counted but not rehashed, so the v4 " | ||
| 296 | f"lines cannot all be rebuilt and the v4 digest is NOT checked; {len(union)} forecasts against " | ||
| 297 | f"the v4 seal's {last['follows']['n']}") | ||
| 298 | say(len(union) == last["follows"]["n"], "every forecast the v4 seal covers is in a group, counted") | ||
| 233 | else: | 299 | else: |
| 234 | got = sha("\n".join(union[k] for k in sorted(union)).encode()) | 300 | got = sha("\n".join(union[k] for k in sorted(union)).encode()) |
| 235 | say(got == last["follows"]["digest"] and len(union) == last["follows"]["n"], | 301 | say(got == last["follows"]["digest"] and len(union) == last["follows"]["n"], |
diff --git a/reveal/v5-000001/manifest.txt.ots b/reveal/v5-000001/manifest.txt.ots index ec78929..06e31c6 100644 --- a/reveal/v5-000001/manifest.txt.ots +++ b/reveal/v5-000001/manifest.txt.ots | |||
| Binary files differ | |||
diff --git a/reveal/v5-000001/seal.json b/reveal/v5-000001/seal.json index 9963ee8..a452f46 100644 --- a/reveal/v5-000001/seal.json +++ b/reveal/v5-000001/seal.json | |||
| @@ -1,6 +1,6 @@ | |||
| 1 | { | 1 | { |
| 2 | "algorithm": "sha256-live-v5", | 2 | "algorithm": "sha256-live-v5", |
| 3 | "anchor_ref": "sha256:ab1c64ee208cedf8ce14e1e4a1568de02e03578c9af283db73392ad2e61c90e8", | 3 | "anchor_ref": "bitcoin:block:969687", |
| 4 | "covers_after": null, | 4 | "covers_after": null, |
| 5 | "covers_through": "2026-10-03T01:35:21.431130Z", | 5 | "covers_through": "2026-10-03T01:35:21.431130Z", |
| 6 | "digest": "ab1c64ee208cedf8ce14e1e4a1568de02e03578c9af283db73392ad2e61c90e8", | 6 | "digest": "ab1c64ee208cedf8ce14e1e4a1568de02e03578c9af283db73392ad2e61c90e8", |