How two nodes prove identity and bind a session cryptographically during the peer handshake.
What you'll learn
≈30 min · Advanced · builds on Transaction signing & verification
Before two nodes trust each other, they have to prove who they are, cryptographically. In this module you'll learn how the peer handshake derives a shared value from the TLS session to bind the connection, how each node proves it owns its key, and how that stops man-in-the-middle and replay attacks cold. It's the moment a stranger becomes a trusted peer.
In brief: two strangers must prove their identities and confirm they share one secure channel.
1. Man-in-the-Middle (MITM)
2. Replay Attack
Evil records handshake messages from previous session
Replays them to impersonate Node A
3. Self-Connection
Node A tries to connect to itself through network loop
Could cause infinite recursion/waste resources
4. Network Mismatch
Mainnet node connects to testnet node
Could cause confusion/invalid transactions
In brief: a value derived from the TLS session that ties the handshake to this exact connection.
Signatures alone aren't enough:
// ❌ INSECURE: Sign static message
auto sig = sign(pk, sk, "I am Node A");
// Problem: Can be replayed in future connections!
We need something unique to THIS specific connection:
// ✅ SECURE: Sign session-specific value
auto sharedValue = deriveFromSSL(session);
auto sig = sign(pk, sk, sharedValue);
// Can only be used for THIS session
// From src/xrpld/overlay/detail/Handshake.cpp
std::optional<uint256>
makeSharedValue(stream_type& ssl, beast::Journal journal)
{
// Get our "Finished" message from SSL handshake
auto const cookie1 = hashLastMessage(
ssl.native_handle(),
SSL_get_finished);
// Get peer's "Finished" message from SSL handshake
auto const cookie2 = hashLastMessage(
ssl.native_handle(),
SSL_get_peer_finished);
if (!cookie1 || !cookie2)
return std::nullopt;
// XOR the two hashes together
auto const result = (*cookie1 ^ *cookie2);
// Ensure they're not identical (would result in zero)
if (result == beast::kZero)
{
JLOG(journal.error()) << "Identical finished messages";
return std::nullopt;
}
// Hash the XOR result to get final shared value
return sha512Half(Slice(result.data(), result.size()));
}
static std::optional<base_uint<512>>
hashLastMessage(
SSL const* ssl,
size_t (*get)(const SSL*, void*, size_t))
{
// Buffer for SSL finished message
unsigned char buf[1024];
size_t len = get(ssl, buf, sizeof(buf));
if (len < 12) // Minimum valid length
return std::nullopt;
// Hash it with SHA-512
base_uint<512> cookie;
SHA512(buf, len, cookie.data());
return cookie;
}
What are "Finished" messages?
In the SSL/TLS handshake, both parties send a "Finished" message that contains:
These messages are:
Key idea. Binding the proof to the TLS session is the trick that defeats man-in-the-middle: a stolen handshake is worthless on any other connection.
In brief: what each side sends to prove it owns its node key.
// From src/xrpld/overlay/detail/Handshake.cpp
void buildHandshake(
boost::beast::http::fields& h,
xrpl::uint256 const& sharedValue,
std::optional<std::uint32_t> networkID,
beast::IP::Address public_ip,
beast::IP::Address remote_ip,
Application& app)
{
// 1. Network identification
if (networkID)
h.insert("Network-ID", std::to_string(*networkID));
// 2. Timestamp (freshness, prevent replay)
h.insert("Network-Time",
std::to_string(app.timeKeeper().now().time_since_epoch().count()));
// 3. Node's public key
h.insert("Public-Key",
toBase58(TokenType::NodePublic, app.nodeIdentity().first));
// 4. CRITICAL: Session signature
auto const sig = signDigest(
app.nodeIdentity().first, // Public key
app.nodeIdentity().second, // Secret key
sharedValue); // Session-specific value
h.insert("Session-Signature", base64_encode(sig));
// 5. Instance cookie (prevent self-connection)
h.insert("Instance-Cookie",
std::to_string(app.getInstanceCookie()));
// 6. Optional: Server domain
auto const domain = app.config().SERVER_DOMAIN;
if (!domain.empty())
h.insert("Server-Domain", domain);
// 7. Ledger information
if (auto closed = app.getLedgerMaster().getClosedLedger())
h.insert("Closed-Ledger", to_string(closed->info().hash));
}
Network-ID:
// Mainnet: 0
// Testnet: 1
// Devnet: 2, etc.
// Prevents nodes from different networks connecting
Network-Time:
// Current time in milliseconds since epoch
// Helps detect replayed handshakes (timestamps too old)
// Not strictly enforced (clocks may be slightly off)
Public-Key:
// Node's public key in Base58 format
// Example: nHUpcmNsxAw47yt2ADDoNoQrzLyTJPgnyq5o3xTmMcgV8X3iVVa7
// Used to verify the signature
Session-Signature:
// Signature of the shared value
// Proves: "I have the secret key for this public key"
// AND "I'm participating in THIS specific SSL session"
Instance-Cookie:
// Random value generated on node startup
// If we receive our own cookie back → we're connecting to ourselves!
Server-Domain (optional):
// Domain name like "ripple.com"
// Can be verified against validator list
// Helps with node identification
std::optional<PublicKey>
verifyHandshake(
http_request_type const& request,
uint256 const& sharedValue,
std::optional<std::uint32_t> networkID,
uint64_t instanceCookie,
beast::Journal journal)
{
// 1. Extract and parse public key
auto const pkStr = request["Public-Key"];
auto const pk = parseBase58<PublicKey>(
TokenType::NodePublic,
pkStr);
if (!pk)
{
JLOG(journal.warn()) << "Invalid public key";
return std::nullopt;
}
// 2. Check network ID matches
if (networkID)
{
auto const theirNetworkID = request["Network-ID"];
if (theirNetworkID.empty() ||
std::to_string(*networkID) != theirNetworkID)
{
JLOG(journal.warn()) << "Network ID mismatch";
return std::nullopt;
}
}
// 3. Check for self-connection
auto const theirCookie = request["Instance-Cookie"];
if (theirCookie == std::to_string(instanceCookie))
{
JLOG(journal.warn()) << "Detected self-connection";
return std::nullopt;
}
// 4. Verify session signature
auto const sigStr = request["Session-Signature"];
auto const sig = base64_decode(sigStr);
if (!verifyDigest(*pk, sharedValue, sig, true))
{
JLOG(journal.warn()) << "Invalid session signature";
return std::nullopt;
}
// 5. Optional: Validate server domain
auto const domain = request["Server-Domain"];
if (!domain.empty() && !isProperlyFormedTomlDomain(domain))
{
JLOG(journal.warn()) << "Invalid server domain";
return std::nullopt;
}
// Success! Return authenticated public key
JLOG(journal.info()) << "Handshake verified for " << toBase58(*pk);
return pk;
}
In brief: what the handshake guarantees: mutual authentication, session binding, no replay, no MITM.
Both nodes prove they possess their private keys:
Node A proves: "I have SK_A"
Node B proves: "I have SK_B"
Signatures are specific to this connection:
Signature valid ONLY for THIS SSL session
Cannot be replayed in different session
sharedValue = derived from THIS session's SSL handshake
Old signatures from previous sessions won't verify
Attacker cannot forge signatures without private keys
SSL provides encryption, handshake provides authentication
if (theirCookie == myCookie) {
// We're talking to ourselves!
reject();
}
if (theirNetwork != myNetwork) {
// Different networks (mainnet vs testnet)
reject();
}
No:
Attacker needs to:
1. Know Node A's secret key (impossible - properly secured)
2. Sign the shared value (requires secret key)
Without SK_A, cannot create valid signature
No:
Shared value is different for each SSL session
Old signature: sign(SK, oldSharedValue)
New session: verify(PK, newSharedValue, oldSignature)
Result: Verification fails (different shared values)
Very difficult:
SSL provides:
- Encryption (attacker can't read/modify)
- Certificate validation (can detect impersonation)
Application handshake provides:
- Signature verification (requires private keys)
- Session binding (tied to SSL session)
Attacker would need to:
1. Break SSL (extremely difficult)
2. AND forge signatures (impossible without keys)
// 1. Always verify the shared value
auto sharedValue = makeSharedValue(ssl, journal);
if (!sharedValue) {
disconnect("Failed to create shared value");
}
// 2. Always require canonical signatures
if (!verifyDigest(pk, sharedValue, sig, true)) {
disconnect("Invalid signature");
}
// 3. Always check network ID
if (theirNetwork != myNetwork) {
disconnect("Network mismatch");
}
// 4. Always check instance cookie
if (theirCookie == myCookie) {
disconnect("Self-connection detected");
}
// ❌ Don't skip signature verification
if (config.TRUSTED_NODE) {
// Skip verification - WRONG!
}
// ❌ Don't ignore network ID
// connect(); // Oops, might be wrong network
// ❌ Don't allow self-connections
// They waste resources and can cause issues
// Handshake happens once per connection
// Not a performance bottleneck
Typical handshake time:
- SSL/TLS handshake: 50-100ms
- Shared value computation: <1ms
- Signature creation: <1ms
- Signature verification: <1ms
Total: ~50-100ms
// Amortized over connection lifetime (hours/days)
// Cost is negligible
This module covered how two strangers become trusted peers. The handshake derives a shared value from the TLS session, binding the proof to that exact connection, and each node proves it owns its node key. Together these give mutual authentication and defeat man-in-the-middle and replay attacks: a stolen handshake is worthless on any other connection.
To remember:
makeSharedValue)buildHandshake / verifyHandshakesrc/xrpld/overlay/detail/Handshake.cppn...), not a validator's master key; do not conflate the twoNext up. The crypto all works; now learn where it quietly breaks. Next, the tour every reviewer needs: crypto pitfalls and performance.
Resources
Assignments
0 of 2 complete