UNPKG

micro-key-producer

Version:

Produces secure keys and passwords. Supports SSH, PGP, BLS, OTP, and many others

371 lines (369 loc) 15.6 kB
import { ctr } from '@noble/ciphers/aes'; import { ensureBytes, numberToBytesBE } from '@noble/curves/abstract/utils'; import { bls12_381 } from '@noble/curves/bls12-381'; import { hkdf } from '@noble/hashes/hkdf'; import { pbkdf2 } from '@noble/hashes/pbkdf2'; import { scrypt } from '@noble/hashes/scrypt'; import { sha256 } from '@noble/hashes/sha256'; import { bytesToHex, concatBytes, hexToBytes, isBytes, randomBytes, utf8ToBytes, } from '@noble/hashes/utils'; /* Implements: - EIP-2333: BLS12-381 Key Generation - EIP-2334: BLS12-381 Deterministic Account Hierarchy - EIP-2335: BLS12-381 Keystore The standards are not used anywhere outside of eth validator keys as per 2024. */ const { getPublicKey } = bls12_381; const { Fr } = bls12_381.fields; // Octet Stream to Integer function os2ip(bytes) { 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, length) { if (value < 0 || value >= 1n << BigInt(8 * length)) { throw new Error(`bad I2OSP call: value=${value} length=${length}`); } const res = Array.from({ length }).fill(0); for (let i = length - 1; i >= 0; i--) { res[i] = value & 0xff; value >>>= 8; } return new Uint8Array(res); } function ikmToLamportSK(ikm, salt) { 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) { if (!Number.isSafeInteger(index) || index < 0 || index > 2 ** 32 - 1) { throw new TypeError('Expected valid uint32 number'); } } function parentSKToLamportPK(parentSK, index) { parentSK = ensureBytes('parentSK', 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 https://www.ietf.org/archive/id/draft-irtf-cfrg-bls-signature-05.html#name-keygen * @param ikm - secret octet string * @param keyInfo - additional key information */ export function hkdfModR(ikm, keyInfo = new Uint8Array()) { ikm = ensureBytes('IKM', ikm); keyInfo = ensureBytes('key information', keyInfo); 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); } export function deriveMaster(seed) { return hkdfModR(seed); } export function deriveChild(parentKey, index) { return hkdfModR(parentSKToLamportPK(parentKey, index)); } export function deriveSeedTree(seed, path) { if (typeof path !== 'string') throw new Error('Derivation path must be string'); const indices = path.split('/'); if (indices.shift() !== 'm') throw new Error('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; } export const EIP2334_KEY_TYPES = ['withdrawal', 'signing']; export function deriveEIP2334Key(seed, type, index) { if (!isBytes(seed)) throw new Error('Valid seed expected'); if (!EIP2334_KEY_TYPES.includes(type)) throw new Error('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) * @returns same as deriveEIP2334Key(seed, 'signing', index), but without access to seed * @example * const signing = bls.deriveEIP2334Key(seed, 'signing', 0); * const withdrawal = bls.deriveEIP2334Key(seed, 'withdrawal', 0); * const derivedSigning = bls.deriveEIP2334SigningKey(withdrawal.key); * deepStrictEqual(derivedSigning, signing.key); */ export function deriveEIP2334SigningKey(withdrawalKey, index = 0) { withdrawalKey = ensureBytes('withdrawal key', withdrawalKey, 32); assertUint32(index); return deriveChild(withdrawalKey, index); } function normalizePassword(s) { 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) { 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' }, }; // Non-strict version just validates same way as json schema from spec // Maybe worth exporting? function validateKeystore(store, 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]; 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, salt, kdf) { 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 * @returns decrypted secret and optionally path * @example decryptEIP2335Keystore(JSON.parse(keystoreString), 'my_password'); */ export function decryptEIP2335Keystore(store, password) { 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)); 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; } /** * Class for generation multiple keystores with same password * @example * const ctx = new EIP2335Keystore(password, 'scrypt'); * const res = [0, 1, 2, 3].map((i) => ctx.createDerivedEIP2334(seed, keyType, i)); * ctx.clean(); * console.log(res); // res is array of encrypted keystores with same password */ export class EIP2335Keystore { /** * Creates context for EIP2335 Keystore generation * @param password - password * @param kdf - scrypt | pbkdf2 * @param _random - (optional) secure PRNG function like 'randomBytes' from '@noble/hashes/utils' */ constructor(password, kdf, _random = randomBytes) { this.destroyed = false; 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, path = '', // EIP2335 allows storing not derived keys description = '', pubkey) { 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 = { 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(ensureBytes('public key', pubkey)); 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, keyType, index, description = '') { const { key: privKey, path } = deriveEIP2334Key(seed, keyType, index); const pubkey = bls12_381.getPublicKey(privKey); return this.create(privKey, path, description, pubkey); } /** * Clean internal key material */ clean() { 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 * @example * createDerivedEIP2334Keystores('my_password', 'scrypt', await mnemonicToSeed(mnemonic, ''), 'signing', [0, 1, 2, 3]) */ export function createDerivedEIP2334Keystores(password, kdf, seed, keyType, indexes) { // 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 = /* @__PURE__ */ { normalizePassword, deriveEIP2335Key }; //# sourceMappingURL=bls.js.map