eskdf.ts 7.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183
  1. import { bytes as assertBytes } from './_assert.js';
  2. import { hkdf } from './hkdf.js';
  3. import { sha256 } from './sha256.js';
  4. import { pbkdf2 as _pbkdf2 } from './pbkdf2.js';
  5. import { scrypt as _scrypt } from './scrypt.js';
  6. import { bytesToHex, createView, hexToBytes, toBytes } from './utils.js';
  7. // A tiny KDF for various applications like AES key-gen.
  8. // Uses HKDF in a non-standard way, so it's not "KDF-secure", only "PRF-secure".
  9. // Which is good enough: assume sha2-256 retained preimage resistance.
  10. const SCRYPT_FACTOR = 2 ** 19;
  11. const PBKDF2_FACTOR = 2 ** 17;
  12. // Scrypt KDF
  13. export function scrypt(password: string, salt: string): Uint8Array {
  14. return _scrypt(password, salt, { N: SCRYPT_FACTOR, r: 8, p: 1, dkLen: 32 });
  15. }
  16. // PBKDF2-HMAC-SHA256
  17. export function pbkdf2(password: string, salt: string): Uint8Array {
  18. return _pbkdf2(sha256, password, salt, { c: PBKDF2_FACTOR, dkLen: 32 });
  19. }
  20. // Combines two 32-byte byte arrays
  21. function xor32(a: Uint8Array, b: Uint8Array): Uint8Array {
  22. assertBytes(a, 32);
  23. assertBytes(b, 32);
  24. const arr = new Uint8Array(32);
  25. for (let i = 0; i < 32; i++) {
  26. arr[i] = a[i] ^ b[i];
  27. }
  28. return arr;
  29. }
  30. function strHasLength(str: string, min: number, max: number): boolean {
  31. return typeof str === 'string' && str.length >= min && str.length <= max;
  32. }
  33. /**
  34. * Derives main seed. Takes a lot of time. Prefer `eskdf` method instead.
  35. */
  36. export function deriveMainSeed(username: string, password: string): Uint8Array {
  37. if (!strHasLength(username, 8, 255)) throw new Error('invalid username');
  38. if (!strHasLength(password, 8, 255)) throw new Error('invalid password');
  39. const scr = scrypt(password + '\u{1}', username + '\u{1}');
  40. const pbk = pbkdf2(password + '\u{2}', username + '\u{2}');
  41. const res = xor32(scr, pbk);
  42. scr.fill(0);
  43. pbk.fill(0);
  44. return res;
  45. }
  46. type AccountID = number | string;
  47. /**
  48. * Converts protocol & accountId pair to HKDF salt & info params.
  49. */
  50. function getSaltInfo(protocol: string, accountId: AccountID = 0) {
  51. // Note that length here also repeats two lines below
  52. // We do an additional length check here to reduce the scope of DoS attacks
  53. if (!(strHasLength(protocol, 3, 15) && /^[a-z0-9]{3,15}$/.test(protocol))) {
  54. throw new Error('invalid protocol');
  55. }
  56. // Allow string account ids for some protocols
  57. const allowsStr = /^password\d{0,3}|ssh|tor|file$/.test(protocol);
  58. let salt: Uint8Array; // Extract salt. Default is undefined.
  59. if (typeof accountId === 'string') {
  60. if (!allowsStr) throw new Error('accountId must be a number');
  61. if (!strHasLength(accountId, 1, 255)) throw new Error('accountId must be valid string');
  62. salt = toBytes(accountId);
  63. } else if (Number.isSafeInteger(accountId)) {
  64. if (accountId < 0 || accountId > 2 ** 32 - 1) throw new Error('invalid accountId');
  65. // Convert to Big Endian Uint32
  66. salt = new Uint8Array(4);
  67. createView(salt).setUint32(0, accountId, false);
  68. } else {
  69. throw new Error(`accountId must be a number${allowsStr ? ' or string' : ''}`);
  70. }
  71. const info = toBytes(protocol);
  72. return { salt, info };
  73. }
  74. type OptsLength = { keyLength: number };
  75. type OptsMod = { modulus: bigint };
  76. type KeyOpts = undefined | OptsLength | OptsMod;
  77. function countBytes(num: bigint): number {
  78. if (typeof num !== 'bigint' || num <= BigInt(128)) throw new Error('invalid number');
  79. return Math.ceil(num.toString(2).length / 8);
  80. }
  81. /**
  82. * Parses keyLength and modulus options to extract length of result key.
  83. * If modulus is used, adds 64 bits to it as per FIPS 186 B.4.1 to combat modulo bias.
  84. */
  85. function getKeyLength(options: KeyOpts): number {
  86. if (!options || typeof options !== 'object') return 32;
  87. const hasLen = 'keyLength' in options;
  88. const hasMod = 'modulus' in options;
  89. if (hasLen && hasMod) throw new Error('cannot combine keyLength and modulus options');
  90. if (!hasLen && !hasMod) throw new Error('must have either keyLength or modulus option');
  91. // FIPS 186 B.4.1 requires at least 64 more bits
  92. const l = hasMod ? countBytes(options.modulus) + 8 : options.keyLength;
  93. if (!(typeof l === 'number' && l >= 16 && l <= 8192)) throw new Error('invalid keyLength');
  94. return l;
  95. }
  96. /**
  97. * Converts key to bigint and divides it by modulus. Big Endian.
  98. * Implements FIPS 186 B.4.1, which removes 0 and modulo bias from output.
  99. */
  100. function modReduceKey(key: Uint8Array, modulus: bigint): Uint8Array {
  101. const _1 = BigInt(1);
  102. const num = BigInt('0x' + bytesToHex(key)); // check for ui8a, then bytesToNumber()
  103. const res = (num % (modulus - _1)) + _1; // Remove 0 from output
  104. if (res < _1) throw new Error('expected positive number'); // Guard against bad values
  105. const len = key.length - 8; // FIPS requires 64 more bits = 8 bytes
  106. const hex = res.toString(16).padStart(len * 2, '0'); // numberToHex()
  107. const bytes = hexToBytes(hex);
  108. if (bytes.length !== len) throw new Error('invalid length of result key');
  109. return bytes;
  110. }
  111. // We are not using classes because constructor cannot be async
  112. type ESKDF = Promise<
  113. Readonly<{
  114. /**
  115. * Derives a child key. Child key will not be associated with any
  116. * other child key because of properties of underlying KDF.
  117. *
  118. * @param protocol - 3-15 character protocol name
  119. * @param accountId - numeric identifier of account
  120. * @param options - `keyLength: 64` or `modulus: 41920438n`
  121. * @example deriveChildKey('aes', 0)
  122. */
  123. deriveChildKey: (protocol: string, accountId: AccountID, options?: KeyOpts) => Uint8Array;
  124. /**
  125. * Deletes the main seed from eskdf instance
  126. */
  127. expire: () => void;
  128. /**
  129. * Account fingerprint
  130. */
  131. fingerprint: string;
  132. }>
  133. >;
  134. /**
  135. * ESKDF
  136. * @param username - username, email, or identifier, min: 8 characters, should have enough entropy
  137. * @param password - password, min: 8 characters, should have enough entropy
  138. * @example
  139. * const kdf = await eskdf('example-university', 'beginning-new-example');
  140. * const key = kdf.deriveChildKey('aes', 0);
  141. * console.log(kdf.fingerprint);
  142. * kdf.expire();
  143. */
  144. export async function eskdf(username: string, password: string): ESKDF {
  145. // We are using closure + object instead of class because
  146. // we want to make `seed` non-accessible for any external function.
  147. let seed: Uint8Array | undefined = deriveMainSeed(username, password);
  148. function deriveCK(protocol: string, accountId: AccountID = 0, options?: KeyOpts): Uint8Array {
  149. assertBytes(seed, 32);
  150. const { salt, info } = getSaltInfo(protocol, accountId); // validate protocol & accountId
  151. const keyLength = getKeyLength(options); // validate options
  152. const key = hkdf(sha256, seed!, salt, info, keyLength);
  153. // Modulus has already been validated
  154. return options && 'modulus' in options ? modReduceKey(key, options.modulus) : key;
  155. }
  156. function expire() {
  157. if (seed) seed.fill(1);
  158. seed = undefined;
  159. }
  160. // prettier-ignore
  161. const fingerprint = Array.from(deriveCK('fingerprint', 0))
  162. .slice(0, 6)
  163. .map((char) => char.toString(16).padStart(2, '0').toUpperCase())
  164. .join(':');
  165. return Object.freeze({ deriveChildKey: deriveCK, expire, fingerprint });
  166. }