DEV Community

Matheus Cardoso
Matheus Cardoso

Posted on • Edited on • Originally published at getstream.io

End-to-End Encrypted Chat with the Web Crypto API

When transmitting or storing user data, especially private conversations, it's essential to consider employing cryptographic techniques to ensure privacy.

Animation shows a chat screen with messages alternating from encrypted text to decrypted text

By reading this tutorial, you'll learn how to end-to-end encrypt data in web applications using nothing but JavaScript and the Web Crypto API, which is a native browser API.

Please note that this tutorial is very basic and strictly educational, may contain simplifications, and rolling your own encryption protocol is not advisable. The algorithms used can contain certain 'gotchas' if not employed properly with the help of security professionals

You can also find the full project in this GitHub repo if you happen to get lost. And if you have any questions, feel free to reach out to me on Twitter :).

What Is End-to-End Encryption?

End-to-end encryption is a communication system where the only people who can read the messages are the people communicating. No eavesdropper can access the cryptographic keys needed to decrypt the conversation—not even a company that runs the messaging service.

What Is the Web Crypto API?

The Web Cryptography API defines a low-level interface to interacting with cryptographic key material that is managed or exposed by user agents. The API itself is agnostic of the underlying implementation of key storage 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 requiring access to the raw keying material.

Onto the Basics

In the following steps, we'll declare the essential functions involved in end-to-end encryption. You can copy each one into a dedicated .js file under a lib folder. Note that all of them are async functions due to the Web Crypto API's asynchronous nature.

Note: Not all browsers implement the algorithms we'll use. Namely, Internet Explorer and Microsoft Edge. Check the compatibility table at MDN web docs: Subtle Crypto - Web APIs.

Generate a Key Pair

Cryptographic key pairs are essential to end-to-end encryption. A key pair consists of a public key and a private key. Each user in your application should have a key pair to protect their data, with the public component available to other users and the private component only accessible to the key pair's owner. You'll understand how these come into play in the next section.

To generate the key pair, we'll use the window.crypto.subtle.generateKey method, and export the private and public keys using window.crypto.subtle.exportKey with the JWK format. The latter is needed to save or transmit these keys. Think of it as a way of serializing the keys for use outside of JavaScript.

PS: if you don't see generateKeyPair.js below due to a bug in dev.to, refresh this page.

Additionally, I chose the ECDH algorith with the P-256 elliptic curve as it is well supported and the right balance between security and performance. This preference can change with time as new algorithms become available.

Note: exporting the private key can lead to security issues, so it must be handled carefully. The approach of allowing the user to copy and paste it that will be presented in the integration part of this tutorial is not a great practice and only done for educational purposes.

Derive Key

We'll use the key pair generated in the last step to derive the symmetric cryptographic key that encrypts and decrypts data and is unique for any two communicating users. For example, User A derives the key using their private key with User B's public key, and User B derives the same key using their private key and User A's public key. No one can generate the derived key without access to at least one of the users' private keys, so it's essential to keep them safe.

In the previous step, we exported the key pair in the JWK format. Before we can derive the key, we need to import those back to the original state using window.crypto.subtle.importKey. To derive the key, we'll use the window.crypto.subtle.deriveKey.

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

Encrypt Text

Now we can use the derived key to encrypt text, so it's safe to transmit it.

Before encryption, we encode the text to a Uint8Array, since that's what the encrypt function takes. We encrypt that array using window.crypto.subtle.encrypt, and then we turn its ArrayBuffer output back to Uint8Array, which we then turn to string and encode it to Base64. JavaScript makes it a little bit complicated, but this is just a way to turn our encrypted data into transmittable text.

As you can see, the AES-GCM algorithm parameter includes an initialization vector (iv). For every encryption operation, it can be random, but absolutely must be unique to ensure the strength of the encryption. It is included in the message so it can be used in the decryption process, which is the next step. Also, though unlikely to reach this number, you should discard the keys after 2^32 usages, as the random IV can repeat at that point.

Decrypt Text

Now we can use the derived key to decrypt any encrypted text we receive, doing precisely the opposite from the encrypt step.

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

It's also possible that this decryption process will fail due to using a wrong derived key or initialization vector, which means the user does not have the correct key pair to decrypt the text they received. In such a case, we return an error message.

Integrating in Your Chat App

And that is all the cryptographic work required! In the following sections, I'll explain how I used the methods we implemented above to end-to-end encrypt a chat application built with Stream Chat's powerful React chat components.

Clone the Project

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

After that, a browser tab should open. But first, we need to configure the project with our own Stream Chat API key.

Configure the Stream Chat Dashboard

Create your account at GetStream.io, create an application, and select development instead of production.

Screenshot of a user creating a development application at GetStream.io

To simplify, let's disable both auth checks and permission checks. Make sure to hit save. When your app is in production, you should keep these enabled and have a backend to provide tokens for the users.

Screenshot of skip auth checks and permission enabled in a Stream App dashboard

For future reference, see the documentation on authentication and the documentation on permissions.

Please take note of the Stream credentials, as we'll use them to initialize the chat client in the app in the next step. Since we disabled authentication and permissions, we'll only really need the key for now. Still, in the future, you'll use the secret in your backend to implement authentication to issue user tokens for Stream Chat, so your chat app can have proper access controls.

Screenshot of credentials on stream dashboard

As you can see, I've redacted my keys. It would be best if you kept these credentials safe.

Change the Credentials

In src/lib/chatClient.js, change the key with yours. We'll use this object to make API calls and configure the chat components.

After this, you should be able to test the application. In the following steps, you'll understand where the functions we defined fit in.

Set the User

In src/lib/setUser.js, we define the function that sets the chat client's user and updates it with the given key pair's public key. Sending the public key is necessary for other users to derive the key required for encrypting and decrypting communication with our user.

In this function, we import the chatClient defined in the previous step. It takes a user id and a key pair, then it calls chatClient.setUser to set the user. After that, it checks whether that user already has a public key and if it matches the public key in the key pair given. If the public key matches or is non-existent, we update that 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 choose our user id, and can generate a key pair using the function we described in generateKey.js, or, if this is an existing user, paste the key pair generated at the time of user creation.

Image shows a login form with an id field, a key pair field,  a generate button, and a submit button

Recipient Component

In src/components/Recipient.js, we define the second screen, where we choose the id of the user with whom we want to communicate. The component will fetch this user with chatClient.queryUsers. The result of that call will contain the user's public key, which we'll use to derive the encryption/decryption key.

Image shows a form with a field for the user id you want to communicate with and a submit button

KeyDeriver Component

In src/components/KeyDeriver.js, we define the third screen, where the key is derived using the method we implemented in deriveKey.js with the sender's (us) private key and the recipient's public key. This component is merely a passive loading screen since the information needed was collected in the previous two screens. But it will show an error if there's an issue with the keys.

EncryptedMessage Component

In src/components/EncryptedMessage.js, we customize Stream Chat's Message component to decrypt the message using the method we defined in decrypt.js alongside the encrypted data and the derived key.

Image shows a chat message saying This is an encrypted message

Without this customization of the Message component, it would show up like this:

Image shows a chat message with unintelligible text

The customization is done by wrapping Stream Chat's MessageSimple component and using the useEffect hook to modify the message prop with the decrypt method.

EncryptedMessageInput Component

In src/components/EncryptedMessageInput.js, we customize Stream Chat's MessageInput component to encrypt the message written before sending it using the method we defined in encrypt.js alongside the original text.

The customization is done by wrapping Stream Chat's MessageInputLarge component and settings the overrideSubmitHandler prop to a function that encrypts the text before sending to the channel.

Chat Component

And finally, in src/components/Chat.js, we build the whole chat screen using Stream Chat's components and our custom Message and EncryptedMessageInput components.

The MessageList component has a Message prop, set to the custom EncryptedMessage component, and the EncryptedMessageInput can just be placed right below it in the hierarchy.

Next Steps With Web Crypto API

Congratulations! You just learned how to implement basic end-to-end encryption in your web apps. It's important to know this is the most basic form of end-to-end encryption. It lacks some additional tweaks that can make it more bullet-proof for the real world, such as randomized padding, digital signature, and forward secrecy, among others. Also, for real-world usage, it's vital to get the help of application security professionals.

PS: Special thanks to Junxiao in the comments for correcting my mistakes :-)

Top comments (19)

Collapse
 
yoursunny profile image
Junxiao Shi • Edited

The usage of constant IV with AES-GCM completely breaks its security. With AES-GCM, the application must guarantee that the IV is never repeated. Otherwise, it's a catastrophic failure.

If you use AES-GCM, each direction needs a different key or use a different IV range, and the IV should include a counter portion that is incremented for each AES block, then the keys must be rotated once the counter reaches the maximum.

It's simpler to use AES-CBC with random IV, and send the IV together with each message.

Collapse
 
cardoso profile image
Matheus Cardoso • Edited

Hi! Thanks a lot for the feedback. I missed this information while doing my very superficial research about AES-GCM (I just saw it wasn't sensitive, but didn't see it had to change every time). I'm currently changing the tutorial's approach to this and removing the misleading info.

Edit: done

Collapse
 
yoursunny profile image
Junxiao Shi

Last time I designed a system using random IV with AES-GCM and it got rejected in security review. Crypto expert says IV must have three parts:

  • Sender identifier. Suppose two parties are using the same key, 1 bit should be used to identify the encrypting party.
  • Random bits, 64 bits minus sender identifier.
  • Counter bits, 32 bits. Start from zero, incremented for each AES block (not message). Key must be rotated when the counter reaches maximum.
Collapse
 
crimsonmed profile image
Médéric Burlet

totally agree I flipped when I read this:

It's not a sensitive parameter in basic end-to-end encryption and only changes in more advanced use cases.

Collapse
 
cardoso profile image
Matheus Cardoso • Edited

Thanks a lot for the feedback. I made wrong assumptions while reading something quickly. I'm fixing the tutorial :)

Edit: done

Collapse
 
yoursunny profile image
Junxiao Shi

How does the user trust that the server is not a Man In The Middle during key exchange? What if the server gives each party its own ECDH public key during key exchange, and then decrypt-reencrypt each message?

Collapse
 
shierve profile image
Sergi Canal

You could verify the public key fingerprint if you wanted to make sure. It is impossible as far as I know to be able to verify identity without each participant getting a certificate with their public key from some sort of CA.

Collapse
 
yoursunny profile image
Junxiao Shi

My buddy made an Android app that lets users scan each other's QR code that encodes the public key. Basically MITM is not preventable without an out of band channel.

Collapse
 
cardoso profile image
Matheus Cardoso • Edited

Thanks a lot for your comment! Although I think it's out of scope for this basic tutorial, I've had this same question before. I believe It's not something that can be solved cryptographically. You need some approach like handing out the public key physically by the person (much like the QR code approach you described in another comment), and/or at least using TOFU (Trust on First Use) which is the approach partially taken by Signal (they just display a warning): en.wikipedia.org/wiki/Trust_on_fir...

Collapse
 
shierve profile image
Sergi Canal

As Junxiao said, it is dangerous to imply that IV is not an important parameter when using GCM. When reusing the IV in GCM, if an attacker captures a few encrypted messages, since basically GCM is an xor of the plaintext and the keystream (and the keystream is the same when using the same iv), then it is trivial to implement an attack that gets the keystream and unencrypts all the captured messages. It is basically the same attack that you would use for reused keys in One Time Pad. I highly encourage you to edit that part, otherwise the article is very useful, thanks.

Collapse
 
cardoso profile image
Matheus Cardoso • Edited

Thanks a lot for the feedback! I'm changing the tutorial's approach to this.

Edit: done

Collapse
 
yoursunny profile image
Junxiao Shi

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

Why do you generate the key as exportable and save the JWK?
It's more secure to use non-exportable private keys, and store the key object in IndexedDB.

Collapse
 
cardoso profile image
Matheus Cardoso

Thanks for your comment! I didn't want to touch on private key persistence methods in this tutorial. If I got into that, I'd also need to tell how to recover or rotate the key pair in case the persistent storage is lost. So it was better just to leave it like this and tell that the private key is sensitive.

Collapse
 
yoursunny profile image
Junxiao Shi

Then you should use non-exportable key and pass it around as a variable.

Thread Thread
 
cardoso profile image
Matheus Cardoso

I left it like that to allow more than one session per user, mostly for not making the test app annoying to use. But I'll add further notes to emphasize that it's not great practice. Thanks :)

Collapse
 
yoursunny profile image
Junxiao Shi

I see you are using ECDH shared secret as AES key. This is not advisable because the shared secret could have bias. It needs to pass through HKDF step.

Also, P-256 can only provide 128-bit strength, so that you should be using AES-128, not AES-256.

Collapse
 
cardoso profile image
Matheus Cardoso

Thank you! I'll try improving this part as well

Collapse
 
yoursunny profile image
Junxiao Shi

This is still wrong:

For every encryption operation, it must be random and different to ensure the strength of the encryption.

IV in AES-GCM must be unique but does not need to be random.
IV in AES-CBC must be random.

Collapse
 
cardoso profile image
Matheus Cardoso • Edited

Thanks for your feedback! I considered switching to CBC, but didn't find a strong enough reason, since the AES-GCM is safe for encrypting 2^32 times with a randomly generated IV using the CSPRNG provided by the Web Crypto API.

Edit: but yes, I removed the "must" and added further details. Thanks again :)