@d10f/crypto
v1.2.0
Published
A thin wrapper around the Web Crypto API to manage cryptographic keys comfortably.
Downloads
17
Readme
A thin wrapper around the Web Crypto API to manage cryptographic keys comfortably and in a type safe way.
Features
- [x] Fluid builder pattern for intuitive and easy to use interface with full TypeScript support.
- [x] All common operations supported: key generation, import, derivation, wrapping, etc.
- [x] Export keys to various portable formats: JSON, PEM, base64 encoded or a good old raw byte array.
- [ ] Meaningful error messages, consistent across all browsers. [^1]
- [ ] Pre-defined functions with sensible defaults for commonly used cryptography operations: encryption, key wrapping, key derivation, key hashing, etc. (including error handling).
- [ ] Client-side key management.
[^1]: Different browsers display different messages for the same errors, and none of them very useful.
Usage
Installation
You can install this package from npm by running this command:
npm install @d10f/cryptoThe CryptoKeyBuilder
The entry point to the library operations. It doesn't need to be instantiated; it only has static methods according to the type of key that you need to build. Use this to build any type of key that you need. Since it uses a fluid builder API, you can just let the IDE autocomplete the next method, and fill in the blanks.
import { CryptoKeyBuilder } from '@d10f/crypto';Generate keys
The CryptoKeyBuilder.generateKey static method can be used to generate a single key, which can be used for things like encryption, wrapping or key derivation:
const key = await CryptoKeyBuilder.generateKey()
.withAlgorithm({ name: 'AES-GCM', length: 256 })
.useTo('encrypt', () => {})
.build();Similarly, the CryptoKeyBuilder.generateKeyPair static method can be used to generate a key pair, for cryptographic operations of asymmetric nature, such as sign and verify.
const { privateKey, publicKey } = await CryptoKeyBuilder.generateKeyPair()
.withAlgorithm({ name: 'ECDSA', namedCurve: 'P-256' })
.useTo('sign', () => {})
.useTo('verify', () => {})
.build();Other methods exist that mirror the native web crypto API to import keys previously exported to an external format, derive new keys out of some other value or unwrap them if they have been wrapped appropriately.
Define the key usage
A key needs to have an action, otherwise it's useless. This is provided by the useTo method during the build process, which takes two arguments: a string describing the intended use, and a callback function that will be invoked when that happens.
This callback function has its context bound to the resulting CryptoKeyObject instance, which also has access to the cryptographic material necessary to perform any operation. For this reason, you need to use the function keyword when declaring this callback, and accept this as its first parameter:
function myEncryptFn(
this: CryptoKeyObject<'AES-GCM'>,
clearText: BufferSource,
iv: BufferSource,
) {
return window.crypto.subtle.encrypt(
{ name: 'AES-GCM', iv },
this.key,
clearText,
);
}
const encryptionKey = await CryptoKeyBuilder.generateKey()
.withAlgorithm({ name: 'AES-GCM', length: 256 })
.useTo('encrypt', myEncryptFn)
.build();In this example, this.key refers to the native CryptoKey object, which is passed to the crypto.subtle.encrypt function.
Algorithm-specific parameters
One of the motivations behind this project is to solve the problem of having to remember the myriad of parameters options that are dependent on the type of key and how its created. For example, let's create an RSA key pair:
const keyPair = await CryptoKeyBuilder.generateKeyPair()
.withAlgorithm({
name: 'RSA-OAEP',
hash: 'SHA-256',
modulusLength: 4096,
publicExponent: new Uint8Array([0x1, 0x0, 0x1]),
})
.useTo('encrypt', () => {})
.useTo('encrypt', () => {})
.useTo('wrapKey', () => {})
.useTo('unwrapKey', () => {})
.build();The object passed to withAlgorithm is quite tedious to remember, type and even to read. Also, while there's some safety at the type level, it's possible to make a typo and provide an invalid value to the modulusLength or publicExponent properties inadvertently; an innocent mistake that can be disastrous and difficult to detect.
To address this, there are algorithm-specific parameters readily available, named after the algorithm and properties they represent. There are also functions available that you can pass as the second argument to the useTo method, which already provide all the correct parameters in their definitions.
import { CryptoKeyBuilder } from '@d10f/crypto';
import { encryptWithRsaOaepParams, decryptWithRsaOaepParams } from '@d10f/crypto/actions';
import { rsaOaepSha256Key, keyWrapRsaOaep, keyUnwrap } from '@d10f/crypto/params';
const keyPair = await CryptoKeyBuilder.generateKeyPair()
.withAlgorithm(rsaOaepSha256Key)
.useTo('encrypt', encryptWithRsaOaepParams)
.useTo('decrypt', decryptWithRsaOaepParams)
.useTo('wrapKey', keyWrapRsaOaep)
.useTo('unwrapKey', unwrapKey)
.build();This improves readability and allows the TypeScript to provide proper type support when invoking the key usage methods received during the build process.
Keep in mind, however, that depending on your use case you probably want to write your own callback functions. Make sure to check the src/actions directory to get an idea of how these functions are implemented and use them for reference.
The CryptoKeyObject
The output of any build operation is an object of type CryptoKeyObject. This wraps the native CryptoKey that is actually used to perform cryptographic operations, and provides additional methods based on the key usages given to the key during the build process.
Following the example from the previous section, the encryption key was given an encrypt method. This can be accessed and invoked directly:
const message = "This is a secret";
const encoded = new TextEncoder().encode(message);
const iv = window.crypto.getRandomValues(new Uint8Array(16));
const encrypted = await encryptionKey.encrypt(encoded, iv);The encryptionKey.encrypt method is fully typed; the TypeScript compiler would complain if none of the passed arguments were of type BufferSource.
Export key as a portable format
One additional method is available, exportAs, to convert the cryptographic key to an external format. For example, to export a private key as a PEM-encoded string, ready to be shared over the network:
const pemEncodedPrivateKey = await privateKey.exportAs('PEM');Note that this might throw an error if the key was not built with the exportable property set to "true". Take a look at the examples below to see how you can use the CryptoKeyBuilder.importKey static method to create a key that was exported in this way.
Examples
Create an AES encryption & decryption key
/**
* A simple example of an AES 256-bit encryption key.
*/
const key = await CryptoKeyBuilder.generateKey()
.withAlgorithm(aesGcm256Key)
.useTo('encrypt', encryptWithAesGcmParams)
.useTo('decrypt', decryptWithAesGcmParams)
.build();
/**
* The method 'encrypt' is now available as a method on the key,
* fully typed and and ready to be used.
*/
const secret = 'Evil world domination plan';
const buffer = new TextEncoder().encode(secret);
const iv = window.crypto.getRandomValues(new Uint8Array(16));
const ciphertext = await key.encrypt(buffer, { iv });
/**
* Some time in the future, Bob decides to check his notes on how to
* dominate the world. Using the same key, he's able to decrypt them.
*/
const cleartext = await key.decrypt(ciphertext, { iv });Derive shared secret key (ECDH)
/**
* Alice and Bob generate a key pair each.
*/
const aliceKeyPair = await CryptoKeyBuilder.generateKeyPair()
.withAlgorithm({ name: 'ECDH', namedCurve: 'P-256' })
.useTo('deriveKey', deriveEcdhKeyFn)
.build();
const bobKeyPair = await CryptoKeyBuilder.generateKeyPair()
.withAlgorithm({ name: 'ECDH', namedCurve: 'P-256' })
.useTo('deriveKey', deriveEcdhKeyFn)
.build();
/**
* Alice and Bob can share their own public key with each other. Now,
* they can combine their own private key with the other's public key
* to produce a new symmetric key to encrypt & decrypt messages with.
* This key is typically referred to as the "shared" or "secret" key.
*/
const aliceSharedKey = await aliceKeyPair.privateKey
.deriveKey()
.withParams({ name: 'ECDH', public: bobKeyPair.publicKey.key })
.derivedAlgorithm(aesGcm256Key)
.useTo('encrypt', encryptWithAesGcmParams)
.build();
const secret = 'Hello, Bob!';
const buffer = new TextEncoder().encode(secret);
const iv = window.crypto.getRandomValues(new Uint8Array(16));
const ciphertext = await aliceSharedKey.encrypt(buffer, { iv });
/**
* Bob receives the encrypted message from Alice, and takes the same
* steps to derive the shared key. He can now decrypt the message.
* Note that in this example we have two variables named differently,
* "aliceSharedKey" and "bobSharedKey", but they hold the exact same
* cryptographic material.
*/
const bobSharedKey = await bobKeyPair.privateKey
.deriveKey()
.withParams({ name: 'ECDH', public: aliceKeyPair.publicKey.key })
.derivedAlgorithm(aesGcm256Key)
.useTo('decrypt', decryptWithAesGcmParams)
.build();
const cleartext = await bobSharedKey.decrypt(ciphertext, {
iv,
});Derive AES key from shared secret (HKDF)
/**
* This is similar to the example above where Alice and Bob exchange
* their respective public keys to derive a shared secret. This time,
* however, the shared secret is passed to a key derivation function
* to obtain the final key.
*
* Note that this is done in two separate steps but it could also be
* simplified further, depending on the `deriveSharedSecret` function,
* defined further down the code.
*/
const aliceKeyPair = await CryptoKeyBuilder.generateKeyPair()
.withAlgorithm({ name: 'ECDH', namedCurve: 'P-384' })
.useTo('deriveBits', deriveSharedSecret)
.build();
const bobKeyPair = await CryptoKeyBuilder.generateKeyPair()
.withAlgorithm({ name: 'ECDH', namedCurve: 'P-384' })
.useTo('deriveBits', deriveSharedSecret)
.build();
/**
* Alice and Bob are able to derive the raw bytes of the shared key,
* which is then used to derive another encryption key with higher
* entropy.
*/
const sharedSecretBytes = await aliceKeyPair.privateKey.deriveBits(
bobKeyPair.publicKey,
384,
);
const sharedSecretKey = await sharedSecretBytes
.deriveKey()
.withParams({
...hkdfSha256,
salt: window.crypto.getRandomValues(new Uint8Array(16)),
info: new TextEncoder().encode('associated data'),
})
.derivedAlgorithm(aesGcm256Key)
.useTo('encrypt', encryptWithAesGcmParams)
.useTo('decrypt', decryptWithAesGcmParams)
.build();
/**
* This can be used to encrypt/decrypt messages as needed.
*/
const secret = 'Hello, Bob!';
const buffer = new TextEncoder().encode(secret);
const iv = window.crypto.getRandomValues(new Uint8Array(16));
const ciphertext = await sharedSecretKey.encrypt(buffer, { iv });
const cleartext = await sharedSecretKey.decrypt(ciphertext, {
iv,
});
async function deriveSharedSecret(
this: CryptoKeyObject<'ECDH'>,
publicKey: CryptoKeyObject<'ECDH'>,
length: number,
) {
const bits = await deriveBitsFromEcdhKey.call(this, publicKey, length);
return CryptoKeyBuilder.importKey()
.as('raw')
.from(bits)
.withAlgorithm({ name: 'HKDF' })
.useTo('deriveKey', function (this: CryptoKeyObject<'HKDF'>) {
return CryptoKeyBuilder.deriveKey().fromKey(this);
})
.build();
}Derive AES key from shared secret (X25519)
/**
* This example also shows hwo to derive an encryption key after
* performing a key exchange, but using the X25519 algorithm, instead
* of ECDH.
*
* It's nearly identical to the previous example shown. Only the
* callback function deriving the key is different.
*/
const aliceKeyPair = await CryptoKeyBuilder.generateKeyPair()
.withAlgorithm({ name: 'X25519' })
.useTo('deriveKey', deriveSharedKey)
.build();
const bobKeyPair = await CryptoKeyBuilder.generateKeyPair()
.withAlgorithm({ name: 'X25519' })
.useTo('deriveKey', deriveSharedKey)
.build();
const salt = crypto.getRandomValues(new Uint8Array(32));
const info = new TextEncoder().encode(
'X25519 key agreement for an AES-GCM-256 key',
);
const aliceSharedSecret = await aliceKeyPair.privateKey.deriveKey(
bobKeyPair.publicKey,
salt,
info,
);
const aliceEncryptionKey = await aliceSharedSecret
.deriveKey()
.derivedAlgorithm({ name: 'AES-GCM', length: 256 })
.useTo('encrypt', encryptWithAesGcmParams)
.build();
const secret = 'Hello, Bob!';
const buffer = new TextEncoder().encode(secret);
const iv = window.crypto.getRandomValues(new Uint8Array(16));
const ciphertext = await aliceEncryptionKey.encrypt(buffer, { iv });
/**
* Later, Bob takes the exact same steps to produce the same shared
* encryption key.
*/
const bobSharedSecret = await bobKeyPair.privateKey.deriveKey(
aliceKeyPair.publicKey,
salt,
info,
);
const bobEncryptionKey = await bobSharedSecret
.deriveKey()
.derivedAlgorithm({ name: 'AES-GCM', length: 256 })
.useTo('decrypt', decryptWithAesGcmParams)
.build();
const cleartext = await bobEncryptionKey.decrypt(ciphertext, {
iv,
});
async function deriveSharedKey(
this: CryptoKeyObject<'X25519'>,
publicKey: CryptoKeyObject<'X25519'>,
salt: BufferSource,
info: BufferSource,
) {
const hkdfParams: HkdfParams = {
name: 'HKDF',
hash: 'SHA-256',
salt,
info,
};
return CryptoKeyBuilder.deriveKey()
.fromKey(this)
.withParams({ name: 'X25519', public: publicKey.key })
.derivedAlgorithm(hkdfParams)
.useTo('deriveKey', function (this: CryptoKeyObject<'HKDF'>) {
return CryptoKeyBuilder.deriveKey()
.fromKey(this)
.withParams(hkdfParams);
})
.build();
}Derive AES key from password (PBKDF2)
/**
* Assume the user has provided a password through an input field.
*/
const password = 'Sup3r_$ecret';
const encoded = new TextEncoder().encode(password);
/**
* Derive key material to derive the encryption key from.
*/
const keyMaterial = await CryptoKeyBuilder.importKey()
.as('raw')
.from(encoded)
.withAlgorithm({ name: 'PBKDF2' })
.useTo('deriveKey', derivePbkdf2KeyFn)
.build();
const encryptionKey = await keyMaterial
.deriveKey()
.withParams({
...pbkdf2Sha256,
salt: window.crypto.getRandomValues(new Uint8Array(32)),
})
.derivedAlgorithm(aesGcm256Key)
.useTo('encrypt', encryptWithAesGcmParams)
.useTo('decrypt', decryptWithAesGcmParams)
.build();
const secret = 'some personal data';
const buffer = new TextEncoder().encode(secret);
const iv = window.crypto.getRandomValues(new Uint8Array(16));
const ciphertext = await encryptionKey.encrypt(buffer, { iv });
const cleartext = await encryptionKey.decrypt(ciphertext, { iv });Encrypt & export a key (wrap it) in a portable format
const { privateKey } = await CryptoKeyBuilder.generateKeyPair()
.withAlgorithm(rsaOaepSha256Key)
.useTo('encrypt', encryptWithRsaOaepParams)
.useTo('decrypt', decryptWithRsaOaepParams)
.canExportKey()
.build();
const wrappingKey = await CryptoKeyBuilder.generateKey()
.withAlgorithm(aesGcm256Key)
.useTo('wrapKey', AES_GCM_WRAP)
.useTo('unwrapKey', unwrapKey)
.build();
const privateWrappedKey = await wrappingKey.wrapKey(privateKey, 'PEM', {
iv: window.crypto.getRandomValues(new Uint8Array(12)),
});This example exports an RSA private signing key in a PEM-encoded format.
License
GNU General Public License v3.0 only
See COPYING to see the full text.
