iso-crypto
v3.1.3
Published
Cryptography with one API in browsers and Node.js, over WebCrypto and node:crypto, for hashing, AES encryption (including GCM), PBKDF2 and HKDF key derivation, and ECDH.
Maintainers
Readme
iso-crypto
Cryptography with one API in browsers and Node.js, over WebCrypto and node:crypto, for hashing, AES encryption (including GCM), PBKDF2 and HKDF key derivation, and ECDH.
Node.js and browsers both have cryptography built in, but with different APIs. Node.js has the node:crypto module, and browsers have WebCrypto (crypto.subtle), which also wants every key imported as a CryptoKey before it will use it.
iso-crypto provides one async API for both, for hashing, encryption, key derivation and ECDH. Package imports conditions pick node:crypto on Node.js and crypto.subtle in the browser, so the same code runs in both. Secrets, keys and IVs are plain Uint8Arrays or strings, so there's no importKey or CryptoKey to deal with.
encryptdefaults to AES-256-GCM, which detects tampering.decryptrejects altered content or the wrong secret, and additional authenticated data can bind a ciphertext to its context.deriveKeymakes a key from a password with PBKDF2, or from a high-entropy secret with HKDF, on both platforms.eccEncryptandeccDecryptencrypt between two parties in one call. They derive a shared secret from one party's private key and the other's public key, then encrypt with AES.- ECC keys can be generated, and public keys compressed or decompressed, on P-256, P-384 and P-521.
- Some lower level utilities are included too, like text, hex, base64 and base64url encoding, and secure random bytes.
encrypt stretches its secret with a single hash, which isn't enough for a password, so derive keys from passwords with deriveKey first. There is no scrypt, Argon2, secp256k1 or X25519. For those, see @noble/hashes and @noble/curves.
Contents
Supported Algorithms
Hash
- ✅ sha1
- ✅ sha256
- ✅ sha384
- ✅ sha512
Symmetric Encryption
- ✅ aes-128-cbc
- ⚠️ aes-192-cbc (deprecated)
- ✅ aes-256-cbc
- ✅ aes-128-ctr
- ⚠️ aes-192-ctr (deprecated)
- ✅ aes-256-ctr
- ✅ aes-128-gcm
- ✅ aes-256-gcm
192-bit keys are deprecated, because Chromium-based browsers (Chrome, Edge, …) do not support them, so aes-192-* rejects there. They still work on Node.js, Firefox and Safari, so existing data stays readable, but use 128 or 256 bits for anything new. GCM only accepts 128 or 256 bits.
Key Derivation
- ✅ PBKDF2
- ✅ HKDF
ECDH (Asymmetric)
- ✅ prime256v1
- ✅ secp384r1
- ✅ secp521r1
Install
npm i iso-cryptoNo dependencies or peer dependencies. Requires Node.js 22.12 or later (engines); browsers use crypto.subtle.
Example
Encoding
import { decode, encode } from 'iso-crypto';
const buffer: Uint8Array = decode('This is my text');
const encoded = encode(buffer); // 'This is my text'Random
import { randomBytes } from 'iso-crypto';
const rand: Uint8Array = await randomBytes(10);Hash
import { encode, hash } from 'iso-crypto';
const hashed: Uint8Array = await hash('This is my text');
encode(hashed, 'hex'); // 04405de6b2dcefcd85a03503cefad765a88c8083c5fede2eb7e4354e5643e2df
const customHashed: Uint8Array = await hash(
{ text: 'abc123', encoding: 'hex' },
{
algorithm: 'SHA2',
size: 512,
}
);
encode(customHashed, 'base64'); // 8ff0IFBTdySF86lYF23zbCfmerD9ieLgid5Ak+5pRwffOgSY8yQ78HifX0lxZ0hKHZP4uZ0Nfr9dcwZmo5Mkfw==Symmetric Encryption
import { decrypt, encode, encrypt } from 'iso-crypto';
const secret = 'Super duper secret password';
const encrypted = await encrypt({
data: 'This is my message',
secret,
});
const decrypted = encode(await decrypt({
...encrypted,
secret,
})); // 'This is my message'
const customAlgEncrypted = await encrypt(
{
data: 'This is my response',
secret,
},
{
hash: { algorithm: 'SHA1' },
encryption: {
cipher: 'AES',
size: 128,
mode: 'CBC',
},
}
);
const customAlgDecrypted = encode(await decrypt(
{
...customAlgEncrypted,
secret,
},
{
hash: { algorithm: 'SHA1' },
encryption: {
cipher: 'AES',
size: 128,
mode: 'CBC',
},
}
)); // 'This is my response'Authenticated encryption with a password
import { decrypt, deriveKey, encrypt, randomBytes } from 'iso-crypto';
// Store the salt alongside the encrypted data
const salt = await randomBytes(16);
const key = await deriveKey({ secret: 'correct horse battery staple', salt });
// The key is already the right size, so skip hashing it
const encrypted = await encrypt({ data: 'This is my message', secret: key }, { hash: 'raw' });
// Rejects if `encrypted` was altered, or the key is wrong
const decrypted = await decrypt({ ...encrypted, secret: key }, { hash: 'raw' });ECC
import { eccDecrypt, eccEncrypt, encode, generateEccPrivateKey, generateEccPublicKey } from 'iso-crypto';
const anne: Uint8Array = await generateEccPrivateKey();
const bob: Uint8Array = await generateEccPrivateKey();
const fromAnneEncrypted = await eccEncrypt({
data: 'Hello, would you like a cup to tea?',
privateKey: anne,
publicKey: generateEccPublicKey(bob),
});
const fromAnneDecrypted = encode(await eccDecrypt({
...fromAnneEncrypted,
privateKey: bob,
})); // 'Hello, would you like a cup to tea?'
const fromBobEncrypted = await eccEncrypt(
{
data: 'I am more of a coffee drinker myself',
privateKey: bob,
publicKey: generateEccPublicKey(anne),
},
{
encryption: {
cipher: 'AES',
size: 128,
mode: 'GCM',
},
}
);
const fromBobDecrypted = encode(await eccDecrypt(
{
...fromBobEncrypted,
privateKey: anne,
},
{
encryption: {
cipher: 'AES',
size: 128,
mode: 'GCM',
},
}
)); // 'I am more of a coffee drinker myself'Usage
iso-crypto is an ESM module. That means it must be imported. To load from a CJS module, use dynamic import const { eccEncrypt } = await import('iso-crypto');.
Most cryptographic methods return instances of Uint8Array, instead of strings. Those can encoded as text via the encode method. In NodeJS the response is often actually a Buffer which extends Uint8Array.
Any method that takes input "text" (InputText, see Types) allows 3 formats:
- string - inferred as
UTF8encoding - Uint8Array - "raw" encoding
- { text: string; encoding: string } - text with a custom encoding
- Supported encodings:
hex,base64,base64url,utf8
- Supported encodings:
A Uint8Array input must be typed Uint8Array<ArrayBuffer> (what new Uint8Array(n), decode and every iso-crypto output give you). A value typed as a plain Uint8Array (Uint8Array<ArrayBufferLike>) is a type error, because it could be backed by a SharedArrayBuffer.
Algorithm options are literal types. An options object declared on its own widens 'AES' to string and 128 to number, which is a type error when passed in. Annotate it (const encryption: Encryption = { ... }), add as const, or write it inline in the call. The Sizes, Ciphers, Modes and Algorithms enums are exported and accepted in place of the literals.
Any method that performs hashing can take the Hash algorithm as an option. The default algorithm is SHA256. In any case where hashing should not be used (e.g. custom hashing already implemented), declare the raw option.
e.g.
import { encrypt } from 'iso-crypto';
await encrypt(
{
data: 'My message',
secret: 'Already hashed',
},
{
hash: 'raw',
}
);Any method that performs symmetric encryption can take the encryption algorithm as an option. The default algorithm is aes-256-gcm ({ cipher: 'AES', size: 256, mode: 'GCM' }). Before iso-crypto 3 it was aes-256-ctr, see Upgrading to 3.0.
Similarly the default ECC algorithm is prime256v1 ({ curve: 'p256' }).
Many algorithms require keys of a fixed size. The general approach is to hash the input, then adjust the bytes to fit: buffers that are too large are stripped from their beginning to match the desired size. With CBC and CTR, buffers that are too small have 0 prepended, as they always have. GCM rejects them instead (a raw key must be exactly the key size, and a hash at least that long), since padding silently weakens the key.
It is important to note that encryption IS NOT the same as compression. In fact encryption methods will generally be slightly larger than the original input.
Ideally any cryptographic output should have statistically uniform distribution of their outputs. That means every byte in the output is equally likely to be 00 as it is FF, and everything in between.
As such, if you intend to combine encryption with compression, it is important to apply compression before encryption, to take advantage of repeated and common patterns in the input.
Many methods in this module deal with "private" or "secret" values. Proper handling of those values are not explicitly covered by this module. Make sure you understand how to securely transfer and access these values before deploying encryption in production, as improper handling and data leaks can effectively render even the most secure encryption algorithms moot.
Upgrading to 3.0
The default encryption is AES-256-GCM, instead of AES-256-CTR. GCM detects tampering, so
decryptrejects altered content or the wrong secret. Data encrypted with the old default, byencryptoreccEncryptwithout anencryptionoption, no longer decrypts with the default: it rejects rather than returning garbage. PassunsafeLegacyEncryption, the old default, todecryptoreccDecryptto read it:import { decrypt, unsafeLegacyEncryption } from 'iso-crypto'; await decrypt({ encrypted, iv, secret }, { encryption: unsafeLegacyEncryption });Data encrypted with an explicit
encryptionoption is unaffected. Consider re-encrypting old data with the new default.decoderejects invalid hex. Hex text with characters other than0-9anda-fthrows aTypeError, where those characters used to decode as0. Valid hex decodes as before.192-bit AES keys are deprecated, as Chromium-based browsers do not support them. They still work for CBC and CTR where supported.
Implementation Details
These are far from thorough explanations of cryptographic functions, but hopefully serve as enough to justify design decisions and explain method interfaces.
Hashing
Hashing algorithms are a "one way" programs, that output a fixed-size buffer that is effectively random for a given input. Re-hashing the same input with the algorithm always returns the same output, but given the output it is virtually impossible to guess a valid input. Similarly it is virtually impossible to "guess" the output without fully applying the algorithm.
Symmetric Encryption
Symmetric encryption algorithms take a message (a.k.a. data) and a "key" (a.k.a. secret) and produce a seemingly random output that represents that message. This output is effectively safe to expose publicly, as the original message is cryptographically hidden.
The same key (this is what makes it "symmetric") can then be used decrypt the output and retrieve the original message.
The key length is specified by the algorithm (e.g. aes-256-ctr requires 32 bytes), which may not necessarily match the actual length of the secret provided. Similarly the provided secret may not have "uniform distribution" of bytes (e.g. Password123 only has bytes between 30-7A). To mediate, the secret is hashed, and has bytes removed from (or, for CBC and CTR, prepended to) the beginning of the buffer to match. A single hash is not enough for passwords: derive the key with deriveKey and pass it with { hash: 'raw' }.
These algorithms also require an effectively random "Initialization Vector" (a.k.a. iv). This buffer is not necessarily private, but it must not be known ahead of time, and should never be reused. Encryption methods in iso-crypto will generate this iv internally from a random number generator (see randomBytes) and exposed in the output alongside the encrypted data.
Given that the same secret it used to both encrypt and decrypt a message, it is not appropriate for sending messages between multiple parties. Instead, it should be used only in cases where the data is being stored/transferred by an untrusted third party and then re-read by original party (e.g. storing sensitive files on a third party server).
Asymmetric Encryption
Asymmetric encryption differs from Symmetric encryption in regards to the "secret". In Asymmetric encryption, messages are secured with both a private key (known only to one party, treated as secret) and a public key (derived from a private key. Safe, if not encouraged, to share publicly).
ECDH
Presently, the only form of asymmetric encryption implemented by this module is Elliptic Curve Diffie-Hellman (ECDH).
How ECC works
Without going into extreme details of curve mathematics, the algorithm is based on points on curve. "Adding" (for simplicity, traditional mathematical terms/symbols are used, but the logic itself is not as trivial as traditional addition) points on a curve is a straightforward equation. "Multiplying" a point can only be achieved by adding the original point N times, which can be simplified into binary logic (doubling + adding). These new points also lie on the curve (and are therefore verifiable).
For example P1 * 10 can be simplified as P1 * ((8 * 1) + (4 * 0) + (2 * 1) + (1 * 0)) -> P1 * 8 + P1 * 2. Note that P1 * 2 can be rewritten as P1 + P1 (straightforward) and P1 * 4 can be rewritten as P2 = P1 * 2; P2 * 2 (re-using a cached value). As such, multiplying a Point can be solved in O(log(n)) time (multiplying by 2^256 takes ~ 256 "steps") which is very acceptable.
Given the complication of multiplication, there is no known "division" operator in ECC. As such, an algorithm can agree on an original point on a curve. Then the "private key" is simply a random number between the curves "domain" (prime256v1 domain is between 0 and 2^256). The "public key" is the resulting point on the curve after multiplication.
A public key technically represents a point (both X and Y coordinates). However, since the equation for the curve is known, it is possible to provide only the X coordinate, and a single byte indicating whether the associated Y coordinate is odd or even. This is known as the "compressed" form of a public key.
Given one parties public key (a point), and another's private key (an integer), these can again be multiplied to produce a "shared secret". Due to the communicative properties of addition/multiplication, both combinations of PublicA * PrivateB AND PrivateA * PublicB produces the same shared secret.
Implementation
This module focuses on the point mathematics required by ECC (relying on native modules as much as possible). Once a shared secret can be calculated, encryption is performed by the symmetric encryption algorithms provided by this module.
With GCM, the AES key is derived from the shared secret with HKDF-SHA256 (no salt, with iso-crypto ECDH <curve> AES-<size>-GCM as the info). The shared secret is a curve coordinate rather than uniformly random bytes, and the info keeps one key pair from producing the same key for two algorithms.
With CBC and CTR, the shared secret is used as the key directly (trimmed to the key size), as it always has been, so existing data stays readable. No hash option applies to ECC encryption.
Any public keys returned by this module will use the compressed form of a public key by default, however both forms are accepted as input.
API
randomBytes(size: number)
Produces a promise of Uint8Array of length size filled with random bytes.
decode(input: InputText)
Decodes any input as Uint8Array, given the provided encoding (see InputText).
Throws a TypeError for hex text with characters other than 0-9 and a-f (in either case). Odd-length hex implies a leading 0.
encode(input: InputText, outputEncoding?: 'hex' | 'base64' | 'base64url' | 'utf8')
Encodes any input (ideally as Uint8Array, but potentially as a differently encoded text) as the desired encoding.
Defaults to UTF8.
decodeObject(input: Record<string, string>)
Decodes each attribute in an object to Uint8Array. Convenience method around manually calling decode for each attribute.
Potentially useful for converting serialized text (e.g. JSON containing hex-decoded data) to usable buffers.
encodeObject(input: Record<string, Uint8Array>)
Encodes each attribute in an object to string. Convenience method around manually calling encode for each attribute.
Potentially useful for serializing decoded text (JSON does not like raw Uint8Array).
hash(input: InputText, algorithm?: Hash)
Asynchronously hashes the input, and returns the output buffer. Defaults to SHA256 algorithm. With 'raw' the input is returned unchanged (as bytes).
algorithm is { algorithm: 'SHA1' } or { algorithm: 'SHA2', size: 256 | 384 | 512 }.
encrypt(params: { data: InputText; secret: InputText }, options?: { hash?: Hash; encryption?: Encryption })
Asynchronously encrypts the input using the provided algorithms (defaults to aes-256-gcm). Hashes the secret internally with the provided hashing algorithm (defaults to SHA256).
Returns both the encrypted data as a Uint8Array, as well as the internally generated iv.
Both of these properties will need to be provided to the decrypt method in order to decrypt the original data.
decrypt(params: { encrypted: InputText; iv: InputText; secret: InputText }, options?: { hash?: Hash; encryption?: Encryption })
Asynchronously decrypts the data using the provided algorithms. Make sure that these are the same algorithms used to encrypt the data originally.
Both the encrypted + iv comes from the output of encrypt, alongside the original secret.
With GCM, the last 16 bytes of encrypted are the authentication tag (the same layout as Web Crypto), and decrypt rejects if the content, IV or secret don't match what was encrypted.
Additional authenticated data
With GCM, encrypt, decrypt, eccEncrypt and eccDecrypt take an additionalData option: data that is authenticated but not encrypted, and not part of the output. Decryption rejects unless it is given the same value, which binds a ciphertext to its context, e.g. a record ID, so it can't be swapped into another record.
const encryption = { cipher: 'AES', size: 256, mode: 'GCM' } as const;
const encrypted = await encrypt({ data, secret }, { encryption, additionalData: `user:${userId}` });
// Rejects if `encrypted` was stored under a different user
await decrypt({ ...encrypted, secret }, { encryption, additionalData: `user:${userId}` });Other modes don't authenticate anything, so they throw a TypeError rather than ignore it.
deriveKey(params: { secret: InputText; salt: InputText }, options?: Pbkdf2Options | HkdfOptions)
Asynchronously derives a key of a fixed size from a secret. Use the result as the secret of encrypt/decrypt with { hash: 'raw' }.
salt is a random value (e.g. randomBytes(16)) stored alongside whatever the key protects, so equal secrets derive different keys.
| Option | Type | Default | Description |
| ------------ | -------------------- | ---------- | ----------------------------------------------------------------------------------------------------------------------------------------------------- |
| algorithm | 'PBKDF2' \| 'HKDF' | 'PBKDF2' | PBKDF2 is deliberately slow, for passwords. HKDF is fast, for secrets that are already random, such as an ECDH shared secret. |
| hash | HashAlgorithm | SHA256 | Hash the derivation is built on: { algorithm: 'SHA1' } or { algorithm: 'SHA2', size: 256 \| 384 \| 512 }. Unlike Hash, 'raw' is not accepted. |
| size | number | 256 | Size of the key in bits, a multiple of 8. |
| iterations | number | 600_000 | PBKDF2 only. OWASP's recommendation for PBKDF2 with SHA256. |
| info | InputText | '' | HKDF only. Context for the key, so one secret can derive several unrelated keys. |
generateEccPrivateKey(curve?: Curve)
Asynchronously generates an ECC private key as a Uint8Array.
generateEccPublicKey(privateKey: InputText, curve?: Curve)
Generates the corresponding public key for the provided private key.
compressEccPublicKey(publicKey: InputText, curve?: Curve)
Calculates the corresponding compressed public key for the provided public key.
NOOP if public key is already compressed (idempotent).
Generally available for convenience. All public keys outputted by ecc methods are compressed by default, and accept both forms as input.
decompressEccPublicKey(publicKey: InputText, curve?: Curve)
Calculate the corresponding uncompressed public key for the provided public key.
NOOP if public key is already uncompressed (idempotent).
Throws if a compressed key isn't a point on the curve (or isn't a compressed key at all). In the browser, eccEncrypt and eccDecrypt decompress the publicKey they're given, so they reject such keys the same way.
eccEncrypt(params: { data: InputText; privateKey: InputText; publicKey: InputText }, options?: { curve?: Curve; encryption?: Encryption })
Asynchronously determines the shared secret between public + private key, and encrypts the data using the specified encryption algorithm (defaults to aes-256-gcm).
Ensure that the keys are generated using the same curve algorithm provided.
Note that the publicKey should be the "receiver's" public key, not simply the public key for the provided privateKey ("sender").
Returns both the encrypted + iv data (see encrypt) as well as the publicKey corresponding to the input privateKey for convenience.
eccDecrypt(params: { encrypted: InputText; iv: InputText; privateKey: InputText; publicKey: InputText }, options?: { curve?: Curve; encryption?: Encryption })
Asynchronously decrypts the data from eccEncrypt using the receiver's privateKey and the sender's publicKey.
Ensure that the provided curve + encryption algorithm is the same as used to encrypt.
Types
Each is exported as a type, e.g. import type { InputText } from 'iso-crypto'.
InputText
Type used to represent allowed text/data input.
string | Uint8Array<ArrayBuffer> | { text: string; encoding: 'hex' | 'base64' | 'base64url' | 'utf8' } | { text: Uint8Array<ArrayBuffer>; encoding: 'raw' }.
Uint8ArrayBuffer is the Uint8Array<ArrayBuffer> member.
To verify the "buffer" input, pass the text to decode (which is done internally).
A plain string is inferred to be UTF8 encoded.
Hash
Type used to represent a specific hash algorithm.
{ algorithm: 'SHA1'; size?: 160 } | { algorithm: 'SHA2'; size: 256 | 384 | 512 } | 'raw'.
To avoid hashing (as it is sometimes used internally by algorithms) provide the 'raw' option to return the input unchanged. HashAlgorithm is the same type without 'raw'.
Generally defaults to { algorithm: 'SHA2', size: 256 }.
Encryption
Type used to represent a specific symmetric encryption algorithm.
{ cipher: 'AES'; size: 128 | 192 | 256; mode: 'CBC' | 'CTR' } | { cipher: 'AES'; size: 128 | 256; mode: 'GCM' }.
A size of 192 is deprecated, see Symmetric Encryption.
Defaults to { cipher: 'AES', size: 256, mode: 'GCM' }. unsafeLegacyEncryption is the default before iso-crypto 3, { cipher: 'AES', size: 256, mode: 'CTR' }.
Curve
Type used to represent a specific ECC curve. 'p256' | 'p384' | 'p521' (prime256v1, secp384r1, secp521r1).
Generally defaults to p256.
