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>
180 lines
5.2 KiB
Python
180 lines
5.2 KiB
Python
"""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
|