Engineering November 26, 2024 6 min read

Handshake: ECDH between Ruby on Rails and Web Crypto APIs

Handshake: ECDH between Ruby on Rails and Web Crypto APIs

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:

Code
// 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.

Code
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
end

Most of the algorithm rests on being able to add handshake negotiation seamlessly so all it needs further to work is the following:

Code
def handshake(&block)
  HandshakeManagerService.new(params).on_initialize do |parameters|
    block.call(parameters)
  end
end

Endpoint 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.

Code
// 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