open-standards/wai/claim-conformance/_ed25519.py
Transaction Science f312e2cafa Python verifiers: one strict Ed25519 rule, _ed25519.py
claim_verify.py made the key, order and range checks itself and left the equation
to `cryptography`; http_verify.py had its own RFC 8032 implementation; and
pack_verify.py called `cryptography` alone, which accepts a key of small order, a
second spelling of a key, and an R of small order. All three, and the JWP and
signer verifiers through claim_verify, now call claim-conformance/_ed25519.py,
written from RFC 8032 with the standard library (as http_verify's was): canonical
A of large order, S < L, R decoded and not of small order, and the cofactorless
equation with R compared byte for byte. It reports a bad key apart from a bad
signature, keeps http_verify's --mutate small-order-key and small-order-r, and
checks itself against RFC 8032's first two vectors and the three forgeries.

Every committed corpus verifies unchanged: no vector's expectation moves. The OER
reference keeps its own rule (oer-receipts §5).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-07 09:22:27 -04:00

182 lines
6.8 KiB
Python

"""Ed25519 verification as WAI verifies every signature (SPEC §7 *Signatures*), written
from RFC 8032's definitions with Python's integers and nothing beyond the standard
library: the one routine the Python verifiers of WAI's corpora share. It shares no code
with wai-rs (`wai::sig`).
The rule, in order:
1. the key A decodes, and is the one canonical encoding of its point: y < p, and x = 0
never carries the sign bit (RFC 8032 §5.1.3);
2. A is not of small order (its order divides 8): under such a key the equation can
hold for a signature made without any private key;
3. S < L (RFC 8032 §5.1.7);
4. R decodes, is not of small order, and [S]B = R + [k]A holds with [S]B - [k]A encoded
and compared with R byte for byte (the cofactorless equation), k = SHA-512(R || A ||
M) mod L over the bytes of R and A as given.
`verify` returns None when every step holds, "bad_key" when step 1 or 2 fails and
"bad_signature" otherwise, so a verifier names the two outcomes with its own reasons.
The OER/2 reference verifier does not use this: oer-receipts §5 settles its own rule,
which refuses the same keys and leaves R to RFC 8032.
"""
import hashlib
P = 2 ** 255 - 19
L = 2 ** 252 + 27742317777372353535851937790883648493
D = -121665 * pow(121666, P - 2, P) % P
_SQRT_M1 = pow(2, (P - 1) // 4, P)
# The checks a verifier's own self-test may leave out, by name, to show its corpus
# catches the defect; never left out otherwise.
SKIPPABLE = ("small-order-key", "small-order-r")
def _recover_x(y, sign):
if y >= P:
return None
x2 = (y * y - 1) * pow(D * y * y + 1, P - 2, P) % P
if x2 == 0:
return None if sign else 0
x = pow(x2, (P + 3) // 8, P)
if (x * x - x2) % P:
x = x * _SQRT_M1 % P
if (x * x - x2) % P:
return None
return P - x if (x & 1) != sign else x
def decode(b):
"""RFC 8032 §5.1.3: the point `b` encodes, in extended coordinates (X, Y, Z, T), or
None when it is not the canonical encoding of a point (y >= p, x = 0 with the sign
bit set, or no x)."""
if len(b) != 32:
return None
y = int.from_bytes(b, "little")
sign, y = y >> 255, y & ((1 << 255) - 1)
x = _recover_x(y, sign)
return None if x is None else (x, y, 1, x * y % P)
def _add(p, q):
"""Twisted Edwards addition (a = -1) in extended coordinates; complete on this curve."""
a = (p[1] - p[0]) * (q[1] - q[0]) % P
b = (p[1] + p[0]) * (q[1] + q[0]) % P
c = 2 * p[3] * q[3] * D % P
d = 2 * p[2] * q[2] % P
e, f, g, h = b - a, d - c, d + c, b + a
return (e * f % P, g * h % P, f * g % P, e * h % P)
def _neg(p):
return (P - p[0], p[1], p[2], P - p[3])
def _identity(p):
return p[0] % P == 0 and (p[1] - p[2]) % P == 0
def encode(p):
zi = pow(p[2], P - 2, P)
x, y = p[0] * zi % P, p[1] * zi % P
return (y | ((x & 1) << 255)).to_bytes(32, "little")
_BY = 4 * pow(5, P - 2, P) % P
B = (_recover_x(_BY, 0), _BY, 1, _recover_x(_BY, 0) * _BY % P)
IDENTITY = (0, 1, 1, 0)
def small_order(p):
"""Whether [8]p is the identity: p's order divides 8."""
for _ in range(3):
p = _add(p, p)
return _identity(p)
def strict_key(pk):
"""Steps 1 and 2: whether `pk` is the canonical encoding of a point of large order."""
a = decode(pk)
return a is not None and not small_order(a)
def _double_mul(s, p, k, q):
"""[s]p + [k]q, one doubling per bit (Shamir's trick)."""
pq = _add(p, q)
acc = IDENTITY
for i in range(max(s.bit_length(), k.bit_length()) - 1, -1, -1):
acc = _add(acc, acc)
bs, bk = (s >> i) & 1, (k >> i) & 1
if bs and bk:
acc = _add(acc, pq)
elif bs:
acc = _add(acc, p)
elif bk:
acc = _add(acc, q)
return acc
def verify(pk, msg, sig, skip=()):
"""The whole rule over `msg`: None when every step holds, else "bad_key" (steps 1
and 2) or "bad_signature" (steps 3 and 4). `skip` names checks of SKIPPABLE to leave
out, for a verifier's self-test only."""
if len(pk) != 32:
return "bad_key"
a = decode(pk)
if a is None or (small_order(a) and "small-order-key" not in skip):
return "bad_key"
if len(sig) != 64:
return "bad_signature"
s = int.from_bytes(sig[32:], "little")
if s >= L:
return "bad_signature"
r = decode(sig[:32])
if r is None or (small_order(r) and "small-order-r" not in skip):
return "bad_signature"
k = int.from_bytes(hashlib.sha512(sig[:32] + pk + msg).digest(), "little") % L
if encode(_double_mul(s, B, k, _neg(a))) != sig[:32]:
return "bad_signature"
return None
def verifies(pk, msg, sig, skip=()):
return verify(pk, msg, sig, skip) is None
def _self_test():
"""RFC 8032 §7.1 TEST 1 and TEST 2, and the three signatures a lenient verifier
accepts and this rule refuses; a list of the checks that fail."""
fails = []
t1_pk = bytes.fromhex("d75a980182b10ab7d54bfed3c964073a0ee172f3daa62325af021a68f707511a")
t1_sig = bytes.fromhex("e5564300c360ac729086e2cc806e828a84877f1eb8e5d974d873e06522490155"
"5fb8821590a33bacc61e39701cf9b46bd25bf5f0595bbe24655141438e7a100b")
t2_pk = bytes.fromhex("3d4017c3e843895a92b70aa74d1b7ebc9c982ccf2ec4968cc0cd55f12af4660c")
t2_sig = bytes.fromhex("92a009a9f0d4cab8720e820b5f642540a2b27b5416503f8fb3762223ebdb69da"
"085ac1e43e15996e458f3613d0f11d8c387b2eaeb4302aeeb00d291612bb0c00")
if verify(t1_pk, b"", t1_sig) is not None or verify(t2_pk, b"\x72", t2_sig) is not None:
fails.append("rfc8032-vectors")
if verify(t2_pk, b"\x73", t2_sig) != "bad_signature":
fails.append("rfc8032-wrong-message")
one = (1).to_bytes(32, "little")
b_enc = encode(B)
long_identity = (P + 1).to_bytes(32, "little")
forged = b_enc + one # (R, S) = (B, 1): [1]B = B + [k]O under the identity
if verify(one, b"any", forged) != "bad_key" or verify(long_identity, b"any", forged) != "bad_key":
fails.append("identity-key")
if verify(one, b"any", forged, skip=("small-order-key",)) is not None:
fails.append("identity-key-is-a-forgery")
k = int.from_bytes(hashlib.sha512(one + b_enc + b"m").digest(), "little") % L
r_small = one + k.to_bytes(32, "little") # under B (scalar 1): [k]B = O + [k]B
if verify(b_enc, b"m", r_small) != "bad_signature" or verify(b_enc, b"m", r_small, skip=("small-order-r",)) is not None:
fails.append("small-order-r")
s_plus_l = t1_sig[:32] + (int.from_bytes(t1_sig[32:], "little") + L).to_bytes(32, "little")
if verify(t1_pk, b"", s_plus_l) != "bad_signature":
fails.append("s-not-reduced")
return fails
if __name__ == "__main__":
import sys
f = _self_test()
print(("PASS" if not f else "FAIL") + " Ed25519 strict rule self-test " + ",".join(f))
sys.exit(1 if f else 0)