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
+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
|
||||
|
||||
Reference in new issue
Block a user