Home | Send Feedback

Building encrypted chat app with the Web Cryptography API

Published: 3. May 2020  •  Updated: 20. May 2021  •  javascript, spring, java, ionic

In a previous blog post, I showed you how to build a simple JavaScript/Java chat application with Cettia. In another blog post, I showed you how to build a chat application that automatically translates messages with Google Cloud Translate.

Both chat applications send messages in clear text over the server. Someone eavesdropping on the server can read all the messages. In this blog post, I'm going to show you how you can implement a chat application that encrypts all messages between participants. The application builds on the base chat application from this blog post. Check out that article first if you want to learn how the application works. In this tutorial, I discuss only the differences to the original application, especially the code that handles encryption and decryption.

It is important to note that I'm not a security expert, just a developer that dabbles from time to time with cryptography. If you find a bug or design flaw, please send me a message so that I can fix it. You can use the code I present here in your application but double and triple check it.
Another important point I have to make is that the application I present in this blog post encrypts all messages, so the server can't read them. But the client still leaks much information to the server. The server stores room names and user names in cleartext. The server also sees who wrote and who received messages. Still a lot of metadata that can be valuable for an eavesdropper.

Application

You find the complete source code for the server (Java) and client (JavaScript) in this GitHub repository:
https://github.com/ralscha/blog2020/tree/master/cryptochat

To run the application locally on your computer, install Java, Node.js, and the Ionic CLI.

Start the server with the following command

cd server
./mvnw spring-boot:run

And the client with these commands

cd client
npm install
ionic serve

Cryptography

For the cryptography tasks, we don't need to add a new library to our JavaScript project. Instead, we use the Web Cryptography API. This is a cryptography library built into modern browsers. It is widely supported in all modern browsers.

When you work with the Web Cryptography API and sometimes are not exactly sure how to call a method, I recommend taking a look at the examples from this GitHub repository: https://github.com/diafygi/webcrypto-examples/
There you find examples for all algorithms and methods that the API provides.


Algorithms

Messages are encrypted and decrypted with AES256-GCM. AES is a symmetric encryption cipher and requires a symmetric key.

  1. User A encrypts message with key X
  2. User A sends encrypted message to user B
  3. User B encrypts message with key X

The difficulty is to find a secure way to exchange this key. A naïve approach is to simply send the key over the wire to the other user. But this is a bad idea. If a man-in-the-middle intercepts the key, he can decrypt all messages the two users exchange.

A better solution is to exchange the AES key outside the current communication channel, called an out-of-band exchange. Participants could meet in person and exchange the key with QR codes or USB drives. This is not very convenient, and it makes it also difficult to regularly change the keys if you have such a policy in place. But it has the benefit that both users know each other and can trust the key they exchange.

The chat application I show you in this blog post uses a different approach and implements a Diffie–Hellman key exchange. This is a smart method to establish a shared key between two parties over a public channel without ever sending the key over the channel.

DH

  1. User A and B create a public and private key.
  2. User A sends his public key to user B.
  3. User B sends his public key to user A.
  4. User A takes his private key and the public key from user B and derives the shared key.
  5. User B takes his private key and the public key from user A and derives the shared key.
  6. Both parties created the same key without sending the key itself over the wire.
  7. User A takes the shared key and encrypts a message.
  8. User A sends encrypted message to user B.
  9. User B decrypts message with shared key.

Note that the private key must never leave the user's device. A man-in-the-middle can intercept the public keys from user A and B, but he can't derive the shared key from this information.


This application does not use the classical Diffie–Hellman key exchange (DHKE); instead, it uses Elliptic Curve Diffie–Hellman Key Exchange (ECDH). The difference is that ECDH uses elliptic-curve cryptography, whereas DHKE uses modular exponentiations.


Because of these architecture choices, the chat app can only establish shared keys between two parties. It can't create one shared key for all users in one chat room. When we have a chat room with 5 users, the application generates 5 public/private keys, and for each user pair a shared key, a total of ten keys. This is not a very scalable architecture but should work fine if you only have a few users in one chat room.

Keys

System Overview

This crypto chat application differs in two significant areas to the original chat application.


Key management

Key management

  1. Client application starts and creates public/private key pair.
  2. New user signs in and client sends username, room name, and public key to server where there were stored.
  3. Server sends back username and public key of all currently connected users in the same room.
  4. Server sends join message to all other users in the same room containing username and public key of new user
  5. Client of new user calculates shared keys for each other user.
  6. Clients of all other users calculate shared key for new user.

Message delivery

The significant change to the original chat application is that the client has to send a message to each user individually. In the original application, the client sends one message to the server, and the server takes care of forwarding the message to all other users. That does not work here because each message is encrypted with a different AES key, and only the recipient with the same shared key can decrypt the message.

Message delivery

  1. User writes and sends message.
  2. Client encrypts the message for each other user.
  3. Client sends the encrypted messages individually to the server.
  4. The server forwards the messages to the recipients.
  5. Recipient encrypts message with corresponding shared key.

Implementation: Key management

1. Client: Public/Private key

After the application has loaded, it creates a public/private key pair. The application keeps the keys in memory.

  private generateSharedKeysPromise: Promise<void[] | void> | null = null;

chat.service.ts

    this.generateKeyPairPromise = this.generateKeyPair();

chat.service.ts

  private async generateKeyPair(): Promise<ArrayBuffer> {
    this.myKeyPair = await window.crypto.subtle.generateKey({
      name: 'ECDH',
      namedCurve: 'P-256'
    }, false, ['deriveKey']);
    if (this.myKeyPair) {
      return await window.crypto.subtle.exportKey('raw', this.myKeyPair.publicKey);
    }
    throw new Error('Key pair generation failed');
  }

chat.service.ts

generateKey() creates the key/pair and expects three arguments: algorithm, extractable, usage.
With extractable=false we make sure that nobody can extract the private key from the object this.myKeyPair with the method exportKey(). As you see here, this only affects the private key, because we can always export the public key.


2. Client: Sign in

After the user has signed in, the application sends the username, room name, and the public key to the server.

  async signin(username: string, room: string): Promise<boolean> {
    const rawPublicKey = await this.generateKeyPairPromise;
    this.room = room;
    this.username = username;

    return new Promise<boolean>(resolve => {
      this.socket.send('join', {username, room, publicKey: new Uint8Array(rawPublicKey)}, (ok: boolean) => {
        resolve(ok);
      });
    });
  }

chat.service.ts

Here we see a nice feature of Cettia in action. You can send text and binary data together in one message. There is no need to convert binary data to a base64 or hex string. Cettia internally recognizes when a message contains binary components and sends the message in MessagePack format instead of JSON to the server.


3. Server: Handle Sign in request

On the server, we not only have to save the room and user name but also the public key for each user. For this purpose, I created a User POJO and changed the map that stores the room/user information

  private final Map<String, Set<User>> roomUsers = new ConcurrentHashMap<>();

Application.java


When the server receives the sign-in request from the client, he stores the information from the new user into the map. Then he sends back the users message to the new user with all currently connected users and their public keys. And lastly, sends a join message to all other users with the username and public key of the new user.

  private Action<Reply<Map<String, Object>>> handleJoin(Server server,
      ServerSocket socket) {
    return reply -> {
      String username = (String) reply.data().get("username");
      String room = (String) reply.data().get("room");

      Set<User> users = this.roomUsers.get(room);
      if (users != null
          && users.stream().anyMatch(u -> u.getUsername().equals(username))) {
        reply.resolve(false);
        return;
      }

      socket.set("username", username);
      socket.set("room", room);

      byte[] publicKey = (byte[]) reply.data().get("publicKey");

      this.roomUsers.computeIfAbsent(room, k -> ConcurrentHashMap.newKeySet())
          .add(new User(username, publicKey));

      // send list of room users to new user
      socket.send("users", this.roomUsers.get(room));

      // broadcast to other room users that new user joined
      server
          .find(ServerSocketPredicates.attr("room", room)
              .and(ServerSocketPredicates.id(socket).negate()))
          .send("join", new User(username, publicKey));

      reply.resolve(true);
    };
  }

Application.java


4. Client: Handle room message

A new user receives the room message containing usernames and public keys for all the other users currently connected to this chat room. The client computes the shared key for each user.

    this.socket.on('users', (users: User[]) => {
      this.usersSubject.next(users);
      this.generateSharedKeysPromise = this.generateSharedKeys();
    });

chat.service.ts

  private async generateSharedKeys(): Promise<void[]> {
    const users = this.usersSubject.getValue();
    return Promise.all(users.filter(user => user.username !== this.username).map(user => this.generateSharedKey(user)));
  }

chat.service.ts

To generate the shared AES key the application first has to import the public key from the other party with importKey(). The application exchanges the public keys in typed arrays (Uint8Array), and we have to convert them to CryptoKey objects. The Web Cryptography API expect keys in this object.

  private async generateSharedKey(user: User): Promise<void> {
    const publicKey = await window.crypto.subtle.importKey('raw', user.publicKey,
      {name: 'ECDH', namedCurve: 'P-256'}, false, []);

    user.sharedKey = await window.crypto.subtle.deriveKey(
      {name: 'ECDH', public: publicKey},
      this.myKeyPair!.privateKey!,
      {name: 'AES-GCM', length: 256}, false, ['encrypt', 'decrypt']
    );
  }

chat.service.ts

Then the method calls deriveKey() to compute the shared key.
The code passes the public and private key as the first and second arguments to the method. The third argument is an object defining the algorithm the key will be used for. In our application, this is an AES key.
With the fourth argument (exportable) we tell the API that the resulting key can't be exported with exportKey().
And with the last argument, we tell the API that the new key will be used for encryption and decryption only.


5. Client: Handle join message

The client receives a join message each time a new user has joined the chat room. The client computes the shared key for this new user with a call to the generateSharedKey() method.

    this.socket.on('join', (user: User) => {
      this.usersSubject.next([user, ...this.usersSubject.getValue()]);
      this.addMessage({
        type: 'SYSTEM',
        user: user.username,
        msg: 'has joined the room',
        ts: Math.floor(Date.now() / 1000)
      });
      this.generateSharedKeysPromise = this.generateKeyPairPromise.then(() => this.generateSharedKey(user));
    });

chat.service.ts

Implementation: Message handling

1. Client: Message delivery

As mentioned before, message sending works a bit differently than in the original chat application. Instead of sending just one message to the server, the client has to create a message for each other room user, encrypt and send them individually to the server.

  send(msg: string): void {
    if (this.username === null) {
      throw new Error('username not set');
    }

    const ts = Math.floor(Date.now() / 1000);

    for (const user of this.usersSubject.getValue().filter(u => u.username !== this.username)) {
      this.encrypt(user.username, msg)
        .then(encryptedMsg => this.socket.send('message', {toUser: user.username, msg: encryptedMsg, ts}));
    }

    this.addMessage({ts, msg, user: this.username, type: 'MSG', img: 'guy1.png'});
  }

chat.service.ts

The send() method iterates over all users in this chat room, excluding the user that sends the message. Then it encrypts the message with the encrypt() method and sends the message to the server. The important change here to the original chat application is that we specify the receiver with toUser. Because the client encrypts the message with the AES key that it shares with this user, and only he will be able to decrypt the message. toUser is information for the server, so he knows to which user he has to forward the message.

The encrypt() method expects the receiver user name and the message in plain text. The method returns a Promise with the encrypted message in a Uint8Array object.

  private async encrypt(toUsername: string, plainTextMsg: string): Promise<Uint8Array> {

    if (!this.username) {
      throw new Error('username not set');
    }

    await this.generateKeyPairPromise;
    const user = this.usersSubject.getValue().find(u => u.username === toUsername);

    if (!user?.sharedKey) {
      throw new Error('shared key not set');
    }

    const iv = window.crypto.getRandomValues(new Uint8Array(12));

    const encryptedMsg = await window.crypto.subtle.encrypt(
      {name: 'AES-GCM', iv, tagLength: 128, additionalData: this.textEncoder.encode(this.username)},
      user.sharedKey,
      this.textEncoder.encode(plainTextMsg)
    );

    return ChatService.concatUint8Array(iv, new Uint8Array(encryptedMsg));
  }

chat.service.ts

The encrypt() method first has to fetch the AES key that the client shares with the recipient. The users are stored in an RxJS subject (usersSubject), and the key is stored in the property sharedKey of the user object.

To encrypt a message the code calls the encrypt() method. This method expects three arguments: algorithm, key, data.

AES-GCM requires an initialization vector (IV) that is 12 bytes long. You must never use the same IV with the same AES key twice. Always generate a new IV for each message. A secure way is to use the random generator getRandomValues() and fill the 12 bytes with random values.

As the second argument, the method expects the shared key, and as the last argument, the plain text message. Note that you can't just pass the string here. The method only accepts ArrayBuffer or typed arrays objects.

In this application, I use the TextEncoder for this conversion. This is an object that is built into the browser and converts strings to Uint8Array objects containing UTF-8 encoded text.


The encrypt() method of the Web Cryptography API returns an ArrayBuffer with the encrypted text. Our method concatenates the IV together with the encrypted message. This is important because the decryption process has to use the same IV. There is no problem with sending the IV in clear text over the wire.


2. Server: Message forwarding

The server receives the message message and handles it in the handleChatMessage() method.

  private static Action<Map<String, Object>> handleChatMessage(Server server,
      ServerSocket socket) {
    return message -> {
      String username = socket.get("username");
      String room = socket.get("room");

      String toUsername = (String) message.get("toUser");
      byte[] msg = (byte[]) message.get("msg");
      int ts = (int) message.get("ts");

      server
          .find(ServerSocketPredicates.attr("room", room)
              .and(ServerSocketPredicates.attr("username", toUsername)))
          .send("message", Map.of("user", username, "msg", msg, "ts", ts));
    };
  }

Application.java

The method extracts the value of toUser, searches the corresponding Cettia socket of this user, and sends the message to that client. Relevant here is that the sender of the message is added in the user field of the message. The receiving client needs this information for the encryption.


3. Client: Handle incoming messages

Incoming messages are handled in the message handler. This method calls the decrypt method to decrypt the message.

    this.socket.on('message', async (msg: EncryptedMessage) => {
      const decryptedMessage = await this.decrypt(msg.user, msg.msg);
      this.addMessage({
        user: msg.user,
        ts: msg.ts,
        msg: decryptedMessage,
        img: 'guy1.png',
        type: 'MSG'
      });
    });

chat.service.ts

The decrypt method expects the sender user name and the encrypted message as arguments and returns a Promise with the decrypted message.

  private async decrypt(fromUsername: string, encryptedMsg: Uint8Array): Promise<string> {
    await this.generateKeyPairPromise;
    const user = this.usersSubject.getValue().find(u => u.username === fromUsername);

    if (!user?.sharedKey) {
      throw new Error('shared key not set');
    }

    const iv = encryptedMsg.slice(0, 12);
    const data = encryptedMsg.slice(12);

    const plainTextArrayBuffer = await window.crypto.subtle.decrypt(
      {name: 'AES-GCM', iv, tagLength: 128, additionalData: this.textEncoder.encode(fromUsername)},
      user.sharedKey,
      data
    );

    return this.textDecoder.decode(plainTextArrayBuffer);
  }

chat.service.ts

Similar to the encryption process, this method first has to search for the AES key of the message sender. Then it has to extract the IV. We know that the IV is always 12 bytes long so we can simply use slice() here to split the encryptedMsg object into IV and encrypted message.

Next, the code passes the data to the decrypt() method. Like the encrypt() method it expects three arguments: algorithm, key, data.
The first two arguments have to be the same arguments we passed to the encrypt() method. The third argument is the encrypted message.

decrypt() does not return the plain text as a string. Instead, it returns an ArrayBuffer object. We can convert this object to a string with the browser built-in TextDecoder object. The decode() takes an ArrayBuffer and returns a string.


That concludes this tutorial about creating a secure chat application that exchanges encrypted messages between all the chat users. If you want to learn more about the Web Cryptography API checkout the documentation on MDN.