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