advanced 45 min

Transaction signing & verification

The signing / verification pipeline for secp256k1 and ed25519, canonical signatures and malleability.

Prerequisites

Complete these before starting this module:

What you'll learn

  • Sign and verify with secp256k1 (DER, RFC 6979) and ed25519.
  • Explain signature malleability and fully-canonical signatures.
  • Understand `HashPrefix::TxSign` and multi-signing.
Complete this module by mentor review and a quiz. Jump to assessment

Introduction

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

The Signature: Mathematical Proof of Authorization

A digital signature proves three things:

  1. Authenticity: The signature was created by someone with the secret key
  2. Integrity: The signed data hasn't been modified
  3. Non-repudiation: The signer cannot deny having signed
Transaction Data + Secret Key  →  Signature
Transaction Data + Public Key + Signature  →  Valid/Invalid

Creating a Signature

In brief: sign the transaction with the account's private key (secp256k1 or ed25519).

A signature end to end: getSigningData serializes the transaction with the TxSign prefix, sign produces the signature, it is attached as sfTxnSignature, and every node verifies it in preflight2.

The High-Level Interface

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

case 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:

  1. Allocate 64-byte buffer
  2. Call ed25519_sign with message, keys, and output buffer
  3. Return the signature

Properties:

  • Always produces exactly 64 bytes
  • Deterministic: same message + key = same signature
  • Fast: ~50 microseconds
  • No pre-hashing needed

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

Secp256k1 Signing: More Complex

How it works:

  1. Pre-hash the message: Compute SHA-512-Half of the message
  2. Sign the digest: Use ECDSA to sign the 32-byte hash
  3. 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:

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:

  • 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

In brief: check the signature against the public key before trusting the transaction.

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

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
}

Canonicality check:

Why check canonicality?
Ensures the S component is in the valid range. This prevents malformed signatures from being processed.

Secp256k1 Verification

if (*type == KeyType::secp256k1)
{
    // Hash the message first (same as signing)
    return verifyDigest(
        publicKey,
        sha512Half(m),
        sig,
        mustBeFullyCanonical);
}

The digest verification function:

Steps:

  1. Check canonicality: Ensure signature is in canonical form
  2. Parse public key: Convert from compressed format to library format
  3. Parse signature: Decode DER encoding
  4. Verify: Check mathematical relationship between public key, message, and signature

Signature Malleability and Canonicality

In brief: why a signature must be fully canonical, and how that blocks tampering.

The Problem: Signature Malleability

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:

Attack scenarios:

  1. Transaction ID confusion: Applications tracking txID1 won't see the transaction confirmed (it confirms as txID2)
  2. Double-spend attempts: Submit both versions, one might get through
  3. 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:

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

Checking Canonicality

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

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

Transaction Signing in Practice

Signing a Transaction

// 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:

  1. A prefix (HashPrefix::TxSign)
  2. All transaction fields (except the signature itself)

Verifying a Transaction

Multi-Signing

In brief: combine several signers' signatures to authorize a single transaction.

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

Ed25519:    ~50 microseconds
Secp256k1:  ~200 microseconds

Ed25519 is 4x faster for signing.

Verification Speed

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.

Signature Size

Ed25519:    64 bytes (fixed)
Secp256k1:  ~71 bytes (variable, DER encoded)

Ed25519 signatures are slightly smaller and fixed-size.

Summary

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:

  • What is signed: the serialized transaction with HashPrefix::TxSign prepended
  • secp256k1: ECDSA with DER encoding and RFC 6979 deterministic nonces; ed25519: EdDSA, not malleable
  • Malleability = altering a valid signature into another valid one, changing the tx hash; fully-canonical signatures kill it
  • Verification path: Transactor::checkSign then STTx::checkSign then verify() (dispatch on key type)
  • Multisigning combines a SignerList's weighted signatures against a quorum
  • Code: include/xrpl/protocol/SecretKey.h (sign) and PublicKey.h (verify + canonicality)
  • The key-type prefix (0xED or not) decides which verifier runs
  • Watch out: accepting a non-canonical secp256k1 signature reintroduces malleability; never bypass that check

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.

Assignments

0 of 2 complete

Unlocks

Finishing this module opens up:

XRPL Academy © 2026