import { bytes as assertBytes } from './_assert.js'; import { hkdf } from './hkdf.js'; import { sha256 } from './sha256.js'; import { pbkdf2 as _pbkdf2 } from './pbkdf2.js'; import { scrypt as _scrypt } from './scrypt.js'; import { bytesToHex, createView, hexToBytes, toBytes } from './utils.js'; // A tiny KDF for various applications like AES key-gen. // Uses HKDF in a non-standard way, so it's not "KDF-secure", only "PRF-secure". // Which is good enough: assume sha2-256 retained preimage resistance. const SCRYPT_FACTOR = 2 ** 19; const PBKDF2_FACTOR = 2 ** 17; // Scrypt KDF export function scrypt(password, salt) { return _scrypt(password, salt, { N: SCRYPT_FACTOR, r: 8, p: 1, dkLen: 32 }); } // PBKDF2-HMAC-SHA256 export function pbkdf2(password, salt) { return _pbkdf2(sha256, password, salt, { c: PBKDF2_FACTOR, dkLen: 32 }); } // Combines two 32-byte byte arrays function xor32(a, b) { assertBytes(a, 32); assertBytes(b, 32); const arr = new Uint8Array(32); for (let i = 0; i < 32; i++) { arr[i] = a[i] ^ b[i]; } return arr; } function strHasLength(str, min, max) { return typeof str === 'string' && str.length >= min && str.length <= max; } /** * Derives main seed. Takes a lot of time. Prefer `eskdf` method instead. */ export function deriveMainSeed(username, password) { if (!strHasLength(username, 8, 255)) throw new Error('invalid username'); if (!strHasLength(password, 8, 255)) throw new Error('invalid password'); const scr = scrypt(password + '\u{1}', username + '\u{1}'); const pbk = pbkdf2(password + '\u{2}', username + '\u{2}'); const res = xor32(scr, pbk); scr.fill(0); pbk.fill(0); return res; } /** * Converts protocol & accountId pair to HKDF salt & info params. */ function getSaltInfo(protocol, accountId = 0) { // Note that length here also repeats two lines below // We do an additional length check here to reduce the scope of DoS attacks if (!(strHasLength(protocol, 3, 15) && /^[a-z0-9]{3,15}$/.test(protocol))) { throw new Error('invalid protocol'); } // Allow string account ids for some protocols const allowsStr = /^password\d{0,3}|ssh|tor|file$/.test(protocol); let salt; // Extract salt. Default is undefined. if (typeof accountId === 'string') { if (!allowsStr) throw new Error('accountId must be a number'); if (!strHasLength(accountId, 1, 255)) throw new Error('accountId must be valid string'); salt = toBytes(accountId); } else if (Number.isSafeInteger(accountId)) { if (accountId < 0 || accountId > 2 ** 32 - 1) throw new Error('invalid accountId'); // Convert to Big Endian Uint32 salt = new Uint8Array(4); createView(salt).setUint32(0, accountId, false); } else { throw new Error(`accountId must be a number${allowsStr ? ' or string' : ''}`); } const info = toBytes(protocol); return { salt, info }; } function countBytes(num) { if (typeof num !== 'bigint' || num <= BigInt(128)) throw new Error('invalid number'); return Math.ceil(num.toString(2).length / 8); } /** * Parses keyLength and modulus options to extract length of result key. * If modulus is used, adds 64 bits to it as per FIPS 186 B.4.1 to combat modulo bias. */ function getKeyLength(options) { if (!options || typeof options !== 'object') return 32; const hasLen = 'keyLength' in options; const hasMod = 'modulus' in options; if (hasLen && hasMod) throw new Error('cannot combine keyLength and modulus options'); if (!hasLen && !hasMod) throw new Error('must have either keyLength or modulus option'); // FIPS 186 B.4.1 requires at least 64 more bits const l = hasMod ? countBytes(options.modulus) + 8 : options.keyLength; if (!(typeof l === 'number' && l >= 16 && l <= 8192)) throw new Error('invalid keyLength'); return l; } /** * Converts key to bigint and divides it by modulus. Big Endian. * Implements FIPS 186 B.4.1, which removes 0 and modulo bias from output. */ function modReduceKey(key, modulus) { const _1 = BigInt(1); const num = BigInt('0x' + bytesToHex(key)); // check for ui8a, then bytesToNumber() const res = (num % (modulus - _1)) + _1; // Remove 0 from output if (res < _1) throw new Error('expected positive number'); // Guard against bad values const len = key.length - 8; // FIPS requires 64 more bits = 8 bytes const hex = res.toString(16).padStart(len * 2, '0'); // numberToHex() const bytes = hexToBytes(hex); if (bytes.length !== len) throw new Error('invalid length of result key'); return bytes; } /** * ESKDF * @param username - username, email, or identifier, min: 8 characters, should have enough entropy * @param password - password, min: 8 characters, should have enough entropy * @example * const kdf = await eskdf('example-university', 'beginning-new-example'); * const key = kdf.deriveChildKey('aes', 0); * console.log(kdf.fingerprint); * kdf.expire(); */ export async function eskdf(username, password) { // We are using closure + object instead of class because // we want to make `seed` non-accessible for any external function. let seed = deriveMainSeed(username, password); function deriveCK(protocol, accountId = 0, options) { assertBytes(seed, 32); const { salt, info } = getSaltInfo(protocol, accountId); // validate protocol & accountId const keyLength = getKeyLength(options); // validate options const key = hkdf(sha256, seed, salt, info, keyLength); // Modulus has already been validated return options && 'modulus' in options ? modReduceKey(key, options.modulus) : key; } function expire() { if (seed) seed.fill(1); seed = undefined; } // prettier-ignore const fingerprint = Array.from(deriveCK('fingerprint', 0)) .slice(0, 6) .map((char) => char.toString(16).padStart(2, '0').toUpperCase()) .join(':'); return Object.freeze({ deriveChildKey: deriveCK, expire, fingerprint }); } //# sourceMappingURL=eskdf.js.map