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
+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