How rippled turns randomness (or a seed) into secp256k1 / ed25519 key pairs and account IDs.
What you'll learn
≈45 min · Advanced · builds on Hash functions in XRPL
Where does an XRPL account actually come from? In this module you'll follow the path from randomness (or a seed) to a full key pair (for both secp256k1 and ed25519) and on to an account ID and address. You'll see how a seed makes the whole thing reproducible, and why the two signature schemes derive their keys so differently.
Rippled supports two approaches to key generation:
In brief: make a fresh key pair from cryptographically secure randomness.
randomSecretKey()// 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;
}
Step-by-step breakdown:
crypto_prng() to fill with random bytesSecretKey objectSecretKey object (move semantics, no copy)// 32 bytes = 256 bits
std::uint8_t buf[32];
// This provides 2^256 possible keys
// That's approximately 10^77 combinations
// More than atoms in the observable universe!
Security level:
std::pair<PublicKey, SecretKey> randomKeyPair(KeyType type)
{
// Generate random secret key
SecretKey sk = randomSecretKey();
// Derive public key from secret
PublicKey pk = derivePublicKey(type, sk);
return {pk, sk};
}
In brief: regenerate the same key pair from a seed, every time.
A seed is a compact representation (typically 16 bytes) from which many keys can be derived:
// Seed structure
class Seed
{
private:
std::array<std::uint8_t, 16> buf_; // 128 bits
public:
// Construction, access, etc.
};
Why seeds matter:
std::pair<PublicKey, SecretKey>
generateKeyPair(KeyType type, Seed const& seed)
{
switch (type)
{
case KeyType::secp256k1:
return generateSecp256k1KeyPair(seed);
case KeyType::ed25519:
return generateEd25519KeyPair(seed);
}
}
// For ed25519, derivation is straightforward
case KeyType::ed25519: {
// Hash the seed to get secret key
auto const sk = generateSecretKey(type, seed);
// Derive public key from secret
return {derivePublicKey(type, sk), sk};
}
SecretKey generateSecretKey(KeyType::ed25519, Seed const& seed)
{
// Simply hash the seed
auto const secret = sha512Half_s(makeSlice(seed));
return SecretKey{secret};
}
Why this works:
// For secp256k1, need to handle curve order constraint
case KeyType::secp256k1: {
detail::Generator g(seed);
return g(0); // Generate the 0th key pair
}
Why more complex?
Not all 32-byte values are valid secp256k1 secret keys. The value must be:
// secp256k1 curve order
// Any secret key must be: 0 < key < order
static const uint256 CURVE_ORDER =
"0xFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFEBAAEDCE6AF48A03BBFD25E8CD0364141";
class Generator
{
private:
Seed seed_;
public:
explicit Generator(Seed const& seed) : seed_(seed) {}
// Generate the n-th key pair
std::pair<PublicKey, SecretKey> operator()(std::uint32_t ordinal)
{
// Derive root key from seed
SecretKey rootKey = deriveRootKey(seed_, ordinal);
// Derive public key
PublicKey publicKey = derivePublicKey(KeyType::secp256k1, rootKey);
return {publicKey, rootKey};
}
};
SecretKey deriveRootKey(Seed const& seed, std::uint32_t ordinal)
{
// Try up to 128 times to find valid key
for (int i = 0; i < 128; ++i)
{
// Create buffer: seed (16 bytes) + ordinal (4 bytes)
std::array<std::uint8_t, 20> buf;
// Copy seed
std::copy(seed.data(), seed.data() + 16, buf.begin());
// Append ordinal (big-endian)
buf[16] = (ordinal >> 24) & 0xFF;
buf[17] = (ordinal >> 16) & 0xFF;
buf[18] = (ordinal >> 8) & 0xFF;
buf[19] = (ordinal >> 0) & 0xFF;
// Hash it
auto const candidate = sha512Half(makeSlice(buf));
// Check if valid secp256k1 secret key
if (isValidSecretKey(candidate))
return SecretKey{candidate};
// Not valid, increment ordinal and try again
++ordinal;
}
// Should never reach here (probability ~ 1 in 2^128)
Throw<std::runtime_error>("Failed to derive key from seed");
}
bool isValidSecretKey(uint256 const& candidate)
{
// Must be in range: 0 < candidate < CURVE_ORDER
return candidate > 0 && candidate < CURVE_ORDER;
}
Why this loop?
The probability that a random 256-bit value is >= CURVE_ORDER is approximately 1 in 2^128.
This is so unlikely that we almost never need a second try, but the code handles it correctly.
Incrementing ordinal:
If the first hash isn't valid, we increment the ordinal and try again. This ensures:
Key idea. The seed is the real secret: it deterministically regenerates the key pair, which is why backing up the seed is the same as backing up the account.
In brief: derive the public key from the private key (differently for secp256k1 and ed25519).
PublicKey derivePublicKey(KeyType::secp256k1, SecretKey const& sk)
{
secp256k1_pubkey pubkey_imp;
// Perform elliptic curve point multiplication: PublicKey = SecretKey × G
secp256k1_ec_pubkey_create(
secp256k1Context(),
&pubkey_imp,
reinterpret_cast<unsigned char const*>(sk.data()));
// Serialize to compressed format
unsigned char pubkey[33];
std::size_t len = sizeof(pubkey);
secp256k1_ec_pubkey_serialize(
secp256k1Context(),
pubkey,
&len,
&pubkey_imp,
SECP256K1_EC_COMPRESSED); // 33 bytes: prefix + X coordinate
return PublicKey{Slice{pubkey, len}};
}
Compressed vs Uncompressed:
Uncompressed: 0x04 | X (32 bytes) | Y (32 bytes) = 65 bytes
Compressed: 0x02/0x03 | X (32 bytes) = 33 bytes
Prefix byte indicates Y parity:
- 0x02: Y is even
- 0x03: Y is odd
Why compress?
PublicKey derivePublicKey(KeyType::ed25519, SecretKey const& sk)
{
unsigned char buf[33];
buf[0] = 0xED; // Type prefix marker
// Derive public key using Ed25519 algorithm
ed25519_publickey(sk.data(), &buf[1]);
return PublicKey(Slice{buf, sizeof(buf)});
}
Simpler than secp256k1:
In brief: hash the public key (RIPEMD160 of SHA-256) to get the account ID.
Once we have a public key, we derive the account ID:
AccountID calcAccountID(PublicKey const& pk)
{
ripesha_hasher h;
h(pk.data(), pk.size());
return AccountID{static_cast<ripesha_hasher::result_type>(h)};
}
class ripesha_hasher
{
private:
openssl_sha256_hasher sha_;
public:
void operator()(void const* data, std::size_t size)
{
// First: SHA-256
sha_(data, size);
}
operator result_type()
{
// Get SHA-256 result
auto const sha256_result =
static_cast<openssl_sha256_hasher::result_type>(sha_);
// Second: RIPEMD-160 of SHA-256
ripemd160_hasher ripe;
ripe(sha256_result.data(), sha256_result.size());
return static_cast<result_type>(ripe);
}
};
The pipeline:
Why double hash?
The final step is encoding the account ID as a human-readable address:
std::string toBase58(AccountID const& accountID)
{
return encodeBase58Token(
TokenType::AccountID,
accountID.data(),
accountID.size());
}
Result:
Account ID (20 bytes): 0x8B8A6C533F09CA0E5E00E7C32AA7EC323485ED3F
Address: rN7n7otQDd6FczFgLdlqtyMVrn3LNU8B4C
We'll explore Base58Check encoding in detail in the Base58Check encoding module.
// Generate random ed25519 key pair
auto [publicKey, secretKey] = randomKeyPair(KeyType::ed25519);
// Derive account ID
AccountID accountID = calcAccountID(publicKey);
// Encode as address
std::string address = toBase58(accountID);
std::cout << "Public Key: " << strHex(publicKey) << "\n";
std::cout << "Account ID: " << strHex(accountID) << "\n";
std::cout << "Address: " << address << "\n";
// Create seed from passphrase (EXAMPLE ONLY - don't do this in production!)
Seed seed = generateSeedFromPassphrase("my secret passphrase");
// Generate deterministic key pair
auto [publicKey, secretKey] = generateKeyPair(KeyType::secp256k1, seed);
// Same seed always produces same keys
auto [publicKey2, secretKey2] = generateKeyPair(KeyType::secp256k1, seed);
assert(publicKey == publicKey2);
assert(secretKey == secretKey2);
// Derive account
AccountID accountID = calcAccountID(publicKey);
std::string address = toBase58(accountID);
std::cout << "Address: " << address << "\n";
Seed seed = /* ... */;
// Create generator
detail::Generator gen(seed);
// Generate multiple accounts
auto [pub0, sec0] = gen(0);
auto [pub1, sec1] = gen(1);
auto [pub2, sec2] = gen(2);
// Each has different address
AccountID acc0 = calcAccountID(pub0);
AccountID acc1 = calcAccountID(pub1);
AccountID acc2 = calcAccountID(pub2);
std::cout << "Account 0: " << toBase58(acc0) << "\n";
std::cout << "Account 1: " << toBase58(acc1) << "\n";
std::cout << "Account 2: " << toBase58(acc2) << "\n";
std::optional<KeyType> publicKeyType(Slice const& slice)
{
if (slice.size() != 33)
return std::nullopt;
// Check first byte
switch (slice[0])
{
case 0x02:
case 0x03:
return KeyType::secp256k1;
case 0xED:
return KeyType::ed25519;
default:
return std::nullopt;
}
}
Buffer sign(PublicKey const& pk, SecretKey const& sk, Slice const& m)
{
// Automatically detect which algorithm to use
auto const type = publicKeyType(pk.slice());
switch (*type)
{
case KeyType::ed25519:
return signEd25519(pk, sk, m);
case KeyType::secp256k1:
return signSecp256k1(pk, sk, m);
}
}
secp256k1_context const* secp256k1Context()
{
// Thread-local context for performance
static thread_local std::unique_ptr<
secp256k1_context,
decltype(&secp256k1_context_destroy)>
context{
secp256k1_context_create(
SECP256K1_CONTEXT_SIGN | SECP256K1_CONTEXT_VERIFY),
&secp256k1_context_destroy
};
return context.get();
}
// ❌ WRONG
void badExample() {
SecretKey sk = randomSecretKey();
}
// ✅ CORRECT
void goodExample() {
SecretKey sk = randomSecretKey();
}
bool validateKeys(PublicKey const& pk, SecretKey const& sk)
{
auto derived = derivePublicKey(publicKeyType(pk).value(), sk);
return derived == pk;
}
class Seed {
~Seed() {
secure_erase(buf_.data(), buf_.size());
}
};
Ed25519:
- Secret key generation: ~50 µs
- Public key derivation: ~50 µs
- Total: ~100 µs
Secp256k1:
- Secret key generation: ~50 µs
- Public key derivation: ~100 µs
- Total: ~150 µs
std::vector<std::pair<PublicKey, SecretKey>> generateKeys(int count)
{
std::vector<std::pair<PublicKey, SecretKey>> keys;
keys.reserve(count);
for (int i = 0; i < count; ++i) {
keys.push_back(randomKeyPair(KeyType::ed25519));
}
return keys;
}
This module followed an account from randomness to address. You generated key pairs both randomly and deterministically from a seed, for secp256k1 and ed25519, derived public keys and then account IDs (RIPEMD160 of SHA-256), and saw why the two schemes derive their keys differently. The seed is the real secret: it regenerates the whole key pair, which is why backing up the seed is backing up the account.
To remember:
ED, 33 bytes)randomSecretKey() pulls 32 bytes from the CSPRNGwallet_propose with "key_type" picks the algorithmsrc/libxrpl/protocol/SecretKey.cpp, PublicKey.cppNext up. You can create secrets; can you keep them? Next: secure memory handling, or how rippled makes sure keys never outlive their use.
Resources
Assignments
0 of 2 complete