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:
Allocate buffer: A 32-byte buffer is created on the stack
Fill with randomness:
crypto_prng()
fills it with cryptographically secure random bytes from OpenSSLCreate SecretKey: The buffer is wrapped in a
SecretKey
objectSecure 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 fastComputing
Secret
fromPublic
requires solving the discrete logarithm problemNo 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?
Compactness: 20 bytes instead of 33 bytes
Defense in depth: If SHA-256 is broken, RIPEMD-160 provides protection; if RIPEMD-160 is broken, SHA-256 does
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
Randomness is critical: Weak randomness = weak keys = stolen funds
One-way functions enable public-key crypto: Easy to derive public from secret, impossible to reverse
Two algorithms, same security guarantees: secp256k1 for compatibility, ed25519 for performance
Deterministic generation enables backups: One seed can recover many keys
Secure cleanup prevents leaks: Keys must be erased from memory when no longer needed
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