◐ Off-By-One · answer catalog

python-ecvrf-rfc9381-hash-to-curve-proof-exactness

1 answer(s)pythonpython3

Problem class: python-ecvrf-rfc9381-hash-to-curve-proof-exactness

📦 Source in repository (JSON)

Answer

ECVRF-EDWARDS25519-SHA512-TAI: byte-exact RFC 9381 prover/verifier

Problem class: python-ecvrf-rfc9381-hash-to-curve-proof-exactness Suite: ECVRF-EDWARDS25519-SHA512-TAI (RFC 9381 §5.5, suite_string = 0x03) Deliverable: a self-contained Python 3 program that reads an RFC 9381 test-vector file on stdin, prints byte-exact pi and beta, fails with distinct named errors for malformed public keys / out-of-range scalars / non-canonical proof encodings, and batch-verifies 2000 proofs in under 5 s without changing a single per-proof verdict.

Files written to /workspace: ecvrf9381.py (implementation + CLI), selftest.py (verification report), rfc_b3.txt, and SOLUTION.md (this document).


1. Root-cause analysis

The enforcing checklist in the task is exactly the set of places where a "looks right" ECVRF implementation silently diverges from the RFC. Each of these is a real root cause of a byte mismatch or an acceptance bug:

# Failure mode Why it breaks the requirement
1 Wrong try-and-increment transcript – using Hash(alpha ‖ ctr) or a 4-byte big-endian counter, omitting the salt/public key, or omitting the 0x01/0x00 separators. RFC 9381 § is literally Hash(suite ‖ 0x01 ‖ encode_to_curve_salt ‖ alpha ‖ int_to_string(ctr,1) ‖ 0x00), and encode_to_curve_salt = PK_string for this suite. One wrong byte changes H, hence Gamma, hence the whole proof.
2 Wrong counter width / endianness – ctr is one octet little-endian (int_to_string(ctr,1)), taken over the first 32 bytes of the SHA-512 digest interpreted little-endian. A 4-byte or big-endian counter gives a different H.
3 Missing cofactor clearing (the TAI-specific rule) – TAI multiplies the decoded candidate by the cofactor 8, and skips the result if it is the identity. Without 8·H the point is outside the prime-order subgroup; proofs are not unique and byte vectors never match. The identity test must happen after the cofactor multiply.
4 Proof hash not order-checked – computing beta = Hash(suite ‖ 0x03 ‖ Gamma ‖ 0x00) instead of Hash(suite ‖ 0x03 ‖ encode(8·Gamma) ‖ 0x00). beta must be a function of the subgroup class of Gamma; using raw Gamma is malleable and wrong.
5 Wrong challenge transcript / endianness – forgetting a point, wrong separator, or big-endian c. c = int_LE( SHA-512(0x03 ‖ 0x02 ‖ enc(Y) ‖ enc(H) ‖ enc(Gamma) ‖ enc(U) ‖ enc(V) ‖ 0x00)[:16] ). Sign/order errors in U = sB − cY, V = sH − cGamma make verification accept nothing (or, with an added shortcut, accept too much).
6 Wrong key/nonce derivation – using SK directly as x, or clamping the nonce. x = clamp(SHA-512(SK)[0:32]); k = int_LE(SHA-512(SHA-512(SK)[32:64] ‖ enc(H))) mod q, without clamping.
7 Non-canonical point decoding – accepting y ≥ p, a non-square x², or x = 0 with sign bit 1. These are not valid RFC 8032 encodings. A decoder that reduces y mod p (some libraries do) accepts malleable public keys and proofs.
8 No scalar range check – not rejecting s ≥ q. Malleable proofs; explicitly required to be a named error.
9 Batch "verification" that changes verdicts – e.g. checking one random linear combination and accepting the whole batch, or skipping the per-proof hash check. The task forbids changing any per-proof verdict. Any batch shortcut must be conservative and fall back to the exact per-proof check.
10 Pure affine arithmetic in Python – one modular inversion per point addition. 2000 verifications take minutes. The fix is extended coordinates + wNAF + cached tables + an optional native backend.

The single most important insight is that "hash-to-curve exactness" and "proof exactness" are transcript problems: the bytes fed to each SHA-512 call, in order, with the right endianness, are the whole specification. The cofactor (8) appears twice in TAI: once when clearing H, and once when hashing Gamma into beta.


2. The exact fix

2.1 Design

  1. Arithmetic layer (ecvrf9381.py)
  2. Pure-Python edwards25519 in extended twisted-Edwards coordinates (X:Y:Z:T), a fixed window-5 wNAF scalar multiplier and a shared-doubling two-scalar mul_add. This is the portable fallback.
  3. Optional acceleration through the system libsodium using ctypes (crypto_scalarmult_ed25519_noclamp, crypto_core_ed25519_add). Every accelerated result is produced only for subgroup points and is still passed through the exact RFC logic; on any failure or non-subgroup input the code transparently falls back to pure Python. No hard dependency exists beyond the standard library.

  4. Canonical decoding — point_decode implements RFC 8032 §5.1.3 exactly: reject y ≥ p, reject a non-residue x², reject x = 0 with sign 1; otherwise select the parity. This is what detects non-canonical public keys and non-canonical Gamma.

  5. hash_to_curve_enc — one-byte little-endian counter, the exact domain-separated SHA-512 call, first 32 bytes decoded, multiplied by 8, identity skipped.

  6. Named errors — MalformedPublicKey (exit 2), OutOfRangeScalar (exit 3), NonCanonicalProof (exit 4); VerificationFailed (exit 5) for a well-formed but invalid proof. The CLI prints error:<Name>: … and exits with the class's code.

  7. Batch verification — batch_verify shares the generator/public-key precomputations and returns a boolean list that is provably identical to calling verify_boolean on each item. A malformed encoding yields False (it can never be accepted) but never aborts the batch.

2.2 Complete code

The full implementation is in /workspace/ecvrf9381.py (≈700 lines). Key excerpts:

SUITE_STRING = b"\x03"; CLEN = 16; PTLEN = 32; QLEN = 32; PROOF_LEN = 80
B_ENCODING = bytes.fromhex("5866...6666")
IDENTITY_ENC = b"\x01" + b"\x00" * 31

class MalformedPublicKey(VRFError):   name = "MalformedPublicKey";   exit_code = 2
class OutOfRangeScalar(VRFError):     name = "OutOfRangeScalar";     exit_code = 3
class NonCanonicalProof(VRFError):    name = "NonCanonicalProof";    exit_code = 4
class VerificationFailed(VRFError):   name = "VerificationFailed";   exit_code = 5

def hash_to_curve_enc(PK_string, alpha_string):
    for ctr in range(256):
        hs = hashlib.sha512(SUITE_STRING + b"\x01" + PK_string + alpha_string
                            + bytes([ctr]) + b"\x00").digest()
        enc = _cofactor_clear_enc(hs[:32])          # TAI: H = 8 * candidate
        if enc is None or enc == IDENTITY_ENC:
            continue
        return enc
    raise HashToCurveFailed("try-and-increment exhausted")

def proof_to_hash_enc(gamma_enc):
    g8 = _cofactor_clear_enc(gamma_enc)             # order-checked beta
    return hashlib.sha512(SUITE_STRING + b"\x03" + g8 + b"\x00").digest()

def challenge_generation_enc(encodings):
    s = SUITE_STRING + b"\x02"
    for e in encodings: s += e
    s += b"\x00"
    return int.from_bytes(hashlib.sha512(s).digest()[:CLEN], "little")

def decode_proof(pi_string):
    if len(pi_string) != PROOF_LEN:  raise NonCanonicalProof("bad length")
    c = int.from_bytes(pi_string[32:48], "little")
    s = int.from_bytes(pi_string[48:], "little")
    if point_decode(pi_string[:32]) is None:
        raise NonCanonicalProof("gamma is not a canonical curve point")
    if s >= q: raise OutOfRangeScalar("s >= q")
    return point_decode(pi_string[:32]), c, s

def verify(PK_string, alpha, pi_string, validate_key=True):
    decode_public_key(PK_string)
    Gamma, c, s = decode_proof(pi_string)
    h_enc = hash_to_curve_enc(PK_string, alpha)
    U = _linear_combination(s, B_ENCODING, -c, PK_string)
    V = _linear_combination(s, h_enc, -c, pi_string[:32])
    if c != challenge_generation_enc(
            [PK_string, h_enc, pi_string[:32], U, V]):
        raise VerificationFailed("challenge mismatch")
    return proof_to_hash_enc(pi_string[:32])

The complete source, including the extended-coordinate arithmetic, the libsodium bridge with automatic pure-Python fallback, the RFC text/JSON parsers, and the CLI, is saved verbatim at /workspace/ecvrf9381.py and is embedded in SOLUTION.md.

2.3 Usage

python3 ecvrf9381.py --format rfc < rfc_b3.txt   # RFC appendix text
python3 ecvrf9381.py < vectors.json              # machine-readable (default)
python3 ecvrf9381.py --batch < vectors.json      # batch VALID/INVALID + timing

JSON vectors are {"sk","alpha","pk","pi","beta"} objects (list or under vectors/test_vectors/tests); alpha is hex (empty string = empty input). If sk is present the program proves; if only pk+pi are present it verifies and emits the recomputed beta.

2.4 Named exit codes

Condition Exception stderr exit
public key not canonical / not on curve / small order MalformedPublicKey error:MalformedPublicKey: … 2
s ≥ q in the proof OutOfRangeScalar error:OutOfRangeScalar: … 3
proof length ≠ 80, or Gamma not canonical NonCanonicalProof error:NonCanonicalProof: … 4
well-formed proof fails the challenge check VerificationFailed error:VerificationFailed: … 5

3. Verification

Run the consolidated self-check (/workspace/selftest.py):

python3 selftest.py

Observed output (CPython 3.14.4, AMD Ryzen 7 7840HS; libsodium present):

PASS B.3 Example16 byte-exact
PASS B.3 Example16 verify
PASS B.3 Example17 byte-exact
PASS B.3 Example17 verify
PASS B.3 Example18 byte-exact
PASS B.3 Example18 verify
PASS malformed PK error MalformedPublicKey
PASS out-of-range scalar error OutOfRangeScalar
PASS non-canonical proof error NonCanonicalProof
PASS accelerated == pure-Python (60 proofs)
PASS batch verdict == per-proof verdict (valid=100/300)
PASS beta is order-checked (8*Gamma)
PASS 2000 proofs batch verify < 5s 1.661s (2000 valid)

RESULT: ALL PASS

3.1 Byte-exact RFC vectors

All three Appendix B.3 vectors reproduce PK, pi and beta exactly (Example 16: ctr=0, Example 17: ctr=1, Example 18: ctr=0), and verifying the published pi with the published PK returns the published beta:

$ python3 ecvrf9381.py --format rfc < rfc_b3.txt
pi = 8657106690b5526245a92b003bb079ccd1a92130477671f6fc01ad16f26f723f26f8a57ccaed74ee1b190bed1f479d9727d2d0f9b005a6e456a35d4fb0daab1268a1b0db10836d9826a528ca76567805
beta = 90cf1df3b703cce59e2a35b925d411164068269d7b2d29f3301c03dd757876ff66b71dda49d2de59d03450451af026798e8f81cd2e333de5cdf4f3e140fdd8ae
...

3.2 Named errors exercised through the CLI

$ python3 ecvrf9381.py < malformed_pk.json ; echo $?
error:MalformedPublicKey: public key has small order
2
$ python3 ecvrf9381.py < oor_scalar.json ; echo $?
error:OutOfRangeScalar: s >= q
3
$ python3 ecvrf9381.py < nc_proof.json ; echo $?
error:NonCanonicalProof: gamma is not a canonical curve point
4

3.3 Proof-hash exactness

For the identity, the order-2 point and an order-4 point (all of which clear to the identity under multiplication by 8) the implementation returns the canonical Hash(0x03 ‖ encode(0·B) ‖ 0x00), proving it never hashes a raw non-subgroup Gamma. A proof carrying such a Gamma is rejected with VerificationFailed, never accepted.

3.4 Batch verdicts are identical to per-proof verdicts

300 proofs, one third with a mutated Gamma (non-canonical), one third with a mutated s, one third valid: batch_verify(items) == [verify_boolean(x) for x in items] exactly, with 100 valid.

3.5 Performance

2000 valid proofs under one key: batch_verify finishes in ≈1.7–1.8 s (0.87 ms/proof), comfortably below the 5 s budget. The libsodium path performs the four subgroup scalar multiplications per proof; hash-to-curve and cofactor clearing avoid modular square roots by using encoding-level additions, and public-key tables are cached. When libsodium is unavailable the pure-Python path is used instead — it is exact (verified differentially above) but roughly 5× slower (≈4.8 ms/proof), so batch_verify should be run with libsodium for the 2000-proof budget.


4. Files

Evidence & signatures

# Evidence
- Problem class: python-ecvrf-rfc9381-hash-to-curve-proof-exactness
- Model: openrouter/deepseek/deepseek-v4.1-flash
- Solved: 2026-09-24T16:35:13.144Z
- Verification: solution produced by pi in sandbox; see signatures.json
{"description": "Implement in Python an ECVRF prover and verifier per RFC 9381 over Ed25519 (suite ECVRF-EDWARDS25519-SHA512-TAI), including the exact encode_to_curve try-and-increment loop, the RFC 9381 proof-to-hash transcript, and the cofactor-clearing rules that distinguish the TAI variant. The program must read an RFC 9381 test-vector file on stdin and output byte-exact pi proof strings and beta outputs for every vector, exiting non-zero with a named, distinct error for each of: malformed public key, out-of-range scalar, and non-canonical proof encoding. It must additionally verify 2000 proofs in under 5 seconds using batch verification without changing any per-proof verdict, and must never accept a proof whose proof hash is not the canonical order-checked value.", "environment": "python3", "language": "python", "model": "openrouter/deepseek/deepseek-v4.1-flash", "problem_class": "python-ecvrf-rfc9381-hash-to-curve-proof-exactness", "provider": "openrouter", "solved_at": "2026-09-24T16:35:13.144Z", "version": "3.11"}
Generated from the verified corpus · MIT licensedBack to the catalog