184 lines
7.0 KiB
Plaintext
184 lines
7.0 KiB
Plaintext
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: string, salt: string): Uint8Array {
|
|
return _scrypt(password, salt, { N: SCRYPT_FACTOR, r: 8, p: 1, dkLen: 32 });
|
|
}
|
|
|
|
// PBKDF2-HMAC-SHA256
|
|
export function pbkdf2(password: string, salt: string): Uint8Array {
|
|
return _pbkdf2(sha256, password, salt, { c: PBKDF2_FACTOR, dkLen: 32 });
|
|
}
|
|
|
|
// Combines two 32-byte byte arrays
|
|
function xor32(a: Uint8Array, b: Uint8Array): Uint8Array {
|
|
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: string, min: number, max: number): boolean {
|
|
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: string, password: string): Uint8Array {
|
|
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;
|
|
}
|
|
|
|
type AccountID = number | string;
|
|
|
|
/**
|
|
* Converts protocol & accountId pair to HKDF salt & info params.
|
|
*/
|
|
function getSaltInfo(protocol: string, accountId: 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: Uint8Array; // 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 };
|
|
}
|
|
|
|
type OptsLength = { keyLength: number };
|
|
type OptsMod = { modulus: bigint };
|
|
type KeyOpts = undefined | OptsLength | OptsMod;
|
|
|
|
function countBytes(num: bigint): number {
|
|
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: KeyOpts): number {
|
|
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: Uint8Array, modulus: bigint): Uint8Array {
|
|
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;
|
|
}
|
|
|
|
// We are not using classes because constructor cannot be async
|
|
type ESKDF = Promise<
|
|
Readonly<{
|
|
/**
|
|
* Derives a child key. Child key will not be associated with any
|
|
* other child key because of properties of underlying KDF.
|
|
*
|
|
* @param protocol - 3-15 character protocol name
|
|
* @param accountId - numeric identifier of account
|
|
* @param options - `keyLength: 64` or `modulus: 41920438n`
|
|
* @example deriveChildKey('aes', 0)
|
|
*/
|
|
deriveChildKey: (protocol: string, accountId: AccountID, options?: KeyOpts) => Uint8Array;
|
|
/**
|
|
* Deletes the main seed from eskdf instance
|
|
*/
|
|
expire: () => void;
|
|
/**
|
|
* Account fingerprint
|
|
*/
|
|
fingerprint: string;
|
|
}>
|
|
>;
|
|
|
|
/**
|
|
* 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: string, password: string): ESKDF {
|
|
// We are using closure + object instead of class because
|
|
// we want to make `seed` non-accessible for any external function.
|
|
let seed: Uint8Array | undefined = deriveMainSeed(username, password);
|
|
|
|
function deriveCK(protocol: string, accountId: AccountID = 0, options?: KeyOpts): Uint8Array {
|
|
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 });
|
|
}
|