The signing / verification pipeline for secp256k1 and ed25519, canonical signatures and malleability.
What you'll learn
≈45 min · Advanced · builds on Base58Check encoding
Watch this short video by XRPL Commons first, then dive into the details below.
Signing is where a key proves it authorized a transaction, and where subtle bugs can undo everything. In this module you'll learn the signing and verification pipelines for secp256k1 and ed25519, and confront signature malleability: what it is, why it's dangerous, and how fully-canonical signatures shut it down. This is authorization done right.
A digital signature proves three things:
Transaction Data + Secret Key → Signature
Transaction Data + Public Key + Signature → Valid/Invalid
In brief: sign the transaction with the account's private key (secp256k1 or ed25519).
// From src/libxrpl/protocol/SecretKey.cpp
Buffer sign(
PublicKey const& pk,
SecretKey const& sk,
Slice const& m)
{
// Automatically detect key type from public key
auto const type = publicKeyType(pk.slice());
switch (*type)
{
case KeyType::ed25519:
return signEd25519(pk, sk, m);
case KeyType::secp256k1:
return signSecp256k1(pk, sk, m);
}
}
Parameters:
pk: Public key (for key type detection)sk: Secret key (the signing key)m: Message (the data to sign)Returns:
Buffer containing the signature bytescase KeyType::ed25519: {
Buffer b(64); // Ed25519 signatures are always 64 bytes
ed25519_sign(
m.data(), m.size(), // Message to sign
sk.data(), // Secret key
pk.data() + 1, // Public key (skip 0xED prefix)
b.data()); // Output buffer
return b;
}
How it works:
ed25519_sign with message, keys, and output bufferProperties:
Signature format:
[R (32 bytes)][S (32 bytes)] = 64 bytes total
Where R and S are elliptic curve points/scalars (mathematical details abstracted by the library).
case KeyType::secp256k1: {
// Step 1: Hash the message with SHA-512-Half
sha512_half_hasher h;
h(m.data(), m.size());
auto const digest = sha512_half_hasher::result_type(h);
// Step 2: Sign the digest (not the raw message)
secp256k1_ecdsa_signature sig_imp;
secp256k1_ecdsa_sign(
secp256k1Context(),
&sig_imp,
reinterpret_cast<unsigned char const*>(digest.data()),
reinterpret_cast<unsigned char const*>(sk.data()),
secp256k1_nonce_function_rfc6979, // Deterministic nonce
nullptr);
// Step 3: Serialize to DER format
unsigned char sig[72];
size_t len = sizeof(sig);
secp256k1_ecdsa_signature_serialize_der(
secp256k1Context(),
sig,
&len,
&sig_imp);
return Buffer{sig, len};
}
How it works:
Why pre-hash?
Why DER encoding?
DER (Distinguished Encoding Rules) is a standard binary format from X.509:
DER Format:
0x30 [total length]
0x02 [R length] [R bytes]
0x02 [S length] [S bytes]
Example:
30 44
02 20 [32 bytes of R]
02 20 [32 bytes of S]
Total: ~70-72 bytes (variable length!)
Deterministic Nonces (RFC 6979):
secp256k1_nonce_function_rfc6979
This is critical for security. ECDSA requires a random "nonce" (number used once) for each signature. If:
RFC 6979 derives the nonce deterministically from the message and secret key, making it:
In brief: check the signature against the public key before trusting the transaction.
// From src/libxrpl/protocol/PublicKey.cpp
bool verify(
PublicKey const& publicKey,
Slice const& m,
Slice const& sig,
bool mustBeFullyCanonical) noexcept
{
// Detect key type
auto const type = publicKeyType(publicKey);
if (!type)
return false;
if (*type == KeyType::secp256k1)
{
return verifySecp256k1(publicKey, m, sig, mustBeFullyCanonical);
}
else if (*type == KeyType::ed25519)
{
return verifyEd25519(publicKey, m, sig);
}
return false;
}
Parameters:
publicKey: The public key to verify againstm: The message that was signedsig: The signature to verifymustBeFullyCanonical: Whether to enforce strict canonicality (important!)Returns:
true if signature is validfalse if signature is invalid or malformedelse if (*type == KeyType::ed25519)
{
// Check signature is canonical
if (!ed25519Canonical(sig))
return false;
// Verify signature
return ed25519_sign_open(
m.data(), m.size(), // Message
publicKey.data() + 1, // Public key (skip 0xED prefix)
sig.data()) == 0; // Signature
}
Canonicality check:
static bool ed25519Canonical(Slice const& sig)
{
// Signature must be exactly 64 bytes
if (sig.size() != 64)
return false;
// Ed25519 curve order (big-endian)
static std::uint8_t const Order[] = {
0x10, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x14, 0xDE, 0xF9, 0xDE, 0xA2, 0xF7, 0x9C, 0xD6,
0x58, 0x12, 0x63, 0x1A, 0x5C, 0xF5, 0xD3, 0xED
};
// S component (second 32 bytes) must be < Order
auto const le = sig.data() + 32;
std::uint8_t S[32];
std::reverse_copy(le, le + 32, S); // Convert to big-endian
return std::lexicographical_compare(S, S + 32, Order, Order + 32);
}
Why check canonicality?
Ensures the S component is in the valid range. This prevents malformed signatures from being processed.
if (*type == KeyType::secp256k1)
{
// Hash the message first (same as signing)
return verifyDigest(
publicKey,
sha512Half(m),
sig,
mustBeFullyCanonical);
}
The digest verification function:
bool verifyDigest(
PublicKey const& publicKey,
uint256 const& digest,
Slice const& sig,
bool mustBeFullyCanonical)
{
// Check signature canonicality
auto const canonical = ecdsaCanonicality(sig);
if (!canonical)
return false;
if (mustBeFullyCanonical && *canonical != ECDSACanonicality::fullyCanonical)
return false;
// Parse public key
secp256k1_pubkey pubkey_imp;
if (secp256k1_ec_pubkey_parse(
secp256k1Context(),
&pubkey_imp,
reinterpret_cast<unsigned char const*>(publicKey.data()),
publicKey.size()) != 1)
return false;
// Parse signature from DER
secp256k1_ecdsa_signature sig_imp;
if (secp256k1_ecdsa_signature_parse_der(
secp256k1Context(),
&sig_imp,
reinterpret_cast<unsigned char const*>(sig.data()),
sig.size()) != 1)
return false;
// Verify!
return secp256k1_ecdsa_verify(
secp256k1Context(),
&sig_imp,
reinterpret_cast<unsigned char const*>(digest.data()),
&pubkey_imp) == 1;
}
Steps:
In brief: why a signature must be fully canonical, and how that blocks tampering.
In secp256k1, a signature is a pair of numbers (R, S). Due to the mathematics of elliptic curves:
If (R, S) is valid, then (R, -S mod n) is also valid
Where n is the curve order. This means one message has two valid signatures.
Why this is dangerous:
// Alice creates and signs a transaction
Transaction tx = Payment{ /* ... */ };
Signature sig1 = sign(alice.publicKey, alice.secretKey, tx);
// Transaction ID includes the signature
uint256 txID1 = hash(tx, sig1);
// Attacker sees tx + sig1 in network
// Attacker creates malleated signature sig2 = (R, -S mod n)
Signature sig2 = malleate(sig1);
// sig2 is also valid!
bool valid = verify(alice.publicKey, tx, sig2); // Returns true
// But produces different transaction ID
uint256 txID2 = hash(tx, sig2);
assert(txID1 != txID2); // Different IDs!
Attack scenarios:
Require S to be in the "low" range:
// Canonical if S <= order/2
if (S > order/2) {
S = order - S; // Flip to the low range
}
This makes each signature unique, only one valid signature per message.
std::optional<ECDSACanonicality>
ecdsaCanonicality(Slice const& sig)
{
// Parse DER-encoded signature
auto r = sigPart(p); // Extract R
auto s = sigPart(p); // Extract S
if (!r || !s)
return std::nullopt; // Invalid DER encoding
// uint264: local alias in PublicKey.cpp for a 264-bit
// boost::multiprecision integer (large enough for any DER value)
uint264 R(sliceToHex(*r));
uint264 S(sliceToHex(*s));
// secp256k1 curve order
static uint264 const G(
"0xFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFEBAAEDCE6AF48A03BBFD25E8CD0364141");
// Both R and S must be < G
if (R >= G || S >= G)
return std::nullopt;
// Calculate G - S (the "flipped" value)
auto const Sp = G - S;
// Is S in the lower half?
if (S > Sp)
return ECDSACanonicality::canonical; // Valid but not fully canonical
return ECDSACanonicality::fullyCanonical; // Perfect!
}
Canonicality levels:
enum class ECDSACanonicality {
fullyCanonical, // S <= order/2 (preferred)
canonical // S > order/2 but valid (deprecated)
};
Enforcement:
if (mustBeFullyCanonical && *canonical != ECDSACanonicality::fullyCanonical)
return false; // Reject non-canonical signatures
In production, XRPL always sets mustBeFullyCanonical = true to prevent malleability.
Ed25519 signatures are inherently canonical, there's only one valid signature per message. The curve mathematics don't allow the kind of malleability that exists in ECDSA.
// Ed25519: Each message has exactly ONE valid signature
Signature sig = sign(pk, sk, message);
// No way to create sig2 that's also valid
This is one of the design advantages of Ed25519 over secp256k1.
Watch out. secp256k1 signatures are malleable unless required to be fully canonical; otherwise an attacker can alter a signature (and the transaction id) while keeping it valid. ed25519 has no such problem.
// From src/libxrpl/protocol/STTx.cpp
void STTx::sign(PublicKey const& publicKey, SecretKey const& secretKey)
{
// Serialize transaction for signing (single signature path)
auto const data = getSigningData(*this);
// Create signature
auto const signature = xrpl::sign(publicKey, secretKey, s.slice());
// Add signature to transaction
setFieldVL(sfTxnSignature, signature);
}
What gets signed:
Serializer getSigningData(STTx const& tx) // single-sign; multisign uses
{ // startMultiSigningData + TxMultiSign
Serializer s;
// Add signing prefix
s.add32(HashPrefix::TxSign);
// Serialize all transaction fields except signature
tx.addWithoutSigningFields(s);
return s;
}
The signature is computed over:
HashPrefix::TxSign)bool STTx::checkSign(bool mustBeFullyCanonical) const
{
try
{
// Get the signing public key
auto const publicKey = getSigningPubKey();
// Get the signature
auto const signature = getFieldVL(sfTxnSignature);
// Rebuild the data that was signed
Serializer s = buildMultiSigningData(*this, publicKey);
// Verify!
return verify(publicKey, s.slice(), signature, mustBeFullyCanonical);
}
catch (...)
{
return false; // Any error = invalid
}
}
In brief: combine several signers' signatures to authorize a single transaction.
XRPL supports multi-signature transactions where multiple parties must sign:
struct Signer {
AccountID account;
PublicKey publicKey;
Buffer signature;
uint16_t weight;
};
bool checkMultiSign(STTx const& tx) {
auto const signers = tx.getFieldArray(sfSigners);
uint32_t totalWeight = 0;
for (auto const& signer : signers) {
// Extract signer info
auto const account = signer.getAccountID(sfAccount);
auto const pubKey = signer.getFieldVL(sfSigningPubKey);
auto const sig = signer.getFieldVL(sfTxnSignature);
// Verify this signer's signature
Serializer s = buildMultiSigningData(tx, account, pubKey);
if (!verify(pubKey, s.slice(), sig, true))
return false; // Invalid signature
// Add weight
totalWeight += getSignerWeight(account);
}
// Check if total weight meets quorum
return totalWeight >= getRequiredQuorum(tx);
}
Each signer independently signs the transaction, and all signatures are verified.
Ed25519: ~50 microseconds
Secp256k1: ~200 microseconds
Ed25519 is 4x faster for signing.
Ed25519: ~100 microseconds
Secp256k1: ~500 microseconds
Ed25519 is 5x faster for verification.
Why verification speed matters:
Every validator must verify every transaction signature. In a high-throughput system:
1000 transactions/second × 500 μs/verification = 0.5 seconds of CPU time
1000 transactions/second × 100 μs/verification = 0.1 seconds of CPU time
Ed25519's speed advantage is significant at scale.
Ed25519: 64 bytes (fixed)
Secp256k1: ~71 bytes (variable, DER encoded)
Ed25519 signatures are slightly smaller and fixed-size.
This module traced signing and verification for secp256k1 and ed25519. You saw how a transaction is signed and then verified against the public key, and confronted signature malleability, where a valid signature can be altered into another valid one and change the transaction id. XRPL requires fully-canonical signatures to shut that down for secp256k1; ed25519 is not malleable in the first place.
To remember:
HashPrefix::TxSign prependedTransactor::checkSign then STTx::checkSign then verify() (dispatch on key type)include/xrpl/protocol/SecretKey.h (sign) and PublicKey.h (verify + canonicality)Next up. You can prove a transaction came from you. But when your node talks to a peer, who proves the peer is real? Next: the peer handshake.
Resources
Assignments
0 of 2 complete