File size: 7,371 Bytes
2a1c46d
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
/**
 * Experimental KDF for AES.
 */
import { hkdf } from './hkdf.ts';
import { pbkdf2 as _pbkdf2 } from './pbkdf2.ts';
import { scrypt as _scrypt } from './scrypt.ts';
import { sha256 } from './sha256.ts';
import { abytes, bytesToHex, clean, createView, hexToBytes, kdfInputToBytes } from './utils.ts';

// 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 {
  abytes(a, 32);
  abytes(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');
  // Declared like this to throw off minifiers which auto-convert .fromCharCode(1) to actual string.
  // String with non-ascii may be problematic in some envs
  const codes = { _1: 1, _2: 2 };
  const sep = { s: String.fromCharCode(codes._1), p: String.fromCharCode(codes._2) };
  const scr = scrypt(password + sep.s, username + sep.s);
  const pbk = pbkdf2(password + sep.p, username + sep.p);
  const res = xor32(scr, pbk);
  clean(scr, pbk);
  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 string of length 1..255');
    salt = kdfInputToBytes(accountId);
  } else if (Number.isSafeInteger(accountId)) {
    if (accountId < 0 || accountId > Math.pow(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 = kdfInputToBytes(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
export interface ESKDF {
  /**
   * 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): Promise<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 {
    abytes(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 });
}