Encryption must be considered to ensure privacy when transferring or storing user data, especially private conversations.

By reading this tutorial, you will learn how to encrypt data end-to-end in a Web application using only JavaScript and the Web Crypto API, a native browser API.

Please note that this tutorial is very basic and is strictly educational and may contain some simplifications, it is not recommended to use your own encryption protocols, and the algorithms used may contain certain “traps” if not used correctly with the help of a security expert

If you happen to get lost, the full project can also be found in this GitHub repository.

What is end-to-end encryption?

End-to-end encryption is a communication system in which the only person who can read a message is the person communicating it. No eavesdropper can access the encryption keys needed to decrypt conversations, not even the company running the messaging service.

What is the Web Crypto API?

The Web Cryptography API defines a low-level interface for interacting with cryptographic key material managed or exposed by user agents. The API itself is unknowable about the underlying implementation of the key store, but provides a common set of interfaces that allow rich Web applications to perform operations such as signature generation and verification, hashing and verification, encryption and decryption without access to the raw key material.

Basic knowledge of

In the following steps, we will declare the basic capabilities involved in end-to-end encryption. You can copy each file to a dedicated.js file in the lib folder. Note that they are all asynchronous functions due to the asynchronous nature of the Web Crypto API.

Note: Not all browsers can implement the algorithm we will use. IE and the old Microsoft Edge. Please check the compatibility table of THE MDN Web document: Subtle Crypto – Web APIs.

Generating a key pair

An encryption key pair is critical for end-to-end encryption. A key pair consists of a public key and a private key. Each user in the application should have a key pair to protect their data, other users can use the public components, and the owner of the key pair can only access the private components. You’ll see what these features do in the next section.

To generate a key pair, we will use the window. The crypto. Subtle. The generateKey method, and has the format of JWK window. Use crypto. Subtle. ExportKey export private and public keys. You can think of it as a way to serialize keys for use outside of JavaScript.

generateKeyPair.js

export default async() = > {const keyPair = await window.crypto.subtle.generateKey(
    {
      name: "ECDH".namedCurve: "P-256",},true["deriveKey"."deriveBits"]);const publicKeyJwk = await window.crypto.subtle.exportKey(
    "jwk",
    keyPair.publicKey
  );

  const privateKeyJwk = await window.crypto.subtle.exportKey(
    "jwk",
    keyPair.privateKey
  );

  return { publicKeyJwk, privateKeyJwk };
};
Copy the code

In addition, I chose the ECDH algorithm with p-256 elliptic curves because it is well supported and strikes the right balance between security and performance. That preference will change over time as new algorithms roll out.

Note: Exporting the private key can cause security issues and must be handled with caution. The practice of letting users copy and paste, introduced in the integration section of this tutorial, is not a good practice, but is for educational purposes.

The derived keys

We will use the key pair generated in the last step to derive a symmetric encryption key that encrypts and decrypts data and is unique to any two communicating users. For example, user A uses their private key and user B’s public key to derive the key, and user B uses their private key and user A’s public key to derive the same key. No one can generate derived keys without accessing at least one user private key, so it is important to keep them secure.

In the previous step, we exported the key pair in JWK format. Before we derive the key, we need to use the window. The crypto. Subtle. ImportKey these imported into the original state. In order to export the key, we will use the window. The crypto. Subtle. DeriveKey.

deriveKey.js

export default async (publicKeyJwk, privateKeyJwk) => {
  const publicKey = await window.crypto.subtle.importKey(
    "jwk",
    publicKeyJwk,
    {
      name: "ECDH".namedCurve: "P-256",},true[]);const privateKey = await window.crypto.subtle.importKey(
    "jwk",
    privateKeyJwk,
    {
      name: "ECDH".namedCurve: "P-256",},true["deriveKey"."deriveBits"]);return await window.crypto.subtle.deriveKey(
    { name: "ECDH".public: publicKey },
    privateKey,
    { name: "AES-GCM".length: 256 },
    true["encrypt"."decrypt"]); };Copy the code

In this case, I chose the AES-GCM algorithm because of its known security/performance balance and browser availability.

Encrypted text

We can now encrypt the text with a derived key, so that it can be safely transmitted.

Before encrypting, we encode the text as Uint8Array because that is what is needed for the encryption function. We use the window. The crypto. Subtle. Encrypt to encryption of the array, then the output back to the Uint8Array ArrayBuffer, then converts it to a string and its encoded in Base64. JavaScript makes it a little complicated, but it’s just one way to turn our encrypted data into transportable text.

encrypt.js

export default async (messageJSON, derivedKey) => {
  try {
    const message = JSON.parse(messageJSON);
    const text = message.base64Data;
    const initializationVector = new Uint8Array(message.initializationVector).buffer;

    const string = atob(text);
    const uintArray = new Uint8Array(
      [...string].map((char) = > char.charCodeAt(0)));const algorithm = {
      name: "AES-GCM".iv: initializationVector,
    };
    const decryptedData = await window.crypto.subtle.decrypt(
      algorithm,
      derivedKey,
      uintArray
    );

    return new TextDecoder().decode(decryptedData);
  } catch (e) {
    return `error decrypting message: ${e}`; }};Copy the code

As you can see, the AES-GCM algorithm parameters include an initialization vector (iv). For each encryption operation, it can be random, but it must be absolutely unique to guarantee the strength of the encryption. It’s included in the message, so it can be used in the decryption process, which is the next step. Also, while this number is unlikely to be reached, you should discard the key after 2³² uses, since random IV will repeat itself at this point.

Decrypted text

Now we can use the derived key to decrypt any encrypted text we receive, doing the exact opposite of the encryption step.

Before decryption, we retrieve the initialization vector, convert the string from Base64 back into a Uint8Array, and decrypt it using the same algorithm definition. After that, we decode the ArrayBuffer and return a human-readable string.

decrypt.js

export default async (messageJSON, derivedKey) => {
  try {
    const message = JSON.parse(messageJSON);
    const text = message.base64Data;
    const initializationVector = new Uint8Array(message.initializationVector).buffer;

    const string = atob(text);
    const uintArray = new Uint8Array(
      [...string].map((char) = > char.charCodeAt(0)));const algorithm = {
      name: "AES-GCM".iv: initializationVector,
    };
    const decryptedData = await window.crypto.subtle.decrypt(
      algorithm,
      derivedKey,
      uintArray
    );

    return new TextDecoder().decode(decryptedData);
  } catch (e) {
    return `error decrypting message: ${e}`; }};Copy the code

It is also possible that the decryption process fails due to the use of the wrong derived key or initialization vector, meaning that the user does not have the correct key pair to decrypt the text they receive. In this case, we return an error message.

Integrate into your chat application

And that’s all the encryption is needed! In the following sections, I’ll explain how I used the approach we implemented above to encrypt end-to-end a Chat application built using Stream Chat’s powerful React Chat component.

Cloning project

Clone the Encryption-web-chat repository into a local folder, install the dependencies, and run it.

$ git clone https://github.com/getstream/encrypted-web-chat
$ cd encrypted-web-chat/
$ yarn install
$ yarn start
Copy the code

After that, you should open the browser TAB. But first, we need to configure the project using our own Stream Chat API key.

Configure the Stream Chat Dashboard

Create an account on getstream. IO, create an application, and choose development over production.

For simplicity, let’s disable both authentication checking and permission checking. Make sure you hit Save. When your application is in production, you should keep these enabled and have a back end that provides tokens to users.

Pay attention to the Stream credentials, because they will be used in the next step to initialize the chat client in the application. Since we have disabled authentication and permissions, we only really need the key now. In the future, however, you’ll still use keys for authentication in your background, issuing user tokens for Stream Chat so that your Chat application has proper access control.

As you can see, I have edited the key. It is best to keep these credentials secure.

Change the credentials

In SRC /lib/chatclient.js, change the key with your key. We will use this object to make API calls and configure the chat component.

chatClient.js

import { StreamChat } from "stream-chat";

export default new StreamChat("[api_key]");
Copy the code

After that, you should be able to test the application. In the following steps, you will see where our defined functions apply.

Set user

In SRC /lib/setuser.js, we define a function to set the user of the chat client and update it with the given public key pair. Sending public keys is necessary for other users to obtain the keys needed to encrypt and decrypt communications with our users.

setUser.js

import chatClient from "./chatClient";

export default async (id, keyPair) => {
  const response = await chatClient.setUser(
    {
      id,
      name: id,
      image: `https://getstream.io/random_png/? id=cool-recipe-9&name=${id}`,
    },
    chatClient.devToken(id)
  );

  if( response.me? .publicKeyJwk && response.me.publicKeyJwk ! =JSON.stringify(keyPair.publicKeyJwk)
  ) {
    await chatClient.disconnect();
    throw "This user id already exists with a different key pair. Choose a new user id or paste the correct key pair.";
  }

  await chatClient.upsertUsers([
    { id, publicKeyJwk: JSON.stringify(keyPair.publicKeyJwk) },
  ]);
};
Copy the code

In this function, we import the chatClient defined in the previous version. It requires a user ID and a key pair, and then calls chatClient.setuser to set up the user. After that, it checks to see if the user already has a public key and if it matches the public key in the given key pair. If the public key matches or does not exist, we update the user with the given public key; If not, we disconnect and display an error.

Sender component

In SRC/Components/sender.js, we define the first screen, where we select our user ID and can use the function we described in generateKey.js to generate a key pair, and if this is an existing user, paste the key pair generated when the user is created.

Recipient Composition

In the SRC/components/Recipient. The js, we defined the second screen, here we choose to communicate with the user’s id. The component will use chatClient.queryUsers to get the user. The result of this call will contain the user’s public key, which we will use to export the encryption/decryption key.

KeyDeriver components

In SRC/components/KeyDeriver js, we defined the third screen, of which the key is to use in our deriveKey. Js implemented method is derived, the method USES the sender (us) to the private key and the recipient’s public key. This component is just a passive loading screen because the required information has already been gathered in the first two screens. But if there is a problem with the key, it will display an error.

EncryptedMessage components

In the SRC/components/EncryptedMessage. Js, our custom Stream Chat Message components, used in the decrypt. We defined js method to decrypt the Message, and at the same time provide encrypted data derived keys.

If this customization is not done for the Message component, it will look like this:

Customize by wrapping Stream Chat’s MessageSimple component and using the useEffect hook to modify message properties using the DEcrypt method.

EncryptedMessageInput components

In the SRC/components/EncryptedMessageInput. Js, our custom Stream Chat MessageInput components, To encrypt the written message with the original text before sending using the methods we defined in encrypt.js.

Customization is done by wrapping Stream Chat’s MessageInputLarge component and setting the overrideSubmitHandler Prop as a function that encrypts the text before sending it down the channel.

Chat components

Finally, in SRC/Components/chat.js, we build the entire Chat screen using the Stream Chat component and our custom Message and EncryptedMessageInput components.

Next steps in the Web Crypto API

Congratulations to you! You just learned how to implement basic end-to-end encryption in a Web application, and it’s important to know that this is the most basic form of end-to-end encryption. It lacks some additional tweaks that would make it more resilient in the real world, such as randomized padding, digital signatures, and forward secrecy. In addition, getting help from an application security professional is critical for practical use.


Matheus Cardoso, Levelup.gitConnected.com