Skip to content

Instantly share code, notes, and snippets.

@rpivo
Last active May 1, 2024 19:54
Show Gist options
  • Star 4 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save rpivo/2904951ee31ce9146d32321f12c941bd to your computer and use it in GitHub Desktop.
Save rpivo/2904951ee31ce9146d32321f12c941bd to your computer and use it in GitHub Desktop.
Encrypting & Decrypting Sensitive Data With the Web Crypto API

Encrypting & Decrypting Sensitive Data With the Web Crypto API

Chris Veness created a really great gist that shares code to encrypt and decrypt data using the Web Crypto API here.

This gist breaks down the code in that gist step by step, detailing a real-world scenario with actual data.

It might be helpful to have that gist open in another tab and use this gist to walk through each line of code.

One interesting use case for this would be to encrypt on a client, and then decrypt in the cloud with something like a Lambda function. In order to do this, you could use Node's latest version as of this gist, version 15, which includes a webcrypto module that's designed to be a Node implementation of the Web Crypto API. Lambdas can't use v15 by default -- however, you can create a custom Lambda layer that contains v15.

Prerequisite Arguments

To use both the encryption and decryption methods, we need:

  • plaintext: a string of text that will be encrypted and decrypted.
  • password: a string of text that will be a "secret" that's used to generate a key in the encryption & decryption process. Assuming a scenario where a client is sending sensitive data to a server, the password would be known by the client and the server, but would never be sent between the two.

Encryption

Calling the Encryption Function

To start, we'll encrypt some data.

The encryption function has this signature:

async function aesGcmEncrypt(plaintext: string, password: string): string { ... }

Let's assume we call this function like so:

aesGcmEncrypt('hello world', 'foo')

Converting the Password to a Typed Array

We first need to convert the password to a typed array.

const pwUtf8 = new TextEncoder().encode('foo')

TextEncoder's encode() method returns a Uint8Array (typed array) where each character from the original plaintext is converted to an 8-byte unsigned integer (0 to 255).

This will return a Uint8Array (typed array) that looks like this:

Uint8Array(3) [102, 111, 111]

Converting the Password Typed Array to a Digest

A digest is a fixed-length value derived from some dynamically-lengthed input. We need to use a hashing algorithm to generate a digest.

Per the MDN docs:

Cryptographic digests should exhibit collision-resistance, meaning that it's hard to come up with two different inputs that have the same digest value.

The digest will be used later to generate a key, which in turn will be used to encrypt the plaintext.

To do this, we can use the Web Crypto API's digest() method. As arguments, we pass in a string representing the hashing algorithm to be used ('SHA-256', in this case), as well as the Uint8Array generated from the password.

// pwUtf8 is a Uint8Array here: Uint8Array(3) [102, 111, 111]
const pwHash = await crypto.subtle.digest('SHA-256', pwUtf8)
// returns ArrayBuffer(32) {}

This will return an ArrayBuffer: that is, a collection of data in memory for which we don't yet have a means of reading from or writing to. If we want to read or write it, we need to convert it to a DataView (typed array) like Uint8Array.

Why is this property on crypto called subtle? This webkit.org page explains it well:

The interface is named subtle because it warns developers that many of the crypto algorithms have sophisticated usage requirements that must be strictly followed to get the expected algorithmic security guarantees.

Generating an Initialization Vector (IV)

Certain hashing algorithms within the Web Crypto API require an initialization vector (iv). The AES-GCM algorithm is one of those algorithms, which is the algorithm we are using for encryption.

An initialization vector provides a kind of "initial state" for a hashing algorithm. The IV should generally be random, which is what we are doing here using the crypto.getRandomValues() method.

const iv = crypto.getRandomValues(new Uint8Array(12))
// returns a Uint8Array

This will return a typed Array of the same size and type that was passed in, but with randomized values set. The above call would return something like:

Uint8Array(12) [123, 122, 30, 130, 94, 75, 23, 139, 105, 187, 229, 250]

Initializing the AesGcmParams Dictionary

For these functions, we are using a specific type of hashing algorithm called AES-GCM.

"AES" stands for Advanced Encryption Standard, and is a popular specification for data encryption.

"GCM" stands for Galois/Counter Mode, and is a mode of operation that's regarded highly for its performance.

To use the AES-GCM algorithm, the params dictionary needs a name property set to 'AES-GCM' and an iv property set to the previously initialized iv value.

const alg = { name: 'AES-GCM', iv }

Generating the Encryption Key

We will use the crypto.subtle.importKey() to generate a key for encrypting our plaintext. This method takes four arguments:

  • The first argument is the format. The format is a string describing the data format of the key to import. In the 'raw' format, the key is supplied as an ArrayBuffer containing the raw bytes for the key.
  • The next argument represents the keyData. keyData is an ArrayBuffer, a TypedArray, a DataView, or a JSONWebKey object containing the key in the given format.
  • The next argument is an algorithm object. This is a dictionary object defining the type of key to import and providing extra algorithm-specific parameters.
  • The fourth argument is the "extractable" boolean, indicating whether it will be possible to export the key using SubtleCrypto.exportKey() or SubtleCrypto.wrapKey(). Since the decryption method will generate a key locally, and since we won't be reusing this key, we set this property to false.
  • The final argument is an array of strings called keyUsages. This indicates what can be done with the key. Possible array values are: 'encrypt', 'decrypt', 'sign', 'verify', 'deriveKey', 'deriveBits', 'wrapKey', and 'unwrapKey'. Because this key is single-use, we only list 'encrypt' here.
const key = await crypto.subtle.importKey('raw', pwHash, alg, false, ['encrypt'])
// returns a CryptoKey object

The above returns a CryptoKey object.

{
  algorithm: {name: "AES-GCM", length: 256},
  extractable: false,
  type: "secret",
  usages: ["encrypt"],
}

This key will be used as an argument when encrypting the plaintext.

Converting the Plaintext to a Uint8Array

Similar to how we encoded the password, we encode the plaintext.

const ptUint8 = new TextEncoder().encode('hello world')
// returns Uint8Array(11) [104, 101, 108, 108, 111, 32, 119, 111, 114, 108, 100]

Encrypting the Plaintext Typed Array with the Key

To convert the plaintext Typed Array into ciphertext, we use the crypto.subtle.encrypt() method. This method takes in the alg params dictionary, a key, and the data to encrypt, which needs to be in a typed array format.

const ctBuffer = await crypto.subtle.encrypt(alg, key, ptUint8)
// returns ArrayBuffer(27) {}

This will return an ArrayBuffer (similar to the return value of crypto.subtle.digest().

Finally, we'll need to convert the encrypted data in this ArrayBuffer into a string that can easily be sent elsewhere (to a server, for instance).

Storing the ArrayBuffer Data in a Regular Array

We're then going to store this ArrayBuffer data in a regular array, which will make it easier to convert to a string that will be easy to send elsewhere.

First, we need to convert the ArrayBuffer into a TypedArray so that it's readable. To do this, we do new Uint8Array(ctBuffer).

We then convert the values inside this typed array to a regular array using Array.from().

const ctArray = Array.from(new Uint8Array(ctBuffer))
// returns [189, 8, 103, 104, 68, 126, 35, 103, 47, 114, 102, 170, 61, 23, 203, 13, 175, 99, 114, 234, 3, 99, 107, 136, 107, 115, 220]

Converting the Array of Byte Values to a String of UTF-8 Characters

We'll then convert each byte in the array into an array of UTF-8 characters by calling String.fromCharCode() on each byte.

After this map takes place, we then join all these characters in the array together with join('').

const ctStr = ctArray.map(byte => String.fromCharCode(byte)).join('')
// returns "½�ghD~#g/rfª=�Ë
¯crê�ck�ksÜ"

Base64 Encoding the Ciphertext

The ciphertext may have some odd special characters that could be problematic when transmitting the data elsewhere. This is true for the string above. To get around this, we can Base64 encode this string.

const ctBase64 = btoa(ctStr)
// returns "vQhnaER+I2cvcmaqPRfLDa9jcuoDY2uIa3Pc"

Converting the Initialization Vector to a Hexadecimal String

We also need to bundle the initialization vector (IV) along with the ciphertext since it will need to be used during the decryption process.

Remember that iv is a Uint8Array, so we first need to create a regular array from this Uint8Array data using Array.from().

We then map over this array and convert each byte (b) to a hexadecimal string value with b.toString(16).

Finally, we join each item in the array together to form a single string.

const ivHex = Array.from(iv).map(b => ('00' + b.toString(16)).slice(-2)).join('')
// returns "7b7a1e825e4b178b69bbe5fa"

Returning the Ciphertext

Finally, we return the hexadecimal-encoded IV along with the ciphertext.

return ivHex+ctBase64
// returns "7b7a1e825e4b178b69bbe5favQhnaER+I2cvcmaqPRfLDa9jcuoDY2uIa3Pc"

Decryption

Calling the Decryption Function

We'll call our decryption function with our newly encrypted string as well as the same password we used for the encryption process.

async function aesGcmDecrypt('7b7a1e825e4b178b69bbe5favQhnaER+I2cvcmaqPRfLDa9jcuoDY2uIa3Pc', 'foo') { ... }

Converting the Password to a Typed Array

As we did in the encryption method, we need to convert the password to a typed array:

const pwUtf8 = new TextEncoder().encode('foo')
// returns Uint8Array(3) [102, 111, 111]

Converting the Password Typed Array to a Digest

We again convert the password to a digest just like we did during encryption:

const pwHash = await crypto.subtle.digest('SHA-256', pwUtf8)
// returns ArrayBuffer(32) {}

Parsing the Initialization Vector from the Ciphertext

This is where the decryption process gets interesting. We need to pull off the initialization vector (IV) from the ciphertext. Remember that we appended this value to the beginning of the ciphertext.

The initialization vector was initially an array of 12 8-bit values. We stored them as hexadecimal values for transmission, which means that each value would have taken 2 characters. So, the first 24 characters of the ciphertext will account solely for the IV.

We can get these characters by using ciphertext.slice(0,24).

Then, we can create an array of paired characters by using match(/.{2}/g).

We then want to convert these hex strings to integers. To do this, we can map over each string pair and call parseInt on them using a radix of 16 (hexadecimal). This will give us our 8-bit number values that we originally started with in the encryption process.

const iv = ciphertext.slice(0,24).match(/.{2}/g).map(byte => parseInt(byte, 16))
// returns [123, 122, 30, 130, 94, 75, 23, 139, 105, 187, 229, 250]

Initializing the AesGcmParams Dictionary

We can then initialize the params dictionary for our hashing algorithm now that we have the IV. We'll do this exactly how we did it during encryption, with the exception that we'll convert the regular IV array to a Uint8Array since the params dictionary needs the IV as a typed array:

const alg = { name: 'AES-GCM', iv: new Uint8Array(iv) }

Generating the Encryption Key

We then locally generate the encryption key. This will also be mostly like how we generated the key during encryption, except that we'll set the keyUsages to be ['decrypt'] this time:

const key = await crypto.subtle.importKey('raw', pwHash, alg, false, ['decrypt'])

Decoding the Base64 Ciphertext

We had previously Base64-encoded the ciphertext. We now need to decode this text.

const ctStr = atob(ciphertext.slice(24))
// returns "½�ghD~#g/rfª=�Ë
¯crê�ck�ksÜ"

Converting the Ciphertext String into an Array of Byte Values

Before we can decrypt, we need to convert our ciphertext string into a Uint8Array of byte values.

To do this, we'll create an array of each character by matching any whitespace (\s) as well as any non-whitespace (\S) character in the string.

Then, we map over this array and convert each character ch to a byte value using ch.charCodeAt(0).

const ctUint8 = new Uint8Array(ctStr.match(/[\s\S]/g).map(ch => ch.charCodeAt(0)))
// returns Uint8Array(27) [189, 8, 103, 104, 68, 126, 35, 103, 47, 114, 102, 170, 61, 23, 203, 13, 175, 99, 114, 234, 3, 99, 107, 136, 107, 115, 220]

Decrypting the Ciphertext

We then will decrypt the ciphertext using crypto.subtle.decrypt(). Like encrypt(), this takes in the algorithm params dictionary alg, the key, and the Uint8Array to be decrypted.

This returns an ArrayBuffer.

const plainBuffer = await crypto.subtle.decrypt(alg, key, ctUint8)
// returns ArrayBuffer(11) {}

Decoding the Decrypted ArrayBuffer

Then, we can use TextDecoder to decode the ArrayBuffer into readable text.

const plaintext = new TextDecoder().decode(plainBuffer)

Return the Plaintext

Finally, we return the plaintext.

return plaintext
// returns 'hello world'

References

gist.github.com / chrisveness / Uses the SubtleCrypto interface of the Web Cryptography API to encrypt and decrypt text using AES-GCM (AES Galois counter mode).

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment