forked from markusberg/unixcrypt
-
Notifications
You must be signed in to change notification settings - Fork 0
/
index.ts
353 lines (304 loc) · 8.16 KB
/
index.ts
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
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
import { createHash, timingSafeEqual } from "crypto"
import { Buffer } from "buffer"
interface Conf {
id: HashType
saltString: string
rounds: number
specifyRounds: boolean
}
enum HashType {
"sha256" = 5,
"sha512" = 6,
}
interface ShuffleMap {
sha256: number[]
sha512: number[]
}
const dictionary =
"./0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz"
// prettier-ignore
const shuffleMap: ShuffleMap = {
sha256: [
20, 10, 0,
11, 1, 21,
2, 22, 12,
23, 13, 3,
14, 4, 24,
5, 25, 15,
26, 16, 6,
17, 7, 27,
8, 28, 18,
29, 19, 9,
30, 31
],
sha512: [
42, 21, 0,
1, 43, 22,
23, 2, 44,
45, 24, 3,
4, 46, 25,
26, 5, 47,
48, 27, 6,
7, 49, 28,
29, 8, 50,
51, 30, 9,
10, 52, 31,
32, 11, 53,
54, 33, 12,
13, 55, 34,
35, 14, 56,
57, 36, 15,
16, 58, 37,
38, 17, 59,
60, 39, 18,
19, 61, 40,
41, 20, 62,
63,
]
};
const roundsDefault = 5000
/**
* Generate a random string
* @param length Length of salt
*/
function getRandomString(length: number): string {
var result = ""
for (let i = 0; i < length; i++) {
result += dictionary[Math.floor(Math.random() * dictionary.length)]
}
return result
}
/**
* Normalize salt for use with hash, for example: "$6$rounds=1234&saltsalt" or "$6$saltsalt"
* @param conf The separate parts of id, rounds, specifyRounds, and saltString
*/
function normalizeSalt(conf: Conf): string {
const parts = ["", conf.id]
if (conf.specifyRounds || conf.rounds !== roundsDefault) {
parts.push(`rounds=${conf.rounds}`)
}
parts.push(conf.saltString)
return parts.join("$")
}
/**
* Parse salt into pieces, performs sanity checks, and returns proper defaults for missing values
* @param salt Standard salt, "$6$rounds=1234$saltsalt", "$6$saltsalt", "$6", "$6$rounds=1234"
*/
function parseSalt(salt?: string): Conf {
const roundsMin = 1000
const roundsMax = 999999999
const conf: Conf = {
id: HashType.sha512,
saltString: getRandomString(16),
rounds: roundsDefault,
specifyRounds: false,
}
if (salt) {
const parts = salt.split("$")
conf.id = Number(parts[1])
if (conf.id !== HashType.sha256 && conf.id !== HashType.sha512) {
throw new Error("Only sha256 and sha512 is supported by this library")
}
if (parts.length < 2 || parts.length > 4) {
throw new Error("Invalid salt string")
} else if (parts.length > 2) {
const rounds = parts[2].match(/^rounds=(\d*)$/)
if (rounds) {
// number of rounds has been specified
conf.rounds = Number(rounds[1])
conf.specifyRounds = true
if (parts[3]) {
conf.saltString = parts[3]
}
} else {
// default number of rounds has already been set
conf.saltString = parts[2]
}
}
}
// sanity-check rounds
conf.rounds =
conf.rounds < roundsMin
? roundsMin
: conf.rounds > roundsMax
? /* istanbul ignore next */
(conf.rounds = roundsMax)
: conf.rounds
// sanity-check saltString
conf.saltString = conf.saltString.substr(0, 16)
if (conf.saltString.match("[^./0-9A-Za-z=+]")) {
throw new Error("Invalid salt string")
}
return conf
}
/**
* Steps 1-12 in the spec
* @param plaintext
* @param conf
*/
function generateDigestA(plaintext: string, conf: Conf): Buffer {
const digestSize = conf.id === HashType.sha256 ? 32 : 64
// steps 1-8
const hashA = createHash(HashType[conf.id])
hashA.update(plaintext)
hashA.update(conf.saltString)
const hashB = createHash(HashType[conf.id])
hashB.update(plaintext)
hashB.update(conf.saltString)
hashB.update(plaintext)
const digestB = hashB.digest()
// step 9
const plaintextByteLength = Buffer.byteLength(plaintext)
for (
let offset = 0;
offset + digestSize < plaintextByteLength;
offset += digestSize
) {
hashA.update(digestB)
}
// step 10
const remainder = plaintextByteLength % digestSize
hashA.update(digestB.slice(0, remainder))
// step 11
plaintextByteLength
.toString(2)
.split("")
.reverse()
.forEach((num) => {
hashA.update(num === "0" ? plaintext : digestB)
})
// step 12
return hashA.digest()
}
function generateHash(plaintext: string, conf: Conf) {
const digestSize = conf.id === HashType.sha256 ? 32 : 64
const hashType = HashType[conf.id]
// steps 1-12
const digestA = generateDigestA(plaintext, conf)
// steps 13-15
const plaintextByteLength = Buffer.byteLength(plaintext)
const hashDP = createHash(hashType)
for (let i = 0; i < plaintextByteLength; i++) {
hashDP.update(plaintext)
}
const digestDP = hashDP.digest()
// step 16a
const p = Buffer.alloc(plaintextByteLength)
for (
let offset = 0;
offset + digestSize < plaintextByteLength;
offset += digestSize
) {
p.set(digestDP, offset)
}
// step 16b
const remainder = plaintextByteLength % digestSize
p.set(digestDP.slice(0, remainder), plaintextByteLength - remainder)
// step 17-19
const hashDS = createHash(hashType)
const step18 = 16 + digestA[0]
for (let i = 0; i < step18; i++) {
hashDS.update(conf.saltString)
}
const digestDS = hashDS.digest()
// step 20
const s = Buffer.alloc(conf.saltString.length)
// step 20a
// Isn't this step redundant? The salt string doesn't have 32 or 64 bytes. It's truncated to 16 characters
const saltByteLength = Buffer.byteLength(conf.saltString)
for (
let offset = 0;
offset + digestSize < saltByteLength;
offset += digestSize
) {
/* istanbul ignore next */
s.set(digestDS, offset)
}
// step 20b
const saltRemainder = saltByteLength % digestSize
s.set(digestDS.slice(0, saltRemainder), saltByteLength - saltRemainder)
// step 21
const rounds = Array(conf.rounds).fill(0)
const digestC: Buffer = rounds.reduce((acc, curr, idx) => {
const hashC = createHash(hashType)
// steps b-c
if (idx % 2 === 0) {
hashC.update(acc)
} else {
hashC.update(p)
}
// step d
if (idx % 3 !== 0) {
hashC.update(s)
}
// step e
if (idx % 7 !== 0) {
hashC.update(p)
}
// steps f-g
if (idx % 2 !== 0) {
hashC.update(acc)
} else {
hashC.update(p)
}
return hashC.digest()
}, digestA)
// step 22
return base64Encode(digestC, (<any>shuffleMap)[hashType])
}
function base64Encode(digest: Buffer, shuffleMap: number[]): string {
let hash = ""
for (let idx = 0; idx < digest.length; idx += 3) {
const buf = Buffer.alloc(3)
buf[0] = digest[shuffleMap[idx]]
buf[1] = digest[shuffleMap[idx + 1]]
buf[2] = digest[shuffleMap[idx + 2]]
hash += bufferToBase64(buf)
}
// adjust hash length by stripping trailing zeroes induced by base64-encoding
return hash.slice(0, digest.length === 32 ? -1 : -2)
}
/**
* Encode buffer to base64 using our dictionary
* @param buf Buffer of bytes to be encoded
*/
function bufferToBase64(buf: Buffer): string {
const first = buf[0] & parseInt("00111111", 2)
const second =
((buf[0] & parseInt("11000000", 2)) >>> 6) |
((buf[1] & parseInt("00001111", 2)) << 2)
const third =
((buf[1] & parseInt("11110000", 2)) >>> 4) |
((buf[2] & parseInt("00000011", 2)) << 4)
const fourth = (buf[2] & parseInt("11111100", 2)) >>> 2
return (
dictionary.charAt(first) +
dictionary.charAt(second) +
dictionary.charAt(third) +
dictionary.charAt(fourth)
)
}
/**
* Create sha256 or sha512 crypt of plaintext password
* @param plaintext The plaintext password
* @param salt optional salt, for example "$6$salt" or "$6$rounds=10000$salt"
*/
function encrypt(plaintext: string, salt?: string) {
const conf = parseSalt(salt)
const hash = generateHash(plaintext, conf)
return normalizeSalt(conf) + "$" + hash
}
/**
* Verify plaintext password against expected hash
* @param plaintext The plaintext password
* @param hash The expected hash
*/
function verify(plaintext: string, hash: string): boolean {
const salt = hash.slice(0, hash.lastIndexOf("$"))
const computedHash = encrypt(plaintext, salt)
return timingSafeEqual(
Buffer.from(computedHash, "utf8"),
Buffer.from(hash, "utf8"),
)
}
export { encrypt, verify }