ed25519
The cofactor is back
ristretto255 hides the cofactor of edwards25519. Monero doesn't bother. Its keys are plain ed25519 points, 32 bytes each, and the 8 sits right there in the group. So @agntn/curves/ed25519 keeps it too.
import { multiplyGenerator } from "@agntn/curves/ed25519";
multiplyGenerator(1n); // "5866666666666666…6666"
multiplyGenerator(0n); // "0100000000000000…0000"
All those sixes? That's y = 4/5, the base point of RFC 8032. The identity is 01 and zeros. And 32 zero bytes? In ristretto255 that's the identity. Here it's a point of order 4. Same bytes, other curve, other answer.
Valid isn't the same as safe
Every canonical encoding decodes, whatever its order. A point with a piece of order 2, 4 or 8 in it is a fine point. It's also a lousy key. isTorsionFree catches it: l times the point has to land on the identity.
import { isTorsionFree, isValidPoint, multiplyPoint } from "@agntn/curves/ed25519";
const sneaky = "c7176a703d4dd84fba3c0b760d10670f2a2053fa2c39ccc64ec7fd7792ac037a";
isValidPoint(sneaky); // true
isTorsionFree(sneaky); // false
multiplyPoint(sneaky, 8n); // "0100000000000000…0000"
Monero's check_address wants two things. Not the identity, and nothing of small order. The identity is in the subgroup, so isTorsionFree waves it through. isIdentity does the other half.
A Monero subaddress
Two points, one sum, one product. D = B + mG and C = aD. B is the public spend key, a the private view key. And m? Keccak of SubAddr\0, a and the two indices, reduced mod l.
import { addPoints, multiplyGenerator, multiplyPoint, reduceScalar } from "@agntn/curves/ed25519";
const spend = "1b3bd040020d3712ab84992b773d0a965134eb2df0392fb84af95de8a17be2ab";
const a = reduceScalar("49774391fa5e8d249fc2c5b45dadef13534bf2483dede880dac88f061e809100");
const m = reduceScalar("ce0c5c5c5ebc57951183b271e8257cd136d1efff778c4ecc6f89e95e575324cc"); // that keccak
const d = addPoints(spend, multiplyGenerator(m)); // "8e4841533f2f07f9…9abd"
multiplyPoint(d, a); // "b833d78e77da6d8d…5269"
That's account 1, index 1 of a test wallet, and monero-wallet-cli shows its address there too. Keccak comes from @agntn/hashes. The address around the keys lives in @agntn/keys.
Scalars
reduceScalar reads hex little-endian and reduces it mod l. 32 bytes is Monero's sc_reduce32, 64 bytes is sc_reduce, the one for a SHA-512. Monero runs every key through the first one, so it's not optional.
Everything else takes bigints from 0 to l - 1, like ristretto255. An RFC 8032 secret scalar sits above l after clamping. Reduce it first. On the base point the answer doesn't change.
Decoding follows RFC 8032 to the letter. y at or above p, a y with no x on the curve, a sign bit on x = 0. All three throw Invalid ed25519 point.
Sign twice, get one signature
RFC 8032 test 1 signs nothing at all. Zero bytes in, 64 bytes out.
import { getPublicKey, sign, verify } from "@agntn/curves/ed25519";
const secret = "9d61b19deffd5a60ba844af492ec2cc44449c5697b326919703bac031cae7f60";
const publicKey = getPublicKey(secret); // "d75a980182b10ab7…f707511a"
const signature = sign(secret, { message: "" }); // "e5564300c360ac72…8e7a100b"
verify(signature, { message: "", publicKey }); // true
sign(secret, { message: "" }) === signature; // true, every time
The secret is 32 random bytes, 64 hex digits without 0x. SHA-512 cuts it in two. The low half gets clamped into the scalar s, and A = sB is the public key. The high half seeds every nonce.
That's why the bytes never move. The nonce is a hash of that half and the message. One secret and one message, one signature. sr25519 hands you a new one on every call. Ed25519 doesn't bother.
Writing a test? There's no { random } to pass and nothing to seed. No random number generator, so nothing to get wrong there.
What does sign refuse?
Wrong shapes throw, same as in verify.
sign("00", { message: "" }) // Secret must be 64 hex digits without 0x, the 32 bytes RFC 8032 hashes
sign(secret, { message: "0x00" }) // Message must be hex without 0x
sign(secret, "") // Call it as sign(secret, { message })
A 64-byte secret gets the first answer too. The last one is on purpose. A call in another library's shape, sign(message, secret), fails loudly. Signing the wrong bytes in silence would be worse. And none of the messages quote your secret.
From a terminal or a model
The CLI signs the same empty message.
curves ed25519 sign --secret 9d61b19deffd5a60ba844af492ec2cc44449c5697b326919703bac031cae7f60 --message ""
{"operation":"sign","signature":"e5564300c360ac729086e2cc806e828a84877f1eb8e5d974d873e065224901555fb8821590a33bacc61e39701cf9b46bd25bf5f0595bbe24655141438e7a100b","publicKey":"d75a980182b10ab7d54bfed3c964073a0ee172f3daa62325af021a68f707511a"}
curves ed25519 keypair --secret … prints just the key. The agent tool curves_ed25519_compute has the same keypair and sign. Mind the transcript, though. The secret goes in as is, so test vectors and throwaway keys only.
Verify an Ed25519 signature
RFC 8032 test 1 signs an empty message. Change the message and the signature doesn't match.
import { verify } from "@agntn/curves/ed25519";
const publicKey = "d75a980182b10ab7d54bfed3c964073a0ee172f3daa62325af021a68f707511a";
const signature = "e5564300c360ac729086e2cc806e828a84877f1eb8e5d974d873e065224901555fb8821590a33bacc61e39701cf9b46bd25bf5f0595bbe24655141438e7a100b";
verify(signature, { message: "", publicKey }); // true
verify(signature, { message: "00", publicKey }); // false
The signature is R then S, 128 hex digits total. The key is 64 hex digits in RFC 8032 encoding. The message is hex of any length, including empty. Leave 0x off all three.
ZIP 215 or strict rules?
ZIP 215 is the default. It accepts non-canonical encodings of R and the key. That includes y at or above p, read mod p. It also accepts a sign bit on x = 0. A key of small order gets through too.
Set zip215: false for strict rules. R and the key must have canonical encodings. Keys of order 1, 2, 4 or 8 are refused.
const zeroSignature = `${sneaky}${"00".repeat(32)}`;
verify(zeroSignature, { message: "68656c6c6f", publicKey: sneaky }); // true
verify(zeroSignature, { message: "68656c6c6f", publicKey: sneaky, zip215: false }); // false
That key can "sign" anything under ZIP 215. Lousy key, convenient trick. Eight times sneaky is the identity. With S = 0, both sides of the equation are zero.
Both rules use RFC 8032's cofactored equation: 8SB = 8R + 8kA. The challenge k is SHA-512 over R, the key and the message. R and the key are hashed exactly as written.
Zcash wrote ZIP 215 so verifiers agree on valid signatures. @noble/curves uses it by default too. @agntn/keys uses strict rules for OpenPGP. For XRPL and signed messages, it uses the default.
What does it refuse?
Wrong shapes throw. The messages tell you what needs fixing.
verify("00", { message: "", publicKey }) // Signature must be 128 hex digits without 0x, R then S
verify(signature, { message: "0x00", publicKey }) // Message must be hex without 0x
verify(signature, { message: "", publicKey: "02" + publicKey }) // Public key must be 64 hex digits without 0x
verify(signature, { message: "", publicKey, zip215: "no" }) // zip215 must be a boolean
A wrong signature returns false instead. So do an R or key that doesn't decode, and S at or above l. A small order key returns false under strict rules.
CLI and agent tool
The CLI checks the same empty message.
curves ed25519 verify --public-key d75a980182b10ab7d54bfed3c964073a0ee172f3daa62325af021a68f707511a --message "" --signature e5564300c360ac729086e2cc806e828a84877f1eb8e5d974d873e065224901555fb8821590a33bacc61e39701cf9b46bd25bf5f0595bbe24655141438e7a100b
{"operation":"verify","valid":true}
Add --no-zip215 for strict rules. The agent tool is curves_ed25519_compute, with operation verify. It takes publicKey, message, signature and zip215.
Written from the specs, again
The encoding is RFC 8032. The additions and doublings are the ones ristretto255 runs on, the same file underneath. The tests hold it to the keys of RFC 8032 and to that Monero wallet. A local run against @noble/curves agreed on 300 random cases, small order points mixed in.
The base point takes about 0.45 ms to multiply, any other point about 1 ms. Every scalar takes the same steps. Even scalar 1 has to do the work of l - 1.
In a local dudect run, fixed and random scalars stayed within about one percent. JavaScript promises nothing about timing, though. Puzzles yes, a wallet with money in it no.
Verification tests run all five RFC 8032 vectors under both rules. They also run all 210 Wycheproof Ed25519 vectors under both rules. Wycheproof calls small order keys "acceptable". That's exactly where the rules split.
All twelve ed25519-speccheck cases match @noble/curves 2.4.0 under both rules. In a local run, verification took about 1.4 ms. Noble took about 1.1 ms. Verification takes public input only, so timing doesn't matter here.
Signing comes out of the tests byte for byte. That's all five RFC 8032 vectors, keys included, and six signatures frozen from @noble/curves 2.4.0. In a local run, signing took about 1.8 ms. Noble took about 1.0 ms. Slower, and not hiding it.
Is signing constant time, then? No. Both multiplications, sB and rB, take the same steps for every scalar. SHA-512 and the bigint math mod l promise nothing about time.