If you have ever tried to implement the Signal Protocol or parse WhatsApp Multi-Device primitives in languages like Elixir, Go, or Rust, you likely hit a conceptual wall: "How are they using the exact same X25519 public key for Diffie-Hellman AND for verifying digital signatures?"
The short answer is XEdDSA.
In this article, we will look at why XEdDSA exists, how it solves key reuse for Curve25519, and how to translate the math behind axlsign / TweetNaCl into clean code.
The Problem: X25519 vs. Ed25519
Curve25519 is one of the most widely used elliptic curves. However, it is commonly represented in two different mathematical forms:
- Montgomery (X25519): Optimized for Key Exchange (ECDH). It only stores the u (or x) coordinate of curve points.
- Twisted Edwards (Ed25519): Optimized for Signatures (EdDSA). It stores complete (x, y) coordinates.
In traditional cryptographic designs, you need two separate key pairs per device: a key exchange key (X25519) and an identity/signing key (Ed25519).
Managing, storing, and exchanging two sets of public keys per user increases protocol overhead. XEdDSA (designed by Trevor Perrin for the Signal Foundation) solves this by enabling a single X25519 key pair to be used for both operations.
How XEdDSA Works Under the Hood
Instead of modifying the private key derivation, XEdDSA converts the Montgomery public key into the Edwards format before signing or verifying.
This conversion relies on a mathematical mapping called Birational Equivalence.
Point Mapping (u -> y)
Given a point with coordinate u on the Montgomery curve (X25519) over the finite field modulo p = 2^255 - 19, the corresponding y coordinate on the Edwards curve (Ed25519) is calculated as:
y = (u - 1) / (u + 1) mod p
Because the u coordinate on a Montgomery curve discards the sign of the v coordinate, converting an X25519 public key to Edwards introduces a sign ambiguity. XEdDSA handles this deterministically by forcing the sign bit of the converted public key A to 0.
The Verification Flow (XEd25519)
When you receive a message signed with XEd25519, validation follows these steps:
[X25519 Key (u)] ----> Point Conversion (u -> y) ----> [Ed25519 Key (A)]
|
[Signature (R, s)] -------------------------------------------+---> Validate: s*B = R + h*A
[Message M] ----> Hash: h = SHA512(R || A || M) -------+
- Recover Public Key A: Take the 32-byte X25519 public key (representing u) and compute the y coordinate. Ensure the most significant bit (sign bit) is cleared.
- Unpack Signature: The 64-byte signature consists of R (32 bytes, ephemeral Edwards point) and s (32 bytes, scalar).
- Compute Challenge Hash (h): Compute h = SHA-512(R || A || M) mod L (where L is the curve order).
- Verify Equation: Check if the group equation s * B = R + h * A holds true.
Implementing in Elixir: From Math to Code
If you need to perform this verification in Elixir without relying on external native C dependencies, you can implement the field arithmetic using Erlang's :crypto module.
Here is the core logic that converts an X25519 public key into the Edwards format:
defmodule XEd25519 do
# Prime p = 2^255 - 19
@p 57896044618658097711785492504343953926634992332820282019728792003956564819949
@doc """
Converts a Montgomery u public key (X25519) to an Edwards Y point (Ed25519).
"""
def x25519_pk_to_ed25519(u_bytes) when byte_size(u_bytes) == 32 do
# Decode Little-Endian bytes to Integer
u = :binary.decode_unsigned(u_bytes, :little)
# y = (u - 1) * inv(u + 1) mod p
num = Integer.mod(u - 1, @p)
den = Integer.mod(u + 1, @p)
y = Integer.mod(num * inv(den, @p), @p)
# Encode back to 32-byte Little-Endian binary
y_bytes = :binary.encode_unsigned(y, :little) |> pad_bytes(32)
# In XEd25519, the sign bit of A (bit 255) is forced to 0
y_bytes
end
# Modular Inverse via Fermat's Little Theorem: a^(p-2) mod p
defp inv(a, p) do
:crypto.mod_pow(a, p - 2, p)
|> :binary.decode_unsigned()
end
defp pad_bytes(bytes, size) when byte_size(bytes) < size do
bytes <> :binary.copy(<<0>>, size - byte_size(bytes))
end
defp pad_bytes(bytes, _size), do: bytes
end
Practical Shortcut with libsodium
If performance is critical in production, libsodium provides native functions that perform point conversion:
// In C / libsodium
crypto_sign_ed25519_pk_to_curve25519(curve25519_pk, ed25519_pk);
In Elixir, packages such as :enacl expose C NIF bindings to handle these transformations efficiently.
Conclusion
XEdDSA is an elegant solution in modern cryptography: a mathematical bridge that eliminates key management complexity without sacrificing security. Protocols like Signal and WhatsApp rely on it to maintain a single identity key per device for billions of users.
If you are writing protocol parsers or porting Signal clients to functional languages: convert the u coordinate to y, and you have an Ed25519 public key ready for signature verification.
Top comments (0)