loading...

I learned enough Web Crypto to be dangerous

subterrane profile image Will Munslow ・4 min read

Disclaimer: security is hard. Keep your private keys private. I'm not a security expert, there's reasons not to do this. I'm not sure what they all are so proceed at your own peril.

I was asked to encrypt some data in a browser before sending it to a server. Sounds simple enough: I figured I'd get someone's public key, encrypt some data with it and send it on its way. They'd decrypt it with their private key, easy-peasy. Nope.

I learned quickly that asymmetric key pairs are (usually) used to encrypt symmetric keys and a symmetric key is used to encrypt the data. This is due to speed and the amount of data that can be encrypted is dependent on key length and zzzzzz...sorry, I fell asleep.

So, you make up your own key. Which explains why Web Crypto gives you this handy function: generateKey()

Here is an example function to encrypt some data:

    // encrypt form input
    let cypher = await encrypt(input.value);
    console.dir('cyphertext: ' + cypher.data);

    async function encrypt(data) {
        const key = await window.crypto.subtle.generateKey({ name: 'AES-GCM', length: 256 }, true, ['encrypt', 'decrypt']);
        const iv = window.crypto.getRandomValues(new Uint8Array(12));
        const cypher = ab2str(await window.crypto.subtle.encrypt({ name: 'AES-GCM', iv: iv }, key, str2ab(data)));

        return {
            data: cypher,
            iv: iv,
            key: key
        };
    }

And to decrypt:

    async function decrypt(data, key, iv) {
        return ab2str(await window.crypto.subtle.decrypt({ name: 'AES-GCM', iv: iv }, key, str2ab(data)))
    }

The second thing you learn is that the Web Crypto functions work on a BufferSource, not a string. There are APIs available to encode and decode strings to buffers (TextEncoder) but I had some difficulty using them so I used a couple of functions by Renato Mangini.

    function ab2str(buf) {
        return String.fromCharCode.apply(null, new Uint16Array(buf));
    }

    function str2ab(str) {
        let buf = new ArrayBuffer(str.length * 2);
        let bufView = new Uint16Array(buf);
        for (let i = 0, strLen = str.length; i < strLen; i++) {
            bufView[i] = str.charCodeAt(i);
        }
        return buf;
    }

Now we have some encrypted data, a key and an initialization vector (Pro Tip: saying 'initialization vector' makes you sound smart in meetings). We need to encrypt the key we made with someone's public key. That way, we can send the encrypted data, the initialization vector and the encrypted symmetric key to them and only they can decrypt they symmetric key to decrypt the data.

It's similar to how your realtor is able to get into that house you want to see. The house has a key and the key is placed in a lockbox on the front door. Your realtor knows the code to the lockbox, so s/he opens it up, gets the key, unlocks the house and shows you around. You decide you really would prefer an open-concept kitchen and a master ensuite so you leave and the realtor puts the key in the lockbox. The lockbox is a terrible analogy for a public/private key pair but you get the idea that the key to open the house gets secured in some manner.

For fun, we can make our own key pair with a command line tool. For extra fun, we can convert it to JSON Web Key format to make it easy to deal with. The Web Crypto API had methods to allow you to create and export key pairs in JWK format. I used the generateKey method above to make a symmetric key. But I needed to be able to use a public key that someone else created so I went through these steps to see if I could make it work.

I used this package by dannycoates. First, make a key:

openssl genrsa 2048 | pem-jwk > private_key.jwk

Then convert it to .pem:

pem-jwk private_key.jwk > private_key.pem

Derive the public key from the private key:

openssl rsa -pubout -in private_key.pem -out public_key.pem

Then convert the public key to jwk format:

pem-jwk public_key.pem > public_key.jwk

You end up with 4 files:

  • private_key.jwk
  • private_key.pem
  • public_key.jwk
  • public_key.pem

I wrote a couple more functions

    async function importPublicKey() {
        const key = /* contents of public_key.jwk */ ;
        const algo = {
            name: 'RSA-OAEP',
            hash: { name: 'SHA-256' }
        };
        return await window.crypto.subtle.importKey('jwk', key, algo, false, ['wrapKey']);
    }

    async function importPrivateKey() {
        const key = /* contents of private_key.jwk */;
        const algo = {
            name: 'RSA-OAEP',
            hash: { name: 'SHA-256' }
        };
        return await window.crypto.subtle.importKey('jwk', key, algo, false, ['unwrapKey']);
    }

Disclaimer: Again, keep your private key private. This is just for kicks, man, don't do this in real life.

Web Crypto gives you the tools to encrypt and decrypt a key: wrapKey and unwrapKey and with a key, you can decrypt your BufferSource:

        // import public key
        const publicKey = await importPublicKey();

        // wrap symmetric key
        const wrappedKey =  ab2str(await window.crypto.subtle.wrapKey('raw', cypher.key, publicKey, { name: 'RSA-OAEP' }));
        console.log('wrappedKey: ' + wrappedKey);

        // import private key
        const privateKey = await importPrivateKey();

        // unwrap symmetric key
        const unwrappedKey =  await window.crypto.subtle.unwrapKey('raw', str2ab(wrappedKey), privateKey, { name: 'RSA-OAEP' }, { name: 'AES-GCM', length: 256 }, false, ['decrypt']);
        console.log('unwrappedKey: ' + unwrappedKey);

        // decrypt encrypted data
        let plaintext = await decrypt(cypher.data, unwrappedKey, cypher.iv);
        console.log('plaintext: ' + plaintext);

Posted on by:

Discussion

markdown guide
 

Great article.
We are in the same boat as u and struggling to see how it can be achieved and if it is worth doing over and above just using Https to transport the sensitive data.
U mention 'I learned quickly that asymmetric key pairs are (usually) used to encrypt symmetric keys and a symmetric key is used to encrypt the data.' have u any info/references for this 'best practise'?
Did u actually end up with a viable production quality solution?
Thanks for your time.
Regards
John

 

I don't have a best practice reference. It might have been mentioned here schneier.com/books/applied_cryptog... but I only read about a third of that book back in 1998. If I recall, I asked a very experience dev how it was done and he mentioned it was done that way (the guy I asked used to work on a S/MIME toolkit back in the day).

My solution worked when I tested it but it never actually shipped. The company I worked for folded (for other reasons!) before it was deployed.

 

Thanks for the prompt response and the great article.

 

The reason that you only typically use the asymmetric pair to encrypt/decrypt a symmetric key is that asymmetric keys are very large, CPU intensive, and fairly slow. If you're only encrypting relatively small messages at a fairly slow rate, or large messages at a VERY slow rate, it's ok to just use the asymmetric keys. This is how PGP works, for example.

 
 

For just getting data to the server there is no reason whatsoever to use Web Crypto as the same algorithms are used to transfer data.

The typical reason to use Web Crypto is this flow:

  1. Client (Encrypt)
  2. Server (Can't read)
  3. Client (Decrypt)

Some of the more obvious examples of companies that would use this are ones like Dropbox and 1Password, but potentially anywhere sensitive data is kept might be the case for client->client crypto.

For example if a law firm uses an online document editor, it would be a huge risk both to the law firm and to the company hosting their documents if they stored unencrypted blobs on the server.