The Lifecycle of a Cryptographic Key

Introduction

Let's follow the lifecycle of a cryptographic key in rippled, from its creation as random noise to its role as the foundation of an account's identity. This journey touches every aspect of rippled's cryptographic system and shows how the pieces fit together.

Understanding this lifecycle is crucial because keys are the foundation of everything in XRPL. Every account, every transaction, every validator message—all depend on the proper generation, handling, and use of cryptographic keys.

The Journey Begins: Birth Through Randomness

Everything begins with randomness. Not the pseudo-randomness of Math.random() or std::rand(), but true cryptographic randomness—numbers that are fundamentally unpredictable.

Why Randomness Matters

If an attacker can predict your random numbers, they can predict your keys. If they can predict your keys, they own your account. The stakes couldn't be higher.

// ❌ WRONG - Predictable and insecure
void generateWeakKey() {
    std::srand(std::time(nullptr));  // Predictable seed!
    std::uint8_t buf[32];
    for (auto& byte : buf)
        byte = std::rand() % 256;  // NOT cryptographically secure
}

// ✅ CORRECT - Cryptographically secure
SecretKey generateStrongKey() {
    std::uint8_t buf[32];
    beast::rngfill(buf, sizeof(buf), crypto_prng());  // CSPRNG
    SecretKey sk{Slice{buf, sizeof(buf)}};
    secure_erase(buf, sizeof(buf));  // Clean up
    return sk;
}

The Birth of a Secret Key

In rippled, randomness comes from the crypto_prng() function, which wraps OpenSSL's RAND_bytes:

// From src/libxrpl/protocol/SecretKey.cpp
SecretKey randomSecretKey()
{
    std::uint8_t buf[32];
    beast::rngfill(buf, sizeof(buf), crypto_prng());
    SecretKey sk(Slice{buf, sizeof(buf)});
    secure_erase(buf, sizeof(buf));
    return sk;
}

What happens here:

  1. Allocate buffer: A 32-byte buffer is created on the stack

  2. Fill with randomness: crypto_prng() fills it with cryptographically secure random bytes from OpenSSL

  3. Create SecretKey: The buffer is wrapped in a SecretKey object

  4. Secure cleanup: The temporary buffer is securely erased to prevent key material from lingering in memory

Where Randomness Comes From

When you call crypto_prng(), OpenSSL pulls entropy from multiple sources:

┌─────────────────────────────────────────┐
│     OpenSSL Entropy Pool                │
└─────────────────────────────────────────┘
           ↑           ↑           ↑
           │           │           │
    ┌──────┴────┐ ┌───┴────┐ ┌────┴──────┐
    │  Hardware │ │  OS    │ │  Timing   │
    │   RNG     │ │Entropy │ │  Jitter   │
    └───────────┘ └────────┘ └───────────┘

Hardware RNG:
- CPU instructions (RDRAND, RDSEED on x86)
- True random number generators

OS Entropy:
- /dev/urandom (Unix/Linux)
- CryptGenRandom (Windows)
- System events, disk I/O, network timing

Timing Jitter:
- High-resolution timers
- Thread scheduling randomness
- Cache timing variations

This multi-source approach ensures that even if one entropy source is weak, others provide backup security.

Growth: From Secret to Public

With a secret key in hand, we need to derive its public key—the identity we can share with the world. This derivation is one of the beautiful ideas in modern cryptography: a mathematical function that's easy to compute in one direction but effectively impossible to reverse.

The One-Way Function

// Easy direction: secret → public (microseconds)
PublicKey publicKey = derivePublicKey(KeyType::ed25519, secretKey);

// Impossible direction: public → secret (longer than age of universe)
// There is NO function: secretKey = deriveSecretKey(publicKey);

This asymmetry is what makes public-key cryptography possible.

Two Algorithms, Two Approaches

XRPL supports two cryptographic algorithms, each with its own derivation process:

secp256k1: Elliptic Curve Point Multiplication

// From src/libxrpl/protocol/SecretKey.cpp
case KeyType::secp256k1: {
    secp256k1_pubkey pubkey_imp;

    // Multiply the generator point G by the secret key
    secp256k1_ec_pubkey_create(
        secp256k1Context(),
        &pubkey_imp,
        reinterpret_cast<unsigned char const*>(sk.data()));

    // Serialize to compressed format (33 bytes)
    unsigned char pubkey[33];
    std::size_t len = sizeof(pubkey);
    secp256k1_ec_pubkey_serialize(
        secp256k1Context(),
        pubkey,
        &len,
        &pubkey_imp,
        SECP256K1_EC_COMPRESSED);  // Compressed format

    return PublicKey{Slice{pubkey, len}};
}

How it works:

  • Elliptic curve has a special "generator" point G

  • Public key = Secret key × G (point multiplication on the curve)

  • Result is a point with X and Y coordinates

  • Compressed format stores X coordinate + one bit for Y (33 bytes total)

    • Prefix byte: 0x02 or 0x03 (indicates Y parity)

    • X coordinate: 32 bytes

Why it's secure:

  • Computing Public = Secret × G is fast

  • Computing Secret from Public requires solving the discrete logarithm problem

  • No known efficient algorithm exists for this problem

ed25519: Curve25519 Operations

case KeyType::ed25519: {
    unsigned char buf[33];
    buf[0] = 0xED;  // Type prefix
    ed25519_publickey(sk.data(), &buf[1]);
    return PublicKey(Slice{buf, sizeof(buf)});
}

How it works:

  • Uses Ed25519 curve operations (optimized variant of Curve25519)

  • Derives public key through curve arithmetic

  • Adds 0xED prefix byte to identify key type

  • Total 33 bytes (1 prefix + 32 public key)

Why it's secure:

  • Based on different curve with different security proofs

  • Specifically designed for signing (not encryption)

  • More resistant to implementation errors

The Beauty of One-Way Functions

Secret Key                Public Key
(32 random bytes)    →    (33 bytes)
    KEEP SECRET!           SHARE FREELY!
         │                      │
         │                      │
    ┌────▼────┐           ┌─────▼──────┐
    │Can sign │           │Can verify  │
    │messages │           │signatures  │
    └─────────┘           └────────────┘
         │                      │
         │                      │
    ┌────▼─────────────────────▼────┐
    │   Both needed to prove        │
    │   authorization                │
    └──────────────────────────────┘

The public key can be:

  • Posted on websites

  • Included in transactions

  • Sent to strangers

  • Stored in public databases

No matter who has it or what they do with it, they can't derive your secret key. Your private identity remains private.

Alternative Path: Deterministic Generation

Sometimes we don't want pure randomness. Sometimes we want to be able to recreate the exact same key pair from a remembered or stored value. This is where seed-based deterministic key generation comes in.

Why Deterministic Keys?

Problem with pure randomness:

Generate Key1 → Secret1, Public1
Generate Key2 → Secret2, Public2
Generate Key3 → Secret3, Public3

To backup: Must save Secret1, Secret2, Secret3, ...

Solution with seeds:

Remember one seed → Can regenerate all keys

Seed → Key1 (ordinal 0)
    → Key2 (ordinal 1)
    → Key3 (ordinal 2)
    → ...

How Seeds Work

A seed is a small piece of data—typically 16 bytes—that serves as the "master secret" for an entire family of keys:

// From src/libxrpl/protocol/SecretKey.cpp
std::pair<PublicKey, SecretKey>
generateKeyPair(KeyType type, Seed const& seed)
{
    switch (type)
    {
        case KeyType::secp256k1: {
            detail::Generator g(seed);
            return g(0);  // Generate the 0th key pair
        }
        case KeyType::ed25519: {
            auto const sk = generateSecretKey(type, seed);
            return {derivePublicKey(type, sk), sk};
        }
    }
}

For ed25519: Simple and Direct

// Hash the seed to get the secret key
SecretKey generateSecretKey(KeyType::ed25519, Seed const& seed)
{
    auto const secret = sha512Half_s(makeSlice(seed));  // Secure hash
    return SecretKey{secret};
}

Simple, deterministic, and secure. Same seed always produces same key.

For secp256k1: Handling Edge Cases

// Must ensure result is valid secret key
SecretKey deriveDeterministicRootKey(Seed const& seed)
{
    std::uint32_t ordinal = 0;

    // Try up to 128 times to find valid key
    for (int i = 0; i < 128; ++i)
    {
        // Create buffer with seed + ordinal
        std::array<std::uint8_t, 20> buf;
        std::copy(seed.data(), seed.data() + 16, buf.begin());
        buf[16] = (ordinal >> 24) & 0xFF;
        buf[17] = (ordinal >> 16) & 0xFF;
        buf[18] = (ordinal >>  8) & 0xFF;
        buf[19] = (ordinal >>  0) & 0xFF;

        // Hash it
        auto const secret = sha512Half(makeSlice(buf));

        // Check if it's a valid secret key
        if (isValidSecretKey(secret))
            return SecretKey{secret};

        // If not valid, try next ordinal
        ++ordinal;
    }

    // Should never happen (probability ~ 1 in 2^128)
    Throw<std::runtime_error>("Failed to generate key from seed");
}

Why the loop? Not all 32-byte values are valid secret keys for secp256k1. The value must be less than the curve's "order" (a large prime number). If the hash result is too large, increment a counter and try again.

The odds of needing more than one attempt are vanishingly small (roughly 1 in 2^128), but the code handles it correctly.

The Generator Pattern

The Generator class enables creating multiple independent keys from one seed:

detail::Generator g(seed);

auto [pub0, sec0] = g(0);  // First key pair
auto [pub1, sec1] = g(1);  // Second key pair
auto [pub2, sec2] = g(2);  // Third key pair

Each ordinal produces a cryptographically independent key pair. This enables powerful features like:

  • Hierarchical wallets: One seed, many accounts

  • Key rotation: Generate new keys without remembering multiple seeds

  • Backup simplicity: One seed backs up everything

From Key to Identity: Account IDs

A public key isn't an address. To get the human-readable XRPL address (starting with 'r'), we need one more transformation:

// From src/libxrpl/protocol/AccountID.cpp
AccountID calcAccountID(PublicKey const& pk)
{
    ripesha_hasher h;
    h(pk.data(), pk.size());
    return AccountID{static_cast<ripesha_hasher::result_type>(h)};
}

The RIPESHA Double Hash

Public Key (33 bytes)

  SHA-256 hash

  256-bit digest

  RIPEMD-160 hash

  160-bit (20 byte) Account ID

  Base58Check encode with type byte

  Address: rN7n7otQDd6FczFgLdlqtyMVrn3LNU8B4C

Why two hash functions?

  1. Compactness: 20 bytes instead of 33 bytes

  2. Defense in depth: If SHA-256 is broken, RIPEMD-160 provides protection; if RIPEMD-160 is broken, SHA-256 does

  3. Compatibility: Same scheme used by other blockchain systems

Why hash at all?

  • Shorter addresses are easier to use

  • Provides a level of indirection (can't derive public key from address)

  • Quantum-resistant: even if quantum computers break elliptic curve crypto, they can't derive the public key from the address alone

The Complete Lifecycle

Let's trace a key from birth to address:

// 1. BIRTH: Generate random secret key
SecretKey secretKey = randomSecretKey();
// Result: 32 random bytes
// Example: 0x1a2b3c4d...

// 2. GROWTH: Derive public key
PublicKey publicKey = derivePublicKey(KeyType::ed25519, secretKey);
// Result: 33 bytes (0xED prefix + 32 bytes)
// Example: 0xED9434799226374926EDA3B54B1B461B4ABF7237962EEB1144C10A7CA6A9D32C64

// 3. IDENTITY: Calculate account ID
AccountID accountID = calcAccountID(publicKey);
// Result: 20 bytes
// Example: 0x8B8A6C533F09CA0E5E00E7C32AA7EC323485ED3F

// 4. PRESENTATION: Encode as address
std::string address = toBase58(accountID);
// Result: Human-readable address
// Example: rN7n7otQDd6FczFgLdlqtyMVrn3LNU8B4C

Each step is irreversible:

  • Can't derive secret from public

  • Can't derive public from account ID

  • Can't derive account ID from address (but can decode)

Lifecycle Management: RAII and Secure Cleanup

The SecretKey class demonstrates proper lifecycle management:

class SecretKey
{
private:
    std::uint8_t buf_[32];

public:
    SecretKey(Slice const& slice)
    {
        std::memcpy(buf_, slice.data(), sizeof(buf_));
    }

    ~SecretKey()
    {
        // Automatically called when SecretKey goes out of scope
        secure_erase(buf_, sizeof(buf_));
    }

    // Prevent copying to avoid multiple erasures
    SecretKey(SecretKey const&) = delete;
    SecretKey& operator=(SecretKey const&) = delete;

    // Allow moving (transfers ownership)
    SecretKey(SecretKey&&) noexcept = default;
    SecretKey& operator=(SecretKey&&) noexcept = default;
};

RAII (Resource Acquisition Is Initialization):

  • Constructor acquires resource (the secret key)

  • Destructor releases resource (securely erases key)

  • No manual cleanup needed

  • Automatic cleanup even if exceptions occur

Usage pattern:

void signTransaction(Transaction const& tx)
{
    SecretKey sk = loadKeyFromSecureStorage();

    auto signature = sign(pk, sk, tx);

    // sk destructor automatically called here
    // Key material is securely erased
}

Even if sign() throws an exception, the destructor still runs and the key is erased. This is defensive programming—making it impossible to forget cleanup.

Key Type Detection

How does rippled know which algorithm a key uses? The first byte:

std::optional<KeyType> publicKeyType(Slice const& slice)
{
    if (slice.size() != 33)
        return std::nullopt;

    switch (slice[0])
    {
        case 0x02:
        case 0x03:
            return KeyType::secp256k1;
        case 0xED:
            return KeyType::ed25519;
        default:
            return std::nullopt;
    }
}

This automatic detection means higher-level code doesn't need to track key types—the keys themselves carry the information.

secp256k1 public key: 0x02[32 bytes] or 0x03[32 bytes]
ed25519 public key:   0xED[32 bytes]

Summary: The Key Lifecycle

BIRTH

  ├─ Random: crypto_prng() → 32 random bytes

  └─ Deterministic: hash(seed) → 32 bytes


   SECRET KEY (32 bytes)

       │ One-way function


   PUBLIC KEY (33 bytes)

       │ Double hash


   ACCOUNT ID (20 bytes)

       │ Base58Check encode


   ADDRESS (human-readable)


   SECURE CLEANUP
     (key erased from memory)

Key Takeaways

  1. Randomness is critical: Weak randomness = weak keys = stolen funds

  2. One-way functions enable public-key crypto: Easy to derive public from secret, impossible to reverse

  3. Two algorithms, same security guarantees: secp256k1 for compatibility, ed25519 for performance

  4. Deterministic generation enables backups: One seed can recover many keys

  5. Secure cleanup prevents leaks: Keys must be erased from memory when no longer needed

  6. RAII makes security automatic: Proper C++ patterns prevent human error

In the next chapter, we'll see how these keys are used to create and verify signatures—the mathematical proof of authorization that makes XRPL secure.

Last updated