Libsodium is now 13 years old!
I started that project to pursue Dan Bernstein’s desire to make cryptography simple to use. That meant exposing a limited set of high-level functions and parameters, providing a simple API, and writing documentation for users, not cryptographers or developers. Libsodium’s goal was to expose APIs to perform operations, not low-level functions. Users shouldn’t even have to know or care about what algorithms are used internally. This is how I’ve always viewed libsodium.
Never breaking the APIs is also something I’m obsessed with. APIs may not be great, and if I could start over from scratch, I would have made them very different, but as a developer, the best APIs are not the most beautifully designed ones, but the ones that you don’t have to worry about because they don’t change and upgrades don’t require any changes in your application either. Libsodium started from the NaCl API, and still adheres to it.
These APIs exposed high-level functions, but also some lower-level functions that high-level functions wrap or depend on. Over the years, people started using these low-level functions directly. Libsodium started to be used as a toolkit of algorithms and low-level primitives.
That made me sad, especially since it is clearly documented that only APIs from builds with --enable-minimal are guaranteed to be tested and stable. But after all, it makes sense. When building custom protocols, having a single portable library with a consistent interface for different functions is far better than importing multiple dependencies, each with their own APIs and sometimes incompatibilities between them.
That’s a lot of code to maintain. It includes features and target platforms I don’t use but try to support for the community. I also maintain a large number of other open source projects.
Still, the security track record of libsodium is pretty good, with zero CVEs in 13 years even though it has gotten a lot of scrutiny.
However, while recently experimenting with adding support for batch signatures, I noticed inconsistent results with code originally written in Zig. The culprit was a check that was present in a function in Zig, but that I forgot to add in libsodium.
The bug
The function crypto_core_ed25519_is_valid_point(), a low-level function used to check if a given elliptic curve point is valid, was supposed to reject points that aren’t in the main cryptographic group, but some points were slipping through.
Why does this matter?
Edwards25519 is like a special mathematical playground where cryptographic operations happen.
It is used internally for Ed25519 signatures, and includes multiple subgroups of different sizes (order):
- Order 1: just the identity (0, 1)
- Order 2: identity + point (0, -1)
- Order 4: 4 points
- Order 8: 8 points
- Order L: the “main subgroup” (L = ~2^252 points) where all operations are expected to happen
- Order 2L, 4L, 8L: very large, but not prime order subgroups
The validation function was designed to reject points not in the main subgroup. It properly rejected points in the small-order subgroups, but not points in the mixed-order subgroups.
What went wrong technically?
To check if a point is in the main subgroup (the one of order L), the function multiplies it by L. If the order is L, multiplying any point by L gives the identity point (the mathematical equivalent of zero). So, the code does the multiplication and checks that we ended up with the identity point.
Points are represented by coordinates. In the internal representation used here, there are three coordinates: X, Y, and Z. The identity point is represented internally with coordinates where X = 0 and Y = Z. Z can be anything depending on previous operations; it doesn’t have to be 1.
The old code only checked X = 0. It forgot to verify Y = Z. This meant some invalid points (where X = 0 but Y ≠ Z after the multiplication) were incorrectly accepted as valid.
Concretely: take any main-subgroup point Q (for example, the output of crypto_core_ed25519_random) and add the order-2 point (0, -1), or equivalently negate both coordinates. Every such Q + (0, -1) would have passed validation before the fix, even though it’s not in the main subgroup.
The fix
The fix is trivial and adds the missing check:
// OLD:
return fe25519_iszero(pl.X);
// NEW:
fe25519_sub(t, pl.Y, pl.Z);
return fe25519_iszero(pl.X) & fe25519_iszero(t);
Now it properly verifies both conditions: X must be zero and Y must equal Z.
Who is affected?
You may be affected if you:
- Use a point release <=
1.0.20or a version oflibsodiumreleased before December 30, 2025. - Use
crypto_core_ed25519_is_valid_point()to validate points from untrusted sources - Implement custom cryptography using arithmetic over the Edwards25519 curve
But don’t panic. Most users are not affected.
None of the high-level APIs (crypto_sign_*) are affected; they don’t even use or need that function. Scalar multiplication using crypto_scalarmult_ed25519 won’t leak anything even if the public key is not on the main subgroup. And public keys created with the regular crypto_sign_keypair and crypto_sign_seed_keypair functions are guaranteed to be on the correct subgroup.
Recommendation
Support for the Ristretto255 group was added to libsodium in 2019 specifically to solve cofactor-related issues. With Ristretto255, if a point decodes, it’s safe. No further validation is required.
If you implement custom cryptographic schemes doing arithmetic over a finite field group, using Ristretto255 is recommended. It’s easier to use, and as a bonus, low-level operations will run faster than over Edwards25519.
If you can’t update libsodium and need an application-level workaround, use the following function:
int is_on_main_subgroup(const unsigned char p[crypto_core_ed25519_BYTES])
{
/* l - 1 (group order minus 1) */
static const unsigned char L_1[crypto_core_ed25519_SCALARBYTES] = {
0xec, 0xd3, 0xf5, 0x5c, 0x1a, 0x63, 0x12, 0x58,
0xd6, 0x9c, 0xf7, 0xa2, 0xde, 0xf9, 0xde, 0x14,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x10
};
/* Identity point encoding: (x=0, y=1) */
static const unsigned char ID[crypto_core_ed25519_BYTES] = {
0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00
};
unsigned char t[crypto_core_ed25519_BYTES];
unsigned char r[crypto_core_ed25519_BYTES];
if (crypto_scalarmult_ed25519_noclamp(t, L_1, p) != 0 ||
crypto_core_ed25519_add(r, t, p) != 0) {
return 0;
}
return sodium_memcmp(r, ID, sizeof ID) == 0;
}
Fixed packages
This issue was fixed immediately after discovery. All stable packages released after December 30, 2025 include the fix:
- official tarballs
- binaries for Visual Studio
- binaries for MingW
- NuGet packages for all architectures including Android
swift-sodiumxcframework (butswift-sodiumdoesn’t expose low-level functions anyway)- Rust
libsodium-sys-stable libsodium.js
A new point release is also going to be tagged.
If libsodium is useful to you, please keep in mind that it is maintained by one person, for free, in time I could spend with my family or on other projects. The best way to help the project would be to consider sponsoring it, which helps me dedicate more time to improving it and making it great for everyone, for many more years to come.