feat: minted keys, VRF seeds, RFC 9162 proofs, history roots, tag URIs and a transparency log
Keys (spec/02 §2.4), the rule paperstack settled on:
- A work key is a Scholar-style citation key, a hyphen and the first four
Crockford base32 characters of SHA-256("scienting-key/1:" + identity),
where the identity is the first of doi, arxiv, isbn, pmid, pmcid and
openalex in same_as, else the folded title, year and family name. A
collision lengthens the later key by two characters; a key is minted
once and never recomputed. A document key adds .si, .v<seq> or .<type>
for versions other than the version of record.
- Every fixture key is now minted (hale2026harmonic-damb,
okafor2025solvent-acth, writer2027fixed-ynr7.v3, tiny2026tiny-j4qm), so
every node and record ID was re-minted and the docs rewritten to match.
The validator checks the document key against the work key and version
(doc.key.work) and the hash part against the identifier (doc.key.suffix).
vectors/keys.jsonl pins the cases paperstack's tests pin.
Review:
- Spot-check seeds are ECVRF-EDWARDS25519-SHA512-TAI proofs (RFC 9381),
implemented in tools/oracle/vrf.py in plain Python and reproduced
against the RFC's Example 16; the sample ranks by the VRF output. The
earlier Ed25519 seed could be ground by a dishonest checker.
- RFC 9162 inclusion and consistency proofs (crypto.inclusion_path,
verify_inclusion, consistency_proof, verify_consistency) with
scienting-proof/1, vectors/proofs.jsonl and two fixture records.
- document.json carries `history`, the root over the corrections, with
the empty-tree hash for none (spec/11 §11.5, rule doc.history).
- A provisional transparency log (spec/23): log entries, signed-note
checkpoints with key hashes as transparency logs share them, the rule
that a logged revocation beats a self-asserted time, and vectors.
- Reserved signature schemes cose and ml-dsa-65 (spec/08 §8.4).
Identifiers and text:
- urn:scienting: becomes tag:scient.ing,2026: (RFC 4151), since the URN
namespace was never registered.
- Unicode 16.0 is pinned for normalization and categories (spec/01 §1.4);
the checker refuses another version.
Checking:
- check.py: keys, proofs, VRF and checkpoint vectors; the proof and
checkpoint records; a check that every ID and hash the prose quotes
exists in the fixtures or vectors; three more broken packages
(history-wrong, key-suffix, key-work).
- stress.py: key, VRF, proof and checkpoint properties.
- Docs: a keys section in the concepts and FAQ, rationale entries for
keys, VRF, proofs, tag URIs and the Unicode pin, a "Looking ahead"
section, media-type registration and OCI distribution notes.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
307 files changed
+2698
-1497
No files matched your search
+201
-62
@@ -25,10 +25,10 @@ from tools.oracle import align as AL # noqa: E402
|
||||
from tools.oracle import anchors as AN # noqa: E402
|
||||
from tools.oracle import corrections as CO # noqa: E402
|
||||
from tools.oracle import crypto, fingerprint as FP, grade as GR, hashing, jcs, receipts as RC, rights as RI # noqa: E402
|
||||
from tools.oracle import targets as TG # noqa: E402
|
||||
from tools.oracle import keys as K, log as LG, targets as TG, vrf # noqa: E402
|
||||
from tools.oracle.model import LEAVES, MATH_KINDS, RUN_KINDS, VERBATIM_KINDS, Doc # noqa: E402
|
||||
from tools.oracle.notation import fold_math # noqa: E402
|
||||
from tools.oracle.package import ZIP_EXTENSION, jsonl, line, witness_id, write, write_zip # noqa: E402
|
||||
from tools.oracle.package import ZIP_EXTENSION, history_items, jsonl, line, witness_id, write, write_zip # noqa: E402
|
||||
from tools.oracle.schema import Store # noqa: E402
|
||||
from tools.oracle.text import letter_first, mint_node_id, norm, record_id, sha256_hex # noqa: E402
|
||||
from tools.oracle.tree import Refusal, _flatten, mint_ids, validate_structure # noqa: E402
|
||||
@@ -175,19 +175,23 @@ def expand(s, page=None):
|
||||
|
||||
|
||||
class Names:
|
||||
"""Local names (`@x`) and the IDs minted for them, per document."""
|
||||
"""Local names (`@x`) and the IDs minted for them, per document, registered under the source's name."""
|
||||
|
||||
registry = {}
|
||||
|
||||
def __init__(self, key):
|
||||
def __init__(self, name, key):
|
||||
self.name = name
|
||||
self.key = key
|
||||
self.ids = {}
|
||||
Names.registry[key] = self
|
||||
Names.registry[name] = self
|
||||
|
||||
def resolve(self, v):
|
||||
if isinstance(v, str) and v.startswith("ext:"):
|
||||
_, doc, name = v.split(":", 2)
|
||||
return doc + "#" + Names.registry[doc].ids[name]
|
||||
parts = v.split(":", 2)
|
||||
other = Names.registry[parts[1]]
|
||||
if len(parts) == 2: # `ext:<source>`: the document, qualified by the store
|
||||
return STORE + "/" + other.key
|
||||
return other.key + "#" + other.ids[parts[2]]
|
||||
if isinstance(v, str) and v.startswith("@"):
|
||||
return self.ids[v]
|
||||
if isinstance(v, list):
|
||||
@@ -412,8 +416,13 @@ def seal_receipts(spec, names, state_by_rev, final_state, key, wit_sha):
|
||||
|
||||
|
||||
def build_document(src, used=None, extra_files=None):
|
||||
key = src["document"]["key"]
|
||||
names = Names(key)
|
||||
# The keys are minted, never written by hand (spec/02 §2.4): the work key from the work's identity and its
|
||||
# metadata, the document key from the work key and the version.
|
||||
d = src["document"]
|
||||
wkey = K.work_key(d["work"].get("same_as"), d["meta"])
|
||||
key = K.document_key(wkey, d["version"])
|
||||
d["key"], d["work"]["key"] = key, wkey
|
||||
names = Names(src.get("name", key), key)
|
||||
used = used if used is not None else set()
|
||||
spec = expand(src["tree"])
|
||||
nodes, side = establish(key, spec, names, used)
|
||||
@@ -502,7 +511,7 @@ def package_files(src, key, names, side, final, records, receipts=None, roster_r
|
||||
if "_csl" in s:
|
||||
r["csl"] = s["_csl"]
|
||||
res = s.get("_resolution", {"type": "none"})
|
||||
r["resolution"] = res
|
||||
r["resolution"] = {k: names.resolve(v) for k, v in res.items()}
|
||||
refs.append({k: v for k, v in r.items() if v is not None})
|
||||
if refs:
|
||||
files["references.jsonl"] = jsonl(refs)
|
||||
@@ -524,6 +533,7 @@ def package_files(src, key, names, side, final, records, receipts=None, roster_r
|
||||
d = dict(src["document"])
|
||||
d = {"format": "scienting-doc/1", **d}
|
||||
d.update({"hashing": {"scheme": "plain"}, "root": vals[doc.root]["node_hash"], "rev": final.rev,
|
||||
"history": crypto.history_root(history_items(records or [])),
|
||||
"counts": {"nodes": len(nodes), "leaves": sum(1 for n in nodes if n["kind"] in LEAVES)},
|
||||
"fidelity": RC.distribution(levels)})
|
||||
d["meta_hash"] = hashing.meta_hash(d)
|
||||
@@ -535,6 +545,7 @@ def package_files(src, key, names, side, final, records, receipts=None, roster_r
|
||||
|
||||
def build_hale(out):
|
||||
src = json.load(open(os.path.join(ROOT, "fixtures", "src", "hale2026harmonic.json")))
|
||||
src["name"] = "hale2026harmonic"
|
||||
image = png(4, 3, (40, 90, 160))
|
||||
image_sha = sha256_hex(image)
|
||||
key, names, side, final, states, records = build_document(src)
|
||||
@@ -636,14 +647,15 @@ def build_checks_only(out, hale):
|
||||
f["references.jsonl"] = jsonl(refs)
|
||||
d = dict(hale["document"])
|
||||
d.update({"policy": "checks-only", "hashing": {"scheme": "hmac-team", "key_id": crypto.key_id(k)},
|
||||
"root": keyed[doc.root]["node_hash"]})
|
||||
"root": keyed[doc.root]["node_hash"], "history": crypto.EMPTY_ROOT})
|
||||
d["meta_hash"] = hashing.meta_hash(d, hashing.Hasher(k))
|
||||
f["document.json"] = (jcs.dumps(d) + "\n").encode()
|
||||
write(os.path.join(out, "fixtures", "packages", "hale2026harmonic.checks-only"), f, hale["absent"])
|
||||
write(os.path.join(out, "fixtures", "packages", hale["key"] + ".checks-only"), f, hale["absent"])
|
||||
|
||||
|
||||
def build_simple(out, name, used=None):
|
||||
src = json.load(open(os.path.join(ROOT, "fixtures", "src", name + ".json")))
|
||||
src["name"] = name
|
||||
key, names, side, final, states, records = build_document(src)
|
||||
witnesses = []
|
||||
pages = boxes = None
|
||||
@@ -680,8 +692,11 @@ def build_simple(out, name, used=None):
|
||||
|
||||
# --- the tiny document used for corrections vectors and invalid packages -----------------
|
||||
|
||||
TINY = {"document": {"key": "tiny2026example", "kind": "article", "work": {"key": "tiny2026example", "same_as": []},
|
||||
"version": {"type": "version-of-record"}, "relations": [], "meta": {"title": "A tiny example"},
|
||||
TINY = {"name": "tiny2026example",
|
||||
"document": {"kind": "article", "work": {"same_as": ["doi:10.5555/example.tiny.2026"]},
|
||||
"version": {"type": "version-of-record"}, "relations": [],
|
||||
"meta": {"title": "A tiny example", "author": [{"family": "Tiny", "given": "T."}],
|
||||
"issued": {"date-parts": [[2026]]}},
|
||||
"language": "en", "license": {"spdx": "CC0-1.0"}, "policy": "full"},
|
||||
"tree": {"kind": "document", "children": [
|
||||
{"kind": "section", "name": "@sec", "label": "1", "level": 1, "title": "Results", "children": [
|
||||
@@ -707,7 +722,8 @@ TINY = {"document": {"key": "tiny2026example", "kind": "article", "work": {"key"
|
||||
|
||||
|
||||
def tiny_package(out_dir, receipts=True):
|
||||
key, names, side, final, states, records = build_document(copy.deepcopy(TINY))
|
||||
src = copy.deepcopy(TINY)
|
||||
key, names, side, final, states, records = build_document(src)
|
||||
wsha = H("tiny2026example: a file (synthetic)")
|
||||
wit = [{"id": witness_id(wsha), "sha256": wsha, "size": 1000, "kind": "pdf", "media_type": "application/pdf",
|
||||
"pages": 1, "origin": {"source": "author"}, "rights": rights_record("own")}]
|
||||
@@ -716,7 +732,7 @@ def tiny_package(out_dir, receipts=True):
|
||||
recs, _ = seal_receipts([{"name": "t1", "by": "ana", "level": "F3", "scope": "all", "at": "2026-10-11T12:00:00Z",
|
||||
"nodes": ["@a", "@b", "@c", "@d", "@x1", "@y1", "@hx", "@hy", "@r"]}],
|
||||
names, states, final, key, wsha)
|
||||
files, nodes, vals, d = package_files(TINY, key, names, side, final, records, recs, ROSTER if receipts else None,
|
||||
files, nodes, vals, d = package_files(src, key, names, side, final, records, recs, ROSTER if receipts else None,
|
||||
wit)
|
||||
if out_dir:
|
||||
write(out_dir, files)
|
||||
@@ -783,8 +799,8 @@ def vectors(out, hale, tiny):
|
||||
for doc_key, kind, th, occ in [("cornell1995second", "sentence", H("A sentence."), 0),
|
||||
("cornell1995second", "sentence", H("A sentence."), 1),
|
||||
("cornell1995second.si", "sentence", H("A sentence."), 0),
|
||||
("hale2026harmonic", "cell", H("310.0"), 0),
|
||||
("hale2026harmonic", "column", H("Set A $K_r$ (kcal mol⁻¹ Å⁻²)"), 0)]:
|
||||
(hale["key"], "cell", H("310.0"), 0),
|
||||
(hale["key"], "column", H("Set A $K_r$ (kcal mol⁻¹ Å⁻²)"), 0)]:
|
||||
payload = jcs.dumps(["scienting-id/1", doc_key, kind, th, occ])
|
||||
idc.append({"id": "mint-%s-%s-%d" % (doc_key, kind, occ), "doc": doc_key, "kind": kind, "text_hash": th,
|
||||
"occurrence": occ, "payload": payload, "sha256": H(payload),
|
||||
@@ -876,19 +892,23 @@ def vectors(out, hale, tiny):
|
||||
# Five values on a small tree: the tiny document.
|
||||
tnodes = [{k: v for k, v in n.items() if k not in ("text_hash", "match_hash", "align_key", "own_hash", "node_hash")}
|
||||
for n in map(json.loads, tiny["files"]["nodes.jsonl"].decode().splitlines())]
|
||||
tdoc = Doc("tiny2026example", tnodes)
|
||||
tdoc = Doc(tiny["key"], tnodes)
|
||||
tv = hashing.compute(tdoc)
|
||||
own = {nid: hashing.own_content(tdoc, nid, lambda x: tv[x]["text_hash"]) for nid in tdoc.order}
|
||||
wv(out, "hashes.jsonl", [{"id": "tiny2026example", "doc": "tiny2026example", "nodes": tnodes,
|
||||
wv(out, "hashes.jsonl", [{"id": "tiny2026example", "doc": tiny["key"], "nodes": tnodes,
|
||||
"own_content": own, "values": tv}])
|
||||
# Addresses.
|
||||
ad = [("plain", "cornell1995second#k3m9q2a"), ("pinned", "cornell1995second#k3m9q2a@4f1a0c"),
|
||||
("span", "cornell1995second#k3m9q2a@4f1a0c[12:57]"), ("store", "smithlab/cornell1995second#k3m9q2a"),
|
||||
("span-no-pin", "dong2026machine.v2#k3m9q2a[0:0]"), ("si", "cornell1995second.si#m2x8p4d"),
|
||||
("alias", "cornell1995second#b-p03-text-004"), ("bad-reversed", "cornell1995second#k3m9q2a[57:12]"),
|
||||
("bad-pin", "cornell1995second#k3m9q2a@4F1A0C"), ("bad-leading-zero", "k#k3m9q2a[01:2]"),
|
||||
("bad-no-node", "cornell1995second#"), ("bad-upper-key", "Cornell1995#k3m9q2a"),
|
||||
("bad-two-stores", "a/b/c#k3m9q2a"), ("bad-pin-length", "k#k3m9q2a@4f1a")]
|
||||
# Addresses. The example keys are real work keys: Cornell et al. (1995) by its DOI, a fictional preprint.
|
||||
cornell = "cornell1995second-" + K.suffix("doi:10.1021/ja00124a002")
|
||||
dong = "dong2026machine-" + K.suffix("doi:10.5555/example.dong.2026")
|
||||
ad = [("plain", cornell + "#k3m9q2a"), ("pinned", cornell + "#k3m9q2a@4f1a0c"),
|
||||
("span", cornell + "#k3m9q2a@4f1a0c[12:57]"), ("store", "smithlab/" + cornell + "#k3m9q2a"),
|
||||
("span-no-pin", dong + ".v2#k3m9q2a[0:0]"), ("si", cornell + ".si#m2x8p4d"),
|
||||
("alias", cornell + "#b-p03-text-004"), ("bad-reversed", cornell + "#k3m9q2a[57:12]"),
|
||||
("bad-pin", cornell + "#k3m9q2a@4F1A0C"), ("bad-leading-zero", "k-0000#k3m9q2a[01:2]"),
|
||||
("bad-no-node", cornell + "#"), ("bad-upper-key", "Cornell1995-k3f9#k3m9q2a"),
|
||||
("bad-two-stores", "a/b/c-0000#k3m9q2a"), ("bad-pin-length", "k-0000#k3m9q2a@4f1a"),
|
||||
("bad-no-hash", "cornell1995second#k3m9q2a"), ("bad-hash-alphabet", "cornell1995second-k3fi#k3m9q2a"),
|
||||
("bad-short-hash", "cornell1995second-k3f#k3m9q2a")]
|
||||
wv(out, "addresses.jsonl", [{"id": i, "address": a, "parsed": AN.parse_address(a)} for i, a in ad])
|
||||
# Keyed hashes.
|
||||
k = crypto.team_key(TEAM_SECRET)
|
||||
@@ -960,6 +980,7 @@ def vectors(out, hale, tiny):
|
||||
q1 = hale["by_name"]["q1"]
|
||||
wv(out, "spotcheck.jsonl", [{"id": "q1", "receipt": r1, "seed_payload": RC.seed_payload(r1).decode(),
|
||||
"checker_public": ROSTER_BY_ID["ben-spot"]["public"], "seed": q1["qa"]["seed"],
|
||||
"beta": vrf.proof_to_hash(base64.b64decode(q1["qa"]["seed"])).hex(),
|
||||
"n": len(r1["nodes"]), "rate": "0.05", "minimum": 10,
|
||||
"size": RC.sample_size(len(r1["nodes"])), "sample": q1["qa"]["sample"]}]
|
||||
+ [{"id": "size-%d" % n, "n": n, "rate": "0.05", "minimum": 10, "size": RC.sample_size(n)}
|
||||
@@ -968,6 +989,89 @@ def vectors(out, hale, tiny):
|
||||
wv(out, "bounds.jsonl", [{"id": "k%d-n%d" % (kk, nn), "errors": kk, "sampled": nn, "confidence": "0.95",
|
||||
"bound": RC.bound_string(kk, nn), "approx": round(RC.upper_bound(kk, nn), 9)}
|
||||
for kk, nn in [(0, 10), (0, 300), (1, 10), (1, 300), (2, 50), (5, 100), (0, 1), (10, 10)]])
|
||||
# Keys (spec/02 §2.4): Scholar parts, identities and suffixes, including the cases paperstack pins.
|
||||
attn = {"title": "Attention is all you need", "author": [{"family": "Vaswani", "given": "Ashish"}],
|
||||
"issued": {"date-parts": [[2017]]}}
|
||||
kc = []
|
||||
for cid, same_as, meta in [
|
||||
("doi", ["doi:10.48550/arXiv.1706.03762"], attn),
|
||||
("openalex", ["openalex:W2963403868"], attn),
|
||||
("doi-before-arxiv", ["arxiv:1706.03762", "doi:10.48550/arXiv.1706.03762"], attn),
|
||||
("arxiv-only", ["arxiv:1706.03762"], attn),
|
||||
("url-is-not-an-identity", ["url:https://example.org/paper"], attn),
|
||||
("meta", [], {"title": "On the Origin of Species", "author": [{"family": "Darwin", "given": "Charles"}],
|
||||
"issued": {"date-parts": [[1859]]}}),
|
||||
("doi-case", ["doi:10.1109/CVPR.2016.90"], {"title": "Deep Residual Learning for Image Recognition",
|
||||
"author": [{"family": "He", "given": "Kaiming"}],
|
||||
"issued": {"date-parts": [[2016]]}}),
|
||||
("accents", [], {"title": "A new method", "author": [{"literal": "M\u00fcller, J\u00fcrgen"}],
|
||||
"issued": {"date-parts": [[2001]]}}),
|
||||
("apostrophe", [], {"title": "Don't stop", "author": [{"literal": "Conan O'Brien"}],
|
||||
"issued": {"date-parts": [[2020]]}}),
|
||||
("name-suffix", [], {"title": "3D printing of bone", "author": [{"literal": "A. Smith Jr."}],
|
||||
"issued": {"date-parts": [[2015]]}}),
|
||||
("only-stop-words", [], {"title": "The of a", "author": [{"family": "Lee", "given": "Jo"}],
|
||||
"issued": {"date-parts": [[2019]]}}),
|
||||
("no-year", [], {"title": "Untitled", "author": [{"family": "Lee"}]}),
|
||||
("no-author", [], {"title": "Fluids", "issued": {"date-parts": [[2020]]}}),
|
||||
("non-latin-author", [], {"title": "Fluids", "author": [{"family": "\u5f20\u4f1f"}], "issued": {"date-parts": [[2020]]}}),
|
||||
("hale", hale["src"]["document"]["work"]["same_as"], hale["src"]["document"]["meta"]),
|
||||
]:
|
||||
ident = K.identity(same_as, meta)
|
||||
kc.append({"id": cid, "same_as": same_as, "meta": meta, "scholar": K.scholar_part(meta), "identity": ident,
|
||||
"payload": K.SCHEME + ":" + ident, "sha256": H(K.SCHEME + ":" + ident), "suffix": K.suffix(ident),
|
||||
"suffix_8": K.suffix(ident, 8), "key": K.work_key(same_as, meta)})
|
||||
hk = hale["document"]["work"]["key"]
|
||||
for cid, version in [("version-of-record", {"type": "version-of-record"}), ("supplement", {"type": "supplement"}),
|
||||
("preprint-v2", {"type": "preprint", "seq": 2}), ("draft-v3", {"type": "draft", "seq": 3}),
|
||||
("erratum", {"type": "erratum"}), ("accepted-no-seq", {"type": "accepted"})]:
|
||||
kc.append({"id": "document-" + cid, "work_key": hk, "version": version, "document_key": K.document_key(hk, version)})
|
||||
kc.append({"id": "collision", "same_as": attn and ["doi:10.48550/arXiv.1706.03762"], "meta": attn,
|
||||
"taken": [K.work_key(["doi:10.48550/arXiv.1706.03762"], attn)],
|
||||
"key": K.unique_work_key(["doi:10.48550/arXiv.1706.03762"], attn, {K.work_key(["doi:10.48550/arXiv.1706.03762"], attn)})})
|
||||
wv(out, "keys.jsonl", kc)
|
||||
# Proofs (RFC 9162): inclusion for every item of the Merkle vectors, consistency for every prefix, and the
|
||||
# example paper's correction history.
|
||||
pf = [{"id": "empty-history", "kind": "root", "items": [], "root": crypto.history_root([])}]
|
||||
for n in (1, 2, 3, 4, 5, 7):
|
||||
items = [["node", mint_node_id("m", "sentence", H(str(i)), 0), H("h%d" % i)] for i in range(n)]
|
||||
root = crypto.merkle_root(items)
|
||||
for i in range(n):
|
||||
pf.append({"id": "inclusion-%d-of-%d" % (i, n), "kind": "inclusion", "items": items, "index": i, "size": n,
|
||||
"root": root, "leaf": crypto.leaf_hash(items[i]), "path": crypto.inclusion_path(items, i)})
|
||||
for m in range(n + 1):
|
||||
pf.append({"id": "consistency-%d-to-%d" % (m, n), "kind": "consistency", "items": items, "from": m, "to": n,
|
||||
"from_root": crypto.history_root(items[:m]), "to_root": root,
|
||||
"path": crypto.consistency_proof(items, m)})
|
||||
hist = history_items(hale["records"])
|
||||
pf.append({"id": "hale-history-8-to-12", "kind": "consistency", "items": hist, "from": 8, "to": len(hist),
|
||||
"from_root": crypto.history_root(hist[:8]), "to_root": crypto.history_root(hist),
|
||||
"path": crypto.consistency_proof(hist, 8)})
|
||||
pf.append({"id": "hale-history-correction-5", "kind": "inclusion", "items": hist, "index": 4, "size": len(hist),
|
||||
"root": crypto.history_root(hist), "leaf": crypto.leaf_hash(hist[4]), "path": crypto.inclusion_path(hist, 4)})
|
||||
wv(out, "proofs.jsonl", pf)
|
||||
# The VRF (RFC 9381, ECVRF-EDWARDS25519-SHA512-TAI): the RFC's Example 16, two more inputs for its key, and
|
||||
# the spot-check seed of the example paper's first receipt.
|
||||
vv = []
|
||||
for cid, alpha in (("rfc9381-example-16", b""), ("rfc9381-key-alpha-72", b"\x72"), ("rfc9381-key-alpha-af82", b"\xaf\x82")):
|
||||
pi = vrf.prove(sk, alpha)
|
||||
vv.append({"id": cid, "secret": sk.hex(), "public": vrf.public_key(sk).hex(), "alpha": alpha.hex(),
|
||||
"proof": pi.hex(), "beta": vrf.proof_to_hash(pi).hex()})
|
||||
seed = base64.b64decode(q1["qa"]["seed"])
|
||||
vv.append({"id": "ben-spot-seed-q1", "public": base64.b64decode(ROSTER_BY_ID["ben-spot"]["public"]).hex(),
|
||||
"alpha": RC.seed_payload(r1).hex(), "proof": seed.hex(), "beta": vrf.proof_to_hash(seed).hex()})
|
||||
bad = bytearray(seed)
|
||||
bad[40] ^= 1
|
||||
vv.append({"id": "tampered-challenge", "public": base64.b64decode(ROSTER_BY_ID["ben-spot"]["public"]).hex(),
|
||||
"alpha": RC.seed_payload(r1).hex(), "proof": bytes(bad).hex(), "beta": None})
|
||||
vv.append({"id": "other-input", "public": base64.b64decode(ROSTER_BY_ID["ben-spot"]["public"]).hex(),
|
||||
"alpha": (RC.seed_payload(r1) + b"x").hex(), "proof": seed.hex(), "beta": None})
|
||||
wv(out, "vrf.jsonl", vv)
|
||||
# A checkpoint of the team's log (spec/23): every receipt and revocation of the example paper, then the roster.
|
||||
entries = [LG.entry(r) for r in hale["receipts"]] + [LG.entry(ROSTER)]
|
||||
note = LG.sign_checkpoint("scient.ing/log/smithlab", entries, [("ana", secret("ana"))])
|
||||
wv(out, "checkpoint.jsonl", [{"id": "smithlab", "origin": "scient.ing/log/smithlab", "entries": entries,
|
||||
"root": LG.root(entries), "signers": ["ana"], "note": note}])
|
||||
# Alignment.
|
||||
def item(i, runs, container, context="paragraph"):
|
||||
doc, s = sentence_doc(runs)
|
||||
@@ -1077,7 +1181,7 @@ def resolve_vectors(hale):
|
||||
if "alias" in a:
|
||||
anchor["node"] = a["alias"]
|
||||
res = AN.resolve(final, anchor)
|
||||
out.append({"id": cid, "package": "fixtures/packages/hale2026harmonic", "anchor": anchor, "expect": res})
|
||||
out.append({"id": cid, "package": "fixtures/packages/" + hale["key"], "anchor": anchor, "expect": res})
|
||||
return out
|
||||
|
||||
|
||||
@@ -1134,12 +1238,19 @@ def ann_hash(a):
|
||||
return H(jcs.dumpb({k: v for k, v in a.items() if k not in ("status", "receipt", "modified")}))
|
||||
|
||||
|
||||
TAG = "tag:scient.ing,2026:"
|
||||
|
||||
|
||||
def records(out, hale):
|
||||
names, final = hale["names"], hale["final"]
|
||||
doc = final.doc()
|
||||
vals = hashing.compute(doc)
|
||||
rev = final.rev
|
||||
I = names.ids
|
||||
HALE = hale["key"]
|
||||
QHALE = STORE + "/" + HALE
|
||||
OKAFOR = Names.registry["okafor2025open"].key
|
||||
DRAFT = Names.registry["smithlab2027charges"].key
|
||||
|
||||
def anchor(name, quote=None):
|
||||
nid = I[name]
|
||||
@@ -1164,7 +1275,7 @@ def records(out, hale):
|
||||
wr(out, "anchor-leaf.json", long_anchor)
|
||||
# A public layer's anchor on a paywalled paper: keyed by the pages, no text.
|
||||
fps = [p["page_fp"] for p, _ in hale["pages"]]
|
||||
layer_id = "urn:scienting:layer:smithlab/kp4m2x7q"
|
||||
layer_id = TAG + "layer:smithlab/kp4m2x7q"
|
||||
pk = crypto.page_key(fps, "layer:smithlab/kp4m2x7q")
|
||||
hk = hashing.Hasher(pk)
|
||||
ka = anchor("@cc_ak")
|
||||
@@ -1211,11 +1322,11 @@ def records(out, hale):
|
||||
"revenue_or_funding_usd": {"at_most": None}, "competes_with": None,
|
||||
"recorded_by": "acme-admin", "at": "2026-10-01T00:00:00Z"})
|
||||
# A layer with one annotation of each common kind.
|
||||
creator = {"id": "urn:scienting:key:ana", "type": "Person", "name": "Ana Example"}
|
||||
model = {"id": "urn:scienting:software:example-model", "type": "Software", "name": "A model (illustrative)"}
|
||||
creator = {"id": TAG + "key:ana", "type": "Person", "name": "Ana Example"}
|
||||
model = {"id": TAG + "software:example-model", "type": "Software", "name": "A model (illustrative)"}
|
||||
|
||||
def target(a):
|
||||
return {"type": "SpecificResource", "source": "urn:scienting:doc:smithlab/hale2026harmonic",
|
||||
return {"type": "SpecificResource", "source": TAG + "doc:" + QHALE,
|
||||
"selector": [{"type": "AnchorSelector", "anchor": a}]
|
||||
+ ([{"type": "TextQuoteSelector", "exact": a["quote"]["exact"],
|
||||
"prefix": a["quote"].get("prefix", ""), "suffix": a["quote"].get("suffix", "")}]
|
||||
@@ -1224,7 +1335,7 @@ def records(out, hale):
|
||||
def ann(body, tgt, motivation, status="proposed", who=creator, created="2026-10-11T19:00:00Z"):
|
||||
a = {"@context": CTX, "type": "Annotation", "layer": layer_id, "motivation": motivation, "creator": who,
|
||||
"created": created, "body": body, "target": tgt}
|
||||
a["id"] = "urn:scienting:annotation:smithlab/" + record_id(H(jcs.dumpb(a)))
|
||||
a["id"] = TAG + "annotation:smithlab/" + record_id(H(jcs.dumpb(a)))
|
||||
a["status"] = status
|
||||
return a
|
||||
|
||||
@@ -1267,7 +1378,7 @@ def records(out, hale):
|
||||
anns.append(ann({"type": "TextualBody", "kind": "comment", "value": "Agreed; see the residual panel."},
|
||||
{"type": "SpecificResource", "source": anns[2]["id"]}, "replying", created="2026-10-11T19:05:00Z"))
|
||||
confirmed = [a for a in anns if a["status"] == "confirmed"]
|
||||
lr = RC.seal({"format": "scienting-receipt/1", "doc": "hale2026harmonic", "rev": rev, "level": "F3",
|
||||
lr = RC.seal({"format": "scienting-receipt/1", "doc": HALE, "rev": rev, "level": "F3",
|
||||
"scope": "targeted:layer", "nodes": [],
|
||||
"records": sorted([["annotation", a["id"].rsplit("/", 1)[1], ann_hash(a)] for a in confirmed]),
|
||||
"evidence": [], "by": "ana", "at": "2026-10-11T19:30:00Z"}, secret("ana"), "ana")
|
||||
@@ -1278,12 +1389,12 @@ def records(out, hale):
|
||||
"purpose": "Glosses, claim links and exercises for new students.",
|
||||
"audience": "student", "visibility": "team", "license": {"spdx": "CC-BY-4.0"},
|
||||
"creator": creator, "created": "2026-10-11T19:00:00Z", "project": "smithlab-charges",
|
||||
"docs": ["smithlab/hale2026harmonic"], "total": len(anns)})
|
||||
"docs": [QHALE], "total": len(anns)})
|
||||
wr(out, "layer/annotations.jsonl", jsonl(anns))
|
||||
wr(out, "layer/receipt.json", lr)
|
||||
# Submissions and grades.
|
||||
ex_ann = anns[6]
|
||||
sub_ok = {"format": "scienting-submission/1", "exercise": ex_ann["id"], "doc": "smithlab/hale2026harmonic",
|
||||
sub_ok = {"format": "scienting-submission/1", "exercise": ex_ann["id"], "doc": QHALE,
|
||||
"rev": rev, "student": "s-7k2m", "answers": {"anchors": [anchor("@cc_ak")]},
|
||||
"submitted_at": "2026-10-20T10:00:00Z"}
|
||||
sub_ok["id"] = record_id(H(jcs.dumpb(sub_ok)))
|
||||
@@ -1291,7 +1402,7 @@ def records(out, hale):
|
||||
sub_miss.pop("id")
|
||||
sub_miss["id"] = record_id(H(jcs.dumpb(sub_miss)))
|
||||
ch_ann = anns[7]
|
||||
sub_check = {"format": "scienting-submission/1", "exercise": ch_ann["id"], "doc": "smithlab/hale2026harmonic",
|
||||
sub_check = {"format": "scienting-submission/1", "exercise": ch_ann["id"], "doc": QHALE,
|
||||
"rev": rev, "student": "s-7k2m",
|
||||
"answers": {"verdict": "supports", "anchors": [anchor("@s1", "most common description of covalent")]},
|
||||
"submitted_at": "2026-10-20T10:05:00Z"}
|
||||
@@ -1302,7 +1413,7 @@ def records(out, hale):
|
||||
g = GR.grade(body, sub, final)
|
||||
wr(out, "submissions/%s.json" % name, sub)
|
||||
wr(out, "grades/%s.json" % name, g)
|
||||
grades.append({"id": name, "package": "fixtures/packages/hale2026harmonic", "exercise": body,
|
||||
grades.append({"id": name, "package": "fixtures/packages/" + hale["key"], "exercise": body,
|
||||
"submission": sub, "expect": g})
|
||||
wv(out, "grading.jsonl", grades)
|
||||
# Project.
|
||||
@@ -1311,15 +1422,15 @@ def records(out, hale):
|
||||
"members": [{"person": "Ana Example", "role": "owner", "keys": ["ana"]},
|
||||
{"person": "Ben Example", "role": "editor", "keys": ["ben", "ben-spot"]},
|
||||
{"person": "Cara Example", "role": "reader", "keys": ["cara"]}],
|
||||
"documents": [{"doc": "smithlab/hale2026harmonic", "reason": "The bond constants we start from.",
|
||||
"documents": [{"doc": QHALE, "reason": "The bond constants we start from.",
|
||||
"tags": ["parameters"], "status": {"Ben Example": "read", "Cara Example": "reading"}},
|
||||
{"doc": "okafor2025open", "reason": "Solvent shifts for the validation set.", "tags": ["spectra"]}],
|
||||
{"doc": OKAFOR, "reason": "Solvent shifts for the validation set.", "tags": ["spectra"]}],
|
||||
"paths": [{"id": "onboarding", "title": "First week", "steps": [
|
||||
{"type": "document", "ref": "smithlab/hale2026harmonic", "note": "Read the introduction."},
|
||||
{"type": "node", "ref": "smithlab/hale2026harmonic#" + I["@tab1"], "note": "Then the table."},
|
||||
{"type": "document", "ref": QHALE, "note": "Read the introduction."},
|
||||
{"type": "node", "ref": QHALE + "#" + I["@tab1"], "note": "Then the table."},
|
||||
{"type": "claim", "ref": "lode:amber/k-bond"}]}],
|
||||
"layers": [layer_id], "datasets": ["ds-hale-bond-params", "ds-hale-ctct-scan"], "views": ["ctct-scan"],
|
||||
"drafts": ["smithlab/smithlab2027charges"], "claims": {"lode": "amber"},
|
||||
"drafts": [STORE + "/" + DRAFT], "claims": {"lode": "amber"},
|
||||
"settings": {"digit_rule": "anchored-sections", "drift_threshold": "0.25",
|
||||
"spot_check": {"rate": "0.05", "minimum": 10}, "rights_unknown": "refuse",
|
||||
"stages": "scienting-stages-default"}})
|
||||
@@ -1328,7 +1439,7 @@ def records(out, hale):
|
||||
pa = I["@pa"]
|
||||
ft = {t["text"]: t["id"] for t in doc.nodes[pa]["attrs"]["figure_text"]}
|
||||
pts = [((110.0, 360.0), ("1.45", "3.10")), ((150.0, 330.0), ("1.50", "0.40")), ((190.0, 300.0), ("1.55", "2.80"))]
|
||||
dg = {"format": "scienting-digitization/1", "doc": "smithlab/hale2026harmonic",
|
||||
dg = {"format": "scienting-digitization/1", "doc": QHALE,
|
||||
"target": {"anchor": AN.make_anchor(doc, vals, pa, rev, store=STORE), "page_fp": p2["page_fp"],
|
||||
"region": [72.0, 260.0, 300.0, 410.0], "witness": hale["witness"]["sha256"]},
|
||||
"axes": [{"id": "x", "scale": "linear", "unit": "angstrom",
|
||||
@@ -1348,7 +1459,7 @@ def records(out, hale):
|
||||
dg["id"] = record_id(H(jcs.dumpb(dg)))
|
||||
wr(out, "digitization.json", dg)
|
||||
body = {k: v for k, v in dg.items() if k != "confirmation"}
|
||||
conf = RC.seal({"format": "scienting-receipt/1", "doc": "hale2026harmonic", "rev": rev, "level": "F3",
|
||||
conf = RC.seal({"format": "scienting-receipt/1", "doc": HALE, "rev": rev, "level": "F3",
|
||||
"scope": "targeted:digitization", "nodes": [],
|
||||
"records": [["digitization", dg["id"], H(jcs.dumpb(body))]],
|
||||
"evidence": [{"kind": "crop", "node": pa, "sha256": crop_sha("overlay of panel a")}],
|
||||
@@ -1390,14 +1501,14 @@ def records(out, hale):
|
||||
k = len(q1["qa"]["errors"]) + len(q2["qa"]["errors"])
|
||||
wr(out, "qa-report.json", RC.seal({
|
||||
"format": "scienting-qa-report/1", "delivery": "smithlab evidence pilot (fictional)",
|
||||
"docs": ["smithlab/hale2026harmonic"], "receipts": [hale["by_name"]["r1"]["id"], hale["by_name"]["r4"]["id"]],
|
||||
"docs": [QHALE], "receipts": [hale["by_name"]["r1"]["id"], hale["by_name"]["r4"]["id"]],
|
||||
"reviewers": [{"key": "ana", "sampled": n, "errors": k, "confidence": "0.95", "method": "clopper-pearson",
|
||||
"bound": RC.bound_string(k, n)}],
|
||||
"by": "ana", "at": "2026-10-12T10:00:00Z"}, secret("ana"), "ana"))
|
||||
# A checklist for an accessible HTML export of the paper, and the receipt that covers it (spec/08 §8.11).
|
||||
html_sha = H("accessible HTML export of hale2026harmonic (synthetic)")
|
||||
html_sha = H("accessible HTML export of %s (synthetic)" % HALE)
|
||||
checker = {"name": "example-checker", "version": "1.0"}
|
||||
checklist = {"format": "scienting-checklist/1", "doc": "smithlab/hale2026harmonic", "rev": rev,
|
||||
checklist = {"format": "scienting-checklist/1", "doc": QHALE, "rev": rev,
|
||||
"standard": {"name": "WCAG 2.1", "level": "AA", "url": "https://www.w3.org/TR/WCAG21/"},
|
||||
"export": {"kind": "html", "sha256": html_sha, "size": 48213},
|
||||
"items": [
|
||||
@@ -1417,7 +1528,7 @@ def records(out, hale):
|
||||
checklist["id"] = record_id(checklist_hash)
|
||||
wr(out, "checklist/checklist.json", checklist)
|
||||
wr(out, "checklist/receipt.json", RC.seal({
|
||||
"format": "scienting-receipt/1", "doc": "hale2026harmonic", "rev": rev, "level": "F3",
|
||||
"format": "scienting-receipt/1", "doc": HALE, "rev": rev, "level": "F3",
|
||||
"scope": "targeted:checklist", "nodes": [], "records": [["checklist", checklist["id"], checklist_hash]],
|
||||
"evidence": [{"kind": "source-file", "sha256": html_sha, "note": "The exported HTML."}],
|
||||
"by": "ana", "at": "2026-10-12T11:30:00Z"}, secret("ana"), "ana"))
|
||||
@@ -1426,23 +1537,41 @@ def records(out, hale):
|
||||
dataset_anchors = [json.loads(x) for x in
|
||||
hale["files"]["datasets/ds-hale-bond-params/anchors.jsonl"].decode().splitlines()]
|
||||
anchored = sorted({a["anchor"]["node"] for a in dataset_anchors} | {I["@s2"], pa})
|
||||
wv(out, "targets.jsonl", [{"id": "hale-" + rule, "package": "fixtures/packages/hale2026harmonic",
|
||||
wv(out, "targets.jsonl", [{"id": "hale-" + rule, "package": "fixtures/packages/" + hale["key"],
|
||||
"anchored": anchored, "digit_rule": rule,
|
||||
"expect": {"nodes": TG.target_set(doc, anchored, rule)}}
|
||||
for rule in TG.DIGIT_RULES])
|
||||
# Proofs (spec/08 §8.5, spec/11 §11.5): one node of the first receipt was covered, and revision 12 extends
|
||||
# revision 8 of the correction history.
|
||||
r1 = hale["by_name"]["r1"]
|
||||
items = crypto.receipt_items(r1)
|
||||
wr(out, "proof-inclusion.json", {"format": "scienting-proof/1", "kind": "inclusion", "receipt": r1["id"],
|
||||
"item": items[3], "index": 3, "size": len(items), "root": r1["root"],
|
||||
"path": crypto.inclusion_path(items, 3)})
|
||||
hist = history_items(hale["records"])
|
||||
wr(out, "proof-consistency.json", {"format": "scienting-proof/1", "kind": "consistency", "doc": QHALE,
|
||||
"from_rev": 8, "to_rev": len(hist), "from_root": crypto.history_root(hist[:8]),
|
||||
"to_root": hale["document"]["history"], "path": crypto.consistency_proof(hist, 8)})
|
||||
# A checkpoint of the team's log (spec/23), as the parsed form of its signed note.
|
||||
entries = [LG.entry(r) for r in hale["receipts"]] + [LG.entry(ROSTER)]
|
||||
note = LG.sign_checkpoint("scient.ing/log/smithlab", entries, [("ana", secret("ana"))])
|
||||
parsed = LG.parse_checkpoint(note)
|
||||
wr(out, "checkpoint.json", {"format": LG.FORMAT, "origin": parsed["origin"], "size": parsed["size"],
|
||||
"root": parsed["root"], "note": note,
|
||||
"signatures": [{"name": n, "key_hash": kh.hex(), "value": base64.b64encode(sig).decode()}
|
||||
for n, kh, sig in parsed["signatures"]]})
|
||||
# Draft snapshot and sentence IDs.
|
||||
s5 = I["@s5"]
|
||||
snap_anchor = AN.make_anchor(doc, vals, s5, rev, store=STORE)
|
||||
wr(out, "snapshot.json", {"format": "scienting-snapshot/1", "store": STORE, "made_at": "2026-10-11T21:00:00Z",
|
||||
"quotes": [{"address": AN.format_address(STORE, "hale2026harmonic", s5, vals[s5]["node_hash"]),
|
||||
"quotes": [{"address": AN.format_address(STORE, HALE, s5, vals[s5]["node_hash"]),
|
||||
"anchor": snap_anchor, "text": norm(hashing.plain(doc, s5)),
|
||||
"fidelity": "F3", "result": "current"}],
|
||||
"claims": [{"ref": "amber/k-bond", "pin": "illustrative-pin", "level": "illustrative",
|
||||
"typst": "K_r"}],
|
||||
"datasets": [{"id": "ds-hale-ctct-scan", "hash": ds_hash}]})
|
||||
draft = Names.registry["smithlab2027charges"]
|
||||
dd = Doc("smithlab2027charges", [json.loads(x) for x in open(os.path.join(
|
||||
out, "fixtures", "packages", "smithlab2027charges", "nodes.jsonl"))])
|
||||
dd = Doc(DRAFT, [json.loads(x) for x in open(os.path.join(out, "fixtures", "packages", DRAFT, "nodes.jsonl"))])
|
||||
lines = []
|
||||
for nid in dd.preorder():
|
||||
if dd.nodes[nid]["kind"] == "sentence":
|
||||
@@ -1520,12 +1649,12 @@ def invalid(out, tiny):
|
||||
content=[{"kind": "text", "text": "A second paragraph.", "style": ["sub", "sup"]}]))},
|
||||
"A run cannot be both a subscript and a superscript.")
|
||||
# Zipped packages that break spec/12 §12.2: the tiny package's files plus one bad entry.
|
||||
tiny_dir = os.path.join(out, "fixtures", "packages", "tiny2026example")
|
||||
tiny_dir = os.path.join(out, "fixtures", "packages", tiny["key"])
|
||||
entries = []
|
||||
for rel in sorted(os.path.relpath(os.path.join(d, f), tiny_dir).replace(os.sep, "/")
|
||||
for d, _, fs in os.walk(tiny_dir) for f in fs):
|
||||
with open(os.path.join(tiny_dir, rel), "rb") as fh:
|
||||
entries.append(("tiny2026example/" + rel, fh.read(), 0o644))
|
||||
entries.append((tiny["key"] + "/" + rel, fh.read(), 0o644))
|
||||
|
||||
def zip_case(name, rule, note, extra):
|
||||
d = os.path.join(base, name)
|
||||
@@ -1541,9 +1670,9 @@ def invalid(out, tiny):
|
||||
fh.write((jcs.dumps({"rule": rule, "note": note}) + "\n").encode())
|
||||
|
||||
zip_case("zip-traversal", "package.zip.entry", "An archive entry whose path leaves the package folder.",
|
||||
[("tiny2026example/../escape.txt", b"outside\n", 0o644)])
|
||||
[(tiny["key"] + "/../escape.txt", b"outside\n", 0o644)])
|
||||
zip_case("zip-symlink", "package.zip.entry", "An archive entry that is a symbolic link.",
|
||||
[("tiny2026example/assets/link", b"document.json", 0o120777)])
|
||||
[(tiny["key"] + "/assets/link", b"document.json", 0o120777)])
|
||||
# Rules the audit found no broken package for.
|
||||
case("missing-required-file", "package.file.required", {"provenance.json": None}, "A required file is missing.")
|
||||
case("counts-wrong", "doc.counts", {"document.json": _doc_edit(files, counts={"nodes": 1, "leaves": 1})},
|
||||
@@ -1557,7 +1686,7 @@ def invalid(out, tiny):
|
||||
{"id": nm["@a"], "kind": "retired", "successor": None, "node_kind": "sentence", "rev": 1}])},
|
||||
"An ID that is both live and retired.")
|
||||
case("manifest-key", "package.manifest.key", {}, "The manifest names another key than document.json.",
|
||||
raw=lambda d: _manifest_edit(d, key="other2026example"))
|
||||
raw=lambda d: _manifest_edit(d, key="other2026example-0000"))
|
||||
case("unsafe-path", "package.path.unsafe", {}, "The manifest lists a path that leaves the package.",
|
||||
raw=lambda d: _manifest_edit(d, files=[{"path": "../escape.txt", "sha256": "0" * 64, "size": 1}]
|
||||
+ json.load(open(os.path.join(d, "MANIFEST.json")))["files"]))
|
||||
@@ -1567,6 +1696,15 @@ def invalid(out, tiny):
|
||||
case("signer-mismatch", "doc.receipt.signature", {"receipts.jsonl": jsonl([
|
||||
RC.seal(dict(signer, by="cara"), secret("ana"), "ana")])},
|
||||
"A receipt whose signature was made with another key than the signer it names.")
|
||||
case("history-wrong", "doc.history", {"document.json": _doc_edit(files, history="4" * 64)},
|
||||
"document.json's history is not the root over the corrections.")
|
||||
d0 = json.loads(files["document.json"])
|
||||
wk = d0["work"]["key"]
|
||||
bad = wk[:-4] + "zzzz"
|
||||
case("key-suffix", "doc.key.suffix", {"document.json": _doc_edit(files, key=bad, work=dict(d0["work"], key=bad))},
|
||||
"A work key whose hash part is not the hash of the work's DOI.")
|
||||
case("key-work", "doc.key.work", {"document.json": _doc_edit(files, key=wk + ".si")},
|
||||
"A document key whose suffix is not the version's.")
|
||||
|
||||
|
||||
def _manifest_edit(d, **kw):
|
||||
@@ -1655,10 +1793,11 @@ def main(out):
|
||||
build_checks_only(out, hale)
|
||||
build_simple(out, "okafor2025open")
|
||||
build_simple(out, "smithlab2027charges")
|
||||
tiny = tiny_package(os.path.join(out, "fixtures", "packages", "tiny2026example"))
|
||||
tiny = tiny_package(None)
|
||||
tiny_dir = os.path.join(out, "fixtures", "packages", tiny["key"])
|
||||
write(tiny_dir, tiny["files"])
|
||||
# The same package zipped as spec/12 §12.2 says a producer writes a byte-identical archive.
|
||||
write_zip(os.path.join(out, "fixtures", "packages", "tiny2026example"),
|
||||
os.path.join(out, "fixtures", "packages", "tiny2026example" + ZIP_EXTENSION))
|
||||
write_zip(tiny_dir, tiny_dir + ZIP_EXTENSION)
|
||||
records(out, hale)
|
||||
vectors(out, hale, tiny)
|
||||
invalid(out, tiny)
|
||||
|
||||
+146
-15
@@ -26,7 +26,8 @@ from tools.oracle import anchors as AN # noqa: E402
|
||||
from tools.oracle import corrections as CO # noqa: E402
|
||||
from tools.oracle import crypto, db, fingerprint as FP, grade as GR, hashing, jcs, receipts as RC, rights as RI # noqa: E402
|
||||
from tools.oracle import package as PK # noqa: E402
|
||||
from tools.oracle import targets as TG # noqa: E402
|
||||
from tools.oracle import keys as K, log as LG, targets as TG, vrf # noqa: E402
|
||||
import unicodedata # noqa: E402
|
||||
from tools.oracle.model import Doc # noqa: E402
|
||||
from tools.oracle.notation import fold_math # noqa: E402
|
||||
from tools.oracle.schema import Store, validate as sv # noqa: E402
|
||||
@@ -62,6 +63,20 @@ def js(path):
|
||||
|
||||
|
||||
STORE = Store(p("schemas"))
|
||||
|
||||
|
||||
def fixture_key(stem):
|
||||
"""The key the rule of spec/02 §2.4 mints for a fixture source, which names its package folder."""
|
||||
if stem == "tiny2026example":
|
||||
from tools.build import TINY
|
||||
d = TINY["document"]
|
||||
else:
|
||||
d = js(p("fixtures", "src", stem + ".json"))["document"]
|
||||
return K.document_key(K.work_key(d["work"].get("same_as"), d["meta"]), d["version"])
|
||||
|
||||
|
||||
HALE = fixture_key("hale2026harmonic")
|
||||
TINY_KEY = fixture_key("tiny2026example")
|
||||
SCHEMA_FILES = sorted(f for f in os.listdir(p("schemas")) if f.endswith(".schema.json"))
|
||||
FORMAT_SCHEMA = {f["format"]: f["schema"] for f in js(p("registry", "formats.json"))["formats"]}
|
||||
|
||||
@@ -103,6 +118,9 @@ def check_registry():
|
||||
ok("registry", rule in rules, "tools/oracle/%s raises %s, which is not in rules.json" % (f, rule))
|
||||
for v in ("text-hash", "match-hash", "align-key", "own-hash", "node-hash"):
|
||||
ok("registry", "doc.hash." + v in rules, "doc.hash.%s is not in rules.json" % v)
|
||||
# The character data the hashes depend on is pinned to one Unicode version (spec/01 §1.4).
|
||||
ok("registry", unicodedata.unidata_version == "16.0.0",
|
||||
"the tools run on Unicode %s, but the specification pins 16.0" % unicodedata.unidata_version)
|
||||
for d in sorted(os.listdir(p("fixtures", "invalid"))):
|
||||
ok("registry", js(p("fixtures", "invalid", d, "expect.json"))["rule"] in rules, "%s: rule is not in rules.json" % d)
|
||||
|
||||
@@ -147,6 +165,25 @@ def check_records():
|
||||
ok("records", ["checklist", cl["id"], cl_hash] in clr["records"], "the checklist is not covered at its hash")
|
||||
ok("records", RC.receipt_status(clr, [clr], roster) == "valid", "the checklist receipt is not valid")
|
||||
ok("records", {i["result"] for i in cl["items"]} <= {"pass", "fail", "not-applicable"}, "checklist results")
|
||||
# Proofs and the checkpoint (spec/08 §8.5, spec/11 §11.5, spec/23).
|
||||
hale_receipts = {r["id"]: r for r in jl(p("fixtures", "packages", HALE, "receipts.jsonl"))}
|
||||
pi = js(os.path.join(R, "proof-inclusion.json"))
|
||||
rcp = hale_receipts.get(pi["receipt"])
|
||||
ok("records", rcp is not None and pi["item"] in crypto.receipt_items(rcp) and pi["root"] == rcp["root"]
|
||||
and crypto.verify_inclusion(pi["root"], crypto.leaf_hash(pi["item"]), pi["index"], pi["size"], pi["path"]),
|
||||
"the inclusion proof does not verify against its receipt")
|
||||
pc = js(os.path.join(R, "proof-consistency.json"))
|
||||
hist = PK.history_items(jl(p("fixtures", "packages", HALE, "corrections.jsonl")))
|
||||
ok("records", pc["to_root"] == js(p("fixtures", "packages", HALE, "document.json"))["history"]
|
||||
and pc["from_root"] == crypto.history_root(hist[:pc["from_rev"]]) and pc["to_rev"] == len(hist)
|
||||
and crypto.verify_consistency(pc["from_root"], pc["to_root"], pc["from_rev"], pc["to_rev"], pc["path"]),
|
||||
"the consistency proof does not verify against the example's history")
|
||||
cp = js(os.path.join(R, "checkpoint.json"))
|
||||
note, verified = LG.verify_checkpoint(cp["note"], roster)
|
||||
ok("records", note and note["origin"] == cp["origin"] and note["size"] == cp["size"] and note["root"] == cp["root"]
|
||||
and verified == [sg["name"] for sg in cp["signatures"]], "the checkpoint note does not verify")
|
||||
entries = [LG.entry(r) for r in hale_receipts.values()] + [LG.entry(js(os.path.join(R, "roster.json")))]
|
||||
ok("records", LG.root(entries) == cp["root"], "the checkpoint root is not the root over the example's receipts")
|
||||
# A project's settings.stages holds the id of a stage file.
|
||||
stage_ids = {js(p("registry", "stages", f))["id"] for f in os.listdir(p("registry", "stages")) if f.endswith(".json")}
|
||||
stages = js(os.path.join(R, "project.json")).get("settings", {}).get("stages")
|
||||
@@ -174,7 +211,7 @@ def check_records():
|
||||
anns = jl(os.path.join(R, "layer", "annotations.jsonl"))
|
||||
described = {x["anchor"]["node"] for a in anns if a["body"]["kind"] == "description" and a["status"] == "confirmed"
|
||||
for x in a["target"].get("selector", []) if x.get("type") == "AnchorSelector"}
|
||||
figures = {n["id"] for n in jl(p("fixtures", "packages", "hale2026harmonic", "nodes.jsonl")) if n["kind"] == "figure"}
|
||||
figures = {n["id"] for n in jl(p("fixtures", "packages", HALE, "nodes.jsonl")) if n["kind"] == "figure"}
|
||||
ok("records", figures and figures <= described, "a figure of the example has no confirmed description: %s"
|
||||
% sorted(figures - described))
|
||||
ok("records", any(a["body"]["kind"] == "generated" and a["status"] == "proposed" for a in anns),
|
||||
@@ -194,12 +231,12 @@ def check_packages():
|
||||
rep = PK.validate(os.path.join(pk, d), STORE, team_key if d.endswith("checks-only") else None)
|
||||
ok("packages", bool(rep), "%s: %s" % (d, rep.errors[:4]))
|
||||
# The checks-only export carries exactly the HMAC values of the full document.
|
||||
full = state_of(os.path.join(pk, "hale2026harmonic")).doc()
|
||||
full = state_of(os.path.join(pk, HALE)).doc()
|
||||
keyed = hashing.compute(full, hashing.Hasher(team_key))
|
||||
for n in jl(os.path.join(pk, "hale2026harmonic.checks-only", "nodes.jsonl")):
|
||||
for n in jl(os.path.join(pk, HALE + ".checks-only", "nodes.jsonl")):
|
||||
ok("packages", all(n[k] == keyed[n["id"]][k] for k in keyed[n["id"]]), "keyed values of " + n["id"])
|
||||
# Every reported value in the hale dataset is anchored to the cell that prints it, and resolves as current.
|
||||
hp = os.path.join(pk, "hale2026harmonic")
|
||||
hp = os.path.join(pk, HALE)
|
||||
st = state_of(hp)
|
||||
ds = os.path.join(hp, "datasets", "ds-hale-bond-params")
|
||||
desc = js(os.path.join(ds, "datapackage.json"))
|
||||
@@ -262,22 +299,22 @@ def check_zips():
|
||||
PK.write_zip(root, again)
|
||||
ok("zips", filecmp.cmp(again, os.path.join(pk, z), shallow=False), "%s: write_zip reproduces it" % z)
|
||||
# Crafted archives, built from the tiny package.
|
||||
tiny = p("fixtures", "packages", "tiny2026example")
|
||||
tiny = p("fixtures", "packages", TINY_KEY)
|
||||
entries = []
|
||||
for rel in sorted(os.path.relpath(os.path.join(d, f), tiny).replace(os.sep, "/")
|
||||
for d, _, fs in os.walk(tiny) for f in fs):
|
||||
with open(os.path.join(tiny, rel), "rb") as fh:
|
||||
entries.append(("tiny2026example/" + rel, fh.read(), 0o644))
|
||||
entries.append((TINY_KEY + "/" + rel, fh.read(), 0o644))
|
||||
cases = [
|
||||
("two-folders", entries + [("other2026/x.txt", b"x", 0o644)], "package.zip.entry"),
|
||||
("duplicate", entries + [entries[0]], "package.zip.entry"),
|
||||
("folder-not-a-key", [("Tiny Example/" + n.partition("/")[2], d, m) for n, d, m in entries], "package.zip.entry"),
|
||||
("folder-not-the-key", [("other2026example/" + n.partition("/")[2], d, m) for n, d, m in entries],
|
||||
"package.zip.entry"),
|
||||
("dot-dot", entries + [("tiny2026example/assets/../../x", b"x", 0o644)], "package.zip.entry"),
|
||||
("symlink", entries + [("tiny2026example/assets/link", b"document.json", 0o120777)], "package.zip.entry"),
|
||||
("dot-dot", entries + [(TINY_KEY + "/assets/../../x", b"x", 0o644)], "package.zip.entry"),
|
||||
("symlink", entries + [(TINY_KEY + "/assets/link", b"document.json", 0o120777)], "package.zip.entry"),
|
||||
("no-folder", [(n.partition("/")[2], d, m) for n, d, m in entries], "package.zip.entry"),
|
||||
("directory-entries", entries + [("tiny2026example/assets/", b"", 0o755)], None),
|
||||
("directory-entries", entries + [(TINY_KEY + "/assets/", b"", 0o755)], None),
|
||||
]
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
for name, ents, rule in cases:
|
||||
@@ -306,7 +343,7 @@ def check_owner_revocation():
|
||||
No fixture has such a revocation, so this signs one: Ben revokes a receipt of Ana's, once with the
|
||||
roster role owner and once as the editor he is.
|
||||
"""
|
||||
hp = p("fixtures", "packages", "hale2026harmonic")
|
||||
hp = p("fixtures", "packages", HALE)
|
||||
revoker = "ben"
|
||||
secret = bytes.fromhex(open(p("fixtures", "keys", revoker + ".ed25519.hex")).read().strip())
|
||||
roster_rec = js(os.path.join(hp, "roster.json"))
|
||||
@@ -350,7 +387,7 @@ def check_unsafe_paths():
|
||||
"""Loading and writing refuse manifest paths that leave the package, even without a prior validation."""
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
pkg = os.path.join(tmp, "pkg")
|
||||
shutil.copytree(p("fixtures", "packages", "tiny2026example"), pkg)
|
||||
shutil.copytree(p("fixtures", "packages", TINY_KEY), pkg)
|
||||
manifest = js(os.path.join(pkg, "MANIFEST.json"))
|
||||
for bad in ("../outside", "/etc/outside", "assets/../../outside"):
|
||||
m = dict(manifest, files=[{"path": bad, "sha256": "0" * 64, "size": 1}] + manifest["files"])
|
||||
@@ -400,7 +437,9 @@ def check_incremental():
|
||||
for f in sorted(os.listdir(p("fixtures", "src"))):
|
||||
if not f.endswith(".json"):
|
||||
continue
|
||||
_, _, _, _, states, _ = build_document(js(p("fixtures", "src", f)))
|
||||
src = js(p("fixtures", "src", f))
|
||||
src["name"] = f[:-len(".json")] # the name other sources refer to, as the builder sets it
|
||||
_, _, _, _, states, _ = build_document(src)
|
||||
keyed = None
|
||||
for rev in sorted(states):
|
||||
st = states[rev]
|
||||
@@ -415,7 +454,7 @@ def check_incremental():
|
||||
|
||||
def check_gate():
|
||||
"""The gate runs on a proposal against a package's current state and leaves that state unchanged."""
|
||||
st = state_of(p("fixtures", "packages", "hale2026harmonic"))
|
||||
st = state_of(p("fixtures", "packages", HALE))
|
||||
before = json.dumps(st.nodes, sort_keys=True)
|
||||
target = next(n for n in st.nodes if n["kind"] == "sentence" and "Each bond" in hashing.plain(st.doc(), n["id"]))
|
||||
base = {"format": "scienting-correction/1", "evidence": [{"kind": "person", "by": "ana"}], "author": {"key": "ana"},
|
||||
@@ -464,6 +503,9 @@ def V(name):
|
||||
return jl(p("vectors", name))
|
||||
|
||||
|
||||
ROSTER_BY_ID_CHECK = {k["id"]: k for k in js(p("fixtures", "records", "roster.json"))["keys"]}
|
||||
|
||||
|
||||
def check_vectors():
|
||||
from tools.build import parse_doubles, projection_case, sentence_doc
|
||||
for c in V("canonical-json.jsonl"):
|
||||
@@ -544,7 +586,8 @@ def check_vectors():
|
||||
ok("vectors", RC.sample_size(c["n"], c["rate"], c["minimum"]) == c["size"], "sample size " + c["id"])
|
||||
if "seed" in c:
|
||||
seed = base64.b64decode(c["seed"])
|
||||
ok("vectors", crypto.ed25519_verify(base64.b64decode(c["checker_public"]), c["seed_payload"].encode(), seed)
|
||||
beta = vrf.verify(base64.b64decode(c["checker_public"]), c["seed_payload"].encode(), seed)
|
||||
ok("vectors", beta is not None and beta.hex() == c["beta"]
|
||||
and RC.sample(c["receipt"], seed, c["rate"], c["minimum"]) == c["sample"], "spot-check " + c["id"])
|
||||
for c in V("bounds.jsonl"):
|
||||
ok("vectors", RC.bound_string(c["errors"], c["sampled"], c["confidence"]) == c["bound"], "bound " + c["id"])
|
||||
@@ -596,6 +639,59 @@ def check_vectors():
|
||||
and all(d.nodes[n]["kind"] in TG.LEAVES for n, _ in got), "targets %s: only leaves, equations by kind" % c["id"])
|
||||
ok("vectors", set(sets) == set(TG.DIGIT_RULES) and sets["none"] <= sets["anchored-sections"] <= sets["all"]
|
||||
and sets["none"] < sets["all"], "the three digit rules nest")
|
||||
for c in V("keys.jsonl"):
|
||||
if "version" in c:
|
||||
ok("vectors", K.document_key(c["work_key"], c["version"]) == c["document_key"], "keys " + c["id"])
|
||||
elif "taken" in c:
|
||||
ok("vectors", K.unique_work_key(c["same_as"], c["meta"], set(c["taken"])) == c["key"]
|
||||
and len(c["key"].rsplit("-", 1)[1]) == 6, "keys " + c["id"])
|
||||
else:
|
||||
ident = K.identity(c["same_as"], c["meta"])
|
||||
ok("vectors", ident == c["identity"] and K.scholar_part(c["meta"]) == c["scholar"]
|
||||
and K.SCHEME + ":" + ident == c["payload"] and sha256_hex(c["payload"]) == c["sha256"]
|
||||
and K.suffix(ident) == c["suffix"] and K.suffix(ident, 8) == c["suffix_8"]
|
||||
and c["suffix_8"].startswith(c["suffix"]) and K.work_key(c["same_as"], c["meta"]) == c["key"]
|
||||
and K.WORK_KEY_RE.match(c["key"]) and c["key"] == c["scholar"] + "-" + c["suffix"], "keys " + c["id"])
|
||||
for c in V("proofs.jsonl"):
|
||||
items = c["items"]
|
||||
if c["kind"] == "root":
|
||||
ok("vectors", crypto.history_root(items) == c["root"] == crypto.EMPTY_ROOT, "proofs " + c["id"])
|
||||
elif c["kind"] == "inclusion":
|
||||
ok("vectors", crypto.inclusion_path(items, c["index"]) == c["path"]
|
||||
and crypto.leaf_hash(items[c["index"]]) == c["leaf"]
|
||||
and crypto.verify_inclusion(c["root"], c["leaf"], c["index"], c["size"], c["path"]), "proofs " + c["id"])
|
||||
other = (c["index"] + 1) % c["size"]
|
||||
ok("vectors", c["size"] == 1 or not crypto.verify_inclusion(c["root"], c["leaf"], other, c["size"], c["path"]),
|
||||
"proofs %s: the path proves another index" % c["id"])
|
||||
else:
|
||||
ok("vectors", crypto.consistency_proof(items, c["from"]) == c["path"]
|
||||
and crypto.history_root(items[:c["from"]]) == c["from_root"]
|
||||
and crypto.verify_consistency(c["from_root"], c["to_root"], c["from"], c["to"], c["path"]), "proofs " + c["id"])
|
||||
ok("vectors", c["from"] == 0 or not crypto.verify_consistency(c["from_root"], "0" * 64, c["from"], c["to"], c["path"]),
|
||||
"proofs %s: a wrong root passes" % c["id"]) # every tree extends the empty one, so m = 0 proves nothing
|
||||
for c in V("vrf.jsonl"):
|
||||
pub, alpha, proof = bytes.fromhex(c["public"]), bytes.fromhex(c["alpha"]), bytes.fromhex(c["proof"])
|
||||
if "secret" in c:
|
||||
sk = bytes.fromhex(c["secret"])
|
||||
ok("vectors", vrf.public_key(sk) == pub == crypto.ed25519_public(sk) and vrf.prove(sk, alpha) == proof,
|
||||
"vrf %s: prove" % c["id"])
|
||||
beta = vrf.verify(pub, alpha, proof)
|
||||
ok("vectors", (beta.hex() if beta else None) == c["beta"], "vrf %s: verify" % c["id"])
|
||||
if c["beta"]:
|
||||
ok("vectors", vrf.proof_to_hash(proof).hex() == c["beta"], "vrf %s: proof_to_hash" % c["id"])
|
||||
ok("vectors", any(c["id"] == "rfc9381-example-16" and c["proof"].startswith("8657106690b5526245a92b003bb079cc")
|
||||
and c["beta"].startswith("90cf1df3b703cce59e2a35b925d41116") for c in V("vrf.jsonl")),
|
||||
"the RFC 9381 example is not reproduced")
|
||||
from tools.build import secret
|
||||
for c in V("checkpoint.jsonl"):
|
||||
ok("vectors", LG.root(c["entries"]) == c["root"], "checkpoint %s: root" % c["id"])
|
||||
signers = [(n, secret(n)) for n in c["signers"]]
|
||||
ok("vectors", LG.sign_checkpoint(c["origin"], c["entries"], signers) == c["note"], "checkpoint %s: note" % c["id"])
|
||||
note, verified = LG.verify_checkpoint(c["note"], ROSTER_BY_ID_CHECK)
|
||||
ok("vectors", note and note["size"] == len(c["entries"]) and note["root"] == c["root"] and verified == c["signers"],
|
||||
"checkpoint %s: verify" % c["id"])
|
||||
bad = c["note"].replace(c["origin"], c["origin"] + "x", 1)
|
||||
ok("vectors", LG.verify_checkpoint(bad, ROSTER_BY_ID_CHECK)[1] == [], "checkpoint %s: a changed note verifies" % c["id"])
|
||||
|
||||
|
||||
def check_determinism():
|
||||
@@ -907,6 +1003,40 @@ def check_counts():
|
||||
% (rel, i, word, what, have[what]))
|
||||
|
||||
|
||||
def check_doc_values():
|
||||
"""Every node ID, record ID or 64-hex hash the prose quotes exists in the fixtures or vectors, so the guides
|
||||
never describe a fixture that no longer reads that way."""
|
||||
corpus = []
|
||||
for sub in ("fixtures", "vectors"):
|
||||
for d, _, fs in os.walk(p(sub)):
|
||||
for f in fs:
|
||||
if f.endswith((".json", ".jsonl", ".csv", ".hex")):
|
||||
with open(os.path.join(d, f), encoding="utf-8", errors="ignore") as fh:
|
||||
corpus.append(fh.read())
|
||||
corpus = "\n".join(corpus)
|
||||
ids_in_corpus = set(re.findall(r"[a-hjkmnp-tv-z][0-9a-hjkmnp-tv-z]{6,25}", corpus))
|
||||
hashes = set(re.findall(r"[0-9a-f]{64}", corpus))
|
||||
mds = []
|
||||
for d, dirs, fs in os.walk(ROOT):
|
||||
dirs[:] = [x for x in dirs if x not in SKIP_DIRS]
|
||||
mds += [os.path.join(d, f) for f in fs if f.endswith(".md")]
|
||||
for path in sorted(mds):
|
||||
rel = os.path.relpath(path, ROOT)
|
||||
fence = False
|
||||
for i, ln in enumerate(open(path, encoding="utf-8"), 1):
|
||||
if ln.startswith("```"):
|
||||
fence = not fence
|
||||
continue
|
||||
for h in set(re.findall(r"\b[0-9a-f]{64}\b", ln)):
|
||||
ok("docs", h in hashes, "%s:%d: the hash %s… is in no fixture or vector" % (rel, i, h[:12]))
|
||||
# IDs quoted in code spans or after a `#` in an address.
|
||||
for m in re.finditer(r"`([a-hjkmnp-tv-z][0-9a-hjkmnp-tv-z]{6}|[a-hjkmnp-tv-z][0-9a-hjkmnp-tv-z]{12})`|#([a-hjkmnp-tv-z][0-9a-hjkmnp-tv-z]{6})\b", ln):
|
||||
nid = m.group(1) or m.group(2)
|
||||
if m.group(1) and not any(ch.isdigit() for ch in nid):
|
||||
continue # a plain word of seven letters, such as `pattern`, is not an ID
|
||||
ok("docs", nid in ids_in_corpus, "%s:%d: the ID %s is in no fixture or vector" % (rel, i, nid))
|
||||
|
||||
|
||||
def check_site():
|
||||
"""The website builds, every Markdown file has a page, every internal link and fragment resolves, no Markdown
|
||||
is left unrendered, and the namespace addresses the formats name are files the site serves."""
|
||||
@@ -1014,6 +1144,7 @@ def main():
|
||||
check_counts()
|
||||
check_prose()
|
||||
check_links()
|
||||
check_doc_values()
|
||||
check_site()
|
||||
for k in sorted(COUNTS):
|
||||
print("%-17s %5d checks" % (k, COUNTS[k]))
|
||||
|
||||
@@ -12,7 +12,7 @@ from .text import norm
|
||||
|
||||
ADDRESS_RE = re.compile(
|
||||
r"^(?:(?P<store>[a-z0-9][a-z0-9._-]*)/)?"
|
||||
r"(?P<key>[a-z0-9][a-z0-9._-]*)"
|
||||
r"(?P<key>[a-z][a-z0-9]*-[0-9a-hjkmnp-tv-z]{4,51}(?:\.[a-z0-9][a-z0-9-]*)*)"
|
||||
r"#(?P<node>[a-z0-9][a-z0-9._-]*)"
|
||||
r"(?:@(?P<pin>[0-9a-f]{6}))?"
|
||||
r"(?:\[(?P<start>0|[1-9][0-9]*):(?P<end>0|[1-9][0-9]*)\])?$"
|
||||
|
||||
@@ -70,6 +70,130 @@ def receipt_items(receipt):
|
||||
return items
|
||||
|
||||
|
||||
EMPTY_ROOT = hashlib.sha256(b"").hexdigest()
|
||||
|
||||
|
||||
def history_root(items):
|
||||
"""Root over an append-only history, with RFC 9162's hash of the empty list for no items."""
|
||||
if not items:
|
||||
return EMPTY_ROOT
|
||||
return _mth([_leaf(jcs.dumpb(list(it))) for it in items]).hex()
|
||||
|
||||
|
||||
def leaf_hash(item):
|
||||
return _leaf(jcs.dumpb(list(item))).hex()
|
||||
|
||||
|
||||
# --- RFC 9162 proofs (§2.1.3 and §2.1.4) ---------------------------------------
|
||||
|
||||
|
||||
def _path(m, leaves):
|
||||
"""PATH(m, D[n]): the inclusion path for the leaf at index m, as RFC 9162 §2.1.3.1 defines it."""
|
||||
n = len(leaves)
|
||||
if n == 1:
|
||||
return []
|
||||
k = 1
|
||||
while k * 2 < n:
|
||||
k *= 2
|
||||
if m < k:
|
||||
return _path(m, leaves[:k]) + [_mth(leaves[k:])]
|
||||
return _path(m - k, leaves[k:]) + [_mth(leaves[:k])]
|
||||
|
||||
|
||||
def inclusion_path(items, index):
|
||||
"""The audit path of `items[index]` in the tree over `items`, as hex hashes, leaf-most first."""
|
||||
if not 0 <= index < len(items):
|
||||
raise ValueError("index outside the tree")
|
||||
return [h.hex() for h in _path(index, [_leaf(jcs.dumpb(list(it))) for it in items])]
|
||||
|
||||
|
||||
def verify_inclusion(root, leaf, index, size, path):
|
||||
"""Whether `path` proves that the leaf hash `leaf` is item `index` of a tree of `size` with `root`
|
||||
(RFC 9162 §2.1.3.2). Never raises."""
|
||||
try:
|
||||
if not 0 <= index < size:
|
||||
return False
|
||||
fn, sn = index, size - 1
|
||||
r = bytes.fromhex(leaf)
|
||||
for p in path:
|
||||
p = bytes.fromhex(p)
|
||||
if sn == 0:
|
||||
return False
|
||||
if fn & 1 or fn == sn:
|
||||
r = _node(p, r)
|
||||
while fn & 1 == 0 and fn != 0:
|
||||
fn >>= 1
|
||||
sn >>= 1
|
||||
else:
|
||||
r = _node(r, p)
|
||||
fn >>= 1
|
||||
sn >>= 1
|
||||
return sn == 0 and r.hex() == root
|
||||
except (ValueError, TypeError):
|
||||
return False
|
||||
|
||||
|
||||
def _subproof(m, leaves, b):
|
||||
n = len(leaves)
|
||||
if m == n:
|
||||
return [] if b else [_mth(leaves)]
|
||||
k = 1
|
||||
while k * 2 < n:
|
||||
k *= 2
|
||||
if m <= k:
|
||||
return _subproof(m, leaves[:k], b) + [_mth(leaves[k:])]
|
||||
return _subproof(m - k, leaves[k:], False) + [_mth(leaves[:k])]
|
||||
|
||||
|
||||
def consistency_proof(items, m):
|
||||
"""PROOF(m, D[n]): what shows the tree over the first m items is a prefix of the tree over all n
|
||||
(RFC 9162 §2.1.4.1), as hex hashes; empty when m is 0 or n."""
|
||||
n = len(items)
|
||||
if not 0 <= m <= n:
|
||||
raise ValueError("m outside the tree")
|
||||
if m == 0 or m == n:
|
||||
return []
|
||||
return [h.hex() for h in _subproof(m, [_leaf(jcs.dumpb(list(it))) for it in items], True)]
|
||||
|
||||
|
||||
def verify_consistency(root_m, root_n, m, n, proof):
|
||||
"""Whether `proof` shows the tree of size m with `root_m` is a prefix of the tree of size n with `root_n`
|
||||
(RFC 9162 §2.1.4.2). Never raises."""
|
||||
try:
|
||||
if m == 0:
|
||||
return not proof and root_m == EMPTY_ROOT
|
||||
if m == n:
|
||||
return not proof and root_m == root_n
|
||||
if not 0 < m < n or not proof:
|
||||
return False
|
||||
proof = [bytes.fromhex(p) for p in proof]
|
||||
fn, sn = m - 1, n - 1
|
||||
while fn & 1:
|
||||
fn >>= 1
|
||||
sn >>= 1
|
||||
if fn == 0:
|
||||
fr = sr = bytes.fromhex(root_m)
|
||||
else:
|
||||
fr = sr = proof[0]
|
||||
proof = proof[1:]
|
||||
for c in proof:
|
||||
if sn == 0:
|
||||
return False
|
||||
if fn & 1 or fn == sn:
|
||||
fr = _node(c, fr)
|
||||
sr = _node(c, sr)
|
||||
while fn & 1 == 0 and fn != 0:
|
||||
fn >>= 1
|
||||
sn >>= 1
|
||||
else:
|
||||
sr = _node(sr, c)
|
||||
fn >>= 1
|
||||
sn >>= 1
|
||||
return sn == 0 and fr.hex() == root_m and sr.hex() == root_n
|
||||
except (ValueError, TypeError):
|
||||
return False
|
||||
|
||||
|
||||
# --- Ed25519 (RFC 8032) -------------------------------------------------------
|
||||
|
||||
|
||||
|
||||
+3
-1
@@ -128,6 +128,7 @@ def load(conn, root, asset_store):
|
||||
conn.execute("INSERT OR IGNORE INTO works VALUES (?, ?)", (d["work"]["key"], J(d["work"]["same_as"])))
|
||||
ins(conn, "INTO documents", (key, d.get("store"), J(d.get("aliases")), d["kind"], J(d["work"]), J(d["version"]), J(d["relations"]),
|
||||
J(d["meta"]), d["language"], J(d.get("license")), d["policy"], J(d["hashing"]), d["root"], d["meta_hash"],
|
||||
d["history"],
|
||||
d["rev"], J(d["counts"]), J(d["fidelity"]), J(prov), J(roster), package_hash))
|
||||
for i, w in enumerate(read_json(os.path.join(root, "witnesses.json"))["witnesses"]):
|
||||
ins(conn, "INTO witnesses", (key, w["id"], i, w["sha256"], w["size"], w["kind"], w["media_type"], w.get("pages"),
|
||||
@@ -199,7 +200,8 @@ def unload(conn, key, out, asset_store):
|
||||
"meta": P(d["meta"]), "language": d["language"]})
|
||||
_opt(doc, "license", P(d["license"]))
|
||||
doc.update({"policy": d["policy"], "hashing": P(d["hashing"]), "root": d["root_hash"],
|
||||
"meta_hash": d["meta_hash"], "rev": d["rev"], "counts": P(d["counts"]), "fidelity": P(d["fidelity"])})
|
||||
"meta_hash": d["meta_hash"], "history": d["history"], "rev": d["rev"], "counts": P(d["counts"]),
|
||||
"fidelity": P(d["fidelity"])})
|
||||
|
||||
def put(name, rec):
|
||||
files[name] = (jcs.dumps(rec) + "\n").encode("utf-8")
|
||||
|
||||
@@ -0,0 +1,161 @@
|
||||
"""Work and document keys: a Scholar-style citation key plus a short hash of the work's identity.
|
||||
|
||||
Non-normative; see spec/02-model.md §2.4.
|
||||
A work key looks like `vaswani2017attention-k3f9`. The first part is what Google Scholar would call the work; the
|
||||
characters after the hyphen come from a hash of the work's identity, so two works Scholar would name alike never
|
||||
share a key. A document key is the work key with a suffix for anything other than the version of record.
|
||||
"""
|
||||
|
||||
import hashlib
|
||||
import re
|
||||
import unicodedata
|
||||
|
||||
from .text import ALPHABET
|
||||
|
||||
SCHEME = "scienting-key/1"
|
||||
SUFFIX_LENGTH = 4
|
||||
MAX_SUFFIX = 51
|
||||
IDENTIFIER_ORDER = ("doi", "arxiv", "isbn", "pmid", "pmcid", "openalex")
|
||||
WORK_KEY_RE = re.compile(r"^[a-z][a-z0-9]*-[0-9a-hjkmnp-tv-z]{4,51}$")
|
||||
DOCUMENT_KEY_RE = re.compile(r"^[a-z][a-z0-9]*-[0-9a-hjkmnp-tv-z]{4,51}(\.[a-z0-9][a-z0-9-]*)*$")
|
||||
STOP_WORDS = frozenset("a an the of on in for to and or at by with from as into onto about toward towards via "
|
||||
"is are be its it this that these those".split())
|
||||
NAME_SUFFIXES = frozenset(["jr", "sr", "ii", "iii", "iv", "v", "phd", "md"])
|
||||
_YEAR = re.compile(r"^\d{4}$")
|
||||
_DOI_PREFIX = re.compile(r"^(?:doi:|https?://(?:dx\.|www\.)?doi\.org/)+", re.IGNORECASE)
|
||||
|
||||
|
||||
def fold(text):
|
||||
"""Lower-case ASCII with accents removed: `Müller` gives `muller`."""
|
||||
raw = text if isinstance(text, str) else ""
|
||||
return unicodedata.normalize("NFKD", raw).encode("ascii", "ignore").decode().lower()
|
||||
|
||||
|
||||
def title_words(title):
|
||||
"""A title's words, folded, with apostrophes removed and other punctuation splitting."""
|
||||
t = re.sub(r"['’`]", "", fold(title))
|
||||
return re.findall(r"[a-z0-9]+", t)
|
||||
|
||||
|
||||
def family_name(name):
|
||||
"""A best guess at the family name in a display name: the part before a comma, else the last word that is
|
||||
not a suffix such as `Jr.` or `III`."""
|
||||
name = (name or "").strip()
|
||||
if "," in name:
|
||||
head, _, tail = name.partition(",")
|
||||
if tail.strip().rstrip(".").lower() not in NAME_SUFFIXES or " " not in head.strip():
|
||||
return head.strip()
|
||||
name = head
|
||||
parts = name.split()
|
||||
while len(parts) > 1 and parts[-1].rstrip(".").lower() in NAME_SUFFIXES:
|
||||
parts.pop()
|
||||
return parts[-1] if parts else ""
|
||||
|
||||
|
||||
def first_family(meta):
|
||||
"""The first author's family name from a CSL-JSON item, as the key spells it, or `anon`."""
|
||||
authors = meta.get("author") or []
|
||||
if not authors:
|
||||
return "anon"
|
||||
a = authors[0]
|
||||
name = a.get("family") or family_name(a.get("literal") or a.get("name") or "")
|
||||
fam = re.sub(r"[^a-z]", "", fold(name))
|
||||
return fam or "anon"
|
||||
|
||||
|
||||
def year_of(meta):
|
||||
"""The year of a CSL-JSON item's `issued` date, when it is a four-digit year, else the empty string."""
|
||||
parts = (meta.get("issued") or {}).get("date-parts") or []
|
||||
y = parts[0][0] if parts and parts[0] else None
|
||||
s = str(y) if isinstance(y, int) and not isinstance(y, bool) else (y if isinstance(y, str) else "")
|
||||
return s if _YEAR.match(s) else ""
|
||||
|
||||
|
||||
def scholar_part(meta):
|
||||
"""Family name, year and the first title word that is not a stop word: `vaswani2017attention`."""
|
||||
word = next((w for w in title_words(meta.get("title")) if w not in STOP_WORDS), "")
|
||||
return first_family(meta) + year_of(meta) + word
|
||||
|
||||
|
||||
def norm_doi(value):
|
||||
"""A DOI in bare form with ASCII letters lower-cased (DOI Handbook 2.4)."""
|
||||
s = _DOI_PREFIX.sub("", value.strip()).strip()
|
||||
return s.translate(str.maketrans("ABCDEFGHIJKLMNOPQRSTUVWXYZ", "abcdefghijklmnopqrstuvwxyz"))
|
||||
|
||||
|
||||
def identity(same_as, meta):
|
||||
"""What the key's hash is computed over: the first identifier of IDENTIFIER_ORDER among `same_as`, else the
|
||||
folded metadata `meta:<title words>|<year>|<family>`."""
|
||||
by_kind = {}
|
||||
for s in same_as or []:
|
||||
kind, _, value = s.partition(":")
|
||||
by_kind.setdefault(kind, value)
|
||||
for kind in IDENTIFIER_ORDER:
|
||||
if kind in by_kind and by_kind[kind]:
|
||||
value = norm_doi(by_kind[kind]) if kind == "doi" else by_kind[kind].strip()
|
||||
return "%s:%s" % (kind, value)
|
||||
return "meta:%s|%s|%s" % (" ".join(title_words(meta.get("title"))), year_of(meta), first_family(meta))
|
||||
|
||||
|
||||
def suffix(ident, length=SUFFIX_LENGTH):
|
||||
"""The leading 5 × `length` bits of SHA-256 of `scienting-key/1:` and the identity, in Crockford base32."""
|
||||
if not 1 <= length <= MAX_SUFFIX:
|
||||
raise ValueError("a key suffix is 1 to %d characters" % MAX_SUFFIX)
|
||||
digest = int.from_bytes(hashlib.sha256((SCHEME + ":" + ident).encode("utf-8")).digest(), "big")
|
||||
bits = digest >> (256 - 5 * length)
|
||||
return "".join(ALPHABET[(bits >> (5 * (length - 1 - i))) & 31] for i in range(length))
|
||||
|
||||
|
||||
def work_key(same_as, meta, length=SUFFIX_LENGTH):
|
||||
return scholar_part(meta) + "-" + suffix(identity(same_as, meta), length)
|
||||
|
||||
|
||||
def unique_work_key(same_as, meta, taken):
|
||||
"""The work key, lengthened by two characters at a time while another identity already holds it."""
|
||||
for length in range(SUFFIX_LENGTH, MAX_SUFFIX + 1, 2):
|
||||
key = work_key(same_as, meta, length)
|
||||
if key not in taken:
|
||||
return key
|
||||
raise ValueError("no free key for " + identity(same_as, meta))
|
||||
|
||||
|
||||
def version_suffix(version):
|
||||
"""The document key's suffix for a version: nothing for the version of record, `.si` for Supporting
|
||||
Information, `.v2` for a preprint or draft with a sequence number, else the version type."""
|
||||
t = version.get("type")
|
||||
if t == "version-of-record":
|
||||
return ""
|
||||
if t == "supplement":
|
||||
return ".si"
|
||||
seq = version.get("seq")
|
||||
if t in ("preprint", "draft", "submitted", "accepted") and isinstance(seq, int):
|
||||
return ".v%d" % seq
|
||||
return "." + t
|
||||
|
||||
|
||||
def document_key(wkey, version):
|
||||
return wkey + version_suffix(version)
|
||||
|
||||
|
||||
def split_key(dkey):
|
||||
"""A document key's work key and its suffix (with the leading dot, or empty)."""
|
||||
m = WORK_KEY_RE.match(dkey.split(".", 1)[0]) if dkey else None
|
||||
if not m or not DOCUMENT_KEY_RE.match(dkey):
|
||||
raise ValueError("%r is not a document key" % dkey)
|
||||
head, dot, tail = dkey.partition(".")
|
||||
return head, (dot + tail if dot else "")
|
||||
|
||||
|
||||
def suffix_of(wkey):
|
||||
return wkey.rsplit("-", 1)[1]
|
||||
|
||||
|
||||
def suffix_agrees(wkey, same_as, meta):
|
||||
"""Whether a stored work key's hash part is what the identity gives: always checked when the identity is an
|
||||
identifier such as a DOI, which never changes; a metadata identity may have been corrected since, so it
|
||||
is not held to the key."""
|
||||
ident = identity(same_as, meta)
|
||||
if ident.startswith("meta:"):
|
||||
return True
|
||||
sfx = suffix_of(wkey)
|
||||
return sfx == suffix(ident, len(sfx))
|
||||
@@ -0,0 +1,106 @@
|
||||
"""A team's transparency log of receipts, revocations and roster changes, and its signed checkpoints.
|
||||
|
||||
Non-normative; see spec/23-log.md.
|
||||
Entries are the items of an RFC 9162 tree (the same tree as receipts and the correction history), and a
|
||||
checkpoint is a signed note in the format transparency logs share (C2SP `signed-note` and `tlog-checkpoint`):
|
||||
an origin line, the tree size, the root hash in Base64, an empty line, and one signature line per signer.
|
||||
"""
|
||||
|
||||
import base64
|
||||
import hashlib
|
||||
|
||||
from . import crypto, jcs
|
||||
|
||||
ALGORITHM_ED25519 = 0x01
|
||||
FORMAT = "scienting-checkpoint/1"
|
||||
|
||||
|
||||
def entry(record):
|
||||
"""The log item for a signed record: its kind, its ID and its record hash."""
|
||||
kinds = {"scienting-receipt/1": "receipt", "scienting-revocation/1": "revocation", "scienting-roster/1": "roster"}
|
||||
kind = kinds.get(record.get("format"))
|
||||
if kind is None:
|
||||
raise ValueError("only receipts, revocations and rosters are logged")
|
||||
if kind == "roster":
|
||||
return ["roster", record.get("store", ""), hashlib.sha256(jcs.dumpb(record)).hexdigest()]
|
||||
body = {k: v for k, v in record.items() if k not in ("id", "signature")}
|
||||
return [kind, record["id"], hashlib.sha256(jcs.dumpb(body)).hexdigest()]
|
||||
|
||||
|
||||
def root(entries):
|
||||
return crypto.history_root(entries)
|
||||
|
||||
|
||||
def key_hash(name, public):
|
||||
"""The four-byte key ID of a signed-note signer: SHA-256 of the name, a newline, the algorithm byte and the
|
||||
public key, truncated."""
|
||||
return hashlib.sha256(name.encode("utf-8") + b"\n" + bytes([ALGORITHM_ED25519]) + public).digest()[:4]
|
||||
|
||||
|
||||
def _b64(text):
|
||||
"""Canonical standard Base64 only: the bytes, or None when the text is not exactly their encoding."""
|
||||
try:
|
||||
raw = base64.b64decode(text, validate=True)
|
||||
except (ValueError, TypeError):
|
||||
return None
|
||||
return raw if base64.b64encode(raw).decode("ascii") == text else None
|
||||
|
||||
|
||||
def note_body(origin, size, root_hex):
|
||||
return "%s\n%d\n%s\n" % (origin, size, base64.b64encode(bytes.fromhex(root_hex)).decode("ascii"))
|
||||
|
||||
|
||||
def sign_checkpoint(origin, entries, signers):
|
||||
"""A checkpoint note over `entries`, signed by each (name, secret) in `signers`."""
|
||||
body = note_body(origin, len(entries), root(entries))
|
||||
lines = []
|
||||
for name, secret in signers:
|
||||
public = crypto.ed25519_public(secret)
|
||||
sig = crypto.ed25519_sign(secret, body.encode("utf-8"))
|
||||
lines.append("— %s %s" % (name, base64.b64encode(key_hash(name, public) + sig).decode("ascii")))
|
||||
return body + "\n" + "".join(ln + "\n" for ln in lines)
|
||||
|
||||
|
||||
def parse_checkpoint(text):
|
||||
"""The origin, size, root (hex) and [(name, key hash, signature)] of a note, or None when malformed."""
|
||||
try:
|
||||
body, _, sigs = text.partition("\n\n")
|
||||
origin, size, root_b64 = body.split("\n")
|
||||
rt = _b64(root_b64)
|
||||
if rt is None or len(rt) != 32 or not size.isdigit() or str(int(size)) != size or not origin:
|
||||
return None
|
||||
out = []
|
||||
for ln in sigs.splitlines():
|
||||
if not ln.startswith("— "):
|
||||
return None
|
||||
_, name, blob = ln.split(" ", 2)
|
||||
raw = _b64(blob)
|
||||
if raw is None or len(raw) != 68:
|
||||
return None
|
||||
out.append((name, raw[:4], raw[4:]))
|
||||
return {"origin": origin, "size": int(size), "root": rt.hex(), "signatures": out}
|
||||
except (ValueError, TypeError):
|
||||
return None
|
||||
|
||||
|
||||
def verify_checkpoint(text, roster):
|
||||
"""The signers of the note whose Ed25519 signatures verify under the roster's keys of those names.
|
||||
|
||||
Returns (parsed note, [names verified]); the note is None when malformed. Never raises.
|
||||
"""
|
||||
note = parse_checkpoint(text)
|
||||
if note is None:
|
||||
return None, []
|
||||
body = note_body(note["origin"], note["size"], note["root"]).encode("utf-8")
|
||||
verified = []
|
||||
for name, kh, sig in note["signatures"]:
|
||||
key = roster.get(name)
|
||||
if not key or key.get("scheme") != "ed25519":
|
||||
continue
|
||||
try:
|
||||
public = base64.b64decode(key["public"], validate=True)
|
||||
except (ValueError, TypeError):
|
||||
continue
|
||||
if key_hash(name, public) == kh and crypto.ed25519_verify(public, body, sig):
|
||||
verified.append(name)
|
||||
return note, verified
|
||||
+24
-4
@@ -15,7 +15,7 @@ import tempfile
|
||||
import unicodedata
|
||||
import zipfile
|
||||
|
||||
from . import crypto, hashing, jcs, receipts as R
|
||||
from . import crypto, hashing, jcs, keys as K, receipts as R, vrf
|
||||
from .anchors import ADDRESS_RE
|
||||
from .model import LEAVES, MATH_KINDS, RUN_KINDS, VERBATIM_KINDS, Doc
|
||||
from .schema import validate as schema_validate
|
||||
@@ -259,6 +259,16 @@ def _validate(root, store, key, rep, limit=SIZE_LIMIT):
|
||||
document = data["document.json"]
|
||||
if document["key"] != manifest["key"]:
|
||||
rep.add("package.manifest.key", "MANIFEST.json", "key differs from document.json")
|
||||
# Keys (spec/02 §2.4): the document key is the work key and a version suffix, and the work key's hash part is
|
||||
# the hash of the work's identifier when it has one.
|
||||
try:
|
||||
wkey, sfx = K.split_key(document["key"])
|
||||
except ValueError:
|
||||
wkey, sfx = None, None
|
||||
if wkey != document["work"]["key"] or sfx != K.version_suffix(document["version"]):
|
||||
rep.add("doc.key.work", "document.json", "the document key is not the work key with the version's suffix")
|
||||
elif not K.suffix_agrees(wkey, document["work"].get("same_as"), document.get("meta") or {}):
|
||||
rep.add("doc.key.suffix", "document.json", "the work key's hash part is not the hash of the work's identifier")
|
||||
nodes = data["nodes.jsonl"]
|
||||
doc = Doc(document["key"], nodes)
|
||||
try:
|
||||
@@ -338,6 +348,8 @@ def _validate(root, store, key, rep, limit=SIZE_LIMIT):
|
||||
rep.add("doc.policy.withheld", "corrections.jsonl", "a checks-only package carries no corrections or suspects")
|
||||
if document["policy"] == "full" and document["rev"] != len(cors):
|
||||
rep.add("doc.correction.rev", "document.json", "rev must equal the number of corrections")
|
||||
if document.get("history") != crypto.history_root(history_items(cors)):
|
||||
rep.add("doc.history", "document.json", "history is not the root over the corrections")
|
||||
retired_by = {e["id"]: e for e in ids if e["kind"] == "retired"}
|
||||
for c in cors:
|
||||
for r in c.get("retired", []):
|
||||
@@ -432,14 +444,17 @@ def _validate(root, store, key, rep, limit=SIZE_LIMIT):
|
||||
except (binascii.Error, ValueError):
|
||||
rep.add("doc.qa.seed", r["id"], "the seed is not Base64")
|
||||
continue
|
||||
if vrf.proof_to_hash(seed) is None:
|
||||
rep.add("doc.qa.seed", r["id"], "the seed is not a VRF proof")
|
||||
continue
|
||||
ck = roster.get(qa["key"])
|
||||
if roster:
|
||||
if not ck or ck["scheme"] != "ed25519" or "spot-check" not in ck["purposes"]:
|
||||
rep.add("doc.qa.key", r["id"], "the seed key is not a registered Ed25519 spot-check key")
|
||||
elif not ck["registered_at"] <= orig["at"]:
|
||||
rep.add("doc.qa.key", r["id"], "the spot-check key was registered after the receipt")
|
||||
elif not crypto.ed25519_verify(base64.b64decode(ck["public"]), R.seed_payload(orig), seed):
|
||||
rep.add("doc.qa.seed", r["id"], "the seed is not the checker's signature over the receipt")
|
||||
elif vrf.verify(R._b64(ck["public"]) or b"", R.seed_payload(orig), seed) is None:
|
||||
rep.add("doc.qa.seed", r["id"], "the seed is not the checker's VRF proof for the receipt")
|
||||
if qa["sample"] != R.sample(orig, seed, qa["rate"], qa["minimum"]):
|
||||
rep.add("doc.qa.sample", r["id"], "the sample does not follow from the seed")
|
||||
if not withheld and (roster or not recs):
|
||||
@@ -467,6 +482,11 @@ def n_root_hash(nodes, doc):
|
||||
return {n["id"]: n for n in nodes}[doc.root]["node_hash"]
|
||||
|
||||
|
||||
def history_items(corrections):
|
||||
"""The items of a document's history tree (spec/11 §11.5): one per correction, in order."""
|
||||
return [["correction", c["index"], c["id"]] for c in corrections]
|
||||
|
||||
|
||||
# --- zipped packages (spec/12 §12.2 and §12.8) -----------------------------------------
|
||||
|
||||
ZIP_EXTENSION = ".sct.zip"
|
||||
@@ -517,7 +537,7 @@ def _zip_entries(zf, limit):
|
||||
if zi.flag_bits & _FLAG_ENCRYPTED:
|
||||
raise Refusal("package.zip.entry", "%s: encrypted entries are refused" % name)
|
||||
top, sep, rest = name.partition("/")
|
||||
if not sep or not KEY_RE.match(top):
|
||||
if not sep or not K.DOCUMENT_KEY_RE.match(top):
|
||||
raise Refusal("package.zip.entry", "%s: every entry sits under one folder named by the key" % name)
|
||||
if folder is None:
|
||||
folder = top
|
||||
|
||||
@@ -10,7 +10,7 @@ import math
|
||||
from fractions import Fraction
|
||||
from math import comb
|
||||
|
||||
from . import crypto, jcs
|
||||
from . import crypto, jcs, vrf
|
||||
from .model import LEVELS
|
||||
from .text import record_id, sha256_hex
|
||||
|
||||
@@ -76,7 +76,8 @@ def seed_payload(receipt):
|
||||
|
||||
|
||||
def make_seed(receipt, checker_secret):
|
||||
return crypto.ed25519_sign(checker_secret, seed_payload(receipt))
|
||||
"""The seed: the checker's VRF proof (RFC 9381, ECVRF-EDWARDS25519-SHA512-TAI) for the receipt's payload."""
|
||||
return vrf.prove(checker_secret, seed_payload(receipt))
|
||||
|
||||
|
||||
def sample_size(n, rate="0.05", minimum=10):
|
||||
@@ -84,9 +85,13 @@ def sample_size(n, rate="0.05", minimum=10):
|
||||
|
||||
|
||||
def sample(receipt, seed, rate="0.05", minimum=10):
|
||||
"""The sample a seed selects: ranked by SHA-256 of the proof's VRF output followed by the node ID."""
|
||||
beta = vrf.proof_to_hash(seed)
|
||||
if beta is None:
|
||||
raise ValueError("the seed is not a VRF proof")
|
||||
ids = [nid for nid, _ in receipt["nodes"]]
|
||||
m = sample_size(len(ids), rate, minimum)
|
||||
ranked = sorted(ids, key=lambda nid: (hashlib.sha256(seed + nid.encode("utf-8")).hexdigest(), nid))
|
||||
ranked = sorted(ids, key=lambda nid: (hashlib.sha256(beta + nid.encode("utf-8")).hexdigest(), nid))
|
||||
return sorted(ranked[:m])
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,179 @@
|
||||
"""ECVRF-EDWARDS25519-SHA512-TAI, the verifiable random function of RFC 9381 over the Ed25519 curve.
|
||||
|
||||
Non-normative; see spec/08-fidelity.md §8.8.
|
||||
A VRF proof has exactly one valid value for a key and an input, where an Ed25519 signature has many, so a
|
||||
spot-check seed that is a VRF proof cannot be ground by the checker. The arithmetic is the reference form of
|
||||
RFC 8032 in plain Python, slow and clear; the keys are ordinary Ed25519 keys, so the roster needs nothing new.
|
||||
The RFC's Example 16 is pinned in vectors/vrf.jsonl.
|
||||
"""
|
||||
|
||||
import hashlib
|
||||
|
||||
SUITE = b"\x03"
|
||||
COFACTOR = 8
|
||||
P = 2**255 - 19
|
||||
Q = 2**252 + 27742317777372353535851937790883648493
|
||||
D = (-121665 * pow(121666, P - 2, P)) % P
|
||||
_I = pow(2, (P - 1) // 4, P)
|
||||
|
||||
|
||||
def _inv(x):
|
||||
return pow(x, P - 2, P)
|
||||
|
||||
|
||||
def _recover_x(y, sign):
|
||||
if y >= P:
|
||||
return None
|
||||
x2 = (y * y - 1) * _inv(D * y * y + 1) % P
|
||||
if x2 == 0:
|
||||
return None if sign else 0
|
||||
x = pow(x2, (P + 3) // 8, P)
|
||||
if (x * x - x2) % P != 0:
|
||||
x = x * _I % P
|
||||
if (x * x - x2) % P != 0:
|
||||
return None
|
||||
if (x & 1) != sign:
|
||||
x = P - x
|
||||
return x
|
||||
|
||||
|
||||
# Points in extended homogeneous coordinates (X, Y, Z, T) with x = X/Z, y = Y/Z, x*y = T/Z (RFC 8032 §5.1.4).
|
||||
G_Y = 4 * _inv(5) % P
|
||||
G_X = _recover_x(G_Y, 0)
|
||||
G = (G_X, G_Y, 1, G_X * G_Y % P)
|
||||
IDENTITY = (0, 1, 1, 0)
|
||||
|
||||
|
||||
def _add(a, b):
|
||||
x1, y1, z1, t1 = a
|
||||
x2, y2, z2, t2 = b
|
||||
A = (y1 - x1) * (y2 - x2) % P
|
||||
B = (y1 + x1) * (y2 + x2) % P
|
||||
C = t1 * 2 * D * t2 % P
|
||||
Dd = z1 * 2 * z2 % P
|
||||
E, F, Gg, H = B - A, Dd - C, Dd + C, B + A
|
||||
return (E * F % P, Gg * H % P, F * Gg % P, E * H % P)
|
||||
|
||||
|
||||
def _mul(s, pt):
|
||||
out, acc = IDENTITY, pt
|
||||
while s > 0:
|
||||
if s & 1:
|
||||
out = _add(out, acc)
|
||||
acc = _add(acc, acc)
|
||||
s >>= 1
|
||||
return out
|
||||
|
||||
|
||||
def _equal(a, b):
|
||||
return (a[0] * b[2] - b[0] * a[2]) % P == 0 and (a[1] * b[2] - b[1] * a[2]) % P == 0
|
||||
|
||||
|
||||
def point_to_string(pt):
|
||||
z = _inv(pt[2])
|
||||
x, y = pt[0] * z % P, pt[1] * z % P
|
||||
return (y | ((x & 1) << 255)).to_bytes(32, "little")
|
||||
|
||||
|
||||
def string_to_point(s):
|
||||
"""The point a 32-byte string encodes, or None when it is not a valid encoding (RFC 8032 §5.1.3)."""
|
||||
if len(s) != 32:
|
||||
return None
|
||||
y = int.from_bytes(s, "little")
|
||||
sign = y >> 255
|
||||
y &= (1 << 255) - 1
|
||||
x = _recover_x(y, sign)
|
||||
if x is None:
|
||||
return None
|
||||
return (x, y, 1, x * y % P)
|
||||
|
||||
|
||||
def _secret_scalar(sk):
|
||||
h = hashlib.sha512(sk).digest()
|
||||
a = bytearray(h[:32])
|
||||
a[0] &= 248
|
||||
a[31] &= 63
|
||||
a[31] |= 64
|
||||
return int.from_bytes(a, "little"), h[32:]
|
||||
|
||||
|
||||
def public_key(sk):
|
||||
x, _ = _secret_scalar(sk)
|
||||
return point_to_string(_mul(x, G))
|
||||
|
||||
|
||||
def encode_to_curve(pk_string, alpha):
|
||||
"""ECVRF_encode_to_curve_try_and_increment (RFC 9381 §5.4.1.1)."""
|
||||
for ctr in range(256):
|
||||
h = hashlib.sha512(SUITE + b"\x01" + pk_string + alpha + bytes([ctr]) + b"\x00").digest()
|
||||
pt = string_to_point(h[:32])
|
||||
if pt is not None:
|
||||
pt = _mul(COFACTOR, pt)
|
||||
if not _equal(pt, IDENTITY):
|
||||
return pt
|
||||
raise ValueError("no curve point found")
|
||||
|
||||
|
||||
def _challenge(points):
|
||||
"""ECVRF_challenge_generation (RFC 9381 §5.4.3): the first 16 octets of the hash, as a little-endian integer."""
|
||||
s = SUITE + b"\x02" + b"".join(point_to_string(p) for p in points) + b"\x00"
|
||||
return int.from_bytes(hashlib.sha512(s).digest()[:16], "little")
|
||||
|
||||
|
||||
def prove(sk, alpha):
|
||||
"""ECVRF_prove: the 80-byte proof Gamma || c || s for the 32-byte Ed25519 secret `sk` and input `alpha`."""
|
||||
x, suffix = _secret_scalar(sk)
|
||||
y = _mul(x, G)
|
||||
h = encode_to_curve(point_to_string(y), alpha)
|
||||
h_string = point_to_string(h)
|
||||
gamma = _mul(x, h)
|
||||
k = int.from_bytes(hashlib.sha512(suffix + h_string).digest(), "little") % Q
|
||||
c = _challenge([y, h, gamma, _mul(k, G), _mul(k, h)])
|
||||
s = (k + c * x) % Q
|
||||
return point_to_string(gamma) + c.to_bytes(16, "little") + s.to_bytes(32, "little")
|
||||
|
||||
|
||||
def decode_proof(pi):
|
||||
if len(pi) != 80:
|
||||
return None
|
||||
gamma = string_to_point(pi[:32])
|
||||
if gamma is None:
|
||||
return None
|
||||
c = int.from_bytes(pi[32:48], "little")
|
||||
s = int.from_bytes(pi[48:80], "little")
|
||||
if s >= Q:
|
||||
return None
|
||||
return gamma, c, s
|
||||
|
||||
|
||||
def proof_to_hash(pi):
|
||||
"""ECVRF_proof_to_hash: the 64-byte output beta of a proof (RFC 9381 §5.2), or None for a malformed proof."""
|
||||
d = decode_proof(pi)
|
||||
if d is None:
|
||||
return None
|
||||
gamma = d[0]
|
||||
return hashlib.sha512(SUITE + b"\x03" + point_to_string(_mul(COFACTOR, gamma)) + b"\x00").digest()
|
||||
|
||||
|
||||
def _neg(pt):
|
||||
return ((-pt[0]) % P, pt[1], pt[2], (-pt[3]) % P)
|
||||
|
||||
|
||||
def verify(pk_string, alpha, pi):
|
||||
"""ECVRF_verify: beta when `pi` is the proof for `alpha` under the public key, else None. Never raises."""
|
||||
try:
|
||||
y = string_to_point(pk_string)
|
||||
if y is None or _equal(_mul(COFACTOR, y), IDENTITY):
|
||||
return None
|
||||
d = decode_proof(pi)
|
||||
if d is None:
|
||||
return None
|
||||
gamma, c, s = d
|
||||
h = encode_to_curve(pk_string, alpha)
|
||||
u = _add(_mul(s, G), _neg(_mul(c, y)))
|
||||
v = _add(_mul(s, h), _neg(_mul(c, gamma)))
|
||||
if _challenge([y, h, gamma, u, v]) != c:
|
||||
return None
|
||||
return proof_to_hash(pi)
|
||||
except (ValueError, TypeError):
|
||||
return None
|
||||
+101
-9
@@ -34,13 +34,14 @@ from tools.oracle import align as AL # noqa: E402
|
||||
from tools.oracle import anchors as AN # noqa: E402
|
||||
from tools.oracle import corrections as CO # noqa: E402
|
||||
from tools.oracle import crypto, fingerprint as FP, hashing, jcs, receipts as RC, rights as RI, targets as TG # noqa: E402
|
||||
from tools.oracle import keys as K, log as LG, vrf # noqa: E402
|
||||
from tools.oracle import package as PK # noqa: E402
|
||||
from tools.oracle.model import LABELLED, LEAVES, Doc # noqa: E402
|
||||
from tools.oracle.notation import fold_math # noqa: E402
|
||||
from tools.oracle.text import NODE_ID_RE, RECORD_ID_RE, letter_first, norm, record_id, sha256_hex, is_ws # noqa: E402
|
||||
from tools.oracle.tree import Refusal, validate_structure # noqa: E402
|
||||
from tools.build import ROSTER_BY_ID, SCHEMAS, secret, sentence_doc, tiny_package # noqa: E402
|
||||
from tools.check import STORE, reference_values # noqa: E402
|
||||
from tools.check import HALE, STORE, TINY_KEY, reference_values # noqa: E402
|
||||
|
||||
FAILS = []
|
||||
COUNTS = {}
|
||||
@@ -326,10 +327,15 @@ def stress_spotcheck(rng, rounds):
|
||||
for _ in range(rounds):
|
||||
n = rng.randint(1, 60)
|
||||
ids = sorted({letter_first(os.urandom(32), 7) for _ in range(n)})
|
||||
receipt = {"nodes": [[i, sha256_hex(i)] for i in ids]}
|
||||
seed = os.urandom(64)
|
||||
receipt = {"format": "scienting-receipt/1", "nodes": [[i, sha256_hex(i)] for i in ids]}
|
||||
seed = RC.make_seed(receipt, os.urandom(32))
|
||||
rate, minimum = rng.choice(["0.05", "0.1", "0.5", "1"]), rng.randint(1, 15)
|
||||
sample = RC.sample(receipt, seed, rate, minimum)
|
||||
try:
|
||||
RC.sample(receipt, os.urandom(64), rate, minimum)
|
||||
ok("spot-check", False, "random bytes were accepted as a seed")
|
||||
except ValueError:
|
||||
pass
|
||||
size = RC.sample_size(len(ids), rate, minimum)
|
||||
ok("spot-check", len(sample) == size == min(len(ids), max(minimum, -(-Fraction(rate) * len(ids) // 1))),
|
||||
"sample size %d is not the rule's for n=%d" % (len(sample), len(ids)))
|
||||
@@ -384,7 +390,7 @@ def stress_fingerprints(rng, rounds):
|
||||
|
||||
|
||||
def stress_zip(rng, rounds):
|
||||
src = os.path.join(ROOT, "fixtures", "packages", "tiny2026example" + PK.ZIP_EXTENSION)
|
||||
src = os.path.join(ROOT, "fixtures", "packages", TINY_KEY + PK.ZIP_EXTENSION)
|
||||
with open(src, "rb") as f:
|
||||
good = f.read()
|
||||
accepted = 0
|
||||
@@ -421,7 +427,7 @@ def stress_zip(rng, rounds):
|
||||
|
||||
|
||||
def stress_packages(rng, rounds):
|
||||
src = os.path.join(ROOT, "fixtures", "packages", "tiny2026example")
|
||||
src = os.path.join(ROOT, "fixtures", "packages", TINY_KEY)
|
||||
files = sorted(os.path.relpath(os.path.join(d, f), src) for d, _, fs in os.walk(src) for f in fs)
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
for i in range(rounds):
|
||||
@@ -511,7 +517,7 @@ def stress_alignment(rng, rounds):
|
||||
def stress_addresses(rng, rounds):
|
||||
for _ in range(rounds):
|
||||
store = rng.choice([None, "smithlab", "a1", "x.y-z"])
|
||||
key = rng.choice(["hale2026harmonic", "k", "a.b_c-d"])
|
||||
key = rng.choice([HALE, "k-0000", "a1-aaaa.si", "x-k3f9qd.v2"])
|
||||
node = letter_first(os.urandom(32), 7)
|
||||
pin = sha256_hex(rand_text(rng))[:6] if rng.random() < 0.5 else None
|
||||
span = sorted([rng.randint(0, 50), rng.randint(0, 50)]) if rng.random() < 0.5 else None
|
||||
@@ -530,7 +536,7 @@ def stress_addresses(rng, rounds):
|
||||
|
||||
|
||||
def stress_targets(rng, rounds):
|
||||
st = PK.read_state(os.path.join(ROOT, "fixtures", "packages", "hale2026harmonic"))
|
||||
st = PK.read_state(os.path.join(ROOT, "fixtures", "packages", HALE))
|
||||
doc = st.doc()
|
||||
for _ in range(rounds // 4):
|
||||
anchored = rng.sample(doc.order, rng.randint(0, 6))
|
||||
@@ -554,7 +560,7 @@ def stress_receipt_chains(rng, rounds):
|
||||
"""Chains of spot-checks hundreds deep settle without recursion, and alternate as spec/08 §8.6 implies."""
|
||||
depth = max(50, rounds)
|
||||
ids = sorted({letter_first(os.urandom(32), 7) for _ in range(20)})
|
||||
rec = RC.seal({"format": "scienting-receipt/1", "doc": "hale2026harmonic", "rev": 0, "level": "F3", "scope": "all",
|
||||
rec = RC.seal({"format": "scienting-receipt/1", "doc": HALE, "rev": 0, "level": "F3", "scope": "all",
|
||||
"nodes": [[i, sha256_hex(i)] for i in ids], "evidence": [], "by": "ana",
|
||||
"at": "2026-10-11T12:00:00Z"}, secret("ana"), "ana")
|
||||
records = [rec]
|
||||
@@ -563,7 +569,7 @@ def stress_receipt_chains(rng, rounds):
|
||||
seed = RC.make_seed(orig, secret("ben-spot"))
|
||||
sample = RC.sample(orig, seed)
|
||||
hashes = dict(orig["nodes"])
|
||||
q = RC.seal({"format": "scienting-receipt/1", "doc": "hale2026harmonic", "rev": 0, "level": "F3",
|
||||
q = RC.seal({"format": "scienting-receipt/1", "doc": HALE, "rev": 0, "level": "F3",
|
||||
"scope": "qa:" + orig["id"], "nodes": [[n, hashes[n]] for n in sample], "evidence": [],
|
||||
"by": "ben", "at": "2026-10-11T12:%02d:%02dZ" % (i // 60 % 60, i % 60),
|
||||
"qa": {"of": orig["id"], "key": "ben-spot", "seed": __import__("base64").b64encode(seed).decode(),
|
||||
@@ -584,6 +590,88 @@ def stress_receipt_chains(rng, rounds):
|
||||
want = "suspended" if want == "valid" else "valid"
|
||||
|
||||
|
||||
def stress_keys(rng, rounds):
|
||||
"""Keys (spec/02 §2.4): the pattern always holds, the suffix is a prefix of longer ones, lengthening settles a
|
||||
collision without touching the first key, and a key's hash part depends on the identity alone."""
|
||||
words = ["Attention", "On", "the", "Origin", "of", "Species", "H\u2082O", "3D", "Self-consistent", "\u00c9tude", "\u5f20", "don't", "a"]
|
||||
for _ in range(rounds):
|
||||
meta = {"title": " ".join(rng.choice(words) for _ in range(rng.randint(0, 6))),
|
||||
"author": [{"family": rng.choice(["M\u00fcller", "O'Brien", "\u5f20\u4f1f", "", "de la Cruz", "Smith Jr."])}] if rng.random() < 0.8 else [],
|
||||
"issued": {"date-parts": [[rng.choice([2017, 1859, 99, 20261, None])]]} if rng.random() < 0.8 else {}}
|
||||
same_as = [rng.choice(["doi:10.1/X.Y", "doi:10.1/x.y", "arxiv:1706.03762", "url:https://e.org/p", "openalex:W1"])] if rng.random() < 0.6 else []
|
||||
key = K.work_key(same_as, meta)
|
||||
ok("keys", K.WORK_KEY_RE.match(key) and K.DOCUMENT_KEY_RE.match(key), "a minted key breaks the pattern: %r" % key)
|
||||
ident = K.identity(same_as, meta)
|
||||
ok("keys", K.suffix(ident, 8).startswith(K.suffix(ident)), "a longer suffix does not extend the shorter")
|
||||
longer = K.unique_work_key(same_as, meta, {key})
|
||||
ok("keys", longer != key and longer.startswith(key) and len(longer) == len(key) + 2, "lengthening does not extend the key")
|
||||
if same_as and same_as[0].lower().startswith("doi:"):
|
||||
other = dict(meta, title="Another title entirely")
|
||||
ok("keys", K.suffix_of(K.work_key(same_as, other)) == K.suffix_of(key), "the hash part depends on more than the identity")
|
||||
for v in ({"type": "version-of-record"}, {"type": "supplement"}, {"type": "preprint", "seq": rng.randint(1, 9)}, {"type": "erratum"}):
|
||||
dk = K.document_key(key, v)
|
||||
ok("keys", K.DOCUMENT_KEY_RE.match(dk) and K.split_key(dk)[0] == key, "a document key does not split back")
|
||||
|
||||
|
||||
def stress_vrf(rng, rounds):
|
||||
"""The VRF proves and verifies for random keys and inputs, and a single flipped byte never verifies."""
|
||||
for _ in range(max(5, rounds // 20)):
|
||||
sk = os.urandom(32)
|
||||
pk = vrf.public_key(sk)
|
||||
alpha = os.urandom(rng.randint(0, 40))
|
||||
pi = vrf.prove(sk, alpha)
|
||||
beta = vrf.verify(pk, alpha, pi)
|
||||
ok("vrf", beta is not None and beta == vrf.proof_to_hash(pi) and len(pi) == 80 and len(beta) == 64, "a proof does not verify")
|
||||
ok("vrf", vrf.prove(sk, alpha) == pi, "proving is not deterministic")
|
||||
bad = bytearray(pi)
|
||||
bad[rng.randrange(80)] ^= 1 << rng.randrange(8)
|
||||
ok("vrf", vrf.verify(pk, alpha, bytes(bad)) is None, "a tampered proof verifies")
|
||||
ok("vrf", vrf.verify(pk, alpha + b"x", pi) is None and vrf.verify(vrf.public_key(os.urandom(32)), alpha, pi) is None,
|
||||
"a proof verifies for another input or key")
|
||||
ok("vrf", vrf.verify(pk, alpha, os.urandom(80)) is None and vrf.verify(os.urandom(32), alpha, pi) is None, "garbage verifies")
|
||||
|
||||
|
||||
def stress_proofs(rng, rounds):
|
||||
"""RFC 9162 inclusion and consistency proofs verify for every index and prefix of random trees, and fail for a
|
||||
wrong index, root or size."""
|
||||
for _ in range(rounds // 4):
|
||||
n = rng.randint(1, 40)
|
||||
items = [["node", letter_first(os.urandom(32), 7), sha256_hex(os.urandom(4))] for _ in range(n)]
|
||||
root = crypto.history_root(items)
|
||||
for i in range(n):
|
||||
path = crypto.inclusion_path(items, i)
|
||||
leaf = crypto.leaf_hash(items[i])
|
||||
ok("proofs", crypto.verify_inclusion(root, leaf, i, n, path), "inclusion fails for %d of %d" % (i, n))
|
||||
ok("proofs", n == 1 or not crypto.verify_inclusion(root, leaf, (i + 1) % n, n, path), "inclusion passes for another index")
|
||||
ok("proofs", not crypto.verify_inclusion(sha256_hex(b"x"), leaf, i, n, path), "inclusion passes for another root")
|
||||
# A wrong size is not always detectable from a path: RFC 9162 commits to the content through the
|
||||
# root, and two sizes with one tree shape verify alike. The root is what a verifier must trust.
|
||||
for m in range(n + 1):
|
||||
proof = crypto.consistency_proof(items, m)
|
||||
ok("proofs", crypto.verify_consistency(crypto.history_root(items[:m]), root, m, n, proof), "consistency fails %d to %d" % (m, n))
|
||||
if 0 < m < n:
|
||||
ok("proofs", not crypto.verify_consistency(sha256_hex(b"x"), root, m, n, proof)
|
||||
and not crypto.verify_consistency(crypto.history_root(items[:m]), sha256_hex(b"y"), m, n, proof),
|
||||
"a consistency proof passes with a wrong root")
|
||||
ok("proofs", not crypto.verify_consistency(crypto.history_root(items[:1]), root, 1, n, ["zz"]), "garbage path verifies")
|
||||
|
||||
|
||||
def stress_checkpoints(rng, rounds):
|
||||
"""Checkpoint notes parse back to what was signed and reject edits anywhere."""
|
||||
for _ in range(rounds // 10):
|
||||
entries = [[rng.choice(["receipt", "revocation"]), letter_first(os.urandom(32), 13), sha256_hex(os.urandom(4))]
|
||||
for _ in range(rng.randint(0, 20))]
|
||||
note = LG.sign_checkpoint("scient.ing/log/t", entries, [("ana", secret("ana")), ("ben", secret("ben"))])
|
||||
parsed, verified = LG.verify_checkpoint(note, ROSTER_BY_ID)
|
||||
ok("checkpoints", parsed and parsed["size"] == len(entries) and parsed["root"] == LG.root(entries) and verified == ["ana", "ben"],
|
||||
"a checkpoint does not verify")
|
||||
k = rng.randrange(len(note))
|
||||
bad = note[:k] + chr((ord(note[k]) + 1) % 128 or 1) + note[k + 1:]
|
||||
p2, v2 = LG.verify_checkpoint(bad, ROSTER_BY_ID)
|
||||
# An edit in the body loses every signer; an edit in one signature line loses that signer.
|
||||
ok("checkpoints", p2 is None or len(v2) < len(verified), "an edited note still fully verifies: %r" % bad[max(0, k - 5):k + 5])
|
||||
|
||||
|
||||
def main():
|
||||
ap = argparse.ArgumentParser(description=__doc__.splitlines()[0])
|
||||
ap.add_argument("--seed", type=int, default=20261014, help="the random seed (default 20261014)")
|
||||
@@ -607,6 +695,10 @@ def main():
|
||||
stress_addresses(rng, a.rounds)
|
||||
stress_targets(rng, a.rounds)
|
||||
stress_receipt_chains(rng, a.rounds)
|
||||
stress_keys(rng, a.rounds)
|
||||
stress_vrf(rng, a.rounds)
|
||||
stress_proofs(rng, a.rounds)
|
||||
stress_checkpoints(rng, a.rounds)
|
||||
for k in sorted(COUNTS):
|
||||
print("%-17s %6d checks" % (k, COUNTS[k]))
|
||||
if FAILS:
|
||||
|
||||
Reference in new issue
Block a user