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.
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.
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')
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]
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.
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]
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 }
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()
orSubtleCrypto.wrapKey()
. Since the decryption method will generate a key locally, and since we won't be reusing this key, we set this property tofalse
. - 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.
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]
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).
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]
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Ü"
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"
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"
Finally, we return the hexadecimal-encoded IV along with the ciphertext.
return ivHex+ctBase64
// returns "7b7a1e825e4b178b69bbe5favQhnaER+I2cvcmaqPRfLDa9jcuoDY2uIa3Pc"
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') { ... }
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]
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) {}
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]
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) }
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'])
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Ü"
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]
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) {}
Then, we can use TextDecoder
to decode the ArrayBuffer into readable text.
const plaintext = new TextDecoder().decode(plainBuffer)
Finally, we return the plaintext.
return plaintext
// returns 'hello world'