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:
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 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:
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.
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:
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.
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:
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:
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.
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.
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.
The WebCrypto APIs include two methods for encrypting and decrypting data:
Both methods have similar parameters:
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:
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.
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.
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.
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']
)
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:
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 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.
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.
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:
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.
new Uint8Array([1, 0, 1])
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.
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.
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.
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:
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.
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:
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:
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.
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:
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:
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.
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:
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 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.
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:
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:
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:
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.
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:
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
)
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!