Public Key Encryption with the JavaScript Web Crypto API

Posted by ryansouthgate on 23 Jan 2024

In this post I’m going to show you how to implement Public Key Encryption in the browser with standardised APIs in JavaScript. These APIs have been implemented by the browser, so there is no need to reference and use any other JavaScript libraries.

Browsers standardising on APIs and being made available to developers means we’re able to use battle-tested, trusted encryption code. And let’s be honest, we wouldn’t want to write that ourselves, would we?

All modern browsers support SubtleCrypto…even the dreaded IE11

A Use Case

Say we wanted to transfer a message from one device to another, using the web and an intermediary server, hosted by someone else. To do that, I have to trust the service I’m using, to not open my message and have a sneaky look at it.

Ideally, we’d want to implement something similar to WhatsApp/Telegram, where people’s conversations are end-to-end encrypted.

WhatsApp uses end-to-end (E2E) encryption for all your messages. This protects you from WhatsApp, they can’t read your messages. Your messages also can’t be read by anyone who was able to breach WhatsApp, or any law enforcement agencies who might want a copy of your messages.

Using Crypto APIs in the browser, we can leverage this technology to protect (E2E encrypt) messages between devices, so senders/receivers don’t need to trust the middle-man (the service that facilitates the transfer).

What is public key encryption?

Public key encryption (also known as Asymmetric encryption) is a complex topic. The algorithms that facilitate the encryption of our data are created by very clever people.

I’m going to dumb down public key encryption a lot here, but for a full rundown of public key cryptography, this Wikipedia Artcile is an informative read!

With Public key encryption, an entity (person or computer) has a pair of keys. One public, the other private. If I want people to securely send me messages, I distribute my public key and ask people to encrypt their messages with my public key. The messages that have been encrypted with my public key, can only be decrypted using my private key, which, as the name implies, should not be distributed - and should be kept securely by me. If a private key is leaked, anyone can now decrypt the messages that were meant for me. In that case I would generate a new key-pair and distribute my new public key, using my new secure private key to decrypt the messages.

The key pairs are mathematically connected, meaning that someone else’s private key, can’t be used to decrypt information encrypted with my public key. In the case of my private key being leaked, I should generate a new public/private key pair. The new private key will not be able to decrypt messages that have been encrypted with my old public key. As keys (public & private) come as a pair, whenever I generate a new pair, I’ll have to distribute (tell everyone you want to receive messages from) my new public key.

Pros and Cons

Pros:

  1. a different key is used to encrypt and decrypt data. We don’t have to rely on the client (entity encrypting and sending the data) to keep a key safe. As the public key they receive only encrypts data, it doesn’t matter if that is distributed to anyone else. When using symmetric encryption, both parties have to keep that key safe. If a bad-actor manages to take a copy of our symmetric encryption keys, then they’ll be able to decrypt and view the sensitive information we’re sending.

Cons:

  1. Slower than symmetric encryption. For some use cases, Public Key Encryption is used to facilitate a secure transport of a symmetric key.
  2. Different algorithms permit different sizes of data, however, they are all relatively small. RSA (PKCS#1) message size limit is 245 bytes

Code example and demo

The below demo has taken heavy inspiration from the fantastic example by MDN (Mozilla)

Here’s the html:

<div>
    <label>Public Key (as JWK):</label>
    <textarea id="public_key" type="text" readonly></textarea>
</div>
<div>
    <label>Private Key (as JWK):</label>
    <textarea id="private_key" type="text" readonly></textarea>
</div>
<div>
    <div>
        <label>Input Message:</label>
        <input id="input_message" type="text" maxlength="200" />
        <button id="btn_encrypt" type="button">Encrypt</button>
    </div>
    <div>
        <label>Encrypted Message (base64 representation):</label>
        <input id="encrypted_message" type="text" readonly />
    </div>
    <div>
        <label>Decrypted Message:</label>
        <input id="decrypted_message" type="text" readonly />
        <button id="btn_decrypt" type="button">Decrypt</button>
    </div>
</div>

And the JavaScript

let cipherText;
// generate the public/private key pair
window.crypto.subtle.generateKey(
{
    name: "RSA-OAEP",
    modulusLength: 2048,
    publicExponent: new Uint8Array([1, 0, 1]),
    hash: "SHA-256"
},
true,
["encrypt", "decrypt"])
.then((keyPair) => {
    let encoder = new TextEncoder();
    let decoder = new TextDecoder("utf-8");
    window.crypto.subtle.exportKey("jwk", keyPair.publicKey).then(result => {
        // Show the public key in JSON form
        document.querySelector("#public_key").value = JSON.stringify(result);
    });
    window.crypto.subtle.exportKey("jwk", keyPair.privateKey).then(result => {
        // Show the private key in JSON form
        document.querySelector("#private_key").value = JSON.stringify(result);
    });
    const encryptButton = document.querySelector("#btn_encrypt");
    encryptButton.addEventListener("click", () => {
        // stash the message from the input
        const message = document.querySelector("#input_message").value;
        let encoded = new TextEncoder().encode(message);
        // encrypt the message with the public key
        window.crypto.subtle.encrypt(
        {
            name: "RSA-OAEP"
        },
        keyPair.publicKey,
        encoded
        ).then(result => {
            // stash the encrypted result for decryption later
            cipherText = result;
            // show a base64 encoded version of the encrypted message
            document.querySelector("#encrypted_message").value = btoa(cipherText);
        });
    });
    const decryptButton = document.querySelector("#btn_decrypt");
    decryptButton.addEventListener("click", () => {
        // decrypt the message, with the private key
        window.crypto.subtle.decrypt(
        {
            name: "RSA-OAEP"
        },
        keyPair.privateKey,
        cipherText
        ).then(result => {
            // after decryption, show the original message
            document.querySelector("#decrypted_message").value = decoder.decode(result);
        });
    });
});

Conclusion

The above code is great for a robust implementation of RSA encryption, however we’re severly restricted to very small message sizes. As mentioned previously, this kind of encryption is best used for the swapping of symmetric keys (which would rotate frequently). This means that we can communicate securely with a 3rd-party, without relying on them to secure the keys.

I will be creating a future post about how to encrypt much larger payloads (e.g. files) using similar browser APIs, so be sure to check back for that!

Thanks for reading!



comments powered by Disqus