James Bachini

Solidity Encrypted Messaging dApp

Solidity Encrypted Messaging Dapp

This morning Pavel Durov, founder of Telegram, was arrested at a French airport for refusing to provide backdoor access to the messaging application.

This tutorial will demonstrate how to use Elliptic Curve Diffie Hellman (ECDH) cryptography to establish a shared secret and encrypted messaging across a insecure communication channel, in this case a public blockchain.

The problem with blockchain’s are they are completely transparent so this means we need a way to create a shared secret without the ability to transfer keys privately.

The sender and recipient will need to generate and share a public key before they can communicate. The sender can then use their own private key and the recipients public key to create the shared secret which is used to encrypt the message.

The ECDH public keys and encrypted message are stored in a solidity smart contract where they are viewable to everyone. Only the user with the corresponding private key can decrypt the message ensuring private communications on a public network.

The full source code for this is available at: https://github.com/jamesbachini/Privacy.sol

Web app demo is available here: https://jamesbachini.github.io/Privacy.sol/

Contract is deployed to Sepolia Ethereum Testnet: https://sepolia.etherscan.io/address/0xd854a0173f60799930e25039d18fda82c77bd278#code

It includes 3 parts:

  • Privacy.sol – smart contract
  • node-dapp.js – a nodejs command line client
  • web-dapp.js – a web based client

Let’s look at Privacy.sol first.

Privacy.sol Smart Contract

The smart contract is deployed to Sepolia Ethereum testnet here: https://sepolia.etherscan.io/address/0xd854a0173f60799930e25039d18fda82c77bd278#code

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;

import "@openzeppelin/contracts/utils/cryptography/ECDSA.sol";

contract Privacy {
    using ECDSA for bytes32;

    struct Message {
        address sender;
        bytes encryptedMessage;
        uint256 timestamp;
    }

    mapping(address => bytes) public publicKeys;
    mapping(address => Message[]) private messages;

    event PublicKeyRegistered(address indexed user, bytes publicKey);
    event MessageSent(address indexed from, address indexed to);

    function registerPublicKey(bytes calldata _publicKey) external {
        require(_publicKey.length == 64, "Invalid public key length");
        publicKeys[msg.sender] = _publicKey;
        emit PublicKeyRegistered(msg.sender, _publicKey);
    }

    function sendMessage(address _to, bytes calldata _encryptedMessage) external {
        require(publicKeys[_to].length > 0, "Recipient has not registered a public key");
        
        Message memory newMessage = Message({
            sender: msg.sender,
            encryptedMessage: _encryptedMessage,
            timestamp: block.timestamp
        });
        
        messages[_to].push(newMessage);
        emit MessageSent(msg.sender, _to);
    }

    function getMessages(address _user) external view returns (Message[] memory) {
        return messages[_user];
    }

    function getPublicKey(address _user) external view returns (bytes memory) {
        return publicKeys[_user];
    }
}
Public Key Registration

The contract allows users to register their public keys on the blockchain. Each user can submit their public key using the registerPublicKey function. This key, which must be 64 bytes in length, is then stored in the publicKeys mapping, linking the public key to the user’s Ethereum address. The registration of a public key is critical because it allows others to encrypt messages specifically for the user, ensuring that only the intended recipient can decrypt and read them. Upon successful registration, an event PublicKeyRegistered is emitted to signal that the user has successfully registered their public key.

Sending Encrypted Messages

Once both parties have registered a key, users can send encrypted messages to one another using the sendMessage function. The message is sent in its encrypted form and is stored in the messages mapping. The structure Message contains the sender’s address, the encrypted message itself, and a timestamp indicating when the message was sent. The contract emits a MessageSent event whenever a message is successfully sent, recording both the sender and recipient’s addresses.

Retrieving Messages and Public Keys

The contract provides functions to retrieve both ECDH public keys and messages. The getMessages function allows a user to view all the messages that have been sent to them. Since the messages are stored in an encrypted form, only the recipient, who possesses the corresponding private key, can decrypt and read the content. Additionally, the getPublicKey function allows anyone to query and retrieve a registered public key for any user, which can be used to encrypt messages intended for that user.

NodeJS Client

The nodejs client can be used to setup keys and send and receive messages. You’ll need to edit .env-sample > .env and enter an ethereum private key and make sure the wallet has some Sepolia testnet funds in it.

The ALT_PRIVATE_KEY= variable is optional and is just useful if you want to switch between two accounts for sender and recipient when testing.

You’ll also need to install the following dependencies:

npm install dotenv ethers readline

It uses the NodeJS native crypto library which is not compatible with the crypto libary in modern browsers. Here’s what it looks like when run.

Solidity encrypted messaging dapp
require('dotenv').config();
const ethers = require('ethers');
const crypto = require('crypto');
const readline = require('readline');

const contractABI = [
  "function registerPublicKey(bytes calldata _publicKey) external",
  "function sendMessage(address _to, bytes calldata _encryptedMessage) external",
  "function getMessages(address _user) external view returns (tuple(address sender, bytes encryptedMessage, uint256 timestamp)[])",
  "function getPublicKey(address _user) external view returns (bytes memory)"
];

const contractAddress = "0xD854A0173f60799930E25039d18fda82C77bd278";
const provider = new ethers.JsonRpcProvider('https://rpc2.sepolia.org');
const privateKey = process.env.ALT_PRIVATE_KEY; // switch accounts with process.env.ALT_PRIVATE_KEY;
const wallet = new ethers.Wallet(privateKey, provider);
const contract = new ethers.Contract(contractAddress, contractABI, wallet);

const rl = readline.createInterface({
  input: process.stdin,
  output: process.stdout
});

function deriveECDHKeysFromPrivateKey(privateKey) {
  const ecdh = crypto.createECDH('secp256k1');
  ecdh.setPrivateKey(Buffer.from(privateKey.slice(2), 'hex'));
  const fullPublicKey = ecdh.getPublicKey();
  const publicKey = fullPublicKey.slice(1);
  return { privateKey: ecdh.getPrivateKey(), publicKey };
}

async function init() {
  console.log(`Welcome: ${wallet.address}`);
  const balance = await provider.getBalance(wallet.address);
  console.log(`Balance: ${ethers.formatEther(balance)} ETH`);
  const { publicKey } = deriveECDHKeysFromPrivateKey(privateKey);
  const registeredKey = await contract.getPublicKey(wallet.address);
  if (registeredKey == '0x') {
    rl.question('Do you want to register your public key? (yes/no): ', async (answer) => {
      if (answer.toLowerCase() === 'yes' || answer.toLowerCase() === 'y') {
        const tx = await contract.registerPublicKey(publicKey);
        await tx.wait();
        console.log('Public key registered successfully.');
      } else {
        console.log('Public key registration skipped.');
      }
      await checkMessages();
      startMessaging();
    });
  } else {
    console.log('Public key already registered.');
  }
  await checkMessages();
  startMessaging();
}

async function checkMessages() {
  console.log('Checking messages...');
  const messages = await contract.getMessages(wallet.address);
  if (messages.length === 0) return console.log('No messages found.');
  const ecdh = crypto.createECDH('secp256k1');
  ecdh.setPrivateKey(Buffer.from(privateKey.slice(2), 'hex'));
  for (const message of messages) {
      const sender = message.sender;
      const encryptedMessage = message.encryptedMessage;
      const timestamp = new Date(Number(message.timestamp * 1000n)).toLocaleString();
      const senderPublicKey = await contract.getPublicKey(sender);
      if (senderPublicKey.length == '0x') {
          console.log(`No public key found for sender: ${sender}`);
          continue;
      }
      const formattedPublicKey = Buffer.concat([Buffer.from([0x04]), Buffer.from(senderPublicKey.slice(2), 'hex')]);
      const sharedSecret = ecdh.computeSecret(formattedPublicKey);
      const decipher = crypto.createDecipheriv('aes-256-cbc', sharedSecret.slice(0, 32), Buffer.alloc(16, 0));
      let decryptedMessage = decipher.update(Buffer.from(encryptedMessage.slice(2), 'hex'), 'hex', 'utf8');
      decryptedMessage += decipher.final('utf8');
      console.log('--------------------------');
      console.log(`From: ${sender}`);
      console.log(`Timestamp: ${timestamp}`);
      console.log(`Message: ${decryptedMessage}`);
  }
}

async function sendMessage(toAddress, toPublicKey, message) {
  const ecdh = crypto.createECDH('secp256k1');
  ecdh.setPrivateKey(Buffer.from(privateKey.slice(2), 'hex'));
  const formattedPublicKey = Buffer.concat([Buffer.from([0x04]), Buffer.from(toPublicKey.slice(2), 'hex')]);
  const sharedSecret = ecdh.computeSecret(formattedPublicKey);
  const cipher = crypto.createCipheriv('aes-256-cbc', sharedSecret.slice(0, 32), Buffer.alloc(16, 0));
  let encryptedMessage = '0x';
  encryptedMessage += cipher.update(message, 'utf8', 'hex');
  encryptedMessage += cipher.final('hex');
  const tx = await contract.sendMessage(toAddress, encryptedMessage);
  await tx.wait();
  console.log('Message sent!');
}

async function startMessaging() {
  console.log('--------------------------');
  rl.question('Enter recipient address: ', async (recipientAddress) => {
    const recipientPublicKey = await contract.getPublicKey(recipientAddress);
    if (recipientPublicKey == '0x') {
      console.log(`Recipient needs to register public key`);
      return;
    }
    rl.question('Enter message: ', async (message) => {
      await sendMessage(recipientAddress, recipientPublicKey, message);
      startMessaging();
    });
  });
}

async function main() {
  await init();
}

main().catch(console.error);

Web Client

The web client is a simple html page which you can host anywhere. I ran it locally using

npm install http-server
npx http-server ./
Privacy Encrypted Messaging dApp

You’ll need to connect your browser wallet i.e. metamask. You’ll need to input a private key which is horrible UX and something you should never really do. The ECDH keys are derived from the eth private key and this isn’t available programmatically.

Not sure what the work around to this would be in a production environment. Perhaps generate random keys rather than deriving them from the eth address but this would mean you’d need some form of key management. For this simple web page it works how it is. Obviously set up a new metamask wallet or use a testnet specific wallet as you don’t want to be copying and pasting keys to any wallets with significant funds in.

Once you’ve generated the ECDH keys it will store them in localStorage and you can register the public key in the smart contract. Remember you need to do this for both the sender and recipient before you can exchange messages.

Once that goes through you can send messages to the other users eth address and that will be stored in encrypted format in the smart contract.

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>Privacy</title>
  <script src="https://cdnjs.cloudflare.com/ajax/libs/ethers/6.13.2/ethers.umd.min.js"></script>
  <script src="https://cdnjs.cloudflare.com/ajax/libs/crypto-js/4.1.1/crypto-js.min.js"></script>
  <script src="https://cdnjs.cloudflare.com/ajax/libs/elliptic/6.5.7/elliptic.min.js"></script>
  <link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Roboto+Mono:wght@400;500;700&display=swap">
  <style>
    body {
      font-family: 'Roboto Mono', Arial, sans-serif;
      line-height: 1.6;
      margin: 0;
      padding: 20px;
      background-color: #000;
      color: #CCC;
    }

    #app {
      max-width: 1000px;
      margin: 0 auto;
      background-color: #111;
      padding: 20px;
      border-radius: 5px;
      text-align: center;
    }

    h1, h2 {
      color: #2bc71f;
    }

    input[type="text"] {
      width: 80%;
      margin: 5px auto;
      padding: 10px;
      margin-bottom: 10px;
      border: 1px solid #ddd;
      border-radius: 4px;
      background: #333;
      color: #EEE;
      text-align: center;
    }

    button {
      background-color: #000;
      color: white;
      padding: 10px 15px;
      border: 3px solid #2bc71f;
      border-radius: 50px;
      cursor: pointer;
    }

    hr {
      margin: 50px 100px;
      border: 1px solid #333;
    }

    #message-list {
      margin-top: 20px;
    }

    .message {
      background-color: #333;
      border: 1px solid #555;
      padding: 10px;
      margin: 10px;
      border-radius: 4px;
    }

    .green {
      color: #2bc71f;
    }
  </style>
</head>
<body>
  <div id="app">
    <h1>Privacy dApp</h1>
    <div id="connection-status">Not connected</div>
    <div id="account-info">
      <p>Address: <span id="account-address"></span></p>
      <p>Balance: <span id="account-balance"></span> ETH</p>
    </div>
    <div id="key-registration">
      <p>Generate Keys & Register your ECDH public key before sending or receiving messages</p>
      <div>
        <button id="connect-wallet">Connect Wallet</button>
        <button id="generate-keys">Generate Keys</button>
        <button id="register-key">Register Public Key</button>
      </div>
    </div>
    <hr />
    <div id="messaging">
      <h2>Send Message</h2>
      <input type="text" id="recipient-address" placeholder="Recipient Address">
      <input type="text" id="message-input" placeholder="Enter your message">
      <div>
        <button id="send-message">Send Message</button>
      </div>
    </div>
    <hr />
    <div id="messages">
      <h2>Messages</h2>
      <button id="check-messages">Check Messages</button>
      <div id="message-list"></div>
    </div>
  </div>
  <script>
    const contractABI = [
      "function registerPublicKey(bytes calldata _publicKey) external",
      "function sendMessage(address _to, bytes calldata _encryptedMessage) external",
      "function getMessages(address _user) external view returns (tuple(address sender, bytes encryptedMessage, uint256 timestamp)[])",
      "function getPublicKey(address _user) external view returns (bytes memory)"
    ];
    const contractAddress = "0xD854A0173f60799930E25039d18fda82C77bd278";
    let provider, signer, contract, accountAddress;

    async function connectWallet() {
      if (typeof window.ethereum !== 'undefined') {
        await window.ethereum.request({ method: 'eth_requestAccounts' });
        provider = new ethers.BrowserProvider(window.ethereum);
        signer = await provider.getSigner();
        contract = new ethers.Contract(contractAddress, contractABI, signer);
        accountAddress = await signer.getAddress();
        document.getElementById('connection-status').textContent = 'Connected';
        document.getElementById('account-address').textContent = accountAddress;
        const balance = await provider.getBalance(accountAddress);
        document.getElementById('account-balance').textContent = ethers.formatEther(balance);
      } else {
        alert('Please install MetaMask!');
      }
    }

    function deriveECDHKeysFromPrivateKey(privateKey) {
      const ecdh = new elliptic.ec('secp256k1');
      const privateKeyBytes = new Uint8Array(privateKey.slice(2).match(/.{1,2}/g).map(byte => parseInt(byte, 16)));
      const keys = ecdh.keyFromPrivate(privateKeyBytes);
      const fullPublicKey = keys.getPublic();
      const publicKey = fullPublicKey.encode('hex').slice(2);
      return { privateKey: privateKey.slice(2), publicKey };
    }

    async function generateKeys() {
      const privateKey = prompt('Enter private key for wallet:');
      const keysECDH = deriveECDHKeysFromPrivateKey(privateKey);
      localStorage.setItem(accountAddress, JSON.stringify(keysECDH));
    }

    async function registerPublicKey() {
      const keysECDH = JSON.parse(localStorage.getItem(accountAddress));
      const tx = await contract.registerPublicKey('0x' + keysECDH.publicKey);
      await tx.wait();
      console.log('Public key registered successfully');
    }

    function formatPublicKey(toPublicKey) {
      let cleanPublicKey = toPublicKey.startsWith('0x') ? toPublicKey.slice(2) : toPublicKey;
      if (cleanPublicKey.length !== 128) throw new Error('Invalid public key length');
      const publicKeyBytes = new Uint8Array(cleanPublicKey.match(/.{1,2}/g).map(byte => parseInt(byte, 16)));
      const prefix = new Uint8Array([0x04]);
      const formattedPublicKey = new Uint8Array(prefix.length + publicKeyBytes.length);
      formattedPublicKey.set(prefix);
      formattedPublicKey.set(publicKeyBytes, prefix.length);
      return formattedPublicKey;
    }


    async function sendMessage() {
      const recipientAddress = document.getElementById('recipient-address').value;
      const message = document.getElementById('message-input').value;
      const toPublicKey = await contract.getPublicKey(recipientAddress);
      if (toPublicKey === '0x') {
          alert('Recipient has not registered their public key');
          return;
      }
      const keysECDH = JSON.parse(localStorage.getItem(accountAddress));
      const ecdh = new elliptic.ec('secp256k1');
      const privateKeyBytes = new Uint8Array(keysECDH.privateKey.match(/.{1,2}/g).map(byte => parseInt(byte, 16)));
      const keys = ecdh.keyFromPrivate(privateKeyBytes);
      const formattedPublicKey = formatPublicKey(toPublicKey);
      const recipientKey = ecdh.keyFromPublic(formattedPublicKey);
      if (!ecdh.curve.validate(recipientKey.getPublic())) {
          throw new Error('Invalid recipient public key');
      }
      const sharedSecret = keys.derive(recipientKey.getPublic());
      const sharedSecretHex = sharedSecret.toString(16).padStart(64, '0');
      const key = CryptoJS.enc.Hex.parse(sharedSecretHex.slice(0, 64));
      const iv = CryptoJS.enc.Hex.parse('00000000000000000000000000000000'); // 16 bytes of zero IV
      const encryptedMessage = CryptoJS.AES.encrypt(message, key, { iv: iv, mode: CryptoJS.mode.CBC, padding: CryptoJS.pad.Pkcs7 }).ciphertext.toString(CryptoJS.enc.Hex);
      const tx = await contract.sendMessage(recipientAddress, '0x' + encryptedMessage);
      await tx.wait();
      console.log('Message sent successfully');
    }

    async function checkMessages() {
      const messages = await contract.getMessages(accountAddress);    
      const messageList = document.getElementById('message-list');
      messageList.innerHTML = '';
      if (messages.length === 0) {
          messageList.innerHTML = 'No messages found';
          return;
      }
      
      for (const message of messages) {
          console.log(message);
          const sender = message.sender;
          const encryptedMessage = message.encryptedMessage;
          const timestamp = new Date(Number(message.timestamp) * 1000).toLocaleString();

          // Fetch the sender's public key from the contract
          const senderPublicKey = await contract.getPublicKey(sender);
          if (senderPublicKey === '0x') {
              console.log(`No public key found for sender: ${sender}`);
              continue;
          }

          // Derive shared secret using ECDH
          const keysECDH = JSON.parse(localStorage.getItem(accountAddress));
          const ecdh = new elliptic.ec('secp256k1');
          const privateKeyBytes = new Uint8Array(keysECDH.privateKey.match(/.{1,2}/g).map(byte => parseInt(byte, 16)));
          const keys = ecdh.keyFromPrivate(privateKeyBytes);
          const formattedPublicKey = formatPublicKey(senderPublicKey);
          const recipientKey = ecdh.keyFromPublic(formattedPublicKey);

          if (!ecdh.curve.validate(recipientKey.getPublic())) {
              throw new Error('Invalid recipient public key');
          }

          const sharedSecret = keys.derive(recipientKey.getPublic());
          const sharedSecretHex = sharedSecret.toString(16).padStart(64, '0');
          const key = CryptoJS.enc.Hex.parse(sharedSecretHex.slice(0, 64));

          // Decrypt the message using AES-256-CBC
          const encryptedHexStr = CryptoJS.enc.Hex.parse(encryptedMessage.slice(2)); // Convert the encrypted message from hex string to CryptoJS format
          const encryptedBase64Str = CryptoJS.enc.Base64.stringify(encryptedHexStr); // Convert to Base64 string for decryption
          const iv = CryptoJS.enc.Hex.parse('00000000000000000000000000000000'); // Same IV used for encryption
          const decryptedBytes = CryptoJS.AES.decrypt(encryptedBase64Str, key, { iv: iv, mode: CryptoJS.mode.CBC, padding: CryptoJS.pad.Pkcs7 });
          const decryptedMessage = decryptedBytes.toString(CryptoJS.enc.Utf8);

          const messageElement = document.createElement('div');
          messageElement.className = 'message';
          messageElement.innerHTML = `
              <p><span class="green">From:</span> ${sender}</p>
              <p><span class="green">Timestamp:</span> ${timestamp}</p>
              <p><span class="green">Message:</span> ${decryptedMessage}</p>
          `;
          messageList.appendChild(messageElement);
      }
    }


    document.addEventListener('DOMContentLoaded', () => {
      document.getElementById('connect-wallet').addEventListener('click', connectWallet);
      document.getElementById('generate-keys').addEventListener('click', generateKeys);
      document.getElementById('register-key').addEventListener('click', registerPublicKey);
      document.getElementById('send-message').addEventListener('click', sendMessage);
      document.getElementById('check-messages').addEventListener('click', checkMessages);
    });
  </script>
</body>
</html>

This is what the nodejs and web dapp look like when you have messages

solidity encryption
private messages over a public blockchain

The full source code for this is available at: https://github.com/jamesbachini/Privacy.sol

Hope you’ve found this tutorial useful, please share it on socials if you can. Privacy is a fight worth fighting for in a world that never stops watching.


Get The Blockchain Sector Newsletter, binge the YouTube channel and connect with me on Twitter

The Blockchain Sector newsletter goes out a few times a month when there is breaking news or interesting developments to discuss. All the content I produce is free, if you’d like to help please share this content on social media.

Thank you.

James Bachini

Disclaimer: Not a financial advisor, not financial advice. The content I create is to document my journey and for educational and entertainment purposes only. It is not under any circumstances investment advice. I am not an investment or trading professional and am learning myself while still making plenty of mistakes along the way. Any code published is experimental and not production ready to be used for financial transactions. Do your own research and do not play with funds you do not want to lose.