notbefore 0.2.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.
notbefore/__init__.py ADDED
@@ -0,0 +1,12 @@
1
+ """NotBefore — the consumer side of the qrng-beacon-log: verify one hourly commit-then-reveal pair with a
2
+ pinned, vendored verifier, and derive labeled seeds / shuffles / splits from its attested value.
3
+
4
+ Spec: NOTBEFORE.md in https://github.com/docdailey/qrng-beacon-log (draft 0.2). Not a certification of anything.
5
+ """
6
+ __version__ = "0.2.0"
7
+ SPEC = "notbefore/spec/0.2"
8
+ D_DERIVE = b"notbefore/derive/v1"
9
+ D_SHUFFLE = b"notbefore/shuffle/v1"
10
+ DEFAULT_REPO = "https://github.com/docdailey/qrng-beacon-log.git"
11
+ FIRST_ELIGIBLE_REVEAL = 21 # 0020/0021; 18/19 are KNOWN-NONCOMPLIANT (ERR-007)
12
+ ANCHOR_GRACE_S = 1500 # a pulse older than this with no anchor is non-compliant
notbefore/check.py ADDED
@@ -0,0 +1,115 @@
1
+ """Verify one NotBefore pair (commit N-1, reveal N) with the VENDORED verifier and keys (NOTBEFORE.md §5–6).
2
+ The log supplies data only. Exit-code discipline: a CheckResult with ok=False must never yield V or S."""
3
+ import os, sys, json, subprocess, time, base64
4
+ from . import FIRST_ELIGIBLE_REVEAL, ANCHOR_GRACE_S
5
+ from .log import LogSource
6
+
7
+ HERE = os.path.dirname(os.path.abspath(__file__))
8
+ VENDOR = os.path.join(HERE, "verifier")
9
+ KEYS = os.path.join(VENDOR, "keys")
10
+ sys.path.insert(0, os.path.join(VENDOR, "ci"))
11
+
12
+ def vendored_meta():
13
+ try: return json.load(open(os.path.join(VENDOR, "VENDORED.json")))
14
+ except Exception: return {}
15
+
16
+ def known_noncompliant():
17
+ try: return {int(k): v for k, v in json.load(open(os.path.join(VENDOR, "ci", "KNOWN_NONCOMPLIANT.json"))).get("pulses", {}).items()}
18
+ except Exception: return {}
19
+
20
+ class CheckResult:
21
+ def __init__(self, seq):
22
+ self.seq = seq; self.ok = True; self.lines = []; self.verbose = []
23
+ self.commit_seq = self.pulse_hash_reveal = self.pulse_hash_commit = self.attested_value = self.drand_round = None
24
+ self.bls_offline = False; self.tsa_pass = 0; self.anchors = "not checked"
25
+ self.log_git_sha = self.log_ref = None; self.verifier_git_sha = vendored_meta().get("git_sha")
26
+ def say(self, ok, msg, level=None):
27
+ tag = level or ("PASS" if ok else "FAIL")
28
+ if tag == "FAIL": self.ok = False
29
+ self.lines.append(f"[{tag}] {msg}")
30
+ def summary(self):
31
+ return {"ok": self.ok, "bls_offline": self.bls_offline, "tsa_tokens_verified": self.tsa_pass, "anchors": self.anchors,
32
+ "lines": self.lines}
33
+
34
+ def _run(args, cwd=None):
35
+ r = subprocess.run([sys.executable, *args], cwd=cwd, capture_output=True, text=True); return r.returncode, r.stdout + r.stderr
36
+
37
+ def check_pair(seq: int, src: LogSource, refetch=True, anchors=True, verbose=False) -> CheckResult:
38
+ R = CheckResult(seq); R.log_git_sha, R.log_ref = src.log_git_sha, (src.ref or "working tree")
39
+ knc = known_noncompliant()
40
+ # ---- eligibility (§6) before anything is executed
41
+ if seq < FIRST_ELIGIBLE_REVEAL: R.say(False, f"seq {seq} is below the first eligible reveal ({FIRST_ELIGIBLE_REVEAL}); pulses 1–17 are legacy (ERR-005), 18–19 KNOWN-NONCOMPLIANT (ERR-007)")
42
+ for s in (seq, seq - 1):
43
+ if s in knc: R.say(False, f"seq {s} is KNOWN-NONCOMPLIANT ({knc[s].get('erratum')}): {knc[s].get('reason')}")
44
+ if not R.ok: return R
45
+ rev = src.pulse(seq); com = src.pulse(seq - 1)
46
+ if rev is None: R.say(False, f"pulse {seq:04d} is not in the log at {R.log_ref}"); return R
47
+ if com is None: R.say(False, f"predecessor pulse {seq-1:04d} is not in the log"); return R
48
+ rc_, cc_ = rev["core"], com["core"]
49
+ R.pulse_hash_reveal, R.pulse_hash_commit = rev["pulse_hash"], com["pulse_hash"]
50
+ R.say(rc_.get("v") == "0.5", f"reveal {seq:04d} is protocol v0.5 (got {rc_.get('v') or rc_.get('version') or 'legacy'})")
51
+ R.say(rc_.get("type") == "reveal", f"pulse {seq:04d} is a reveal (type {rc_.get('type')})")
52
+ R.say(cc_.get("type") == "commit", f"pulse {seq-1:04d} is a commit (type {cc_.get('type')})")
53
+ R.say((rc_.get("derived") or {}).get("commit_seq") == seq - 1, f"reveal names commit_seq {seq-1} (derived.commit_seq = {(rc_.get('derived') or {}).get('commit_seq')})")
54
+ if not R.ok: return R
55
+ R.commit_seq = seq - 1; R.attested_value = rc_["derived"]["attested_value"]; R.drand_round = rc_["drand"]["round"]
56
+ # ---- materialize the pair (+ the commit's predecessor for its chain link) and run the vendored verifier
57
+ p_rev, p_com = src.materialize(seq), src.materialize(seq - 1)
58
+ p_pp = src.materialize(seq - 2) if src.has_pulse(seq - 2) else None
59
+ vpy = os.path.join(VENDOR, "verify.py"); extra = ["--refetch"] if refetch else []
60
+ rc, out = _run([vpy, p_com, "--pin", KEYS] + (["--prev", p_pp] if p_pp else []) + extra); R.verbose.append(out)
61
+ R.say(rc == 0 and "ALL CHECKS PASSED" in out, f"commit {seq-1:04d}: vendored verify.py (pinned keys{', chained to %04d' % (seq-2) if p_pp else ''}{', drand refetched' if refetch else ''})")
62
+ _bls_c = "[full BLS, offline]" in out and "BLS verification skipped" not in out
63
+ rc, out = _run([vpy, p_rev, "--pin", KEYS, "--prev", p_com] + extra); R.verbose.append(out)
64
+ R.say(rc == 0 and "ALL CHECKS PASSED" in out, f"reveal {seq:04d}: vendored verify.py (pinned keys, chained to {seq-1:04d}, C = SHA256(D_commit||E), V recomputed, timing contract{', drand refetched' if refetch else ''})")
65
+ R.bls_offline = _bls_c and "[full BLS, offline]" in out and "BLS verification skipped" not in out
66
+ R.say(R.bls_offline, "drand round BLS-verified offline under the pinned quicknet group key (py_ecc)")
67
+ if f"Attested value {R.attested_value}" not in out: R.say(False, "verifier's attested value differs from the pulse's derived.attested_value")
68
+ # ---- RFC 3161 on the commit
69
+ rc, out = _run([os.path.join(VENDOR, "tsa.py"), "verify", p_com]); R.verbose.append(out)
70
+ R.tsa_pass = out.count("[PASS] RFC3161")
71
+ R.say(R.tsa_pass >= 2 and "[FAIL]" not in out, f"commit {seq-1:04d}: {R.tsa_pass} RFC 3161 token(s) verify (need ≥ 2: freetsa + DigiCert)")
72
+ # ---- publication anchors (SHOULD; missing anchors fail once the pulse is older than the grace period)
73
+ if anchors:
74
+ R.anchors = _check_anchors(R, src, (seq - 1, seq), rc_, refetch)
75
+ else: R.anchors = "skipped (--no-anchors)"
76
+ return R
77
+
78
+ def _check_anchors(R, src, seqs, rev_core, refetch):
79
+ if not src.anchors_available():
80
+ R.say(True, "publication anchors: no anchors branch reachable from this log source — not checked", "WARN"); return "unavailable"
81
+ try:
82
+ import anchor_lib as L
83
+ except Exception as e:
84
+ R.say(True, f"publication anchors: vendored anchor_lib unavailable ({e})", "WARN"); return "unavailable"
85
+ anchor_pub = L.load_pub(open(os.path.join(KEYS, "anchor.pub"), "rb").read()); rekor_pub = L.load_pub(open(os.path.join(KEYS, "rekor.pub"), "rb").read())
86
+ age = time.time() - float((rev_core.get("derived") or {}).get("round_release_unix_s") or time.time())
87
+ status = []
88
+ for s in seqs:
89
+ rec, stmt = src.anchor(s)
90
+ if rec is None:
91
+ if age > ANCHOR_GRACE_S: R.say(False, f"pulse {s:04d}: no publication anchor {age/60:.0f} min after its round (grace {ANCHOR_GRACE_S//60} min)")
92
+ else: R.say(True, f"pulse {s:04d}: anchor pending ({age:.0f} s since its round)", "WARN")
93
+ status.append("missing"); continue
94
+ pf = os.path.join(src.tmp, "chain", f"pulse-{s:04d}.json")
95
+ want, _ = L.statement_for(pf); entry = rec["rekor"]["entry"]
96
+ ok = stmt == want and rec["statement_sha256"] == L.sha256(want)
97
+ h, k = L.entry_hash_and_key(entry)
98
+ ok &= h == L.sha256(want) and k is not None and L.key_id(L.load_pub(k)) == L.key_id(anchor_pub)
99
+ ok &= L.verify_sig(anchor_pub, base64.b64decode(rec["signature_b64"]), want)
100
+ ok &= entry.get("logID") == L.REKOR_LOG_ID and L.verify_set(entry, rekor_pub)
101
+ inc, why = L.verify_inclusion(entry, rekor_pub); ok &= inc
102
+ live = ""
103
+ if refetch and ok:
104
+ try:
105
+ e2 = L.rekor_get(rec["rekor"]["uuid"]); ok &= e2["body"] == entry["body"] and e2["integratedTime"] == entry["integratedTime"]; live = ", re-fetched live"
106
+ except Exception as ex: R.say(True, f"pulse {s:04d}: Rekor refetch failed ({ex}); offline proof stands", "WARN")
107
+ when = time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime(entry["integratedTime"]))
108
+ R.say(ok, f"pulse {s:04d}: Rekor anchor logIndex {entry['logIndex']} @ {when} — statement, signature, SET, inclusion proof + checkpoint verified offline against pinned keys{live}; OTS {rec.get('ots', {}).get('status')}")
109
+ status.append("ok" if ok else "FAIL")
110
+ if all(x == "ok" for x in status):
111
+ rel = float((rev_core.get("derived") or {}).get("round_release_unix_s") or 0); rec_c, _ = src.anchor(seqs[0])
112
+ it = rec_c["rekor"]["entry"]["integratedTime"]
113
+ if it < rel: R.say(True, f"commit {seqs[0]:04d}: Rekor time precedes the drand release by {rel-it:.0f} s (third independent clock on the commit)")
114
+ else: R.say(True, f"commit {seqs[0]:04d}: Rekor time is {it-rel:.0f} s AFTER the drand release — a retroactive anchor (pulses ≤ 0041 were anchored 2026-09-12 12:47 UTC); the RFC 3161 tokens are the commit-time proof", "WARN")
115
+ return "/".join(status)
notbefore/cli.py ADDED
@@ -0,0 +1,112 @@
1
+ """notbefore — CLI (NOTBEFORE.md §8).
2
+
3
+ notbefore verify <seq>
4
+ notbefore value <seq>
5
+ notbefore seed <seq> --purpose <P>
6
+ notbefore shuffle <seq> --purpose <P> <file>
7
+ notbefore split <seq> --purpose <P> --frac 0.8 <file>
8
+
9
+ `value`/`seed`/`shuffle`/`split` print nothing usable unless `verify` would pass; exit 1 otherwise.
10
+ The verifier and every key are vendored in this package; the log is read as data. Requires `git` and `openssl` on PATH.
11
+ """
12
+ import sys, os, json, argparse, hashlib
13
+ from . import __version__, SPEC, DEFAULT_REPO
14
+ from .log import LogSource, LogError
15
+ from .check import check_pair, vendored_meta
16
+ from . import derive as D
17
+
18
+ def _err(msg): sys.stderr.write("notbefore: " + msg + "\n")
19
+
20
+ def build_parser():
21
+ ap = argparse.ArgumentParser(prog="notbefore", description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
22
+ ap.add_argument("--version", action="version", version=f"notbefore {__version__} ({SPEC}; verifier vendored from {vendored_meta().get('git_sha','?')[:12]})")
23
+ g = ap.add_argument_group("log source")
24
+ g.add_argument("--repo", default=DEFAULT_REPO, help="git URL of the log (default: the public qrng-beacon-log)")
25
+ g.add_argument("--log-dir", help="use this local checkout/directory instead of a cached clone")
26
+ g.add_argument("--log-ref", help="git ref/sha of the log to read (default origin/main; with --log-dir default: working tree)")
27
+ g.add_argument("--cache", help="cache directory for the clone (default ~/.cache/notbefore/qrng-beacon-log)")
28
+ g.add_argument("--offline", action="store_true", help="no network: no fetch, no drand/Rekor refetch (BLS + proofs still verify offline)")
29
+ g.add_argument("--no-anchors", action="store_true", help="skip the Rekor/OpenTimestamps anchor check")
30
+ ap.add_argument("-v", "--verbose", action="store_true", help="print the vendored verifier's full output")
31
+ ap.add_argument("--json", action="store_true", help="machine-readable result on stdout")
32
+ sub = ap.add_subparsers(dest="cmd", required=True)
33
+ def common(p, purpose=False, file=False):
34
+ p.add_argument("seq", type=int, help="reveal seq N (its commit is N-1)")
35
+ for flags, kw in ((("-v", "--verbose"), {}), (("--json",), {}), (("--offline",), {}), (("--no-anchors",), {})):
36
+ p.add_argument(*flags, action="store_true", default=argparse.SUPPRESS, help=argparse.SUPPRESS, **kw)
37
+ if purpose:
38
+ p.add_argument("--purpose", required=True, help="non-secret label, ^[A-Za-z0-9._:/=@+-]+$, ≤ 256 bytes")
39
+ p.add_argument("--transcript", help="write the transcript JSON here (default: ./notbefore-<seq>-<purpose>.json); '-' for stdout, 'none' to skip")
40
+ if file: p.add_argument("file", help="input file, one record per line (UTF-8)")
41
+ common(sub.add_parser("verify", help="verify the pair (commit N-1, reveal N); exit 0/1"))
42
+ common(sub.add_parser("value", help="print the attested value V (hex) if the pair verifies"))
43
+ common(sub.add_parser("seed", help="print the derived seed S = SHA256(D_derive || V || purpose)"), purpose=True)
44
+ common(sub.add_parser("shuffle", help="deterministically shuffle the lines of FILE with S"), purpose=True, file=True)
45
+ sp = sub.add_parser("split", help="shuffle, then split FILE into A (first floor(frac·k)) and B"); common(sp, purpose=True, file=True)
46
+ sp.add_argument("--frac", type=float, required=True); sp.add_argument("--out-a"); sp.add_argument("--out-b")
47
+ return ap
48
+
49
+ def _open_source(a):
50
+ return LogSource(repo=a.repo, log_dir=a.log_dir, cache=a.cache, ref=a.log_ref, offline=a.offline)
51
+
52
+ def _print_check(R, a):
53
+ if a.json: return
54
+ for l in R.lines: sys.stderr.write(l + "\n")
55
+ if a.verbose:
56
+ for v in R.verbose: sys.stderr.write("\n--- verifier output ---\n" + v)
57
+ sys.stderr.write(("VERIFIED" if R.ok else "NOT VERIFIED") + f" — NotBefore {R.seq} (commit {R.commit_seq}), log {str(R.log_git_sha)[:12]}\n")
58
+
59
+ def _read_records(path):
60
+ raw = open(path, "rb").read()
61
+ text = raw.decode("utf-8")
62
+ recs = text.split("\n")
63
+ if recs and recs[-1] == "": recs.pop()
64
+ return [r.rstrip("\r") for r in recs], hashlib.sha256(raw).hexdigest()
65
+
66
+ def _write_transcript(a, t, slug):
67
+ dest = a.transcript
68
+ if dest == "none": return None
69
+ if dest == "-": print(json.dumps(t, indent=1, sort_keys=True)); return "-"
70
+ dest = dest or f"notbefore-{a.seq}-{slug}.json"
71
+ json.dump(t, open(dest, "w"), indent=1, sort_keys=True); _err(f"transcript written: {dest}"); return dest
72
+
73
+ def main(argv=None):
74
+ a = build_parser().parse_args(argv)
75
+ try:
76
+ src = _open_source(a)
77
+ except LogError as e:
78
+ _err(str(e)); return 1
79
+ try:
80
+ R = check_pair(a.seq, src, refetch=not a.offline, anchors=not a.no_anchors, verbose=a.verbose)
81
+ _print_check(R, a)
82
+ if a.cmd == "verify":
83
+ if a.json: print(json.dumps(R.summary() | {"seq": R.seq, "commit_seq": R.commit_seq, "attested_value": R.attested_value if R.ok else None, "log_git_sha": R.log_git_sha}, indent=1))
84
+ return 0 if R.ok else 1
85
+ if not R.ok:
86
+ _err("pair did not verify; refusing to emit a value"); return 1
87
+ if a.cmd == "value":
88
+ print(json.dumps({"seq": R.seq, "attested_value": R.attested_value}) if a.json else R.attested_value); return 0
89
+ try: P = D.normalize_purpose(a.purpose).decode()
90
+ except D.PurposeError as e: _err(f"bad purpose: {e}"); return 2
91
+ S = D.seed(R.attested_value, P); slug = P.replace("/", "_").replace(":", "_")
92
+ if a.cmd == "seed":
93
+ t = D.transcript(R, P, S); _write_transcript(a, t, slug)
94
+ print(json.dumps({"seq": R.seq, "purpose": P, "derived_seed": S.hex()}) if a.json else S.hex()); return 0
95
+ recs, in_sha = _read_records(a.file)
96
+ if a.cmd == "shuffle":
97
+ out = D.shuffle(recs, S); body = "\n".join(out) + ("\n" if out else "")
98
+ t = D.transcript(R, P, S, {"operation": "shuffle", "input_file": os.path.basename(a.file), "input_sha256": in_sha, "record_count": len(recs), "output_sha256": D.sha256_hex(body.encode())})
99
+ _write_transcript(a, t, slug); sys.stdout.write(body); return 0
100
+ if a.cmd == "split":
101
+ try: A_, B_ = D.split(recs, S, a.frac)
102
+ except ValueError as e: _err(str(e)); return 2
103
+ oa, ob = a.out_a or a.file + ".A", a.out_b or a.file + ".B"
104
+ ba, bb = "\n".join(A_) + ("\n" if A_ else ""), "\n".join(B_) + ("\n" if B_ else "")
105
+ open(oa, "w").write(ba); open(ob, "w").write(bb)
106
+ t = D.transcript(R, P, S, {"operation": "split", "frac": a.frac, "input_file": os.path.basename(a.file), "input_sha256": in_sha, "record_count": len(recs),
107
+ "A": {"file": os.path.basename(oa), "count": len(A_), "sha256": D.sha256_hex(ba.encode())}, "B": {"file": os.path.basename(ob), "count": len(B_), "sha256": D.sha256_hex(bb.encode())}})
108
+ _write_transcript(a, t, slug); _err(f"A: {len(A_)} -> {oa} B: {len(B_)} -> {ob}"); return 0
109
+ finally:
110
+ src.close()
111
+
112
+ if __name__ == "__main__": sys.exit(main())
notbefore/derive.py ADDED
@@ -0,0 +1,51 @@
1
+ """The derive layer (NOTBEFORE.md §7): labeled seed, shuffle, split, transcript. Pure functions; no I/O."""
2
+ import hashlib, re, unicodedata, json, time
3
+ from . import D_DERIVE, D_SHUFFLE, SPEC, __version__
4
+
5
+ PURPOSE_RE = re.compile(r"^[A-Za-z0-9._:/=@+-]+$")
6
+
7
+ class PurposeError(ValueError): pass
8
+
9
+ def normalize_purpose(purpose: str) -> bytes:
10
+ """UTF-8 NFC, 1–256 bytes, CLI-safe charset, no newline. Returns the exact bytes that enter the hash."""
11
+ if not isinstance(purpose, str) or purpose == "": raise PurposeError("purpose must be a non-empty string")
12
+ if "\n" in purpose or "\r" in purpose: raise PurposeError("purpose must not contain a newline")
13
+ p = unicodedata.normalize("NFC", purpose).encode("utf-8")
14
+ if len(p) > 256: raise PurposeError("purpose must be at most 256 bytes of UTF-8")
15
+ if not PURPOSE_RE.match(p.decode("utf-8")): raise PurposeError("purpose must match ^[A-Za-z0-9._:/=@+-]+$ (no spaces; use - or _)")
16
+ return p
17
+
18
+ def seed(attested_value_hex: str, purpose: str) -> bytes:
19
+ """S = SHA256( 'notbefore/derive/v1' || V || purpose ). V is the 32 raw bytes of the attested value."""
20
+ V = bytes.fromhex(attested_value_hex)
21
+ if len(V) != 32: raise ValueError("attested_value must be 32 bytes")
22
+ return hashlib.sha256(D_DERIVE + V + normalize_purpose(purpose)).digest()
23
+
24
+ def rank(key: bytes, i: int, record: str) -> bytes:
25
+ return hashlib.sha256(D_SHUFFLE + key + i.to_bytes(8, "big") + record.encode("utf-8")).digest()
26
+
27
+ def shuffle(records, key: bytes):
28
+ """Stable sort by rank ascending; tie-break on the hex of the record bytes. Input order is part of the transcript."""
29
+ if len(key) != 32: raise ValueError("key must be the 32-byte derived seed")
30
+ ranked = [(rank(key, i, x), x.encode("utf-8").hex(), x) for i, x in enumerate(records)]
31
+ ranked.sort(key=lambda t: (t[0], t[1]))
32
+ return [t[2] for t in ranked]
33
+
34
+ def split(records, key: bytes, frac: float):
35
+ if not (0.0 < frac < 1.0): raise ValueError("frac must be in (0, 1)")
36
+ s = shuffle(records, key); k = int(frac * len(s)) # floor(f * k); do not re-draw
37
+ return s[:k], s[k:]
38
+
39
+ def sha256_hex(b: bytes) -> str: return hashlib.sha256(b).hexdigest()
40
+
41
+ def transcript(check, purpose: str, derived: bytes, extra=None):
42
+ """The engineering artifact (§7.5). `check` is a CheckResult from notbefore.check."""
43
+ t = {"spec": SPEC, "derive_domain": D_DERIVE.decode(), "seq": check.seq, "commit_seq": check.commit_seq,
44
+ "pulse_hash_reveal": check.pulse_hash_reveal, "pulse_hash_commit": check.pulse_hash_commit,
45
+ "attested_value": check.attested_value, "drand_round": check.drand_round,
46
+ "purpose": normalize_purpose(purpose).decode("utf-8"), "derived_seed": derived.hex(),
47
+ "verifier_git_sha": check.verifier_git_sha, "log_git_sha": check.log_git_sha, "log_ref": check.log_ref,
48
+ "cli_version": __version__, "verified_utc": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
49
+ "checks": check.summary()}
50
+ if extra: t.update(extra)
51
+ return t
notbefore/log.py ADDED
@@ -0,0 +1,90 @@
1
+ """Read the public log as DATA. Nothing from the log is ever executed: the verifier and keys are vendored in this package."""
2
+ import os, subprocess, tempfile, json, shutil
3
+ from . import DEFAULT_REPO
4
+
5
+ SIDE = ("", ".tsa.json", ".freetsa.tsr", ".digicert.tsr")
6
+
7
+ class LogError(Exception): pass
8
+
9
+ def _git(*a, cwd=None, check=True):
10
+ r = subprocess.run(["git", *a], cwd=cwd, capture_output=True, text=True)
11
+ if check and r.returncode: raise LogError(f"git {' '.join(a)}: {(r.stderr or r.stdout).strip()[:300]}")
12
+ return r
13
+
14
+ class LogSource:
15
+ """Either a remote clone kept in a cache directory (default) or a local directory (--log-dir)."""
16
+ def __init__(self, repo=DEFAULT_REPO, log_dir=None, cache=None, ref=None, offline=False):
17
+ self.repo, self.offline = repo, offline
18
+ self.tmp = tempfile.mkdtemp(prefix="notbefore-")
19
+ if log_dir:
20
+ self.dir = os.path.abspath(log_dir); self.mode = "dir"
21
+ self.is_git = _git("rev-parse", "--is-inside-work-tree", cwd=self.dir, check=False).returncode == 0
22
+ self.ref = ref # None -> working tree
23
+ else:
24
+ self.dir = cache or os.path.join(os.environ.get("XDG_CACHE_HOME") or os.path.expanduser("~/.cache"), "notbefore", "qrng-beacon-log")
25
+ self.mode = "cache"; self.is_git = True
26
+ if not os.path.isdir(os.path.join(self.dir, ".git")):
27
+ if offline: raise LogError(f"no cached clone at {self.dir} and --offline given")
28
+ os.makedirs(os.path.dirname(self.dir), exist_ok=True); _git("clone", "--quiet", repo, self.dir)
29
+ elif not offline:
30
+ _git("fetch", "--quiet", "--prune", "origin", cwd=self.dir)
31
+ self.ref = ref or "origin/main"
32
+ self.log_git_sha = self._sha()
33
+
34
+ def _sha(self):
35
+ if not self.is_git: return "unknown (not a git checkout)"
36
+ return _git("rev-parse", self.ref or "HEAD", cwd=self.dir).stdout.strip()
37
+
38
+ def close(self): shutil.rmtree(self.tmp, ignore_errors=True)
39
+
40
+ # ---- files
41
+ def _read(self, relpath):
42
+ """Bytes of a file at the pinned ref (or the working tree for a plain --log-dir). None if absent."""
43
+ if self.mode == "dir" and self.ref is None:
44
+ p = os.path.join(self.dir, relpath); return open(p, "rb").read() if os.path.exists(p) else None
45
+ r = _git("show", f"{self.ref}:{relpath}", cwd=self.dir, check=False)
46
+ return r.stdout.encode("utf-8", "surrogateescape") if r.returncode == 0 else None
47
+
48
+ def _read_bytes(self, relpath):
49
+ # binary-safe variant for .tsr (git show via text mode would mangle bytes)
50
+ if self.mode == "dir" and self.ref is None:
51
+ p = os.path.join(self.dir, relpath); return open(p, "rb").read() if os.path.exists(p) else None
52
+ r = subprocess.run(["git", "show", f"{self.ref}:{relpath}"], cwd=self.dir, capture_output=True)
53
+ return r.stdout if r.returncode == 0 else None
54
+
55
+ def has_pulse(self, seq): return self._read_bytes(f"chain/pulse-{seq:04d}.json") is not None
56
+
57
+ def materialize(self, seq):
58
+ """Write chain/pulse-NNNN.json and its TSA sidecars into the temp dir; return the pulse path (or None)."""
59
+ out = os.path.join(self.tmp, "chain"); os.makedirs(out, exist_ok=True)
60
+ base = f"chain/pulse-{seq:04d}.json"; main = None
61
+ for s in SIDE:
62
+ b = self._read_bytes(base + s)
63
+ if b is None:
64
+ if s == "": return None
65
+ continue
66
+ p = os.path.join(out, os.path.basename(base + s)); open(p, "wb").write(b)
67
+ if s == "": main = p
68
+ return main
69
+
70
+ def pulse(self, seq):
71
+ b = self._read_bytes(f"chain/pulse-{seq:04d}.json"); return json.loads(b) if b else None
72
+
73
+ # ---- anchors branch
74
+ def anchors_available(self):
75
+ if not self.is_git: return os.path.isdir(os.path.join(self.dir, "anchors"))
76
+ return _git("rev-parse", "--verify", "--quiet", "origin/anchors", cwd=self.dir, check=False).returncode == 0 \
77
+ or os.path.isdir(os.path.join(self.dir, "anchors"))
78
+
79
+ def anchor(self, seq):
80
+ """(record dict, statement bytes) for a pulse from origin/anchors (or an anchors/ worktree), or (None, None)."""
81
+ rec = stmt = None
82
+ if self.is_git:
83
+ r = subprocess.run(["git", "show", f"origin/anchors:pulse-{seq:04d}.anchor.json"], cwd=self.dir, capture_output=True)
84
+ s = subprocess.run(["git", "show", f"origin/anchors:pulse-{seq:04d}.stmt.json"], cwd=self.dir, capture_output=True)
85
+ if r.returncode == 0 and s.returncode == 0: rec, stmt = json.loads(r.stdout), s.stdout
86
+ if rec is None:
87
+ d = os.path.join(self.dir, "anchors")
88
+ rp, sp = os.path.join(d, f"pulse-{seq:04d}.anchor.json"), os.path.join(d, f"pulse-{seq:04d}.stmt.json")
89
+ if os.path.exists(rp) and os.path.exists(sp): rec, stmt = json.load(open(rp)), open(sp, "rb").read()
90
+ return rec, stmt
@@ -0,0 +1,31 @@
1
+ {
2
+ "files": {
3
+ "bls_drand.py": "b909dd175b1f1c2dff0e78b77d83f2446afca73222a9946e35c2233732228ed9",
4
+ "ci/KNOWN_NONCOMPLIANT.json": "403268695cbbb93f5e4f757cdb3568056b40fbb31a14d0a0a1cb8b4ec953abd6",
5
+ "ci/anchor_lib.py": "7b65da28c3ff750808df2bc6e76ee7c8cb7273308cead542da0766c42f2edc71",
6
+ "drand_anchor.py": "8c1d16bd8376218697626e5614644713f52f83b31aeb836cd7dd302b500eac85",
7
+ "hosts/EXPECTED.json": "4fa57dfff93b7227db41fbd5c9001c1687e9ffb9f5e568b84ac8029be92c6e35",
8
+ "hosts/attest_host.py": "036cc9ca5e978fe9b3fb749a0ab7627fe1b18991ccfb05bbdeb3d1d4eec99a6a",
9
+ "hosts/attest_lib.py": "02ffc2a881d15daba1c1e1dda512d7f5efcf2adb9917a3748a7521954eea506f",
10
+ "hosts/entropy_host.py": "d2eaa923b7ac817f63b04a171abc667b0d56229c90b3e832ac787940710cd254",
11
+ "hosts/gnss_probe.py": "e5ccf3e8e400b69be669113f6fb1ce2439225e6635054150b64fcd65f0d12cdb",
12
+ "hosts/stamp_probe.py": "785763bd7d75cf792732387a7c439a78545ebe8414309ce738d3417c2d16fe48",
13
+ "keys/KEYS.json": "9de75424793bcb2b00b42efc271465fa1a6656194507b1dd3ffdb7e28eef9b3f",
14
+ "keys/anchor.pub": "5afc8aef90f65ae47aae97790aca4181c92b4cb37d23c7ab936c3a2b07de7b24",
15
+ "keys/drand-quicknet.json": "11ff6ba94bac65555551f3e097f4a498d5859cbf8a269127d569dedeaa1b8bf7",
16
+ "keys/entropy_signer.pub": "bbd318423b18b0a107382edf4f6f7be4f4a6186be6a0f73b17051e9f6f9713ab",
17
+ "keys/rekor.pub": "dce5ef715502ec9f3cdfd11f8cc384b31a6141023d3e7595e9908a81cb6241bd",
18
+ "keys/time_attester.f9t.RETIRED.pub": "fd6077eb26b8603c8ef8f445edfa3d5d13fa8d5298dcd7f3df20bafb9bd0565d",
19
+ "keys/time_attester.pub": "20b72663dddc2ce0ff73f9af4a4ad70761fe0af8c009a9bce520724d9c4ef424",
20
+ "keys/time_witness.pub": "2af608da26b0140ba3b2327357ebfd2a72167e983895aed1113a8ce19acc8267",
21
+ "schema.py": "d9f4bb521bb40020230ab14506c03b192467c7a6ddc07f5877d0cd56d178f8f1",
22
+ "tsa-certs/freetsa-ca.pem": "2151b61137ffa86bf664691ba67e7da0b19f98c758e3d228d5d8ebf27e044438",
23
+ "tsa-certs/freetsa-tsa.crt": "8bfb0305bb64e2571ca507552ef3245cb1c2fee8728e0ff8689225081ea13467",
24
+ "tsa.py": "dccdd56c6e087ee86e30c6d9130c2c9aef10eac10e6c24c2c39f492f44ed565a",
25
+ "verify.py": "898a3b343225b8ad5f42065c3f8d1690476edde5659bae2acc00d81c70200605"
26
+ },
27
+ "git_sha": "19fed1b1d291db54469fec682960ee544598da61",
28
+ "log_repo": "https://github.com/docdailey/qrng-beacon-log",
29
+ "note": "Verifier, keys and expected-config pinned at this commit. The CLI executes ONLY these files; the log is read as data.",
30
+ "vendored_utc": "2026-09-12T13:26:52Z"
31
+ }
File without changes
@@ -0,0 +1,88 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ bls_drand.py — full BLS verification of drand quicknet rounds (scheme bls-unchained-g1-rfc9380).
4
+
5
+ Checks e(σ, g₂) == e(H(m), pk) on BLS12-381 with
6
+ σ = round signature, 48-byte compressed G1 point
7
+ pk = the League of Entropy quicknet GROUP public key, 96-byte compressed G2 point (pinned)
8
+ m = SHA-256(round as uint64 big-endian) (unchained: no previous signature in the message)
9
+ H = hash-to-curve into G1 per RFC 9380, DST "BLS_SIG_BLS12381G1_XMD:SHA-256_SSWU_RO_NUL_"
10
+
11
+ A valid σ is the unique signature a ≥threshold set of operators could produce for that round; it
12
+ cannot be precomputed, forged, or swapped for another round. This removes the HTTP relay, DNS and TLS
13
+ from the trust base — only the pinned group key and the ≥t-honest-operators assumption remain.
14
+
15
+ Requires `pip install py_ecc` (pure Python; ~1-2 s per verification). Absent it, callers should WARN.
16
+ """
17
+ import hashlib, json, os
18
+
19
+ PIN_FILE = os.path.join(os.path.dirname(os.path.abspath(__file__)), "keys", "drand-quicknet.json")
20
+ DST = b"BLS_SIG_BLS12381G1_XMD:SHA-256_SSWU_RO_NUL_"
21
+
22
+ def available():
23
+ try:
24
+ import py_ecc # noqa
25
+ return True
26
+ except ImportError:
27
+ return False
28
+
29
+ def pinned():
30
+ return json.load(open(PIN_FILE))
31
+
32
+ def message(round_no: int) -> bytes:
33
+ return hashlib.sha256(int(round_no).to_bytes(8, "big")).digest()
34
+
35
+ def verify_round(pk_hex: str, round_no: int, sig_hex: str) -> bool:
36
+ from py_ecc.bls.hash_to_curve import hash_to_G1
37
+ from py_ecc.bls.point_compression import decompress_G1, decompress_G2
38
+ from py_ecc.optimized_bls12_381 import pairing, G2, is_on_curve, b, b2, curve_order, multiply, Z1, Z2
39
+ sig_b, pk_b = bytes.fromhex(sig_hex), bytes.fromhex(pk_hex)
40
+ if len(sig_b) != 48 or len(pk_b) != 96:
41
+ return False
42
+ try:
43
+ sig = decompress_G1(int.from_bytes(sig_b, "big"))
44
+ pk = decompress_G2((int.from_bytes(pk_b[:48], "big"), int.from_bytes(pk_b[48:], "big")))
45
+ except Exception:
46
+ return False
47
+ # subgroup + curve checks (decompress checks curve; be explicit about the prime-order subgroup)
48
+ if not is_on_curve(sig, b) or not is_on_curve(pk, b2):
49
+ return False
50
+ if multiply(sig, curve_order) != Z1 or multiply(pk, curve_order) != Z2:
51
+ return False
52
+ hm = hash_to_G1(message(round_no), DST, hashlib.sha256)
53
+ return pairing(G2, sig) == pairing(pk, hm)
54
+
55
+ def verify_pinned(round_no: int, sig_hex: str, chain_hash: str) -> tuple:
56
+ p = pinned()
57
+ if chain_hash != p["chain_hash"]:
58
+ return False, "chain hash does not match the pinned quicknet chain"
59
+ ok = verify_round(p["public_key"], round_no, sig_hex)
60
+ return ok, ("BLS signature verifies under the pinned League of Entropy quicknet group key" if ok
61
+ else "BLS signature does NOT verify under the pinned group key")
62
+
63
+ if __name__ == "__main__":
64
+ import sys, urllib.request, time
65
+ CH = "52db9ba70e0cc0f6eaf7803dd07447a1f5477735fd3f661792ba94600c84e971"
66
+ def get(u):
67
+ with urllib.request.urlopen(u, timeout=15) as r: return json.load(r)
68
+ print("=== 1. cross-check the group public key across independent operators ===")
69
+ keys = {}
70
+ for base in ("https://api.drand.sh", "https://api2.drand.sh", "https://api3.drand.sh", "https://drand.cloudflare.com"):
71
+ try: i = get(f"{base}/{CH}/info"); keys[base] = (i["public_key"], i["hash"], i["schemeID"], i["genesis_time"], i["period"])
72
+ except Exception as e: keys[base] = ("ERR", str(e)[:40])
73
+ vals = {v for v in keys.values() if v[0] != "ERR"}
74
+ for k, v in keys.items(): print(" %-30s %s" % (k, v[0][:24] + "..." if v[0] != "ERR" else v))
75
+ print(" agreement across operators:", len(vals) == 1, "| scheme:", next(iter(vals))[2])
76
+ pk = next(iter(vals))[0]
77
+ print("\n=== 2. verify REAL rounds (the three we have mixed, plus latest) ===")
78
+ for rnd in (32122604, 32123484, 32123921, None):
79
+ d = get(f"https://api.drand.sh/{CH}/public/{'latest' if rnd is None else rnd}")
80
+ t0 = time.time(); ok = verify_round(pk, d["round"], d["signature"]); dt = time.time() - t0
81
+ print(" round %d: %s (%.2fs)" % (d["round"], "VALID" if ok else "INVALID", dt))
82
+ print("\n=== 3. NEGATIVES — every one of these must be INVALID ===")
83
+ d = get(f"https://api.drand.sh/{CH}/public/32123921")
84
+ print(" same σ, round+1 (relay relabels the round):", "INVALID" if not verify_round(pk, d["round"] + 1, d["signature"]) else "VALID <-- BUG")
85
+ sb = bytearray(bytes.fromhex(d["signature"])); sb[-1] ^= 0x01
86
+ print(" σ with one bit flipped: ", "INVALID" if not verify_round(pk, d["round"], sb.hex()) else "VALID <-- BUG")
87
+ other = get("https://api.drand.sh/8990e7a9aaed2ffed73dbd7092123d6f289930540d7651336225dc172e51b2ce/info")["public_key"]
88
+ print(" real σ against the DEFAULT network's key: ", "INVALID" if not verify_round(other if len(other) == 192 else pk, d["round"], d["signature"]) or len(other) != 192 else "VALID <-- BUG")
@@ -0,0 +1,7 @@
1
+ {
2
+ "note": "Pulses that are EXPECTED to fail the current verifier because of a published erratum. CI counts them separately; any other failure still fails the build, and a listed pulse that unexpectedly PASSES is flagged so this list cannot silently hide anything. Pulses are immutable; the list only grows.",
3
+ "pulses": {
4
+ "18": {"erratum": "ERR-007", "expected_failure": "witness: k3 reports a healthy clock", "reason": "witness epoch guard misfired (kernel TAI offset unset on k3; refid PHC); clock healthy on live evidence"},
5
+ "19": {"erratum": "ERR-007", "expected_failure": "witness: k3 reports a healthy clock", "reason": "same as 18"}
6
+ }
7
+ }
File without changes