The handshake process plays a pivotal role in establishing a foundation of trust and encryption. One such robust and efficient method is the Elliptic-Curve Diffie-Hellman (ECDH) key exchange algorithm, which facilitates secure communication.
Cyber threats becoming increasingly sophisticated and here at Runtime Revolution we know as technology evolves it becomes imperative for developers to leverage advanced cryptographic protocols. ECDH, known for its efficiency and security, stands out as a key player in this arena. Therefore, it’s important to understand it.
(Small example, a 2048-bit RSA key is considered roughly equivalent in security to a 224-bit ECC key)
Now, all computer engineers have heard of SSL (or SSL/TLS) for public-key cryptosystem. ECDH belongs to the same universe and differs only by using coordinates on curves standardized by NISTto generate the keys.
In both cases,Diffie-Hellmanis used to enable the vital exchange of keys to derive the secret for encryption without compromising both good old Alice and Bob. The image below explains the algorithm without going into much mathematical detail.

Most of the communication is enabled in a standardized curve (common paint). As we see, both parties generate a key pair (secret colors) each having their own purpose sending publicly and receiving each others key (public transport). Therefore, they derive their own common secret to establish a secure connection.
Groundwork
Now, handshake negotiation is common on most protocols and to start on our side, most browsers added support for Web Crypto API which helps by including cryptographic primitives to build tools like ours, therefore, such a tool needs to be manipulated more easily, so I created the following service:
// Store our private key on IndexedDB
import {IndexedDBService} from './indexedDBHelpers';
// Setup constants for options
const EXTRACTABLE = true;
const CONNECTION_USAGE = ['encrypt'];
const KEY_USAGE = ['deriveKey', 'deriveBits'];
const ALGORITHM = {name: 'ECDH', namedCurve: 'P-256'};
const DERIVE_ALGORITHM = {name: 'AES-CTR', length: 256};
const ENCRYPT_ALGORITHM = {name: DERIVE_ALGORITHM.name, length: 128};
export const CriptographyService = {
// Generate key pair
generate: () => (
window.crypto.subtle
.generateKey(ALGORITHM, EXTRACTABLE, KEY_USAGE)
.then(keys => (
// Store private key and obtain keys
IndexedDBService.set(1, keys.privateKey) && keys
))
),
// Export key to base64
export: (key) => (
window.crypto.subtle
.exportKey('spki', key)
.then(buffer => (
window.btoa(String.fromCharCode.apply(null, new Uint8Array(buffer)))
))
),
// Import key from base64
import: (key) => (
window.crypto.subtle
.importKey(
'raw',
Uint8Array.from(window.atob(key), (c) => c.charCodeAt(0)).buffer,
ALGORITHM,
EXTRACTABLE,
KEY_USAGE,
)
),
// Encrypt data with key
encrypt: (key, data) => {
const counter = crypto.getRandomValues(new Uint8Array(16));
return window.crypto.subtle
.encrypt({...ENCRYPT_ALGORITHM, counter}, key, new TextEncoder().encode(data))
.then(encryptedData => ({
iv: window.btoa(String.fromCharCode.apply(null, counter)),
encrypted: window.btoa(
String.fromCharCode.apply(null, new Uint8Array(encryptedData))
),
}));
},
// Derive key from server public key
computeSecret: async (publicKey) => (
window.crypto.subtle.deriveKey(
{...ALGORITHM, public: publicKey},
// Get private key
await IndexedDBService.get(1),
DERIVE_ALGORITHM,
EXTRACTABLE,
CONNECTION_USAGE,
)
),
};Now, all NIST curve-names are as follows: P-256(prime256v1), P-384(secp384r1) and P-521(secp521r1)
For reference, P-256 is equal to prime256v1 (curve supported by OpenSSL::EC::PKey) like the rest, which decides where the pair is generated with the purpose of encrypting but keys have differing usages, ones for deriving and others for connections.
Connections need an extra layer to prevent tampering and need particular algorithms like: HMAC, AES-CTR, AES-CBC, AES-GCM, AES-KW, HKDFandPBKDF2, designed to verify both the data integrity and authenticity of a message. Now, to receive information upon encryption, we added the following to handle the server-side handshake negotiations.
class HandshakeManagerService
attr_accessor :initiated, :client_public_key
EXPIRE_TIME = 1.minute
CURVE_NAME = 'prime256v1'.freeze # P-256 on Web Crypto API
DERIVE_NAME = 'aes-256-ctr'.freeze
def initialize(params = {})
@iv = params[:iv]
@token = params[:token]
@encrypted = params[:encrypted]
@client_public_key = params[:public_key]
@initiated = @client_public_key.present?
@content = nil
@shared_key = nil
end
def initiate
# Import client public key
client = OpenSSL::PKey::EC.new(Base64.strict_decode64(client_public_key))
# Generate key pair
server = OpenSSL::PKey::EC.new(CURVE_NAME)
server.generate_key!
# Convert generated key pair to base64
server_public_key = Base64.strict_encode64(server.public_key.to_bn.to_s(2))
# Derive shared key from client public key
@shared_key = server.dh_compute_key(client.public_key)
# Upon each request, unique to token shared key is stored
Rails.cache.write(@token, Base64.strict_encode64(@shared_key), expires_in: EXPIRE_TIME)
{token: @token, public_key: server_public_key}
end
def on_initialize(&block)
return initiate if initiated
if @token
# Get private key particular to a given token
@shared_key = Base64.strict_decode64(Rails.cache.read(@token))
# Upon adding IV and Shared key, decipher encrypted data
@iv = Base64.strict_decode64(@iv)
decipher = OpenSSL::Cipher.new(DERIVE_NAME)
decipher.decrypt
decipher.key = @shared_key
decipher.iv = @iv
decrypted_content = decipher.update(Base64.strict_decode64(@encrypted)) + decipher.final
# Parse decrypted json content
@content = JSON.parse(decrypted_content, symbolize_names: true)
end
return block.call(@content) if @content
block.call
end
endMost of the algorithm rests on being able to add handshake negotiation seamlessly so all it needs further to work is the following:
def handshake(&block)
HandshakeManagerService.new(params).on_initialize do |parameters|
block.call(parameters)
end
endEndpoint logic can be encapsulated with the handshake block remaining the same. This is because parameters get passed unencrypted upon finishing the handshake, allowing for further exploration. However, I would recommend otherwise since this was only for testing purposes to thoroughly understand ECDH.
Handshake

In a standard handshake (image above), the client and server exchanging messages have to agree on how to communicate securely. The client initiates by sharing its preferences, and the server responds with compatible choices. Authentication occurs through digital certificates, and both parties derive a shared secret key for secure communication. The handshake concludes with a message confirming the transition to encrypted data exchange, ensuring confidentiality and verifying the authenticity of the communicating entities.
In our handshake process, the client takes the lead by promptly sending its public key in the first response, initiating a secure exchange. Anticipation of a server public key in return, followed by the derivation of a shared key, reinforces the uniqueness of each key exchange.
One-tap keys, used only once, add an extra layer of security by preventing any potential compromise from affecting subsequent communications. Emphasizing the vital importance of regular key renewal and cleaning demonstrates a proactive stance towards security maintenance, reducing the window of vulnerability and potential exposure.
The last thing to be added is the handshake itself so we can unite both applications, which in our case was by adding a redux saga function.
// Function gets 2 flags: initiated, established and processes request
function* processHandshake(request, initiated, established) {
if (initiated) {
const {endpoint, method, actions} = request.payload[API_CALL];
// Generate pair on initiated flag set
const keyPair = yield call(() => CriptographyService.generate());
// Export public key base64
const publicKey = yield call(() => CriptographyService.export(keyPair.publicKey));
// Generate random token
const token = window.crypto.randomUUID();
// Add to initiated request and send to server
const initiatedRequest = {
actions,
method,
endpoint,
action: request.payload,
body: {token, public_key: publicKey},
};
yield fork(processRequest, {
type: request.type,
payload: {[API_CALL]: initiatedRequest},
});
}
if (established) {
const {endpoint, method, actions} = request.payload.action[API_CALL];
const {payload: {response: {result: {token, public_key: publicKey}}, action: {[API_CALL]: {body}}}} = request;
// Upon established flag set, import base64 public key
const serverPublicKey = yield call(() => CriptographyService.import(publicKey));
// Derive secret from server public key
const sharedKey = yield call(() => CriptographyService.computeSecret(serverPublicKey));
// Convert JSON to string and encrypt data
const encrypted = yield call(() => CriptographyService.encrypt(sharedKey, JSON.stringify(body)));
// Ending handshake on client side send encrypted data to server
const establishedRequest = {
...request.payload.action,
[API_CALL]: {
actions,
method,
endpoint,
body: {...encrypted, token},
},
};
yield put(requestResponse(
{type: actions.REQUEST, payload: establishedRequest})
);
}
}With most of our code ready, we only needed to call the endpoint and each stage of handshake would update accordingly processing the handshake. When connection ends successfully, data gets encrypted on client and unencrypted on server. Hence, anything client sends is only readable by the server and keys that were generated on this one-time connection get deleted and never to be used again. This simplifies the handshake.
Conclusion
We touched a bit on Web Crypto API and OpenSSL::PKey::EC underscoring the ease of uniting both. The dance between these two entities underscore a secure data exchange without compromise allowing us to understand how they connect and intertwine on paper and on the network.
Guided by both APIs and establishing the foundation for a secure dialogue to both server and client, such a high-level for a low-level protocol adds appreciation to the amount of work put in each language and both libraries.
References
https://developer.mozilla.org/en-US/docs/Web/API/Web_Crypto_API
https://www.rubydoc.info/gems/openssl/OpenSSL/PKey/EC