notbefore 0.2.0__tar.gz

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.
Files changed (37) hide show
  1. notbefore-0.2.0/.gitignore +14 -0
  2. notbefore-0.2.0/LICENSE +25 -0
  3. notbefore-0.2.0/PKG-INFO +58 -0
  4. notbefore-0.2.0/README.md +31 -0
  5. notbefore-0.2.0/notbefore/__init__.py +12 -0
  6. notbefore-0.2.0/notbefore/check.py +115 -0
  7. notbefore-0.2.0/notbefore/cli.py +112 -0
  8. notbefore-0.2.0/notbefore/derive.py +51 -0
  9. notbefore-0.2.0/notbefore/log.py +90 -0
  10. notbefore-0.2.0/notbefore/verifier/VENDORED.json +31 -0
  11. notbefore-0.2.0/notbefore/verifier/__init__.py +0 -0
  12. notbefore-0.2.0/notbefore/verifier/bls_drand.py +88 -0
  13. notbefore-0.2.0/notbefore/verifier/ci/KNOWN_NONCOMPLIANT.json +7 -0
  14. notbefore-0.2.0/notbefore/verifier/ci/__init__.py +0 -0
  15. notbefore-0.2.0/notbefore/verifier/ci/anchor_lib.py +197 -0
  16. notbefore-0.2.0/notbefore/verifier/drand_anchor.py +71 -0
  17. notbefore-0.2.0/notbefore/verifier/hosts/EXPECTED.json +21 -0
  18. notbefore-0.2.0/notbefore/verifier/hosts/__init__.py +0 -0
  19. notbefore-0.2.0/notbefore/verifier/hosts/attest_host.py +27 -0
  20. notbefore-0.2.0/notbefore/verifier/hosts/attest_lib.py +86 -0
  21. notbefore-0.2.0/notbefore/verifier/hosts/entropy_host.py +152 -0
  22. notbefore-0.2.0/notbefore/verifier/hosts/gnss_probe.py +105 -0
  23. notbefore-0.2.0/notbefore/verifier/hosts/stamp_probe.py +189 -0
  24. notbefore-0.2.0/notbefore/verifier/keys/KEYS.json +78 -0
  25. notbefore-0.2.0/notbefore/verifier/keys/anchor.pub +4 -0
  26. notbefore-0.2.0/notbefore/verifier/keys/drand-quicknet.json +18 -0
  27. notbefore-0.2.0/notbefore/verifier/keys/entropy_signer.pub +6 -0
  28. notbefore-0.2.0/notbefore/verifier/keys/rekor.pub +4 -0
  29. notbefore-0.2.0/notbefore/verifier/keys/time_attester.f9t.RETIRED.pub +6 -0
  30. notbefore-0.2.0/notbefore/verifier/keys/time_attester.pub +6 -0
  31. notbefore-0.2.0/notbefore/verifier/keys/time_witness.pub +6 -0
  32. notbefore-0.2.0/notbefore/verifier/schema.py +33 -0
  33. notbefore-0.2.0/notbefore/verifier/tsa.py +91 -0
  34. notbefore-0.2.0/notbefore/verifier/verify.py +343 -0
  35. notbefore-0.2.0/pyproject.toml +41 -0
  36. notbefore-0.2.0/tests/test_acceptance.py +54 -0
  37. notbefore-0.2.0/vendor.py +29 -0
@@ -0,0 +1,14 @@
1
+ chain/pending/*.secret
2
+ chain/pending/*.revealed
3
+ cycle.log
4
+ cycle.out
5
+ tsa-certs/
6
+ __pycache__/
7
+ *.pyc
8
+
9
+ # vendored inside the CLI package (the repo-root tsa-certs/ cache stays ignored)
10
+ !cli/notbefore/verifier/tsa-certs/
11
+ cli/dist/
12
+ cli/.venv/
13
+ cli/*.egg-info/
14
+ .pytest_cache/
@@ -0,0 +1,25 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Bill Dailey (docdailey)
4
+
5
+ Applies to the software in this repository: verify.py, pulse.py, tsa.py, drand_anchor.py,
6
+ bls_drand.py, watcher.py, beacon-cycle.py, make_pulse.py, ci/, merkle/*.py, systemd/.
7
+ The published log data and documents are licensed separately — see LICENSE-DATA.
8
+
9
+ Permission is hereby granted, free of charge, to any person obtaining a copy
10
+ of this software and associated documentation files (the "Software"), to deal
11
+ in the Software without restriction, including without limitation the rights
12
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
13
+ copies of the Software, and to permit persons to whom the Software is
14
+ furnished to do so, subject to the following conditions:
15
+
16
+ The above copyright notice and this permission notice shall be included in all
17
+ copies or substantial portions of the Software.
18
+
19
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
20
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
21
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
22
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
23
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
24
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
25
+ SOFTWARE.
@@ -0,0 +1,58 @@
1
+ Metadata-Version: 2.5
2
+ Name: notbefore
3
+ Version: 0.2.0
4
+ Summary: NotBefore: verify an hourly commit-then-reveal seed from the public qrng-beacon-log and derive labeled seeds, shuffles and splits from it — with a pinned, vendored verifier.
5
+ Project-URL: Homepage, https://github.com/docdailey/qrng-beacon-log
6
+ Project-URL: Specification, https://github.com/docdailey/qrng-beacon-log/blob/main/NOTBEFORE.md
7
+ Project-URL: Log, https://github.com/docdailey/qrng-beacon-log/tree/main/chain
8
+ Author: docdailey
9
+ License: MIT
10
+ License-File: LICENSE
11
+ Keywords: beacon,commit-reveal,drand,randomness,reproducibility,transparency-log
12
+ Classifier: Development Status :: 3 - Alpha
13
+ Classifier: Environment :: Console
14
+ Classifier: Intended Audience :: Developers
15
+ Classifier: Intended Audience :: Science/Research
16
+ Classifier: License :: OSI Approved :: MIT License
17
+ Classifier: Programming Language :: Python :: 3
18
+ Classifier: Topic :: Security :: Cryptography
19
+ Requires-Python: >=3.10
20
+ Requires-Dist: cryptography>=42
21
+ Requires-Dist: py-ecc>=7
22
+ Provides-Extra: anchors
23
+ Requires-Dist: opentimestamps-client>=0.7; extra == 'anchors'
24
+ Provides-Extra: test
25
+ Requires-Dist: pytest>=8; extra == 'test'
26
+ Description-Content-Type: text/markdown
27
+
28
+ # notbefore
29
+
30
+ Consumer CLI for the public **qrng-beacon-log** — an hourly, commit-then-reveal, host-attested randomness log
31
+ anchored to drand quicknet, RFC 3161 timestamps, Rekor and OpenTimestamps. Spec: `NOTBEFORE.md` (draft 0.2) in
32
+ <https://github.com/docdailey/qrng-beacon-log>.
33
+
34
+ **What it proves:** the 32-byte attested value \(V\) of hour \(N\) was **fixed before** drand round \(R\) and
35
+ **not selected after** \(R\) existed. **What it does not prove:** that the bits are quantum, secret, certified, or
36
+ unique. This is not a certification of anything; see `CLAIMS.md` in the log repository.
37
+
38
+ ```bash
39
+ pip install notbefore # needs git and openssl on PATH; add [anchors] for OpenTimestamps proofs
40
+ notbefore verify 41 # commit 40 + reveal 41: signatures (pinned keys), drand BLS offline, RFC 3161, Rekor anchors
41
+ notbefore value 41 # V, only if verify passes
42
+ notbefore seed 41 --purpose clinic-qi-roster-2026-09-12 # S = SHA256("notbefore/derive/v1" || V || purpose)
43
+ notbefore shuffle 41 --purpose split:iris-csv:v3 rows.txt # deterministic shuffle of the lines of rows.txt
44
+ notbefore split 41 --purpose split:iris-csv:v3 --frac 0.8 rows.txt
45
+ ```
46
+
47
+ `seed`, `shuffle` and `split` write a **transcript** JSON (`notbefore-<seq>-<purpose>.json`) — the engineering
48
+ artifact that lets anyone reproduce the result from the public log.
49
+
50
+ **Trust model.** The verifier, the signing keys, the drand group key, the Rekor log key, the freetsa CA and the
51
+ expected host configuration are **vendored inside this package**, pinned at a named commit of the log repository
52
+ (`notbefore --version` prints it). The CLI executes only those files; the log is read as data. Updating the
53
+ verifier means updating the package — deliberately.
54
+
55
+ Eligible pairs start at 0020/0021 (0026/0027 preferred, execution enforced). `verify 19` fails by design (ERR-007).
56
+ Pulses 0001–0041 carry retroactive anchors (2026-09-12 12:47 UTC); from 0042 anchors are contemporaneous.
57
+
58
+ MIT. Data in the log: CC BY 4.0.
@@ -0,0 +1,31 @@
1
+ # notbefore
2
+
3
+ Consumer CLI for the public **qrng-beacon-log** — an hourly, commit-then-reveal, host-attested randomness log
4
+ anchored to drand quicknet, RFC 3161 timestamps, Rekor and OpenTimestamps. Spec: `NOTBEFORE.md` (draft 0.2) in
5
+ <https://github.com/docdailey/qrng-beacon-log>.
6
+
7
+ **What it proves:** the 32-byte attested value \(V\) of hour \(N\) was **fixed before** drand round \(R\) and
8
+ **not selected after** \(R\) existed. **What it does not prove:** that the bits are quantum, secret, certified, or
9
+ unique. This is not a certification of anything; see `CLAIMS.md` in the log repository.
10
+
11
+ ```bash
12
+ pip install notbefore # needs git and openssl on PATH; add [anchors] for OpenTimestamps proofs
13
+ notbefore verify 41 # commit 40 + reveal 41: signatures (pinned keys), drand BLS offline, RFC 3161, Rekor anchors
14
+ notbefore value 41 # V, only if verify passes
15
+ notbefore seed 41 --purpose clinic-qi-roster-2026-09-12 # S = SHA256("notbefore/derive/v1" || V || purpose)
16
+ notbefore shuffle 41 --purpose split:iris-csv:v3 rows.txt # deterministic shuffle of the lines of rows.txt
17
+ notbefore split 41 --purpose split:iris-csv:v3 --frac 0.8 rows.txt
18
+ ```
19
+
20
+ `seed`, `shuffle` and `split` write a **transcript** JSON (`notbefore-<seq>-<purpose>.json`) — the engineering
21
+ artifact that lets anyone reproduce the result from the public log.
22
+
23
+ **Trust model.** The verifier, the signing keys, the drand group key, the Rekor log key, the freetsa CA and the
24
+ expected host configuration are **vendored inside this package**, pinned at a named commit of the log repository
25
+ (`notbefore --version` prints it). The CLI executes only those files; the log is read as data. Updating the
26
+ verifier means updating the package — deliberately.
27
+
28
+ Eligible pairs start at 0020/0021 (0026/0027 preferred, execution enforced). `verify 19` fails by design (ERR-007).
29
+ Pulses 0001–0041 carry retroactive anchors (2026-09-12 12:47 UTC); from 0042 anchors are contemporaneous.
30
+
31
+ MIT. Data in the log: CC BY 4.0.
@@ -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
@@ -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)
@@ -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())
@@ -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
@@ -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