Vulnerability Disclosures
Some vulnerabilities are serious enough they must be addressed behind closed doors. Here is what happened for each of them.
Timing leak in EdDSA signatures (2026/06/15; fixed in 4.0.3)
(Found by Lukas Gerlach)
Effect
The internal fe_ccopy() function, used in various places
in EdDSA and Elligator, was often inlined by the compiler, and in some
cases triggered optimisation passes that introduced a secret dependent
index, that may enable cache timing attacks, that may leak secret key
material, and in the worst case enable signature forgeries.
Cause
Here is the culprit:
static void fe_ccopy(fe f, const fe g, int b)
{
i32 mask = -b; // -1 = 0xffffffff
FOR (i, 0, 10) {
i32 x = (f[i] ^ g[i]) & mask;
f[i] = f[i] ^ x;
}
}The optimisation kicks in when the compiler can prove that
b can only have one of 2 values (0 or -1), and replaces the
constant time arithmetic by a cmov instruction, giving a
result very similar to this:
static void fe_ccopy(fe f, const fe g, int b)
{
// Compiler proved that `b` is 0 or 1.
const fe source = b == 0 ? f : g;
FOR (i, 0, 10) {
f[i] = source[i];
}
}In practice, the test itself is constant time. The cmov
instruction is not really a branch, and as of June 2026 is constant time
on most current micro-architectures. Of course, full blown conditional
branch will definitely have a timing leak, and processors without a
conditional move will be vulnerable there.
But there’s more: the cmov hasn’t been performed on the
values. It has been hoisted out of the loop, and performed on the
pointers. And since b is often derived from a
secret, we get pointers that are derived from secrets, and memory reads
whose locations depend on that secret.
This opens us up to a cache timing attack, and in the worst case, full extraction of the signing key. It is not clear how feasible such an attack actually is, and how much effort it would take to target Monocypher. I am personally not too worried about that. What I am worried about, are future timing leaks. Compilers are getting cleverer by the year, they’re bound to find more ways to “optimise” constant time source code into vulnerable binaries. If they find something on, say, the comparison functions, practical attacks are virtually certain.
The only real defence here is checking the generated code. Don’t forget to run ctgrind.
How it was corrected
Lukas Gerlach made the mask volatile, and I
unrolled the loop on top of that. Both mitigations seem to fix the
problem on their own. They have been applied both, in the hope it will
delay the moment compilers a loophole and make us vulnerable again. I
also applied the same fix on fe_cswap() as a precaution.
That one wasn’t vulnerable on the platforms we tested (no secret
dependent pointer in the generated code), but I’m not taking any
chances.
Critical failure of EdDSA checking (2018-06-20; fixed in 2.0.4, 1.1.1)
Effect
When presented with an all zero signature, the
crypto_check() function accepted as legitimate 50% of
messages on average. This allowed the attacker to forge messages with
the following procedure:
- Chose a public key
PKto impersonate. - Choose a message
msgof sizemsg_sizeto forge. - Let
zerobe an all zeroes 64-byte buffer. - Try
crypto_check(zero, PK, msg, msg_size). - If the signature is accepted, the attack is successful. Otherwise, go back to step 2.
The attack is expected to succeed in 2 attempts on average.
Cause
The error was in the ge_scalarmult() internal function.
To speed up computation, the Edwards point was converted to Montgomery
space, then the scalar multiplication was performed with the Montgomery
ladder, and the point was converted back to Edwards space.
This does not work in all cases, for 3 reasons:
- Curve25519 and Edwards25519 are birationally equivalent. It’s like a bijection, except when the denominators of the conversion functions are zero.
- The algorithm used to recover the Y coordinate does not work in all cases.
- The Montgomery ladder conflates points zero and infinity.
When performing signature verification, the signature is under the control of the attacker, potentially allowing them to exploit any error. Even if such errors only happen in exceptional cases, a signature can be specially crafted to trigger them.
How it was corrected
The optimisation has been reverted and replaced by a classical double and add ladder. The complete addition law of twisted Edwards curves guarantees the absence of special cases, and thus ensures the vulnerability has been corrected.
Wycheproof test vectors have also been added to the test suite to ensure non-regression, and probe for any other vulnerability. None was found. The tests pass as expected.
Timeline
On Wednesday the 20th of June 2018, Mike Pechkin informed me, Loup
Vaillant, that crypto_check() accepted the all zero input
as valid:
uint8_t zero[64] = {0};
if (crypto_check(zero, zero, 0, 0)) {
printf("Rejected\n");
} else {
printf("Accepted\n");
}
I initially thought this was because of the all zero public key, which is a low order key. Monocypher makes no guarantee when you verify with an invalid public key. Still, Mike Pechkin found several bugs in earlier versions of Monocypher, I couldn’t dismiss his input out of hand. I tried libsodium and TweetNaCl. libsodium, as expected, rejected the input (it has low order checks). TweetNaCl also rejected the signature. That I did not expect.
I dug deeper and found the following day that Monocypher accepted the signature even with a genuine public key. We had a critical vulnerability. The first one since 1.0.0.
I searched through the git history, and found the bug was introduced by a mostly working, but ultimately faulty, conversion to Montgomery space and back. I reverted the patch, and shipped the fix 4 days later, in versions 2.0.4 and 1.1.1 (the 1.x.x branch is deprecated, but it still had to be fixed).
To ensure the vulnerability doesn’t go back, I added tests that verify the all zero signature is never accepted with legitimate keys. Later, when I became aware of the Wycheproof test vectors, I added them immediately. They didn’t reveal any other vulnerability.
The correction temporarily halved the speed of signatures and verification. The performance loss was recovered later by using standard optimisations: Combs, sliding windows, and double scalar multiplication.
How could this happen?
As catastrophic as it was, the error was fairly subtle. The conversion I was trying to do was mostly correct, but ultimately had exploitable exceptions. I failed to notice it for three reasons:
- I did not look up the term “birational equivalence” and treated that as if it was a bijection.
- I did not read the paper about recovering the v coordinate carefully enough and failed to notice the exceptions.
- I did not look up the exact properties of the Montgomery ladder and failed to learn that it conflated zero and infinity.
Simply put, I played with maths I didn’t fully understand. Never again. Monocypher now only uses stuff I either stole from somewhere else (like field arithmetic, taken from ref10), or understand completely (like sliding windows and combs).