micro-key-producer
Version:
Produces secure passwords & keys for WebCrypto, SSH, PGP, SLIP10, OTP and many others
562 lines (537 loc) • 22.2 kB
text/typescript
/**
* Deterministic producer of ETH validator keys. Implements:
*
* - [EIP-2333](https://eips.ethereum.org/EIPS/eip-2333): BLS12-381 Key Generation
* - [EIP-2334](https://eips.ethereum.org/EIPS/eip-2334): BLS12-381 Deterministic Account Hierarchy
* - [EIP-2335](https://eips.ethereum.org/EIPS/eip-2335): BLS12-381 Keystore
*
* @module
*/
import { ctr } from '@noble/ciphers/aes.js';
import { bls12_381 } from '@noble/curves/bls12-381.js';
import { abytes, numberToBytesBE } from '@noble/curves/utils.js';
import { hkdf } from '@noble/hashes/hkdf.js';
import { pbkdf2 } from '@noble/hashes/pbkdf2.js';
import { scrypt } from '@noble/hashes/scrypt.js';
import { sha256 } from '@noble/hashes/sha2.js';
import {
bytesToHex,
concatBytes,
hexToBytes,
isBytes,
randomBytes,
utf8ToBytes,
} from '@noble/hashes/utils.js';
// treeshake: single helpers should not keep the full longSignatures/fields objects live.
const getPublicKey = /* @__PURE__ */ (() => bls12_381.longSignatures.getPublicKey)();
const Fr = /* @__PURE__ */ (() => bls12_381.fields.Fr)();
// Octet Stream to Integer
function os2ip(bytes: Uint8Array): bigint {
let result = 0n;
for (let i = 0; i < bytes.length; i++) {
const byte = bytes[i]!;
result <<= 8n;
result += BigInt(byte);
}
return result;
}
// Integer to Octet Stream
function i2osp(value: number, length: number): Uint8Array {
if (value < 0 || value >= 1n << BigInt(8 * length)) {
throw new RangeError(`bad I2OSP call: value=${value} length=${length}`);
}
const res = Array.from({ length }).fill(0) as number[];
for (let i = length - 1; i >= 0; i--) {
res[i] = value & 0xff;
value >>>= 8;
}
return new Uint8Array(res);
}
function ikmToLamportSK(ikm: Uint8Array, salt: Uint8Array) {
const okm = hkdf(sha256, ikm, salt, undefined, 32 * 255);
return Array.from({ length: 255 }, (_, i) => okm.slice(i * 32, (i + 1) * 32));
}
function assertUint32(index: number) {
if (typeof index !== 'number') throw new TypeError('Expected uint32 number');
if (!Number.isSafeInteger(index) || index < 0 || index > 2 ** 32 - 1)
throw new RangeError('Expected valid uint32 number');
}
function parentSKToLamportPK(parentSK: Uint8Array, index: number) {
parentSK = abytes(parentSK, undefined, 'parentSK');
assertUint32(index);
const salt = i2osp(index, 4);
const ikm = parentSK;
const lamport0 = ikmToLamportSK(ikm, salt);
const notIkm = ikm.map((byte) => ~byte);
const lamport1 = ikmToLamportSK(notIkm, salt);
const lamportPK = lamport0.concat(lamport1).map((part) => sha256(part));
return sha256(concatBytes(...lamportPK));
}
/**
* Low-level primitive from EIP2333, generates key from bytes.
* KeyGen from {@link https://www.ietf.org/archive/id/draft-irtf-cfrg-bls-signature-05.html#name-keygen | the CFRG BLS signature draft}.
* @param ikm - secret octet string
* @param keyInfo - additional key information
* @returns Derived BLS secret key bytes.
* @example
* Feed raw input keying material into the EIP-2333 keygen primitive.
* ```ts
* import { randomBytes } from '@noble/hashes/utils.js';
* import { hkdfModR } from 'micro-key-producer/bls.js';
* hkdfModR(randomBytes(32));
* ```
*/
export function hkdfModR(ikm: Uint8Array, keyInfo: Uint8Array = Uint8Array.of()): Uint8Array {
ikm = abytes(ikm, undefined, 'ikm');
keyInfo = abytes(keyInfo, undefined, 'key information');
let salt = utf8ToBytes('BLS-SIG-KEYGEN-SALT-');
let SK = 0n;
const input = concatBytes(ikm, Uint8Array.from([0x00]));
const label = concatBytes(keyInfo, Uint8Array.from([0x00, 0x30]));
while (SK === 0n) {
salt = sha256(salt);
const okm = hkdf(sha256, input, salt, label, 48);
SK = Fr.create(os2ip(okm));
}
return numberToBytesBE(SK, 32);
}
/**
* Derives the EIP-2333 master secret key from a seed.
* @param seed - Seed bytes.
* @returns Master secret key bytes.
* @example
* Start from fresh entropy and derive the BLS root secret defined by EIP-2333.
* ```ts
* import { randomBytes } from '@noble/hashes/utils.js';
* import { deriveMaster } from 'micro-key-producer/bls.js';
* const seed = randomBytes(32);
* deriveMaster(seed);
* ```
*/
export function deriveMaster(seed: Uint8Array): Uint8Array {
return hkdfModR(seed);
}
/**
* Derives a hardened child secret key from a parent secret key.
* @param parentKey - Parent secret key bytes.
* @param index - Child index.
* @returns Child secret key bytes.
* @throws On wrong argument types. {@link TypeError}
* @throws On wrong parent-key length or child-index range. {@link RangeError}
* @example
* First derive the master key, then walk one hardened child step.
* ```ts
* import { randomBytes } from '@noble/hashes/utils.js';
* import { deriveChild, deriveMaster } from 'micro-key-producer/bls.js';
* const seed = randomBytes(32);
* deriveChild(deriveMaster(seed), 0);
* ```
*/
export function deriveChild(parentKey: Uint8Array, index: number): Uint8Array {
return hkdfModR(parentSKToLamportPK(parentKey, index));
}
/**
* Derives a key by walking an EIP-2334 path from a seed.
* @param seed - Root seed bytes.
* @param path - Derivation path starting with `m`.
* @returns Derived secret key bytes.
* @throws On wrong argument types. {@link TypeError}
* @throws On malformed derivation paths or child-index ranges. {@link RangeError}
* @example
* Follow a full validator derivation path directly from the seed bytes.
* ```ts
* import { randomBytes } from '@noble/hashes/utils.js';
* import { deriveSeedTree } from 'micro-key-producer/bls.js';
* const seed = randomBytes(32);
* deriveSeedTree(seed, 'm/12381/3600/0/0');
* ```
*/
export function deriveSeedTree(seed: Uint8Array, path: string): Uint8Array {
if (typeof path !== 'string') throw new TypeError('Derivation path must be string');
const indices = path.split('/');
if (indices.shift() !== 'm') throw new RangeError('First character of path must be "m"');
let sk = deriveMaster(seed);
const nodes = indices.map((i) => Number.parseInt(i));
for (const node of nodes) sk = deriveChild(sk, node);
return sk;
}
/** Supported EIP-2334 key usages. */
export const EIP2334_KEY_TYPES = ['withdrawal', 'signing'] as const;
/** Allowed EIP-2334 key usage names. */
export type EIP2334KeyType = (typeof EIP2334_KEY_TYPES)[number];
/**
* Derives an EIP-2334 withdrawal or signing key.
* @param seed - Seed bytes.
* @param type - Requested key usage.
* @param index - Validator account index.
* @returns Derived private key bytes and its derivation path.
* @throws On wrong seed, key-type, or index argument types. {@link TypeError}
* @throws On unsupported key types or validator-index ranges. {@link RangeError}
* @example
* Ask for either the withdrawal or signing branch and keep the returned path string.
* ```ts
* import { randomBytes } from '@noble/hashes/utils.js';
* import { deriveEIP2334Key } from 'micro-key-producer/bls.js';
* const seed = randomBytes(32);
* deriveEIP2334Key(seed, 'signing', 0).path;
* ```
*/
export function deriveEIP2334Key(
seed: Uint8Array,
type: EIP2334KeyType,
index: number
): {
key: Uint8Array;
path: string;
} {
if (!isBytes(seed)) throw new TypeError('Valid seed expected');
if (typeof type !== 'string') throw new TypeError('Valid keystore type expected');
if (!EIP2334_KEY_TYPES.includes(type as EIP2334KeyType))
throw new RangeError('Valid keystore type expected');
assertUint32(index);
// m / purpose / coin_type / account / use
// - purpose: always 12381
// - coin_type: always 3600 (eth2 bls12-381 keys)
// EIP-2334 specifies following derivation paths:
// m/12381/3600/0/0 for withdrawal
// m/12381/3600/0/0/0 for signing (sub account for withdrawal)
const path = `m/12381/3600/${index}/0${type === 'signing' ? '/0' : ''}`;
return { key: deriveSeedTree(seed, path), path };
}
/**
* Derives signing key from withdrawal key without access to seed
* @param withdrawalKey - result of deriveEIP2334Key(seed, 'withdrawal', index)
* @param index - Child signing index below the withdrawal key.
* @returns same as deriveEIP2334Key(seed, 'signing', index), but without access to seed
* @throws On wrong argument types. {@link TypeError}
* @throws On wrong withdrawal-key length or child-index range. {@link RangeError}
* @example
* Show that the signing branch can be reconstructed later from the withdrawal branch.
* ```ts
* import { deepStrictEqual } from 'node:assert';
* import { randomBytes } from '@noble/hashes/utils.js';
* import { deriveEIP2334Key, deriveEIP2334SigningKey } from 'micro-key-producer/bls.js';
* const seed = randomBytes(64);
* const signing = deriveEIP2334Key(seed, 'signing', 0);
* const withdrawal = deriveEIP2334Key(seed, 'withdrawal', 0);
* deepStrictEqual(deriveEIP2334SigningKey(withdrawal.key), signing.key);
* ```
*/
export function deriveEIP2334SigningKey(withdrawalKey: Uint8Array, index = 0): Uint8Array {
withdrawalKey = abytes(withdrawalKey, 32, 'withdrawal key');
assertUint32(index);
return deriveChild(withdrawalKey, index);
}
function normalizePassword(s: string): string {
let out = '';
for (const chr of s.normalize('NFKD')) {
const code = chr.charCodeAt(0);
// C0 are the control codes between 0x00 - 0x1F(inclusive) and C1 codes
// lie between 0x80 and 0x9F(inclusive). Delete, commonly known as “backspace”,
// is the UTF - 8 character 7F which must also be stripped.
// Note that space(Sp UTF - 8 0x20) is a valid character in passwords despite it
// being a pseudo - control character.
if ((0x00 <= code && code <= 0x1f) || (0x7f <= code && code <= 0x9f)) continue;
out += chr;
}
return out;
}
function UUIDv4(buf: Uint8Array): string {
buf = Uint8Array.from(buf);
// UUID version
buf[6] = (buf[6] & 0x0f) | 0x40;
// RFC 4122
buf[8] = (buf[8] & 0x3f) | 0x80;
const parts = [
buf.subarray(0, 4),
buf.subarray(4, 6),
buf.subarray(6, 8),
buf.subarray(8, 10),
buf.subarray(10),
];
return parts.map(bytesToHex).join('-');
}
// Note: dklen, not dkLen, because lowercase is used inside of serialized json keystores
const KDFS = {
scrypt: { dklen: 32, n: 262144, r: 8, p: 1 },
pbkdf2: { dklen: 32, c: 262144, prf: 'hmac-sha256' },
};
type KDFParams<T extends KDFType> = (typeof KDFS)[T];
type KDFType = keyof typeof KDFS;
/** EIP-2335 keystore JSON object. */
export type Keystore<T extends KDFType> = {
/** Schema version. Always `4` for BLS keystores. */
version: number;
/** Optional human-readable description of the protected secret. */
description?: string;
/** Optional hex-encoded public key for validating the decrypted secret. */
pubkey?: string;
/** EIP-2334 derivation path or an empty string for non-derived secrets. */
path: string;
/** RFC 4122 v4 UUID for the keystore object. */
uuid: string;
/** Cipher, checksum, and KDF configuration with their serialized payloads. */
crypto: {
/** Key-derivation function and its serialized parameters. */
kdf: { function: T; params: KDFParams<T> & { salt: string }; message: '' };
/** Checksum algorithm and checksum payload used to verify decryption. */
checksum: { function: 'sha256'; params: {}; message: string };
/** Cipher algorithm, IV, and encrypted secret payload. */
cipher: { function: 'aes-128-ctr'; params: { iv: string }; message: string };
};
};
// Non-strict version just validates same way as json schema from spec
// Maybe worth exporting?
function validateKeystore<T extends KDFType>(store: Keystore<T>, strict = true) {
if (typeof store !== 'object' || store === null) throw new Error('keystore should be object');
if (store.version !== 4)
throw new Error('keystore: wrong version, only version=4 is supported for BLS keys for now');
if (!/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/.test(store.uuid))
throw new Error('keystore: wrong uuid');
if (store.pubkey !== undefined && typeof store.pubkey !== 'string')
throw new Error('keystore: wrong pubkey type, should be string');
if (store.description !== undefined && typeof store.description !== 'string')
throw new Error('keystore: wrong description type, should be string');
const crypto = store.crypto;
if (typeof crypto !== 'object' || crypto === null)
throw new Error('keystore.crypto should be object');
for (const k in crypto) {
if (strict && !['kdf', 'checksum', 'cipher'].includes(k))
throw new Error(`keystore: unknown crypto module: ${k}`);
const mod = crypto[k as keyof Keystore<T>['crypto']];
if (typeof mod !== 'object' || mod === null)
throw new Error(`keystore.crypto.${k} should be object`);
if (typeof mod.function !== 'string')
throw new Error(`keystore.crypto.${k}.function should be string`);
if (typeof mod.params !== 'object' || mod.params === null)
throw new Error(`keystore.crypto.${k}.params should be object`);
if (typeof mod.message !== 'string')
throw new Error(`keystore.crypto.${k}.message should be string`);
}
if (strict) {
if (!KDFS[crypto.kdf.function])
throw new Error('keystore: only script and pbkdf2 kdf supported in version 4');
if (crypto.checksum.function !== 'sha256')
throw new Error('keystore: only sha256 checksum supported in version 4');
if (crypto.cipher.function !== 'aes-128-ctr')
throw new Error('keystore: only aes-128-ctr cipher supported in version 4');
const kdf = crypto.kdf.params;
if (typeof kdf.salt !== 'string') throw new Error(`keystore.crypto.kdf.salt should be string`);
// Not sure if we need this validation, if encryption key was derived using insecure params,
// we cannot do much here (it already happened!), I don't see reasons not to decrypt
// const expKdf = KDFS[crypto.kdf.function];
// for (const k in expKdf) {
// if (kdf[k] !== expKdf[k]) {
// throw new Error(`keystore.crypto.kdf.params.${k} should be ${expKdf[k]}`);
// }
// }
if (typeof crypto.cipher.params.iv !== 'string')
throw new Error(`keystore.crypto.cipher.params.iv should be string`);
}
}
function deriveEIP2335Key(password: string, salt: Uint8Array, kdf: KDFType): Uint8Array {
const pass = utf8ToBytes(normalizePassword(password));
if (kdf === 'scrypt') {
const { n: N, r, p, dklen: dkLen } = KDFS[kdf];
return scrypt(pass, salt, { N, r, p, dkLen });
} else if (kdf === 'pbkdf2') {
const { c, dklen: dkLen } = KDFS[kdf];
return pbkdf2(sha256, pass, salt, { c, dkLen });
} else {
throw new Error(`Unsupported KDF: ${kdf}`);
}
}
/**
* Decrypts EIP2335 Keystore
* NOTE: it validates publicKey if present (which mean you can use it from store if decryption is success)
* @param store - js object
* @param password - Password used by the keystore KDF.
* @returns decrypted secret and optionally path
* @throws If the keystore uses an unsupported KDF or fails checksum/public-key validation. {@link Error}
* @example
* Decrypt the keystore back into the original secret bytes.
* ```ts
* import { randomBytes } from '@noble/hashes/utils.js';
* import { EIP2335Keystore, decryptEIP2335Keystore } from 'micro-key-producer/bls.js';
* const ctx = new EIP2335Keystore('password', 'pbkdf2', randomBytes);
* const store = ctx.create(randomBytes(32));
* decryptEIP2335Keystore(store, 'password');
* ctx.clean();
* ```
*/
export function decryptEIP2335Keystore<T extends KDFType>(
store: Keystore<T>,
password: string
): Uint8Array {
validateKeystore(store);
const c = store.crypto;
const checksumProvided = c.checksum.message;
const ciphertext = hexToBytes(c.cipher.message);
const salt = hexToBytes(c.kdf.params.salt);
const iv = hexToBytes(c.cipher.params.iv);
const key = deriveEIP2335Key(password, salt, c.kdf.function);
// verify checksum
const checksum = bytesToHex(sha256(concatBytes(key.subarray(16, 32), ciphertext)));
if (checksum !== checksumProvided)
throw new Error(`Checksum ${checksum} does not match ${checksumProvided}`);
// decrypt
const secret = ctr(key.subarray(0, 16), iv).decrypt(ciphertext);
// verify pubkey
// NOTE: it is optional, and encrypted value is not neccesarily private key according to EIP2335
if (store.pubkey !== undefined) {
const publicKey = bytesToHex(getPublicKey(secret).toBytes());
if (publicKey !== store.pubkey)
throw new Error(`Pubkey ${publicKey} does not match ${store.pubkey}`);
}
key.fill(0);
iv.fill(0);
ciphertext.fill(0);
return secret;
}
/**
* Secure random-byte generator.
* @param bytes - Number of random bytes to produce.
* @returns Cryptographically secure random bytes.
*/
export type RandFn = (bytes: number) => Uint8Array;
/**
* Class for generation multiple keystores with same password
* @param password - Password used by the keystore KDF.
* @param kdf - Key-derivation function name.
* @param _random - Optional secure random-byte generator.
* @example
* Reuse one keystore context when exporting multiple derived validators with the same password.
* ```ts
* import { randomBytes } from '@noble/hashes/utils.js';
* import { EIP2335Keystore } from 'micro-key-producer/bls.js';
* const ctx = new EIP2335Keystore('password', 'pbkdf2', randomBytes);
* const seed = randomBytes(32);
* const stores = [0, 1].map((i) => ctx.createDerivedEIP2334(seed, 'signing', i));
* ctx.clean();
* ```
*/
export class EIP2335Keystore<T extends KDFType> {
private destroyed = false;
private readonly kdf: T;
private readonly randomBytes: RandFn;
private readonly key: Uint8Array;
private readonly salt: Uint8Array;
/**
* Creates context for EIP2335 Keystore generation
* @param password - password
* @param kdf - scrypt | pbkdf2
* @param _random - Optional secure random-byte generator.
*/
constructor(password: string, kdf: T, _random: RandFn = randomBytes) {
this.kdf = kdf;
// We need this for tests and also to allow usage in context where our randomBytes doesn't work (react-native?)
this.randomBytes = _random;
this.salt = this.randomBytes(32);
this.key = deriveEIP2335Key(password, this.salt, kdf);
}
/**
* Creates keystore in EIP2335 format.
* @param secret - some secret value to encrypt (usually private keys)
* @param path - optional derivation path if secret
* @param description - optional description of secret
* @param pubkey - optional public key. Required if secret is private key.
*/
create(
secret: Uint8Array,
path: string = '', // EIP2335 allows storing not derived keys
description: string = '',
pubkey?: Uint8Array
): Keystore<T> {
if (this.destroyed) throw new Error('EIP2335Keystore was destroyed.');
const iv = this.randomBytes(16);
const uuid = this.randomBytes(16);
// seed, keyType, index checked inside deriveEIP2334Key;
if (typeof description !== 'string') throw new Error('description should be string');
const { key, kdf, salt } = this;
const ciphertext = ctr(key.subarray(0, 16), iv).encrypt(secret);
const checksum = bytesToHex(sha256(concatBytes(key.subarray(16), ciphertext)));
const res: Keystore<T> = {
version: 4,
description,
path,
uuid: UUIDv4(uuid),
crypto: {
kdf: { function: kdf, params: { ...KDFS[kdf], salt: bytesToHex(salt) }, message: '' },
checksum: { function: 'sha256', params: {}, message: checksum },
cipher: {
function: 'aes-128-ctr',
params: { iv: bytesToHex(iv) },
message: bytesToHex(ciphertext),
},
},
};
if (pubkey !== undefined) res.pubkey = bytesToHex(abytes(pubkey, undefined, 'public key'));
return res;
}
/**
* Creates keystore for derived private key (based on EIP2334 seed and index)
* @param seed - EIP2334 seed to derive from
* @param keyType - EIP2334 key type (withdrawal/signing)
* @param index - account index
* @param description - optional keystore description
*/
createDerivedEIP2334(
seed: Uint8Array,
keyType: EIP2334KeyType,
index: number,
description: string = ''
): Keystore<T> {
const { key: privKey, path } = deriveEIP2334Key(seed, keyType, index);
const pubkey = bls12_381.longSignatures.getPublicKey(privKey).toBytes();
return this.create(privKey, path, description, pubkey);
}
/**
* Clean internal key material
*/
clean(): void {
this.destroyed = true;
this.key.fill(0);
this.salt.fill(0);
}
}
/**
* Exports multiple keystore from derived seed
* @param password - password for file encryption
* @param kdf - scrypt | pbkdf2
* @param seed - result of mnemonicToSeed()
* @param keyType - signing | withdrawal
* @param indexes - array of account indeces
* @returns Derived keystore list for the requested indexes.
* @throws If any requested key index is outside the supported range. {@link Error}
* @example
* Export several validator keystores from one mnemonic-derived seed.
* ```ts
* import { mnemonicToSeedSync } from '@scure/bip39';
* import { createDerivedEIP2334Keystores } from 'micro-key-producer/bls.js';
* const mnemonic = 'letter advice cage absurd amount doctor acoustic avoid letter advice cage above';
* const seed = mnemonicToSeedSync(mnemonic, '');
* createDerivedEIP2334Keystores('password', 'pbkdf2', seed, 'signing', [0, 1, 2, 3]);
* ```
*/
export function createDerivedEIP2334Keystores<T extends KDFType>(
password: string,
kdf: T,
seed: Uint8Array,
keyType: EIP2334KeyType,
indexes: number[]
): Keystore<T>[] {
// NOTE: we can probably also cache key derivation for EIP2334 (since it is hierarchical and seed is same)
for (const i of indexes) {
// Assert 1M max keys and 32M stake
if (!Number.isSafeInteger(i) || i < 0 || i > 2 ** 20 - 1) throw new Error('Invalid key index');
}
const ctx = new EIP2335Keystore(password, kdf);
const res = indexes.map((i) => ctx.createDerivedEIP2334(seed, keyType, i));
ctx.clean();
return res;
}
// Internal methods for test purposes only
export const _TEST: {
normalizePassword: typeof normalizePassword;
deriveEIP2335Key: typeof deriveEIP2335Key;
} = /* @__PURE__ */ { normalizePassword, deriveEIP2335Key };