Managing Keys with the Web Cryptography API

Understanding key types and the basics of creation, extraction, and storage

Nieky Allen
Slalom Build
9 min readApr 22, 2024

--

Do you have any friends who leave a key under the mat or keep their password on a sticky note attached to their monitor? Anxiety-inducing, right? Well, the same kinds of poor security practices can occur with the cryptographic keys that secure your personal data.

When you hear about companies experiencing data breaches (or leaks) that result in personal information being exposed, it is not always that the data wasn’t encrypted. Perhaps even more likely, the issue could have been that the keys weren’t securely managed or stored. It’s easy to stop at your encryption step and feel like you are done, but no information is safe if the mechanism used to secure it is not also handled with care.

Today we will discuss some tools included in the Web Crypto API that we can use to help us securely and responsibly manage our cryptographic keys. If you want to refer to the full examples on your own time, they can be found here.

Setup 🧰

Similar to how we began in the previous article, The Web Cryptography API in Action, we will start by centralizing our Web Crypto functionality into a single environment-agnostic operations object to make our examples more streamlined and readable.

Here we initialize our operations from the browser:

// initialize SubtleCrypto and fallback to webkit instance
const operations = window.crypto.subtle || window.crypto.webkitSubtle;

// if Web Crypto or SubtleCrypto is not supported, notify the user
if (!operations) {
alert('Web Crypto is not supported on this browser');
console.warn('Web Crypto API not supported');
}

All these techniques are also usable in Node.js, as it has supported and maintained SubtleCrypto since v15.0.0. If you want to access a SubtleCrypto interface in Node.js, you can simply do this:

import { webcrypto } from 'crypto';

const operations = webcrypto.subtle;

In addition to major browsers and Node.js, the SubtleCrypto interface is also supported by newer server-side runtimes like Bun (via Globals) and Deno (via std library). A standardized implementation of Web Crypto is even available in Cloudflare’s edge runtime, which really goes to show it is available to you almost anywhere you would want to run your JavaScript.

Key Generation 🔑

How you create and manage your keys can differ depending on the needs of your application and the execution context. This section will focus on the generateKey method and the configuration options available when using it. Cryptographic keys created and manipulated using SubtleCrypto will all be instances of the CryptoKey class.

Please keep in mind that while the configuration options will be covered, the concepts they implement will not be discussed in detail. Always do your research about the strengths and weaknesses of your cryptographic approach. It is important to understand core cryptography concepts and the different types.

Single Key vs. Key Pair

During generation, you can create a single key or a key pair for symmetric or asymmetric schemes, respectively. Whether you create a single key or a pair is determined by the parameters passed to generateKey. Usually algorithms that are asymmetric by nature result in two keys during generation, a public key and a private (or secret) key.

const independentKey = await operations.generateKey(
{ name: 'AES-GCM', length: 256 },
false, // key cannot be exported
['encrypt', 'decrypt']
);

const { publicKey, privateKey } = await operations.generateKey(
{
name: 'RSA-OAEP',
modulusLength: 2048,
publicExponent: new Uint8Array([1, 0, 1]),
hash: 'SHA-384',
},
false, // key cannot be exported
['encrypt', 'decrypt']
);

In this example, you may notice that in addition to algorithm names and other cryptographic configuration parameters, we also define what actions can be performed with these keys, which brings us to our next topic: key usages.

Key Usages 🛠

Principle of least privilege is a concept that can be applied in a myriad of ways. Keys are no exception. Whenever possible, they should be created with as few permissions — or rather, usages — as necessary. This will be even more handy when we discuss derivation as you can create an ephemeral key for a single use such as encryption.

In the previous example, the independentKey is a single key that can both encrypt and decrypt. A symmetric key always does both encryption and decryption because if you generate a single key that can only encrypt, it is not going to be very helpful.

However, the subsequent key pair that is generated actually splits those actions between the keys implicitly. If you log the publicKey and privateKey objects, you will see a single action in each configuration, encrypt and decrypt respectively.

log output from generateKey example

Apart from more commonly known purposes covered in the previous article such as encryption or signing, keys can also be created solely for deriving or wrapping other key instances. These actions will be discussed in greater depth as we go on, but the full list of usage options can be found here.

Extractability

Not all keys are created to be shared, and the argument could be made that many never need to leave the execution context of their application. Configuring whether a key can be extracted, or exported, is a defining characteristic that indicates whether the instance can be converted to a readable format and used elsewhere.

Any key that you attempt to export that was not created with an extractable value of true will throw an error when attempting the operation. This configuration option is especially helpful with keys that handle very sensitive data because even if the application context is compromised, the attacker cannot simply export the key in a raw format even if they are able to access the instance in memory.

Now that we know a bit about generation, we can take this a step further and discuss key derivation and how it differs from generation.

Key Derivation 🧪

Key derivation refers to the process of computing, or deriving, a new key from an existing key or key pair. This technique is commonly implemented when performing key rotation and deprecation because you know that each new key will be originating from the same base key (or key pair).

Similar to generation, the desired usage(s) and algorithm configurations help determine the other input parameters. Below is a basic example of using a single baseKey to derive a newKey for encryption and decryption.

const DERIVATION_ALGO = 'ECDH';

const baseKey = await operations.generateKey(
{
name: DERIVATION_ALGO,
},
false,
['deriveKey']
);

const newKey = await operations.deriveKey(
{ name: DERIVATION_ALGO },
baseKey,
{ name: 'AES-GCM', length: 256 },
false,
['encrypt', 'decrypt']
);

Using derivation makes creating and using ephemeral keys much easier than creating them, as you can always initialize a new key from your baseKey. It’s a great solution if you want to maintain a single key for derivation and don’t need to do exchanges with other users, services, or other entities. However, if you need to leverage asymmetric patterns for something like an end-to-end encrypted chat application, then you may want to implement something like this:

const DERIVATION_ALGO = 'ECDH';
const ENCRYPTION_ALGO = 'AES-GCM';
const ENCRYPTION_PARAMS = { name: ENCRYPTION_ALGO, length: 256 };

const { publicKey, privateKey } = await operations.generateKey(
{
name: DERIVATION_ALGO,
namedCurve: 'P-384',
},
false,
['deriveKey']
);

const encryptionKey = await operations.deriveKey(
{ name: DERIVATION_ALGO, public: publicKey },
privateKey,
ENCRYPTION_PARAMS,
false,
['encrypt']
);

const decryptionKey = await operations.deriveKey(
{ name: DERIVATION_ALGO, public: publicKey },
privateKey,
ENCRYPTION_PARAMS,
false,
['decrypt']
);

Here you can see that we are generating a base publicKey andprivateKey instead of a single baseKey. The base pair is then used to derive individual keys that can have different purposes, like encrypt and decrypt used here. The derivation of the new key can even be done using a corresponding key from another pair to derive a key that is a combination of two distinct pairs and thus can secure data between the two entities, maybe users.

Sharing Keys

While the safest place to keep any key is usually on the machine it was created on to avoid exposure, the need for sharing sensitive information safely usually necessitates the sharing of keys — at some point. Thankfully, Web Crypto provides tools for these processes as well.

Export and Import

Starting with the basics, we can both import and export keys as different kinds of data objects. While there are a variety of format options, a common choice is to export them as JSON objects called JSON Web Keys or JWKs. It is important to note this section assumes that the extractable property was set as true during key creation.

const KEY_LEN = 256;

const ENCRYPTION_ALGO = 'AES-GCM';
const ENCRYPTION_USAGES = ['encrypt', 'decrypt'];

const newKey = await operations.generateKey(
{ name: ENCRYPTION_ALGO, length: KEY_LEN },
true, // setting key as extractable
ENCRYPTION_USAGES
);

// can use elsewhere but raw key, i.e. unsafe and vulnerable
const exportedKeyBuffer = await operations.exportKey('jwk', newKey);

// same key as `newKey`
const importedKey = await operations.importKey(
'jwk',
exportedKeyBuffer,
{
name: ENCRYPTION_ALGO,
length: KEY_LEN,
},
false,
ENCRYPTION_USAGES
);

This example shows a single key that gets exported as a JWK that can be used or imported by any implementation that can access it. This is great for sharing and transport but is clearly a potential vulnerability, so handle with care! In the next section, we will go over a way to securely export keys for sharing.

Importing can also be handy when you want to implement a key from a single string or data value. If you encode the data and use it as the key material of the import operation, you can essentially create a key representation of the data passed as the input.

const encoder = new TextEncoder();
const passwordAsKeyData = encoder.encode('superSecretPassword');

const keyFromPassword = await operations.importKey(
'raw',
passwordAsKeyData,
'PBKDF2',
false,
['deriveKey']
);

// use keyFromPassword to create keys

Here we initialize a key that can then be used to create, for example, encryption keys. This technique can be really helpful because you can create this key solely from a hash of a user’s password and use it to secure personal data without having to store the key itself.

Protecting Keys with Wrapping 🎁

I think we can all agree that exporting our keys won’t get us very far if we can’t send them in a secure fashion. This is where key wrapping comes in! In the simplest terms, wrapping is the process of exporting and encrypting the key in a single API operation. Any instance of a CryptoKey can be wrapped. Certain wrapping algorithms even support the wrapping of keys that have extractable set to false. This allows you to then safely transport and/or store the key for future retrieval and use.

During export, the jwk format was used, but since we are encrypting or wrapping the key data, our output is going to be an ArrayBuffer with a format of raw. This change in format is really to our benefit since buffer-based data types are both common and efficient for transport and storage.

const KEY_LEN = 256;

const ENCRYPTION_ALGO = 'AES-GCM';
const ENCRYPTION_USAGES = ['encrypt', 'decrypt'];

const WRAPPING_ALGO = 'AES-KW';
const WRAPPING_USAGES = ['wrapKey', 'unwrapKey'];

const key = await operations.generateKey(
{ name: ENCRYPTION_ALGO, length: KEY_LEN },
true,
ENCRYPTION_USAGES
);

const wrappingKey = await operations.generateKey(
{ name: WRAPPING_ALGO, length: KEY_LEN },
true,
WRAPPING_USAGES,
);

// ArrayBuffer safe for transport and storage
const wrappedKeyBuffer = await operations.wrapKey(
'raw',
key,
wrappingKey,
WRAPPING_ALGO
);

In this example, you can see that we actually have two keys: one for encryption and a second for wrapping (or encrypting) the first. They use different algorithm and usage definitions, but one performs operations on the other as opposed to a piece of, say, user-created data. You can wrap any key regardless of its usage configuration, but your wrapping key requires wrapKey (and likely unwrapKey) actions. Remember that whoever needs to use the wrapped key needs to be able to unwrap it as well.

Wrapping keys provides a streamlined and secure way to obfuscate keys before transport and/or storage that can be critical when designing a secure implementation. Be sure to store the key you used for wrapping your other key(s) in a safe place!

Next Steps 🚶🏾‍♂️

Being a well-informed developer and user is an important part of creating secure and quality software. The concepts discussed here are implemented regularly across industries and organizations to keep our data secure. From key management and encryption in password managers like 1Password to the digital signatures used to power services like DocuSign and even to the foundational principles of blockchain implementations that gave us cryptocurrency, secure key patterns and storage practices are essential to making platforms like these work.

As you design your applications, it’s important to keep in mind that these operations are all tools in your toolbox for you to mix and match in order to develop the most appropriate security solution for your application. Using asymmetric patterns like public key trading and key derivation for scenarios such as rotation can really bolster security and take these concepts a step further. However, before reaching for an external library, it is good to check if it uses native primitives like Web Crypto to ensure you are not loading unnecessary bloat. Always take care to prioritize safe and secure key generation and storage to ensure that your application doesn’t keep the proverbial key under the mat for anyone to use.

--

--

Nieky Allen
Slalom Build

Chicago-based software architect & web enthusiast | NodeJS, TypeScript | https://github.com/dallen4