node:crypto is underused
In this post, I’ll show you* the basics of node:crypto, the builtin
Node.js cryptography module. I find it pretty cool, but not well known.
I’m not saying everybody needs to be a security expert. I’m def not one! But I feel that many engineers I know are needlessly scared of cryptography. With just a tiny sprinkle of it, we can do amazing things: like keeping our users’ data private with encryption or signing stuff we give out to the public to guarantee it hasn’t been tampered with. It’s been a while since I’ve written a tutorial-style blog post, so here it goes.
You can find all code snippets from this on CodeSandbox.
First things first, we need to generate a key pair.
import { function promisify<TCustom extends Function>(fn: CustomPromisify<TCustom>): TCustom (+13 overloads)Takes a function following the common error-first callback style, i.e. taking
an `(err, value) => ...` callback as the last argument, and returns a version
that returns promises.
```js
import { promisify } from 'node:util';
import { stat } from 'node:fs';
const promisifiedStat = promisify(stat);
promisifiedStat('.').then((stats) => {
// Do something with `stats`
}).catch((error) => {
// Handle the error.
});
```
Or, equivalently using `async function`s:
```js
import { promisify } from 'node:util';
import { stat } from 'node:fs';
const promisifiedStat = promisify(stat);
async function callStat() {
const stats = await promisifiedStat('.');
console.log(`This directory is owned by ${stats.uid}`);
}
callStat();
```
If there is an `original[util.promisify.custom]` property present, `promisify`
will return its value, see [Custom promisified functions](https://nodejs.org/docs/latest-v24.x/api/util.html#custom-promisified-functions).
`promisify()` assumes that `original` is a function taking a callback as its
final argument in all cases. If `original` is not a function, `promisify()`
will throw an error. If `original` is a function but its last argument is not
an error-first callback, it will still be passed an error-first
callback as its last argument.
Using `promisify()` on class methods or other methods that use `this` may not
work as expected unless handled specially:
```js
import { promisify } from 'node:util';
class Foo {
constructor() {
this.a = 42;
}
bar(callback) {
callback(null, this.a);
}
}
const foo = new Foo();
const naiveBar = promisify(foo.bar);
// TypeError: Cannot read properties of undefined (reading 'a')
// naiveBar().then(a => console.log(a));
naiveBar.call(foo).then((a) => console.log(a)); // '42'
const bindBar = naiveBar.bind(foo);
bindBar().then((a) => console.log(a)); // '42'
```promisify } from "node:util";
import { function generateKeyPair(type: "rsa", options: RSAKeyPairOptions<"pem", "pem">, callback: (err: Error | null, publicKey: string, privateKey: string) => void): void (+39 overloads)Generates a new asymmetric key pair of the given `type`. RSA, RSA-PSS, DSA, EC,
Ed25519, Ed448, X25519, X448, and DH are currently supported.
If a `publicKeyEncoding` or `privateKeyEncoding` was specified, this function
behaves as if `keyObject.export()` had been called on its result. Otherwise,
the respective part of the key is returned as a `KeyObject`.
It is recommended to encode public keys as `'spki'` and private keys as `'pkcs8'` with encryption for long-term storage:
```js
const {
generateKeyPair,
} = await import('node:crypto');
generateKeyPair('rsa', {
modulusLength: 4096,
publicKeyEncoding: {
type: 'spki',
format: 'pem',
},
privateKeyEncoding: {
type: 'pkcs8',
format: 'pem',
cipher: 'aes-256-cbc',
passphrase: 'top secret',
},
}, (err, publicKey, privateKey) => {
// Handle errors and use the generated key pair.
});
```
On completion, `callback` will be called with `err` set to `undefined` and `publicKey` / `privateKey` representing the generated key pair.
If this method is invoked as its `util.promisify()` ed version, it returns
a `Promise` for an `Object` with `publicKey` and `privateKey` properties.generateKeyPair } from "node:crypto";
const { const privateKey: KeyObjectprivateKey, const publicKey: KeyObjectpublicKey } = await promisify<{
(type: "rsa", options: RSAKeyPairOptions<"pem", "pem">): Promise<{
publicKey: string;
privateKey: string;
}>;
(type: "rsa", options: RSAKeyPairOptions<"pem", "der">): Promise<...>;
(type: "rsa", options: RSAKeyPairOptions<...>): Promise<...>;
(type: "rsa", options: RSAKeyPairOptions<...>): Promise<...>;
(type: "rsa", options: RSAKeyPairKeyObjectOptions): Promise<...>;
(type: "rsa-pss", options: RSAPSSKeyPairOptions<...>): Promise<...>;
(type: "rsa-pss", options: RSAPSSKeyPairOptions<...>): Promise<...>;
(type: "rsa-pss", options: RSAPSSKeyPairOptions<...>): Promise<...>;
(type: "rsa-pss", options: RSAPSSKeyPairOptions<...>): Promise<...>;
(type: "rsa-pss", options: RSAPSSKeyPairKeyObjectOptions): Promise<...>;
(type: "dsa", options: DSAKeyPairOptions<...>): Promise<...>;
(type: "dsa", options: DSAKeyPairOptions<...>): Promise<...>;
(type: "dsa", options: DSAKeyPairOptions<...>): Promise<...>;
(type: "dsa", options: DSAKeyPairOptions<...>): Promise<...>;
(type: "dsa", options: DSAKeyPairKeyObjectOptions): Promise<...>;
(type: "ec", options: ECKeyPairOptions<...>): Promise<...>;
(type: "ec", options: ECKeyPairOptions<...>): Promise<...>;
(type: "ec", options: ECKeyPairOptions<...>): Promise<...>;
(type: "ec", options: ECKeyPairOptions<...>): Promise<...>;
(type: "ec", options: ECKeyPairKeyObjectOptions): Promise<...>;
(type: "ed25519", options: ED25519KeyPairOptions<...>): Promise<...>;
(type: "ed25519", options: ED25519KeyPairOptions<...>): Promise<...>;
(type: "ed25519" ... (+13 overloads)
Takes a function following the common error-first callback style, i.e. taking
an `(err, value) => ...` callback as the last argument, and returns a version
that returns promises.
```js
import { promisify } from 'node:util';
import { stat } from 'node:fs';
const promisifiedStat = promisify(stat);
promisifiedStat('.').then((stats) => {
// Do something with `stats`
}).catch((error) => {
// Handle the error.
});
```
Or, equivalently using `async function`s:
```js
import { promisify } from 'node:util';
import { stat } from 'node:fs';
const promisifiedStat = promisify(stat);
async function callStat() {
const stats = await promisifiedStat('.');
console.log(`This directory is owned by ${stats.uid}`);
}
callStat();
```
If there is an `original[util.promisify.custom]` property present, `promisify`
will return its value, see [Custom promisified functions](https://nodejs.org/docs/latest-v24.x/api/util.html#custom-promisified-functions).
`promisify()` assumes that `original` is a function taking a callback as its
final argument in all cases. If `original` is not a function, `promisify()`
will throw an error. If `original` is a function but its last argument is not
an error-first callback, it will still be passed an error-first
callback as its last argument.
Using `promisify()` on class methods or other methods that use `this` may not
work as expected unless handled specially:
```js
import { promisify } from 'node:util';
class Foo {
constructor() {
this.a = 42;
}
bar(callback) {
callback(null, this.a);
}
}
const foo = new Foo();
const naiveBar = promisify(foo.bar);
// TypeError: Cannot read properties of undefined (reading 'a')
// naiveBar().then(a => console.log(a));
naiveBar.call(foo).then((a) => console.log(a)); // '42'
const bindBar = naiveBar.bind(foo);
bindBar().then((a) => console.log(a)); // '42'
```promisify(function generateKeyPair(type: "rsa", options: RSAKeyPairOptions<"pem", "pem">, callback: (err: Error | null, publicKey: string, privateKey: string) => void): void (+39 overloads)Generates a new asymmetric key pair of the given `type`. RSA, RSA-PSS, DSA, EC,
Ed25519, Ed448, X25519, X448, and DH are currently supported.
If a `publicKeyEncoding` or `privateKeyEncoding` was specified, this function
behaves as if `keyObject.export()` had been called on its result. Otherwise,
the respective part of the key is returned as a `KeyObject`.
It is recommended to encode public keys as `'spki'` and private keys as `'pkcs8'` with encryption for long-term storage:
```js
const {
generateKeyPair,
} = await import('node:crypto');
generateKeyPair('rsa', {
modulusLength: 4096,
publicKeyEncoding: {
type: 'spki',
format: 'pem',
},
privateKeyEncoding: {
type: 'pkcs8',
format: 'pem',
cipher: 'aes-256-cbc',
passphrase: 'top secret',
},
}, (err, publicKey, privateKey) => {
// Handle errors and use the generated key pair.
});
```
On completion, `callback` will be called with `err` set to `undefined` and `publicKey` / `privateKey` representing the generated key pair.
If this method is invoked as its `util.promisify()` ed version, it returns
a `Promise` for an `Object` with `publicKey` and `privateKey` properties.generateKeyPair)("rsa", {
RSAKeyPairKeyObjectOptions.modulusLength: numberKey size in bitsmodulusLength: 2048,
});Easy, right? We’ve got ourselves an RSA public and private key.
Rivest–Shamir–Adleman public-key cryptosystem was discovered in the late 70s, just like hip-hop. You need to now pause reading this, play the “Rapper’s Delight” by The Sugarhill Gang, while reading about RSA Security’s relationship with NSA.
Now back to JavaScript.
Let’s write and encode a short message, so we have something to work with.
const const originalString: "my important message"originalString = "my important message";
const const originalUint8Array: Uint8ArrayoriginalUint8Array = new var Uint8Array: Uint8ArrayConstructor
new (elements: Iterable<number>) => Uint8Array (+4 overloads)
Uint8Array(
const originalString: "my important message"originalString.String.split(separator: string | RegExp, limit?: number | undefined): string[] (+1 overload)Split a string into substrings using the specified separator and return them as an array.split("").Array<string>.map<number>(callbackfn: (value: string, index: number, array: string[]) => number, thisArg?: any): number[]Calls a defined callback function on each element of an array, and returns an array that contains the results.map((ch: stringch) => ch: stringch.String.charCodeAt(index: number): numberReturns the Unicode value of the character at the specified location.charCodeAt(0)),
);
Now anybody can encrypt it with our public key, but only we can decrypt it.
import { function publicEncrypt(key: RsaPublicKey | RsaPrivateKey | KeyLike, buffer: NodeJS.ArrayBufferView | string): BufferEncrypts the content of `buffer` with `key` and returns a new `Buffer` with encrypted content. The returned data can be decrypted using
the corresponding private key, for example using
{@link
privateDecrypt
}
.
If `key` is not a `KeyObject`, this function behaves as if `key` had been passed to
{@link
createPublicKey
}
. If it is an
object, the `padding` property can be passed. Otherwise, this function uses `RSA_PKCS1_OAEP_PADDING`.
Because RSA public keys can be derived from private keys, a private key may
be passed instead of a public key.publicEncrypt, function privateDecrypt(privateKey: RsaPrivateKey | KeyLike, buffer: NodeJS.ArrayBufferView | string): BufferDecrypts `buffer` with `privateKey`. `buffer` was previously encrypted using
the corresponding public key, for example using
{@link
publicEncrypt
}
.
If `privateKey` is not a `KeyObject`, this function behaves as if `privateKey` had been passed to
{@link
createPrivateKey
}
. If it is an
object, the `padding` property can be passed. Otherwise, this function uses `RSA_PKCS1_OAEP_PADDING`.privateDecrypt } from "node:crypto";
const const encryptedBuffer: BufferencryptedBuffer = function publicEncrypt(key: RsaPublicKey | RsaPrivateKey | KeyLike, buffer: string | NodeJS.ArrayBufferView): BufferEncrypts the content of `buffer` with `key` and returns a new `Buffer` with encrypted content. The returned data can be decrypted using
the corresponding private key, for example using
{@link
privateDecrypt
}
.
If `key` is not a `KeyObject`, this function behaves as if `key` had been passed to
{@link
createPublicKey
}
. If it is an
object, the `padding` property can be passed. Otherwise, this function uses `RSA_PKCS1_OAEP_PADDING`.
Because RSA public keys can be derived from private keys, a private key may
be passed instead of a public key.publicEncrypt(const publicKey: KeyObjectpublicKey, const originalUint8Array: Uint8ArrayoriginalUint8Array);
const const decryptedBuffer: BufferdecryptedBuffer = function privateDecrypt(privateKey: RsaPrivateKey | KeyLike, buffer: string | NodeJS.ArrayBufferView): BufferDecrypts `buffer` with `privateKey`. `buffer` was previously encrypted using
the corresponding public key, for example using
{@link
publicEncrypt
}
.
If `privateKey` is not a `KeyObject`, this function behaves as if `privateKey` had been passed to
{@link
createPrivateKey
}
. If it is an
object, the `padding` property can be passed. Otherwise, this function uses `RSA_PKCS1_OAEP_PADDING`.privateDecrypt(const privateKey: KeyObjectprivateKey, const encryptedBuffer: BufferencryptedBuffer);
const const decryptedString: stringdecryptedString = const decryptedBuffer: BufferdecryptedBuffer.Buffer.toString(encoding?: BufferEncoding | undefined, start?: number | undefined, end?: number | undefined): stringDecodes `buf` to a string according to the specified character encoding in`encoding`. `start` and `end` may be passed to decode only a subset of `buf`.
If `encoding` is `'utf8'` and a byte sequence in the input is not valid UTF-8,
then each invalid byte is replaced with the replacement character `U+FFFD`.
The maximum length of a string instance (in UTF-16 code units) is available
as
{@link
constants.MAX_STRING_LENGTH
}
.
```js
import { Buffer } from 'node:buffer';
const buf1 = Buffer.allocUnsafe(26);
for (let i = 0; i < 26; i++) {
// 97 is the decimal ASCII value for 'a'.
buf1[i] = i + 97;
}
console.log(buf1.toString('utf8'));
// Prints: abcdefghijklmnopqrstuvwxyz
console.log(buf1.toString('utf8', 0, 5));
// Prints: abcde
const buf2 = Buffer.from('tést');
console.log(buf2.toString('hex'));
// Prints: 74c3a97374
console.log(buf2.toString('utf8', 0, 3));
// Prints: té
console.log(buf2.toString(undefined, 0, 3));
// Prints: té
```toString();
The functions for signing and verifying have a bit of a peculiar API, where we
can pass null or undefined as the first argument if we already have our key
in a KeyObject instead of a raw string. I’m pretty used to
JSON.stringify(object, null, 2), so I think I actually prefer it over
repeating the algorithm name.
import { function sign(algorithm: string | null | undefined, data: NodeJS.ArrayBufferView, key: KeyLike | SignKeyObjectInput | SignPrivateKeyInput | SignJsonWebKeyInput): Buffer (+1 overload)Calculates and returns the signature for `data` using the given private key and
algorithm. If `algorithm` is `null` or `undefined`, then the algorithm is
dependent upon the key type (especially Ed25519 and Ed448).
If `key` is not a `KeyObject`, this function behaves as if `key` had been
passed to
{@link
createPrivateKey
}
. If it is an object, the following
additional properties can be passed:
If the `callback` function is provided this function uses libuv's threadpool.sign, function verify(algorithm: string | null | undefined, data: NodeJS.ArrayBufferView, key: KeyLike | VerifyKeyObjectInput | VerifyPublicKeyInput | VerifyJsonWebKeyInput, signature: NodeJS.ArrayBufferView): boolean (+1 overload)Verifies the given signature for `data` using the given key and algorithm. If `algorithm` is `null` or `undefined`, then the algorithm is dependent upon the
key type (especially Ed25519 and Ed448).
If `key` is not a `KeyObject`, this function behaves as if `key` had been
passed to
{@link
createPublicKey
}
. If it is an object, the following
additional properties can be passed:
The `signature` argument is the previously calculated signature for the `data`.
Because public keys can be derived from private keys, a private key or a public
key may be passed for `key`.
If the `callback` function is provided this function uses libuv's threadpool.verify } from "node:crypto";
const const signature: Buffersignature = function sign(algorithm: string | null | undefined, data: NodeJS.ArrayBufferView, key: KeyLike | SignKeyObjectInput | SignPrivateKeyInput | SignJsonWebKeyInput): Buffer (+1 overload)Calculates and returns the signature for `data` using the given private key and
algorithm. If `algorithm` is `null` or `undefined`, then the algorithm is
dependent upon the key type (especially Ed25519 and Ed448).
If `key` is not a `KeyObject`, this function behaves as if `key` had been
passed to
{@link
createPrivateKey
}
. If it is an object, the following
additional properties can be passed:
If the `callback` function is provided this function uses libuv's threadpool.sign(null, const originalUint8Array: Uint8ArrayoriginalUint8Array, const privateKey: KeyObjectprivateKey);
const const verified: booleanverified = function verify(algorithm: string | null | undefined, data: NodeJS.ArrayBufferView, key: KeyLike | VerifyKeyObjectInput | VerifyPublicKeyInput | VerifyJsonWebKeyInput, signature: NodeJS.ArrayBufferView): boolean (+1 overload)Verifies the given signature for `data` using the given key and algorithm. If `algorithm` is `null` or `undefined`, then the algorithm is dependent upon the
key type (especially Ed25519 and Ed448).
If `key` is not a `KeyObject`, this function behaves as if `key` had been
passed to
{@link
createPublicKey
}
. If it is an object, the following
additional properties can be passed:
The `signature` argument is the previously calculated signature for the `data`.
Because public keys can be derived from private keys, a private key or a public
key may be passed for `key`.
If the `callback` function is provided this function uses libuv's threadpool.verify(null, const originalUint8Array: Uint8ArrayoriginalUint8Array, const publicKey: KeyObjectpublicKey, const signature: Buffersignature);
If you’re just interested in signing, you should probably use Ed25519. It’s faster than RSA, twisted Edwards curves sound like a band name, and GitHub recommends it for your SSH keys.
const const signingKeys: KeyPairKeyObjectResultsigningKeys = await promisify<{
(type: "rsa", options: RSAKeyPairOptions<"pem", "pem">): Promise<{
publicKey: string;
privateKey: string;
}>;
(type: "rsa", options: RSAKeyPairOptions<"pem", "der">): Promise<...>;
(type: "rsa", options: RSAKeyPairOptions<...>): Promise<...>;
(type: "rsa", options: RSAKeyPairOptions<...>): Promise<...>;
(type: "rsa", options: RSAKeyPairKeyObjectOptions): Promise<...>;
(type: "rsa-pss", options: RSAPSSKeyPairOptions<...>): Promise<...>;
(type: "rsa-pss", options: RSAPSSKeyPairOptions<...>): Promise<...>;
(type: "rsa-pss", options: RSAPSSKeyPairOptions<...>): Promise<...>;
(type: "rsa-pss", options: RSAPSSKeyPairOptions<...>): Promise<...>;
(type: "rsa-pss", options: RSAPSSKeyPairKeyObjectOptions): Promise<...>;
(type: "dsa", options: DSAKeyPairOptions<...>): Promise<...>;
(type: "dsa", options: DSAKeyPairOptions<...>): Promise<...>;
(type: "dsa", options: DSAKeyPairOptions<...>): Promise<...>;
(type: "dsa", options: DSAKeyPairOptions<...>): Promise<...>;
(type: "dsa", options: DSAKeyPairKeyObjectOptions): Promise<...>;
(type: "ec", options: ECKeyPairOptions<...>): Promise<...>;
(type: "ec", options: ECKeyPairOptions<...>): Promise<...>;
(type: "ec", options: ECKeyPairOptions<...>): Promise<...>;
(type: "ec", options: ECKeyPairOptions<...>): Promise<...>;
(type: "ec", options: ECKeyPairKeyObjectOptions): Promise<...>;
(type: "ed25519", options: ED25519KeyPairOptions<...>): Promise<...>;
(type: "ed25519", options: ED25519KeyPairOptions<...>): Promise<...>;
(type: "ed25519" ... (+13 overloads)
Takes a function following the common error-first callback style, i.e. taking
an `(err, value) => ...` callback as the last argument, and returns a version
that returns promises.
```js
import { promisify } from 'node:util';
import { stat } from 'node:fs';
const promisifiedStat = promisify(stat);
promisifiedStat('.').then((stats) => {
// Do something with `stats`
}).catch((error) => {
// Handle the error.
});
```
Or, equivalently using `async function`s:
```js
import { promisify } from 'node:util';
import { stat } from 'node:fs';
const promisifiedStat = promisify(stat);
async function callStat() {
const stats = await promisifiedStat('.');
console.log(`This directory is owned by ${stats.uid}`);
}
callStat();
```
If there is an `original[util.promisify.custom]` property present, `promisify`
will return its value, see [Custom promisified functions](https://nodejs.org/docs/latest-v24.x/api/util.html#custom-promisified-functions).
`promisify()` assumes that `original` is a function taking a callback as its
final argument in all cases. If `original` is not a function, `promisify()`
will throw an error. If `original` is a function but its last argument is not
an error-first callback, it will still be passed an error-first
callback as its last argument.
Using `promisify()` on class methods or other methods that use `this` may not
work as expected unless handled specially:
```js
import { promisify } from 'node:util';
class Foo {
constructor() {
this.a = 42;
}
bar(callback) {
callback(null, this.a);
}
}
const foo = new Foo();
const naiveBar = promisify(foo.bar);
// TypeError: Cannot read properties of undefined (reading 'a')
// naiveBar().then(a => console.log(a));
naiveBar.call(foo).then((a) => console.log(a)); // '42'
const bindBar = naiveBar.bind(foo);
bindBar().then((a) => console.log(a)); // '42'
```promisify(function generateKeyPair(type: "rsa", options: RSAKeyPairOptions<"pem", "pem">, callback: (err: Error | null, publicKey: string, privateKey: string) => void): void (+39 overloads)Generates a new asymmetric key pair of the given `type`. RSA, RSA-PSS, DSA, EC,
Ed25519, Ed448, X25519, X448, and DH are currently supported.
If a `publicKeyEncoding` or `privateKeyEncoding` was specified, this function
behaves as if `keyObject.export()` had been called on its result. Otherwise,
the respective part of the key is returned as a `KeyObject`.
It is recommended to encode public keys as `'spki'` and private keys as `'pkcs8'` with encryption for long-term storage:
```js
const {
generateKeyPair,
} = await import('node:crypto');
generateKeyPair('rsa', {
modulusLength: 4096,
publicKeyEncoding: {
type: 'spki',
format: 'pem',
},
privateKeyEncoding: {
type: 'pkcs8',
format: 'pem',
cipher: 'aes-256-cbc',
passphrase: 'top secret',
},
}, (err, publicKey, privateKey) => {
// Handle errors and use the generated key pair.
});
```
On completion, `callback` will be called with `err` set to `undefined` and `publicKey` / `privateKey` representing the generated key pair.
If this method is invoked as its `util.promisify()` ed version, it returns
a `Promise` for an `Object` with `publicKey` and `privateKey` properties.generateKeyPair)("ed25519");
Both RSA and Ed25519 can be used to sign and verify messages, but Ed25519 is just a digital signature scheme, so you can’t use it for encryption.
If you change rsa to ed25519 in the sandbox, one of the tests
will fail with a friendly message:
Error: error:03000096:digital envelope routines::operation not supported for
this keytype
A bit annoying that it doesn’t tell us about it on the type level, but without redesigning the API with types in mind, the type error would also be a bit nasty.
I remember thinking “Why shouldn’t I just use RSA for everything and limit the number of keys flying around?” sometime in the past. Apart from the performance, and some possible security reasons, there’s apparently some legislation stating that digital signatures are legally binding. I probably wouldn’t interpret hashing a message and then raising the hash to the power of a number that’s kept in secret as a signature, but I am not a lawyer, and I am unfortunately doomed to focus on the implementation.
Further Reading