Chapter 8: Performing Common Cryptographic Operations in the Browser

In the previous chapter, we introduced the idea of performing cryptographic operations in the browser, and we looked at some core concepts, such as how to manage binary data in client-side JavaScript and how to create, import, and export keys with CryptoKey objects.

We'll cover the rest of the topics in this chapter by looking at how to perform the cryptographic operations we learned about in Part 2 of this book, this time within the web browser: hashing, symmetric encryption, asymmetric and hybrid encryption, and digital signatures.

Please note that we won't be explaining what each cryptographic operation does or when you should use which in this chapter. Because of that, I recommend that you familiarize yourself with the various operations first, reviewing Chapter 3, File and Password Hashing with Node.js to Chapter 6, Digital Signatures with Node.js and Trust, if needed.

Lastly, fasten your seatbelts, as we'll be moving fast in the next few pages while covering the following topics:

  • Hashing with SHA using the WebCrypto APIs, and key derivation and password hashing with Argon2 in the browser
  • Symmetric encryption in the browser with AES and the WebCrypto APIs
  • Asymmetric and hybrid encryption with the WebCrypto APIs, using RSA and ECDH
  • Calculating and verifying digital signatures using RSA and ECDSA with the WebCrypto APIs

Technical requirements

Please refer to the Technical requirements section of Chapter 7, Introduction to Cryptography in the Browser, since the technical requirements are the same for this chapter too.

In particular, please note that all the samples in this chapter have been optimized so that they can be included in modern browser-based applications that are packaged using a bundler for JavaScript code, such as Webpack or similar. Alternatively, the code can also be executed in Node.js 15 or higher by importing the compatibility object from the crypto package.

To make it easier to experiment with the code shown in this chapter, we've created a "playground" that you can run within your web browser: https://bit.ly/crypto-playground.

As a reminder, all the code samples for this chapter and the previous can be found in this book's GitHub repository at https://bit.ly/crypto-ch8.

Lastly, please note that some code samples depend on libraries to convert from buffers (such as ArrayBuffer objects or its views) into base64-encoded or hex-encoded strings, or vice versa. As we mentioned in the previous chapter, these routines are not built into the Web Platform, so we need to rely on external libraries instead. While any suitable module from npm will do the job (and we recommended a few in the previous chapter), in these examples we'll be using arraybuffer-encoding (https://www.npmjs.com/package/arraybuffer-encoding).

Hashing and key derivation

Hashing was the first operation we covered in this book, in Chapter 3, File and Password Hashing with Node.js. As you will recall, hashing functions can be used for a variety of purposes, including the following:

  • Calculating the checksum (or digest) of a document or file. For this specific scenario, we recommended the use of the SHA-2 family of hashes (including SHA-256, SHA-384, and SHA-512).
  • Hashing passwords before they're stored in a database. In this case, we recommended using algorithms in the Argon2 suite or scrypt.
  • Deriving symmetric encryption keys from low-entropy inputs such as passphrases, as we saw in Chapter 4, Symmetric Encryption in Node.js. For this scenario, we once again recommended using Argon2 or scrypt.

Support for calculating digests with SHA-2 is built into the Web Platform, and at the time of writing, it is one of the only two hashing algorithms that have been standardized and are available in all browsers (the other one is SHA-1, which, as we've seen, is considered broken and deprecated, and should only be used for compatibility reasons).

Neither Argon2 nor scrypt are available in the Web Platform, so we'll need to rely on external modules, such as the hash-wasm package from npm (https://www.npmjs.com/package/hash-wasm); this specific library was selected because it uses fast WebAssembly binaries. In this chapter, we'll see code samples for Argon2, but the same package contains routines for scrypt as well.

Calculating checksums

We can use the crypto.subtle.digest() method to calculate a SHA-2 hash, as follows:

8.1: Calculating the checksum of a message with SHA-256 (hash-sha256.js)

import {Encode} from 'arraybuffer-encoding/hex'

;(async () => {

    const message = (new TextEncoder())

        .encode('Hello world!')

    const result = await window.crypto.subtle.digest(

        'SHA-256',

        message,

    )

    console.log(Encode(result))

})()

The previous code (which, as usual, is wrapped in an Immediately-Invoked Function Expression (IIFE) so that it can be used the await expression) takes a string message, converts it into a buffer with TextEncoder (as explained in the previous chapter), and then calculates its SHA-256 digest. Eventually, the result is printed to the console, hex-encoded.

The crypto.subtle.digest(algorithm, data) method accepts two arguments:

  • algorithm is the name of hashing algorithm to use, which is a string with a value of 'SHA-256', 'SHA-384', or 'SHA-512'. It's also possible to use 'SHA-1', but that is not secure and should only be used for compatibility with older systems.
  • data is the message to digest, as an ArrayBuffer or one of its views (such as Uint8Array).

The result is a promise that resolves with an ArrayBuffer containing the digest.

Streaming Support

At the time of writing, the WebCrypto APIs do not include support for streams, neither for hashing nor for working with encryption or digital signatures. This makes using the methods presented in this chapter not always practical when dealing with large files.

If your web-based application requires dealing with documents that are too large to be kept in memory, you will need to look into third-party modules from npm (for hashing, this includes hash-wasm, which we'll encounter in the next section). Alternatively, you can build a chunking mechanism, where data is hashed or encrypted in fixed-length chunks. One example is the "DARE" format, which was created by the engineers at MinIO: https://bit.ly/crypto-dare.

Hashing passwords

Using the routines from the hash-wasm module from npm, we can calculate the hash of a password using the argon2d, argon2i, and argon2id functions that it exports. Additionally, it's possible to verify that a password that's been submitted by the user matches the hash stored in a database with the argon2Verify method from the same library.

Please refer to Chapter 3, File and Password Hashing with Node.js, for a recap on Argon2, its details, and its variants:

8.2: Hashing passwords with Argon2 (hash-argon2.js)

import {argon2id, argon2Verify} from 'hash-wasm'

;(async () => {

    const passphrase = 'correct horse battery staple'

    const salt = new Uint8Array(16)

    window.crypto.getRandomValues(salt)

    const hash = await argon2id({

        outputType: 'encoded',

        password: passphrase,

        salt: salt,

        hashLength: 32,

        parallelism: 1,

        iterations: 3,

        memorySize: 4096,

    })

    console.log('Hash:', hash)

    const isValid = await argon2Verify({

        password: passphrase,

        hash: hash

    })

    console.log('Is valid?', isValid)

})()

The previous code takes a passphrase (in this case, hardcoded in the passphrase variable) and calculates its hash with Argon2 in the argon2id variant. The argon2id function accepts a dictionary with several parameters:

  • When hashing passwords to store the hash in a database, you want to set outputType to 'encoded'. Just like we saw in Chapter 3, File and Password Hashing with Node.js, this makes the result a string that contains, in addition to the hash, the salt and all the tuning options that were used for Argon2, making it easier to verify the hash later.
  • The password/passphrase is passed to the password key.
  • We also need to pass to the method a random salt, which we're generating before invoking the function.
  • With hashLength, we can configure the number of bytes to return; that is, how long the hash should be. A value of 32 is a sensible default for password hashing.
  • Lastly, parallelism, iteration, and memorySize (in KB) control the "cost" of the Argon2 invocation. Higher values will require more time and/or memory. You can refer to the discussion in Chapter 4, Symmetric Encryption in Node.js, for more details on these parameters and how they can be tuned.

The function returns a promise that resolves with a string when outputType is 'encoded'. The computed hash can be stored in a database and retrieved later.

When a user types the password again, you can verify that it matches the hash with the argon2Verify method, as shown in the preceding example. This function accepts a dictionary with two keys:

  • password is the password/passphrase the user just typed. The method will check if it matches the one that was used when the hash was created.
  • hash is the hash of the password, as a string in "encoded" form with all the parameters included.

The result is a promise that resolves with a value of true if the password matches the value in the hash.

The previous code sample will print something like this in the console (the actual output will be different every time because of the random salt):

Hash:

$argon2id$v=19$m=4096,t=3,p=1$iSuXUkWhJ9343KE0W9BegA$dL83TLLTij9wLnfJXCTnF0IAMPvgXR3VSIefINM78vs

Is valid? True

Note that the hash in "encoded" mode contains all the parameters that were used to compute it, which are needed to verify that a password matches the hash.

Deriving encryption keys

We can use Argon2 to derive symmetric encryption keys by stretching passphrases, as we saw in Chapter 4, Symmetric Encryption in Node.js. The following is an example:

8.3: Deriving encryption keys with Argon2 (key-derivation-argon2.js)

import {argon2id} from 'hash-wasm'

;(async () => {

    const passphrase = 'correct horse battery staple'

    const salt = new Uint8Array(16)

    window.crypto.getRandomValues(salt)

    const rawKey = await argon2id({

        outputType: 'binary',

        password: passphrase,

        salt: salt,

        hashLength: 32,

        parallelism: 1,

        iterations: 3,

        memorySize: 4096,

    })

    const key = await window.crypto.subtle.importKey(

        'raw', rawKey,

        'AES-GCM', false, ['encrypt', 'decrypt']

    )

})()

In this example, we're once again using the argon2id method, but this time we're setting outputType to 'binary'. This will make the function return a promise that resolves with an ArrayBuffer containing the raw encryption key, with as many bytes as requested with the hashLength option (in the preceding example, we're requesting a 256-bit, or 32-byte, key).

For us to be able to use the derived key as a symmetric encryption key (such as for AES-GCM), we need to import that in a CryptoKey object using the crypto.subtle.importKey method, as we saw in Chapter 7, Introduction to Cryptography in the Browser.

The key object can then be used to encrypt and decrypt data with AES, as we'll see in the next section.

Symmetric encryption

We first encountered symmetric encryption in Chapter 4, Symmetric Encryption in Node.js, where we covered two ciphers: AES and ChaCha20-Poly1305.

In this chapter, we'll focus solely on AES, given that it's the only symmetric cipher that's standardized in the WebCrypto APIs and available on all browsers, with a few different modes of operation. We'll look at encrypting and decrypting data using AES-GCM and AES-CBC, and then we'll use AES-KW (RFC 3394) for wrapping and unwrapping keys.

While we won't cover ChaCha20-Poly1305, you can find several packages on npm that offer support for that, including implementations in pure JavaScript or that leverage native code via WebAssembly.

Encrypting and decrypting messages with AES

The WebCrypto APIs include two methods for encrypting and decrypting data:

  • crypto.subtle.encrypt(algorithm, key, data)
  • crypto.subtle.decrypt(algorithm, key, data)

Both methods have similar parameters:

  • algorithm is an object that contains two keys: the name property of the algorithm and its iv.
  • key is a CryptoKey object containing a symmetric key for the algorithm that you want to use.
  • data is the plaintext (for encrypt) or ciphertext (for decrypt), and it's a buffer or buffer-like object in both cases.

Using AES-CBC and AES-GCM

Let's look at an example of using AES-CBC to encrypt data (note that this is a snippet that uses the await keyword, so it should be placed inside an async function):

8.4: Encrypting data with AES-256-CBC (part of symmetric-aes-cbc.js)

const plaintext = (new TextEncoder()).encode('Hello world!')

const key = await window.crypto.subtle.generateKey(

    {name: 'AES-CBC', length: 256},

    false, ['encrypt', 'decrypt']

)

const iv = new Uint8Array(16)

window.crypto.getRandomValues(iv)

const encrypted = await window.crypto.subtle.encrypt(

    {name: 'AES-CBC', iv: iv}, key, plaintext

)

const encryptedStore = new Uint8Array([

    iv,

    new Uint8Array(encrypted)

])

console.log('encryptedStore:', encryptedStore)

This snippet recreates the same behavior as the code we saw in Chapter 4, Symmetric Encryption in Node.js, but this time it's using the WebCrypto APIs. Let's look at it in more detail:

  1. As we saw regarding hashing in the previous section, if we're trying to encrypt a string, we need to encode that as a Uint8Array using TextEncoder.
  2. We must then generate a new 256-bit key for use with AES-CBC with the crypto.subtle.generateKey method, as we saw in Chapter 7, Introduction to Cryptography in the Browser. We make the key non-extractable (passing false as the second argument) and specify that it can be used to encrypt and decrypt data (with the list of strings in the third argument).
  3. We then proceed to generate a random IV using the crypto.getRandomValues method with a Uint8Array with a size of 16 bytes. This method has the effect of filling the array with random numbers.

    As we discussed in the previous chapter, crypto.getRandomValues does not offer a random generator with high entropy, so we shouldn't use that to generate cryptographic keys; however, it's considered acceptable for generating IVs, as in this case.

  4. Next, we must use the crypto.subtle.encrypt method (asynchronously) to encrypt our buffer. The object that's passed as the first argument, algorithm, contains two keys.

    The name property is set to AES-CBC, specifying the cipher to use and the mode of operation.

    The iv property contains the buffer containing the random IV that we just generated.

    Note that we're not passing the size of the key, which is inferred from the key object, which is passed as the second argument.

  5. The last step involves concatenating the IV and the ciphertext. As we saw in Chapter 4, Symmetric Encryption in Node.js, it's common practice to store or transmit the two together, as a single byte sequence with the IV at the beginning of the message.

To decrypt the message, we can reverse the operations by using the data from encryptedStore we computed previously and the same key object:

8.5: Decrypting data with AES-256-CBC (part of symmetric-aes-cbc.js)

const iv = encryptedStore.slice(0, 16)

const encrypted = encryptedStore.slice(16)

const decrypted = await window.crypto.subtle.decrypt(

    {name: 'AES-CBC', iv: iv}, key, encrypted

)

const plaintext = (new TextDecoder('utf-8')).decode(decrypted)

console.log('decrypted:', plaintext)

To start, we slice the encryptedStore buffer and extract the first 16 bytes, which are our IV. The remaining part of the buffer (from the byte in position 16 onwards) is used as the ciphertext.

We then use the crypto.subtle.decrypt method to perform the decryption. For the first parameter, algorithm, we're passing an object that contains the same properties (and same values) that we used when encrypting the message.

Because the method returns an ArrayBuffer object, we need to use a TextDecoder to convert it into a UTF-8 string and print the message in cleartext again.

The full code sample can be found in this book's GitHub repository, in the symmetric-aes-256-cbc.js file in the ch7-ch8-browser-cryptography folder.

In the same folder, you can also find an example of using AES-GCM, in the symmetric-aes-256-gcm.js file.

As you may recall, AES-GCM is an authenticated cipher that offers a guarantee of integrity in addition to confidentiality. In GCM mode, the result of crypto.subtle.encrypt has the authentication tag automatically appended at the end of the ciphertext, which crypto.subtle.decrypt knows to extract and compare when decrypting data.

Because of that, using AES-GCM with the WebCrypto APIs is as simple as replacing all instances of 'AES-CBC' with 'AES-GCM' in the code; no further changes are required.

Using AES-KW

We first encountered AES-KW in Chapter 4, Symmetric Encryption in Node.js, and explained it's a special mode of operation that's defined in RFC 3394 that is optimized for wrapping and unwrapping (encrypting and decrypting) other symmetric keys that are up to 256 bits in length.

AES-KW is available as a built-in algorithm that can be used with the crypto.subtle.wrap() method. Compared to the implementation available in Node.js, we don't need to set the IV to the static value described by RFC 3394, as that is done automatically for us.

Let's look at an example. Let's assume we have two keys: a symmetricKey that is randomly generated and that we'll use to encrypt our data (for example, to encrypt a message or a file), and a wrappingKey that is derived from a passphrase (for example, using Argon2). In the following snippet, we're defining both variables:

const wrappingKey = await deriveKey(passphrase, salt)

const symmetricKey = await window.crypto.subtle.generateKey(

    {name: 'AES-CBC', length: 256}, true,

    ['encrypt', 'decrypt']

)

  • wrappingKey is returned by a deriveKey() function that you define: for example, it could be a function that uses Argon2 to derive a CryptoKey object from a passphrase and salt (this is implemented in full in the complete example file on GitHub). Regardless of how you derive the wrapping key, two things must be set when you're creating/deriving the CryptoKey object: the algorithm must be 'AES-KW', and the usages must include ['wrapKey', 'unwrapKey'].
  • symmetricKey is a randomly generated AES-CBC key. We can use AES-KW to wrap any kind of symmetric key, so long as it's 256-bit long or less (assuming the wrapping key is 256-bit itself). What matters is that this CryptoKey object must have the exportable flag set to true; otherwise, the key wrapping will fail with an error (keys that can't be exported cannot be wrapped either).

With wrappingKey and symmetricKey defined for both CryptoKey instances, we can proceed to wrap and unwrap the key:

8.6: Wrapping and unwrapping keys with AES-KW (part of symmetric-aes-kw.js)

const wrappedKey = await window.crypto.subtle.wrapKey(

    'raw', symmetricKey, wrappingKey, {name: 'AES-KW'}

)

console.log({wrappedKey})

const unwrappedKey = await window.crypto.subtle.unwrapKey(

    'raw', wrappedKey, wrappingKey, {name: 'AES-KW'},

    {name: 'AES-CBC'}, false, ['encrypt', 'decrypt']

)

console.log({unwrappedKey})

The first part of the sample wraps the key using crypto.subtle.wrap(). This method takes four arguments:

  1. The format, which is always the 'raw' string for symmetric keys.
  2. The key to wrap as a CryptoKey object: in our example, that's the symmetricKey variable. As we've seen, this key must be exportable.
  3. The wrapping key, again as a CryptoKey object. In our case, that's wrappingKey. As a reminder, this key's algorithm must be 'AES-KW' and the key must have 'wrapKey' as allowed usage.
  4. The key wrapping algorithm to use – in this case, an object that contains a name property with a value of 'AES-KW'.

This function returns a promise that resolves with a buffer containing the wrapped key. After optionally encoding it to hex or base64, that key can be stored or transmitted as appropriate (for example, stored in localStorage or transmitted to a remote server to be archived there).

Unwrapping the key requires using the crypto.subtle.unwrap() method, which requires a very long list of parameters. The first four are symmetrical to the ones that are used by crypto.subtle.wrap() :

  • The format of the key to import, which is always 'raw'.
  • The wrapped (encrypted) key as a buffer. This is the same value that was returned by crypto.subtle.wrap().
  • The wrapping key, as a CryptoKey object, which in our example is wrappingKey. This object must have the 'AES-KW' algorithm and 'unwrapKey' as its allowed usage.
  • The key wrapping algorithm to use, which is the same value we used previously; that is, {name: 'AES-KW'}.

The last three arguments are used to specify the properties of the resulting CryptoKey object (returned asynchronously, as the result of a promise) and define what the unwrapped key can be used for: the algorithm the key is used for, whether it's exportable, and its intended usages. These are the same parameters that we've passed to methods such as crypto.subtle.generateKey().

These examples are all that we had to cover with regards to symmetric ciphers. In the next section, we're going to look at asymmetric and hybrid cryptography in the browser.

Asymmetric and hybrid cryptography

In Chapter 5, Using Asymmetric and Hybrid Encryption in Node.js, we explained how asymmetric cryptography differs from symmetric (or shared key), and we looked at various examples of using asymmetric ciphers for encrypting data, performing key agreements, and building hybrid encryption schemes. In this section, we'll build upon what we learned in that chapter and show examples of using the same algorithms in a web browser, using the WebCrypto APIs.

Encrypting and decrypting short messages with RSA

RSA is the first asymmetric cipher we encountered in Chapter 5, Using Asymmetric and Hybrid Encryption in Node.js.

When using RSA, each party has a key pair consisting of a private key and a public one. As we've explained, messages are encrypted with a public key (which can be distributed with the world safely) and decrypted using the corresponding private key (which must be kept highly protected).

As we saw in Chapter 7, Introduction to Cryptography in the Browser, you can generate an RSA key pair using crypto.subtle.generateKey(), like so:

8.7: Generating an RSA-OAEP key pair (part of asymmetric-rsa.js)

const keyPair = await window.crypto.subtle.generateKey(

    {

        name: 'RSA-OAEP',

        modulusLength: 2048,

        publicExponent: new Uint8Array([0x01, 0x00, 0x01]),

        hash: 'SHA-256'

    },

    false, ['encrypt', 'decrypt']

)

console.log(keyPair.publicKey, keyPair.privateKey)

The preceding snippet generates a keyPair object that contains two properties, each a CryptoKey object: keyPair.publicKey and keyPair.privateKey.

This code is very similar to what we saw in the previous examples to generate symmetric keys, and we explained this in detail in Chapter 7, Introduction to Cryptography in the Browser (see the Generating keys section). It's worth recalling that the first argument that's passed to generateKey is a dictionary that specifies the properties of the key. In this case, we're doing the following:

  • We're creating an RSA key that will be used for encrypting and decrypting data with the RSA-OAEP padding (using the name property) and SHA-256 for hashing (set with the hash property). The WebCrypto APIs only support OAEP padding (officially, PKCS#1 v2 padding) and do not implement the legacy PKCS#1 v1.5 padding, which is considered insecure.

    For a refresher of the various padding schemes, you can refer to the Using RSA for encryption and decryption section in Chapter 5, Using Asymmetric and Hybrid Encryption in Node.js.

  • The size of the key in bits is specified in the modulusLength property, and in this case, it is 2048. Other common values include 3072 and 4096.
  • For publicExponent, you should normally use the following static value (equivalent to the decimal 65537):

    new Uint8Array([1, 0, 1])

  • Lastly, we're using SHA-256 as the hashing algorithm with the hash property. Other algorithms are supported, including SHA-1, SHA-384, and SHA-512.

Once you've generated a key pair, you can use that to encrypt and decrypt messages with the crypto.subtle.encrypt() and crypto.subtle.decrypt() methods:

8.8: Encrypting and decrypting messages with RSA-OAEP (part of asymmetric-rsa.js)

const encrypted = await window.crypto.subtle.encrypt(

    {name: 'RSA-OAEP'}, keyPair.publicKey,

    plaintext // Plaintext message as buffer

)

const decrypted = await window.crypto.subtle.decrypt(

    {name: 'RSA-OAEP'}, keyPair.privateKey, encrypted

)

The previous snippet is almost the same as the code we saw for encrypting and decrypting data with AES. The only differences are passing 'RSA-OAEP' as the name of the algorithm, and using the publicKey property of keyPair when encrypting and the privateKey property when decrypting.

The full code sample that shows how to encrypt (short) messages is available in the asymmetric-rsa.js file in this book's GitHub repository, in the ch7-ch8-browser-cryptography folder.

Hybrid encryption with RSA and AES

As you may recall from Chapter 5, Using Asymmetric and Hybrid Encryption in Node.js,asymmetric ciphers such as RSA can only encrypt small amounts of data and are particularly slow. For example, with OAEP padding and SHA-256 as hashing (as we saw in the previous section), RSA can only encrypt 190 bytes! (Trying to encrypt longer messages with the WebCrypto APIs will throw a runtime exception.)

Applications that need to encrypt messages of an arbitrary length (including files), then, leverage hybrid encryption schemes, where the message is encrypted with AES using a random key, and the key is then wrapped using RSA.

From a practical point of view, the implementation is very similar to the example we saw earlier in this chapter, where we used AES-KW to wrap a shared key, except this time, the key wrapping is done with an asymmetric algorithm.

The following snippet generates a random AES-256-GCM key that can be used to encrypt and decrypt data, generates an RSA-4096 key pair for wrapping and unwrapping the key, and then shows you how to use the crypto.subtle.wrapKey() and crypto.subtle.unwrapKey() to wrap and unwrap the symmetric key, respectively. You may notice that it has many similarities to Sample 8.6, where we used AES-KW instead:

8.9: Wrapping an AES key using RSA

const symmetricKey = await window.crypto.subtle.generateKey(

    {name: 'AES-GCM', length: 256}, true,

    ['encrypt', 'decrypt']

)

const rsaKeyPair = await window.crypto.subtle.generateKey(

    {

        name: 'RSA-OAEP', modulusLength: 4096,

        hash: 'SHA-256',

        publicExponent: new Uint8Array([0x01, 0x00, 0x01])

    },

    false, ['wrapKey', 'unwrapKey']

)

const wrappedKey = await window.crypto.subtle.wrapKey(

    'raw', symmetricKey,

    rsaKeyPair.publicKey, {name: 'RSA-OAEP'}

)

const unwrappedKey = await window.crypto.subtle.unwrapKey(

    'raw', wrappedKey,

    rsaKeyPair.privateKey, {name: 'RSA-OAEP'},

    {name: 'AES-GCM'}, false, ['decrypt']

)

These few lines allow you to wrap and unwrap symmetric AES keys, which can then be used to encrypt messages and implement hybrid encryption schemes. For the sake of brevity, we won't be including the full source code here; we will point you to this book's GitHub repository instead. In the hybrid-rsa.js file in the ch7-ch8-browser-cryptography folder, you can find a full, extensively commented implementation of a hybrid encryption scheme that uses AES-GCM with random keys to encrypt and decrypt files, where the keys are then wrapped with RSA.

Using elliptic curves for ECDH key agreements and ECIES hybrid encryption

The second part of Chapter 5, Using Asymmetric and Hybrid Encryption in Node.js, was dedicated to using elliptic-curve cryptography to implement key agreements using Elliptic Curve Diffie-Hellman (ECDH) and then using hybrid encryption schemes based on ECDH and AES. In the same chapter, we covered the benefits of using ECDH for a key agreement rather than wrapping keys with a cipher such as RSA.

Performing an ECDH key agreement

As we saw in the previous chapter, we can generate a key pair for ECDH with a function like this:

8.10: Creating a new key pair for ECDH (part of key-exchange-ecdh.js)

function newKeyPair() {

    return window.crypto.subtle.generateKey(

        {name: 'ECDH', namedCurve: 'P-256'},

        false, ['deriveKey']

    )

}

We should highlight two things in this code sample:

  • The dictionary that contains the algorithm's properties contains two keys: name, which is the name of the algorithm, which should always be 'ECDH' in this case, and namedCurve, which contains the identifier of the elliptic curve to use, with the names that were assigned by the NIST.

    As we saw in Chapter 7, Introduction to Cryptography in the Browser (in the Generating keys section), the WebCrypto APIs only have standardized support for the three most common NIST curves, which include 'P-256' (we referred to this curve as prime256v1 in Node.js, which is sometimes called secp256r1) and the longer 'P-384' and 'P-512'. Other curves, such as the increasingly popular Curve25519 which we encountered in Chapter 5, Using Asymmetric and Hybrid Encryption in Node.js are not (yet) supported in the Web Platform. If your application requires that you use them in the browser, you will need to rely on third-party modules from npm.

  • The key must be generated with 'deriveKey' as an allowed usage.

To perform an ECDH key agreement, given a party's private key and another party's public key, we can use the crypto.subtle.deriveKey() method, as shown in this example:

8.11: Using crypto.subtle.deriveKey to perform an ECDH agreement (part of key-exchange-ecdh.js)

function deriveSecretKey(privateKey, publicKey) {

    return window.crypto.subtle.deriveKey(

        {

            name: 'ECDH',

            public: publicKey

        },

        privateKey,

        {name: 'AES-GCM', length: 256}, false, ['encrypt',           'decrypt']

    )

}

The deriveSecretKey() method returns a promise that resolves with a CryptoKey object and allows you to perform an ECDH key agreement. It takes five arguments:

  1. The first parameter is a dictionary that includes two properties: name (the algorithm's name), which is always 'ECDH' in this case, and public, which contains the second party's public key object.
  2. The second parameter is the first party's private key object.
  3. The remaining three arguments are used to define the kind of key that is derived, whether it can be extracted, and the derived key's usages. These are the same arguments that we've passed to many other methods, including crypto.subtle.generateKey().

With the newKeyPair() and deriveSecretKey() functions we just defined in Sample 8.10 and Sample 8.11, we can demonstrate a key agreement between two parties (our usual friends, Alice and Bob), each one with a ECDH key pair:

8.12: Example of an ECDH agreement between Alice and Bob (part of key-exchange-ecdh.js)

const aliceKeyPair = await newKeyPair()

const bobKeyPair = await newKeyPair()

const aliceSharedKey = await deriveSecretKey(

    aliceKeyPair.privateKey,

    bobKeyPair.publicKey

)

const bobSharedKey = await deriveSecretKey(

    bobKeyPair.privateKey,

    aliceKeyPair.publicKey

)

In this example, after generating two key pairs, we're invoking deriveSecretKey() twice:

  • The first time, we're invoking it as Alice would: with her private key and Bob's public key.
  • The second time, we're invoking it as Bob would: with his private key and Alice's public key.

As we saw in Chapter 5, Using Asymmetric and Hybrid Encryption in Node.js, while learning about ECDH, the shared keys Alice and Bob generate are identical: aliceSharedKey and bobSharedKey contain the same AES-256-GCM key (technically, they're two different CryptoKey objects, but they are built from byte-for-byte identical keys).

Tip

Try running the previous code but make the keys exportable, then export them and compare their values for equivalence.

Stretching the keys derived with ECDH

While the code shown in the previous section is functional, as we learned in Chapter 5, Using Asymmetric and Hybrid Encryption in Node.js, the output of an ECDH agreement is not perfectly uniform, so those keys do not offer the best security right out of the box. Just like we did in that chapter, then, we should stretch the result of the ECDH agreement with a Key Derivation Function (KDF) to increase the entropy.

Any KDF will work, but for this specific use case, we don't need to get particularly fancy and a salted SHA-256 will be enough (as we did in the examples in Chapter 5, Using Asymmetric and Hybrid Encryption in Node.js).

The crypto.subtle.deriveKey() method we used just moments ago does not allow you to modify the derived key. Instead, we need to use the "lower-level" crypto.subtle.deriveBits() method and do a few more rounds of processing.

deriveBits() returns a promise that resolves with an ArrayBuffer (instead of a CryptoKey object, as in deriveKey()) and takes three arguments:

  • The first two arguments are the same as in the deriveKey() method.
  • The third argument is the number of bits that should be derived from the ECDH agreement, such as 256 for a 256-bit key.

Now, we can modify the deriveSecretKey(privateKey, publicKey) function we defined in Sample 8.11 so that it looks like this:

8.13: The updated deriveSecretKey function, which uses a salted SHA-256 hash to stretch the result of the ECDH agreement (part of key-exchange-ecdh-sha256.js)

async function deriveSecretKey(privateKey, publicKey, salt) {

    // #1

    const ecdhResult = await window.crypto.subtle.deriveBits(

        {name: 'ECDH', public: publicKey}, privateKey, 256

    )

    // #2

    const base = new Uint8Array(

        [...new Uint8Array(ecdhResult), ...salt]

    )

    const rawKey = await window.crypto.subtle.digest('SHA-256',        base)

    // #3

    return window.crypto.subtle.importKey(

        'raw', rawKey,

        {name: 'AES-GCM'}, false, ['encrypt', 'decrypt']

    )

}

To start, note that the updated deriveSecretKey() method accepts a third parameter, called salt, which is a buffer object that contains several random bytes (the amount of which is up to your discretion – it could be 16-bytes long, for example). Despite that, the type of the returned object doesn't change: it's still a (promise that resolves with a) CryptoKey object containing a shared key.

The preceding code can be divided into three parts:

  1. The first few lines use the crypto.subtle.deriveBits() method to derive a buffer, in this case containing 256 bits of data (as per the last argument that was passed to it).
  2. Next, we create a Uint8Array object that contains the concatenation of the result of the ECDH function and the salt (note that ecdhResult is an ArrayBuffer object, so we need to create a Uint8Array from it for the code to work). We then calculate the SHA-256 digest of this buffer: the result (which is 256 bits long) will be our shared key, in raw bytes form.
  3. Lastly, we take the raw bytes that were generated by the hashing function and import them into a CryptoKey object using crypto.subtle.importKey(), as we've done many times before.

You can find the full example, showing the usage of the updated deriveSecretKey() function, in the key-exchange-ecdh-sha256.js file in this book's GitHub repository, again in the ch7-ch8-browser-cryptography folder.

Hybrid encryption with ECIES

Elliptic Curve Integrated Encryption Scheme (ECIES) is used to refer to hybrid encryption schemes that use ECDH to perform a key agreement and then encrypt data with a symmetric cipher (for example, AES-GCM). As we saw in Chapter 5, Using Asymmetric and Hybrid Encryption in Node.js, there isn't a single specification that defines how to adopt ECIES in a standard way, so each developer is free to choose any reasonable combination of algorithms as they see fit.

In this book's GitHub repository, you can find a full example of performing hybrid encryption with ECIES in the hybrid-ecies.js file, inside the ch7-ch8-browser-cryptography folder (the full code is too long to comfortably embed within these pages). This code sample, which is extensively commented, is nevertheless based on the newKeyPair() and deriveSecretKey() functions we analyzed previously in Sample 8.10 and Sample 8.13, and it adds the code to encrypt and decrypt data using the derived secret key and a symmetric cipher.

In the code sample in hybrid-ecies.js, we are implementing an ECIES solution that uses the same algorithms as the one we implemented in Chapter 5, Using Asymmetric and Hybrid Encryption in Node.js:

  • Key agreement: ECDH, using the P-256 curve
  • Key derivation function: SHA-256 with a random, 16-byte salt
  • Symmetric encryption algorithm and Message Authentication Code: AES-256-GCM

With this, we have covered everything in Chapter 5, Using Asymmetric and Hybrid Encryption in Node.js, which means we're ready to move on to the next – and last – topic: digital signatures.

Digital signatures

Digital signatures are the last class of cryptographic operations we learned about as we dedicated the biggest part of Chapter 6, Digital Signatures with Node.js and Trust, to them. Once again, in this section we'll revisit them by showing code samples that work in the browser using the WebCrypto APIs instead. Just as we did in Chapter 6, Digital Signatures with Node.js and Trust, we'll look at examples of calculating and verifying digital signatures using both RSA and ECDSA.

Digital signatures with the WebCrypto APIs

With the WebCrypto APIs, we can calculate a signature with the crypto.subtle.sign(algorithm, key, data) method. This returns a promise that resolves with an ArrayBuffer containing our signature's raw bytes, and it requires three parameters:

  • algorithm, the first argument, is an object that contains options for the digital signature algorithm to use. We'll see the details of this in the following sections for the RSA and ECDSA signatures, respectively.
  • key is the CryptoKey object containing the private part of the key. As a reminder, with digital signatures, you use the private part of the key to compute the signature, and the public part to verify it.
  • data is the ArrayBuffer object or buffer view that contains the message to sign.

Likewise, to verify the signature, we can use the built-in crypto.subtle.verify(algorithm, key, signature, data) method. This returns a promise that resolves with a Boolean value that indicates whether the signature is valid. The arguments are as follows:

  1. algorithm is a dictionary with the same options that were passed to the crypto.subtle.sign() method. We'll learn about this more in the next few pages.
  2. key is the CryptoKey object containing the public part of the key.
  3. signature is an ArrayBuffer or buffer view containing the raw bytes of the signature.
  4. data is the ArrayBuffer object or buffer view containing the original message, against which the signature is checked.

Calculating and verifying RSA signatures

Support for RSA digital signatures is built into the WebCrypto APIs, with both the padding schemes we encountered in Chapter 6, Digital Signatures with Node.js and Trust: PKCS#1 v1.5 and PSS.

To generate a key pair, we can use the usual crypto.subtle.generateKey() method:

8.14: Generating an RSA key pair for digital signatures (part of sign-rsa.js)

const keyPair = await window.crypto.subtle.generateKey(

    {

        name: 'RSASSA-PKCS1-v1_5',

        modulusLength: 2048,

        publicExponent: new Uint8Array([0x01, 0x00, 0x01]),

        hash: 'SHA-256'

    },

    false, ['sign', 'verify']

)

const privateKey = keyPair.privateKey

const publicKey = keyPair.publicKey

This is very similar to the previous examples for generating RSA keys, such as Sample 8.7. The following are worth pointing out:

  • In the dictionary that's passed as the first parameter, the value for the name key is 'RSASSA-PKCS1-v1_5' for calculating signatures with the PKCS#1 v1.5 padding. Alternatively, you can pass 'RSA-PSS' for PSS.
  • Also, please note that the hashing algorithm that's used to calculate the signature is specified in the CryptoKey object, and it's defined in the dictionary using the hash key. Besides 'SHA-256', as shown in the previous example, other supported values include 'SHA-384', 'SHA-512', and 'SHA-1' (for compatibility with legacy applications).
  • The usages array (the last parameter) contains the 'sign' and 'verify' values, which indicate that the key pair can be used to calculate and verify digital signatures, respectively.

Of course, you can also import external keys. For example, you may need to import another person's public key when you're verifying a digital signature. You can do that with the crypto.subtle.importKey() method, as we saw in Chapter 7, Introduction to Cryptography in the Browser, in the Importing keys section.

Once we have generated or imported a private key, we can then calculate the digital signature with the crypto.subtle.sign() method we introduced earlier.

For calculating signatures using the PKCS#1 v1.5, the value for the algorithm parameter that's passed to the method should be set like so:

const signature = await window.crypto.subtle.sign(

    {name: 'RSASSA-PKCS1-v1_5'},

    privateKey, message

)

However, if you're using PSS, the algorithm's parameter should be a dictionary similar to what's shown in this example:

const signature = await window.crypto.subtle.sign(

    {name: 'RSA-PSS', saltLength: 32},

    privateKey, message

)

Besides setting the algorithm's name, in this second case, we need to add a saltLength key that specifies the length of the salt to generate. The PKCS#1 v2 specification recommends setting this value to the length in bytes of the digest generated by the hashing function in use. For example, when it comes to using SHA-256 for calculating the digest of the message (as we did in Sample 8.14), the length of the digest and thus the recommended length of the salt for PSS is 32 (other values include 20 for SHA-1, 48 for SHA-384, and 64 for SHA-512).

The value of the signature object (once the promise has been resolved) is an ArrayBuffer object containing the raw bytes of the signature.

Using the public part of the key, we can validate the signature using the crypto.subtle.verify() method, as we introduced earlier:

const signatureValid = await window.crypto.subtle.verify(

    {name: 'RSASSA-PKCS1-v1_5'},

    publicKey, signature, message

)

The value of signatureValid (once the promise has been resolved) is a Boolean that indicates whether the signature is valid for the message.

Note that for PSS padding, you need to replace the first parameter with a dictionary like the one we used earlier: {name: 'RSA-PSS', saltLength: 32}.

In this book's GitHub repository, you can find a full example of calculating and verifying digital signatures in the sign-rsa.js file, in the ch7-ch8-browser-cryptography folder.

Calculating and verifying ECDSA signatures

As we saw in Chapter 6, Digital Signatures with Node.js and Trust, we can also use elliptic-curve cryptography to compute digital signatures using ECDSA and EdDSA, depending on the curve.

Support for ECDSA digital signatures, using the same curves we used for encryption (P-256, P-384, and P-512), is part of the WebCrypto standard. However, EdDSA is currently not implemented; should your application depend on that, you will need to look for third-party modules on npm.

To use ECDSA, the first step is to generate a new key pair or import an existing private or public key (please refer back to the previous chapter for importing keys, such as PEM-encoded ones). To generate a key pair, you can use the following code:

8.15: Generating a key pair for ECDSA (part of sign-ecdsa.js)

const keyPair = await window.crypto.subtle.generateKey(

    {name: 'ECDSA', namedCurve: 'P-256'},

    false, ['sign', 'verify']

)

const privateKey = keyPair.privateKey

const publicKey = keyPair.publicKey

This code sample is very similar to the one we used to generate an RSA key pair (see Sample 8.14), except for the algorithm parameter, which is a dictionary with two keys:

  • name – the name of the algorithm – which is 'ECDSA'.
  • namedCurve indicates the name of the curve, following the NIST's nomenclature. As per the WebCrypto specification, we can use the same three curves that are supported for encryption/decryption for ECDSA: 'P-256' (which we called prime256v1 in Node.js), 'P-384', and 'P-512' .

Note that unlike with RSA keys, we don't define the hashing function when creating/importing the key object. Instead, that's set when we use the methods for signing or verifying.

To calculate an ECDSA digital signature, we can use the same crypto.subtle.sign() method we used for RSA, but with different options for the algorithm dictionary:

const signature = await window.crypto.subtle.sign(

    {name: 'ECDSA', hash: 'SHA-256'},

    privateKey, message

)

For the first parameter, we need to pass a dictionary containing two keys: the name of the algorithm, which is always 'ECDSA' in this case, and the hashing function to use for calculating the digest of the message. The usual hashing functions are supported; that is, 'SHA-256', 'SHA-384', and 'SHA-512', plus 'SHA-1' for compatibility reasons.

The result of the method is a promise that resolves with an ArrayBuffer containing the raw bytes of the signature.

Likewise, we can verify that an ECDSA signature is valid for a message with the crypto.subtle.verify() method. This function (asynchronously) returns a Boolean indicating whether the signature is valid or not:

const signatureValid = await window.crypto.subtle.verify(

    {name: 'ECDSA', hash: 'SHA-256'},

    publicKey, signature, message

)

Summary

In this chapter, we learned how to perform all the common cryptographic operations we've seen throughout this book in the context of a web browser, with JavaScript code that can be used on the client side. This included calculating digests with SHA-2 (and SHA-1); deriving keys and hashing passphrases with Argon2; encrypting and decrypting data using symmetric ciphers (AES), asymmetric ones (RSA), and hybrid schemes such as ECIES (based on ECDH); and calculating and verifying RSA and ECDSA digital signatures.

This chapter concludes both our exploration of cryptography in the browser and this book. I hope this book helped you learn about using cryptography in a practical way Armed with your newly acquired knowledge, I hope you'll be able to build applications that leverage common cryptographic operations and solutions to deliver better security and privacy to your users.

Thank you for reading, and happy coding!

..................Content has been hidden....................

You can't read the all page of ebook, please click here login for view all page.
Reset