Common cryptographic mistakes to avoid and the performance characteristics of XRPL's cryptography.
What you'll learn
≈60 min · Advanced · builds on Transaction signing & verification
Cryptography rarely fails loudly; it fails subtly, years later, in production. This module is a tour of the ten ways crypto code goes wrong, each with the mistake, the danger, and the fix, followed by the real performance numbers behind XRPL's algorithm choices. Treat it as your crypto code-review checklist.
| # | Pitfall | The risk | The reflex |
|---|---|---|---|
| 1 | weak randomness | keys recoverable from the seed time | crypto_prng(), never rand() |
| 2 | secrets left in memory | dumps, swap, cold boot | secure_erase + RAII (SecretKey) |
| 3 | non-canonical signatures | transaction ID malleability | mustBeFullyCanonical = true |
| 4 | key reuse across contexts | cross-protocol replay | one key per purpose + hash prefixes |
| 5 | variable-time comparison | secrets leak byte by byte | CRYPTO_memcmp |
| 6 | short keys | brute force in seconds | 256 bits, always |
| 7 | homemade crypto | subtle, total breaks | OpenSSL / libsodium only |
| 8 | ignored error returns | using an uninitialized key | check, then fail loudly |
| 9 | hardcoded secrets | git history never forgets | env vars, config, HSM |
| 10 | missing validation | undefined behavior on bad input | validate before use |
std::srand(time(nullptr)) seeds from the clock. An attacker who knows roughly when a key was generated tries every second in the window (a day is only 86,400 seeds), regenerates each candidate key, and compares against the public key. Recovery takes seconds.
// BAD: predictable
std::srand(std::time(nullptr));
for (auto& byte : secretKey) byte = std::rand() % 256;
// GOOD: cryptographically secure, then wiped
std::uint8_t buf[32];
beast::rngfill(buf, sizeof(buf), crypto_prng());
SecretKey sk{Slice{buf, sizeof(buf)}};
secure_erase(buf, sizeof(buf));
Detection: any srand, rand, or std::mt19937 near key material is a finding. Good signs: crypto_prng(), RAND_bytes(), randomSecretKey().
Watch out. Predictable randomness means recoverable keys. Always seed key generation from a cryptographically secure source, never from
rand()or a timestamp.
Secrets that outlive their use end up in core dumps, swap files, hibernation images, and debugger reads. A function that returns while secretKeyHex still sits in a std::string has already lost.
// BAD: both copies of the secret survive the call
std::string secretKeyHex = loadFromConfig();
auto signature = sign(pk, parseHex(secretKeyHex), tx);
// GOOD: RAII wipes the key, and the temporary is erased explicitly
SecretKey sk = loadSecretKey(); // destructor erases
auto signature = sign(pk, sk, tx);
secure_erase(const_cast<char*>(secretKeyHex.data()), secretKeyHex.size());
Review checklist: temporary buffers erased, std::string secrets cleaned, secrets in RAII wrappers, no early return that skips cleanup. The Secure memory handling module covers the machinery in depth.
An secp256k1 signature can be malleated: transformed into a second, equally valid signature for the same transaction, which yields a different transaction ID. Applications tracking the original ID never see their confirmation; dependent transactions break.
// BAD: malleability allowed
return verify(tx.publicKey, tx.data, tx.signature, false);
// GOOD: fully canonical only
return verify(tx.publicKey, tx.data, tx.signature, true);
Detection: grep for verify(..., false) and verifyDigest(..., false); each one needs a written justification or a fix.
One master key signing transactions, handshakes, and validations means one captured signature can potentially replay in another context, and one compromise takes down everything.
// GOOD: one key per purpose
struct NodeKeys {
SecretKey accountKey; // account transactions
SecretKey nodeKey; // peer communication
SecretKey validationKey; // validator messages
};
rippled adds a second layer of defence: hash prefixes. sha512Half(HashPrefix::TransactionId, ...) and sha512Half(HashPrefix::Manifest, ...) can never collide, so even identical payloads produce different signed digests across contexts.
A byte-by-byte comparison returns faster when the first byte is wrong than when the first ten are right. Measured over enough attempts, that timing difference spells out the secret one byte at a time.
// BAD: early exit leaks the position of the first mismatch
for (size_t i = 0; i < a.size(); ++i)
if (a[i] != b[i]) return false;
// GOOD: constant time whatever the inputs
return CRYPTO_memcmp(a.data(), b.data(), a.size()) == 0;
Detection: memcmp and == on secret material are red flags; CRYPTO_memcmp / OPENSSL_memcmp are the fix.
Watch out. Compare secrets in constant time. A comparison that returns early leaks the secret one byte at a time.
A 64-bit key is 2^32 brute-force operations; a modern GPU does billions per second, so it falls in seconds. XRPL's standard is 256 bits (2^128 operations, quantum-resistant for the foreseeable future):
std::uint8_t strongKey[32]; // 32 bytes = 256 bits
crypto_prng()(strongKey, sizeof(strongKey));
Rule of thumb: 128 bits is the floor, 256 bits is the standard, more buys nothing.
XOR loops, homemade key schedules, unauthenticated modes: custom crypto fails against attacks its author never heard of (padding oracles, timing channels, weak scheduling). Standard algorithms have survived decades of expert scrutiny.
Never implement your own encryption, hashes, signatures, RNGs, or KDFs. Always wrap OpenSSL, libsodium, or another audited library, and use standard algorithms (AES-GCM, SHA-2, ed25519).
If RAND_bytes fails and nobody checks, the "key" is whatever was on the stack: zeros, predictable, or someone else's key material.
// GOOD: crypto failures are fatal, not warnings
if (RAND_bytes(buf, 32) != 1)
Throw<std::runtime_error>("RNG failure - cannot continue");
Check the return of every RAND_*, secp256k1_*, ed25519_*, EVP_*, and SSL_* call, and fail loudly. Never continue past a failed crypto operation.
A secret in source code lives forever in git history, ships in the binary as a recoverable string, appears in code reviews, and cannot be rotated without a release.
// GOOD: load at runtime from a store you can rotate
auto const seedStr = std::getenv("XRPL_SEED"); // env var
auto const seedStr = readSecureConfig("seed"); // file, mode 600
auto const seedStr = loadFromHSM(); // hardware module
Habits: never commit secrets, restrict file permissions, prefer a secrets manager, rotate regularly, audit access.
Decoding an address that was never validated is undefined behavior waiting to happen; skipping validation is how security checks get bypassed.
// GOOD: validate, then decode, then check existence
if (!isValidAddress(addressStr))
throw std::invalid_argument("Invalid address format");
AccountID account = decodeAddress(addressStr);
if (!ledger.hasAccount(account))
throw std::runtime_error("Account not found");
Validate everything: public keys (format, size, curve point), signatures (canonical, size, encoding), addresses (checksum, prefix), seeds (format, entropy), amounts (sign, limits).
In brief: the tools for inspecting crypto behaviour; the Development & debugging techniques module covers general debugging in depth.
| Tool | Command / entry point | Use it for |
|---|---|---|
| Trace logging | [rpc_startup] + log_level ... trace |
watching sign/verify decisions live |
| Standalone mode | ./xrpld --standalone + ledger_accept |
a private ledger for experiments |
| Test accounts | ./xrpld wallet_propose ed25519 |
fresh keys of either type |
| gdb | break sign if publicKeyType(pk) == KeyType::ed25519 |
stepping into crypto calls; x/32xb &secretKey to dump bytes |
| Valgrind | --leak-check=full, --track-origins=yes |
leaked or uninitialized key material |
| AddressSanitizer | -fsanitize=address at compile |
buffer overflows around key buffers |
| Unit tests | ./xrpld --unittest=xrpl.protocol.SecretKey |
the crypto suites in src/test/protocol |
Two patterns worth keeping at hand. First, a signature verification post-mortem is always the same checklist in order: valid public key type? expected signature size (64 bytes for ed25519)? canonical encoding (secp256k1)? Only then run verify and, on failure, suspect wrong key, wrong message, corrupted signature, or algorithm mismatch.
Second, checking canonicality directly:
auto canon = ecdsaCanonicality(signature);
if (canon == ECDSACanonicality::fullyCanonical)
; // S <= order/2: safe against malleability
else
; // canonical but malleable, or invalid DER: normalize or reject
In brief: the real cost of signing and verifying, and how ed25519 and secp256k1 compare at network scale.
ed25519 wins for three reasons: a curve designed in 2011 with performance in mind (secp256k1 dates from 2000), simpler point arithmetic that fits CPU caches better, and no DER encoding overhead (raw 64-byte signatures).
Every validator verifies every transaction's signature, so verification cost multiplies across the network:
1,000 tx/s x 50 validators = 50,000 verifications/second
secp256k1: 50,000 x 500 us = 25 s of CPU time per second (impossible)
ed25519: 50,000 x 100 us = 5 s of CPU time per second
| Use ed25519 for | Keep secp256k1 for |
|---|---|
| new accounts (recommended default) | existing accounts (keys cannot change) |
| high-throughput applications | cross-chain compatibility |
| anything performance-sensitive | legacy integrations |
SHA-512-Half exists because 64-bit CPUs run SHA-512's 64-bit operations faster than SHA-256's 32-bit ones: you get SHA-512 speed with SHA-256's 32-byte output. Hashing a 1 KB transaction costs about 1.5 us, noise next to a 100-500 us signature verification; hashing is never the bottleneck.
Three caches do most of the work, and they share one rule: only cache verified results, and expire entries.
| Cache | Keyed by | Saves |
|---|---|---|
| public keys | AccountID | repeated derivation and ledger lookups |
| verification results | transaction hash | re-verifying the same transaction (10 min TTL) |
| SHAMap node hashes | tree node | recomputing unchanged subtrees on every change |
The shape is always the same thread-safe read-through pattern:
std::optional<PublicKey> cached = keyCache.get(account); // shared_lock
if (cached) return *cached;
auto pk = deriveFromLedger(account); // slow path
keyCache.put(account, pk); // unique_lock, bounded size
return pk;
ed25519 (and only ed25519) supports batch verification: ed25519_sign_open_batch checks N signatures in roughly 1.2x the time of one, instead of N times. The catch: if the batch fails, one signature is bad and you must re-verify individually to find it.
Signature checks are CPU-bound and independent, so they parallelise perfectly across cores (#pragma omp parallel for, or std::async per transaction). Caches shared between those threads need their locks.
| Do | Don't |
|---|---|
| default new accounts to ed25519 | sacrifice security for speed (skip canonicality, shrink keys) |
| cache keys, verifications, hashes | cache anything before it verified |
| batch ed25519 verifications | micro-optimise hashing (~1 us) while signatures cost 100-500 us |
| profile before optimising | forget thread safety around shared caches |
XRPL mainnet, order of magnitude:
150 tx/ledger x 40 validators / 4 s = 1,500 verifications/second
ed25519: 1,500 x 100 us = 15 percent of one CPU
secp256k1: 1,500 x 500 us = 75 percent of one CPU
Same hardware, five times the headroom: that is why the recommendation for every new account is ed25519.
This module was a tour of the ways cryptography goes subtly wrong, and how XRPL avoids each: weak randomness, leaked secrets, non-canonical signatures, key reuse, and timing attacks. You also compared the real cost of secp256k1 and ed25519 signing and verification at network scale. Treat it as your crypto code-review checklist.
To remember:
include/xrpl/crypto/csprng.h), never rand()secure_erase + RAII (the Secure memory handling module's habit)Next up. Cryptography closed. New phase: your node has identity and secrets, but zero friends. Next: the overlay network, rippled's own layer above TCP.
Resources
Assignments
0 of 2 complete