Digital signatures are the heart of XRPL's security. Every transaction must be signed with the private key corresponding to the sending account. This signature is mathematical proof that the account owner authorized the transaction. Without a valid signature, a transaction is rejected immediately.
In this chapter, we'll trace the complete signing and verification pipeline, understand the differences between secp256k1 and ed25519, and explore why canonical signatures matter.
The Signature: Mathematical Proof of Authorization
A digital signature proves three things:
Authenticity: The signature was created by someone with the secret key
Integrity: The signed data hasn't been modified
Non-repudiation: The signer cannot deny having signed
Transaction Data + Secret Key → Signature
Transaction Data + Public Key + Signature → Valid/Invalid
Creating a Signature
The High-Level Interface
// 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:
A Buffer containing the signature bytes
Ed25519 Signing: Simple and Fast
How it works:
Allocate 64-byte buffer
Call ed25519_sign with message, keys, and output buffer
Return the signature
Properties:
Always produces exactly 64 bytes
Deterministic: same message + key = same signature
Fast: ~50 microseconds
No pre-hashing needed
Signature format:
Where R and S are elliptic curve points/scalars (mathematical details abstracted by the library).
Secp256k1 Signing: More Complex
How it works:
Pre-hash the message: Compute SHA-512-Half of the message
Sign the digest: Use ECDSA to sign the 32-byte hash
Serialize: Encode signature in DER format
Why pre-hash?
ECDSA works on fixed-size inputs (32 bytes)
Messages can be any size
Hashing first normalizes all inputs to 32 bytes
Security proof for ECDSA assumes you're signing a hash
Why DER encoding?
DER (Distinguished Encoding Rules) is a standard binary format from X.509:
Deterministic Nonces (RFC 6979):
This is critical for security. ECDSA requires a random "nonce" (number used once) for each signature. If:
The same nonce is used twice with the same key → secret key can be extracted
The nonce is predictable → secret key can be extracted
RFC 6979 derives the nonce deterministically from the message and secret key, making it:
Different for every message
Unpredictable to attackers
Free from random number generation failures
Verifying a Signature
The High-Level Interface
Parameters:
publicKey: The public key to verify against
m: The message that was signed
sig: The signature to verify
mustBeFullyCanonical: Whether to enforce strict canonicality (important!)
Returns:
true if signature is valid
false if signature is invalid or malformed
Ed25519 Verification
Canonicality check:
Why check canonicality?
Ensures the S component is in the valid range. This prevents malformed signatures from being processed.
Secp256k1 Verification
The digest verification function:
Steps:
Check canonicality: Ensure signature is in canonical form
Parse public key: Convert from compressed format to library format
Parse signature: Decode DER encoding
Verify: Check mathematical relationship between public key, message, and signature
Signature Malleability and Canonicality
The Problem: Signature Malleability
In secp256k1, a signature is a pair of numbers (R, S). Due to the mathematics of elliptic curves:
Where n is the curve order. This means one message has two valid signatures.
Why this is dangerous:
Attack scenarios:
Transaction ID confusion: Applications tracking txID1 won't see the transaction confirmed (it confirms as txID2)
Double-spend attempts: Submit both versions, one might get through
Chain reaction: If txID is used as input to another transaction, that transaction becomes invalid
The Solution: Canonical Signatures
Require S to be in the "low" range:
This makes each signature unique—only one valid signature per message.
Checking Canonicality
Canonicality levels:
Enforcement:
In production, XRPL always sets mustBeFullyCanonical = true to prevent malleability.
Ed25519: No 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.
This is one of the design advantages of Ed25519 over secp256k1.
Transaction Signing in Practice
Signing a Transaction
What gets signed:
The signature is computed over:
A prefix (HashPrefix::txSign)
All transaction fields (except the signature itself)
Verifying a Transaction
Multi-Signing
XRPL supports multi-signature transactions where multiple parties must sign:
Each signer independently signs the transaction, and all signatures are verified.
Performance Characteristics
Signing Speed
Verification Speed
Why verification speed matters:
Every validator must verify every transaction signature. In a high-throughput system:
Ed25519's speed advantage is significant at scale.
// 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;
}
else 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
}
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_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);
}
if (*type == KeyType::secp256k1)
{
// Hash the message first (same as signing)
return verifyDigest(
publicKey,
sha512Half(m),
sig,
mustBeFullyCanonical);
}
If (R, S) is valid, then (R, -S mod n) is also valid
// 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!
// Canonical if S <= order/2
if (S > order/2) {
S = order - S; // Flip to the low range
}
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 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!
}
enum class ECDSACanonicality {
fullyCanonical, // S <= order/2 (preferred)
canonical // S > order/2 but valid (deprecated)
};
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
}
}
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);
}
Ed25519: ~50 microseconds
Secp256k1: ~200 microseconds
Ed25519 is 4x faster for signing.
Ed25519: ~100 microseconds
Secp256k1: ~500 microseconds
Ed25519 is 5x faster for verification.
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: 64 bytes (fixed)
Secp256k1: ~71 bytes (variable, DER encoded)
Ed25519 signatures are slightly smaller and fixed-size.