Monocypher

Boring crypto that simply works

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:

  1. Chose a public key PK to impersonate.
  2. Choose a message msg of size msg_size to forge.
  3. Let zero be an all zeroes 64-byte buffer.
  4. Try crypto_check(zero, PK, msg, msg_size).
  5. 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:

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:

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).