Skip to content
This repository was archived by the owner on Apr 3, 2019. It is now read-only.

Commit 2ca7d9f

Browse files
authored
fix(codes): update recovery code requirements (#333), r=@philbooth
1 parent 5a1535a commit 2ca7d9f

File tree

11 files changed

+896
-270
lines changed

11 files changed

+896
-270
lines changed

config/config.js

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -168,6 +168,20 @@ module.exports = function (fs, path, url, convict) {
168168
default: '',
169169
format: 'String',
170170
env: 'SENTRY_DSN'
171+
},
172+
recoveryCodes: {
173+
keyspace: {
174+
doc: 'Characters allowed in a recovery code',
175+
default: 'abcdefghijklmnopqrstuvwxyz0123456789',
176+
format: 'String',
177+
env: 'RECOVERY_CODE_KEYSPACE'
178+
},
179+
length: {
180+
doc: 'The length of a recovery code',
181+
default: 10,
182+
format: 'nat',
183+
env: 'RECOVERY_CODE_LENGTH'
184+
}
171185
}
172186
})
173187

db-server/test/backend/db_tests.js

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2058,11 +2058,16 @@ module.exports = function (config, DB) {
20582058
})
20592059

20602060
const codeLengthTest = [0, 4, 8]
2061+
const codeTest = /^[a-zA-Z0-9]{0,20}$/
20612062
codeLengthTest.forEach((num) => {
20622063
it('should generate ' + num + ' recovery codes', () => {
20632064
return db.replaceRecoveryCodes(account.uid, num)
20642065
.then((codes) => {
20652066
assert.equal(codes.length, num, 'correct number of codes')
2067+
codes.forEach((code) => {
2068+
assert.equal(codeTest.test(code), true, 'matches recovery code format')
2069+
})
2070+
assert.equal()
20662071
}, (err) => {
20672072
assert.equal(err.errno, 116, 'correct errno, not found')
20682073
})

lib/db/mem.js

Lines changed: 34 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,9 @@ const SESSION_DEVICE_FIELDS = [
5050
'lastAccessTime'
5151
]
5252

53+
const RECOVERY_CODE_KEYSPACE = config.recoveryCodes.keyspace
54+
const RECOVERY_CODE_LENGTH = config.recoveryCodes.length
55+
5356
module.exports = function (log, error) {
5457

5558
function Memory(db) {}
@@ -1307,24 +1310,29 @@ module.exports = function (log, error) {
13071310

13081311
Memory.prototype.replaceRecoveryCodes = function (uid, count) {
13091312
uid = uid.toString('hex')
1313+
let codes = []
13101314
return getAccountByUid(uid)
13111315
.then(() => {
1312-
return dbUtil.generateRecoveryCodes(count)
1313-
.then((codes) => {
1314-
recoveryCodes[uid] = codes.map((code) => {
1315-
return {
1316-
codeHash: dbUtil.createHashSha512(code)
1317-
}
1318-
})
1319-
return codes
1320-
})
1316+
return dbUtil.generateRecoveryCodes(count, RECOVERY_CODE_KEYSPACE, RECOVERY_CODE_LENGTH)
1317+
})
1318+
.then((result) => {
1319+
codes = result
1320+
return codes.map((code) => dbUtil.createHashScrypt(code))
1321+
})
1322+
.all()
1323+
.then((hashes) => {
1324+
recoveryCodes[uid] = hashes.map((value) => {
1325+
return {
1326+
codeHash: value.hash,
1327+
salt: value.salt
1328+
}
1329+
})
1330+
return codes
13211331
})
13221332
}
13231333

13241334
Memory.prototype.consumeRecoveryCode = function (uid, code) {
13251335
uid = uid.toString('hex')
1326-
const codeHash = dbUtil.createHashSha512(code).toString('hex')
1327-
13281336
return getAccountByUid(uid)
13291337
.then(() => {
13301338
const codes = recoveryCodes[uid]
@@ -1333,23 +1341,24 @@ module.exports = function (log, error) {
13331341
throw error.notFound()
13341342
}
13351343

1336-
let foundCode, foundIndex
1337-
for (let i = 0; i < codes.length; i++) {
1338-
const code = codes[i]
1339-
if (codeHash === code.codeHash.toString('hex')) {
1340-
foundCode = code
1341-
foundIndex = i
1342-
break
1343-
}
1344-
}
1344+
const hashes = codes.map((item, index) => {
1345+
return dbUtil.compareHashScrypt(code, item.codeHash, item.salt)
1346+
.then((equals) => {
1347+
return {index, equals}
1348+
})
1349+
})
13451350

1346-
if (! foundCode) {
1347-
throw error.notFound()
1348-
}
1351+
// Filter to only matching hashes
1352+
return P.filter(hashes, item => item.equals)
1353+
.then((result) => {
1354+
if (result.length === 0) {
1355+
throw error.notFound()
1356+
}
13491357

1350-
codes.splice(foundIndex, 1)
1358+
codes.splice(result[0].index, 1)
13511359

1352-
return {createdAt: foundCode.createdAt, remaining: codes.length}
1360+
return {remaining: codes.length}
1361+
})
13531362
})
13541363
}
13551364

lib/db/mysql.js

Lines changed: 44 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ const P = require('../promise')
1111

1212
const patch = require('./patch')
1313
const dbUtil = require('./util')
14+
const config = require('../../config')
1415

1516
const REQUIRED_CHARSET = 'UTF8MB4_BIN'
1617
const DATABASE_NAME = require('../constants').DATABASE_NAME
@@ -32,6 +33,10 @@ const ER_LOCK_ABORTED = 1689
3233
const ER_DELETE_PRIMARY_EMAIL = 2100
3334
const ER_EXPIRED_TOKEN_VERIFICATION_CODE = 2101
3435

36+
37+
const RECOVERY_CODE_KEYSPACE = config.recoveryCodes.keyspace
38+
const RECOVERY_CODE_LENGTH = config.recoveryCodes.length
39+
3540
module.exports = function (log, error) {
3641

3742
var LOCK_ERRNOS = [
@@ -1451,34 +1456,30 @@ module.exports = function (log, error) {
14511456
}
14521457

14531458
const DELETE_RECOVERY_CODES = 'CALL deleteRecoveryCodes_1(?)'
1454-
const INSERT_RECOVERY_CODE = 'CALL createRecoveryCode_1(?, ?)'
1459+
const INSERT_RECOVERY_CODE = 'CALL createRecoveryCode_2(?, ?, ?)'
14551460
MySql.prototype.replaceRecoveryCodes = function (uid, count) {
14561461

14571462
// Because of the hashing requirements the process of replacing
14581463
// recovery codes is done is two separate procedures. First one
14591464
// deletes all current codes and the second one inserts the
14601465
// hashed randomly generated codes.
1461-
return dbUtil.generateRecoveryCodes(count)
1462-
.then((codeList) => {
1466+
return dbUtil.generateRecoveryCodes(count, RECOVERY_CODE_KEYSPACE, RECOVERY_CODE_LENGTH)
1467+
.then((codes) => {
14631468
return this.read(DELETE_RECOVERY_CODES, [uid])
1464-
.then(() => {
1465-
if (codeList <= 0) {
1466-
return P.resolve([])
1467-
}
1468-
1469+
.then(() => codes.map((code) => dbUtil.createHashScrypt(code)))
1470+
.all()
1471+
.then((items) => {
14691472
const queries = []
1470-
codeList.forEach((code) => {
1473+
items.forEach((item) => {
14711474
queries.push({
14721475
sql: INSERT_RECOVERY_CODE,
1473-
params: [uid, dbUtil.createHashSha512(code)]
1476+
params: [uid, item.hash, item.salt]
14741477
})
14751478
})
14761479

14771480
return this.writeMultiple(queries)
14781481
})
1479-
.then(() => {
1480-
return P.resolve(codeList)
1481-
})
1482+
.then(() => codes)
14821483
.catch((err) => {
14831484
if (err.errno === 1643) {
14841485
throw error.notFound()
@@ -1490,9 +1491,36 @@ module.exports = function (log, error) {
14901491
})
14911492
}
14921493

1493-
const CONSUME_RECOVERY_CODE = 'CALL consumeRecoveryCode_1(?, ?)'
1494-
MySql.prototype.consumeRecoveryCode = function (uid, code) {
1495-
return this.readFirstResult(CONSUME_RECOVERY_CODE, [uid, dbUtil.createHashSha512(code)])
1494+
const CONSUME_RECOVERY_CODE = 'CALL consumeRecoveryCode_2(?, ?)'
1495+
const RECOVERY_CODES = 'CALL recoveryCodes_1(?)'
1496+
MySql.prototype.consumeRecoveryCode = function (uid, submittedCode) {
1497+
// Consuming a recovery code is done in a two step process because
1498+
// the stored scrypt hash will need to be calculated against the recovery
1499+
// code salt.
1500+
return this.readOneFromFirstResult(RECOVERY_CODES, [uid])
1501+
.then((results) => {
1502+
// Throw if this user has no recovery codes
1503+
if (results.length === 0) {
1504+
throw error.notFound()
1505+
}
1506+
1507+
const compareResults = results.map((code) => {
1508+
return dbUtil.compareHashScrypt(submittedCode, code.codeHash, code.salt)
1509+
.then((equals) => {
1510+
return {code, equals}
1511+
})
1512+
})
1513+
1514+
// Filter only matching code
1515+
return P.filter(compareResults, result => result.equals)
1516+
.map((result) => result.code)
1517+
})
1518+
.then((result) => {
1519+
if (result.length === 0) {
1520+
throw error.notFound()
1521+
}
1522+
return this.readFirstResult(CONSUME_RECOVERY_CODE, [uid, result[0].codeHash])
1523+
})
14961524
.then((result) => {
14971525
return P.resolve({
14981526
remaining: result.count

lib/db/patch.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,4 +4,4 @@
44

55
// The expected patch level of the database. Update if you add a new
66
// patch in the ./schema/ directory.
7-
module.exports.level = 78
7+
module.exports.level = 79

lib/db/schema/patch-078-079.sql

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
SET NAMES utf8mb4 COLLATE utf8mb4_bin;
2+
3+
ALTER TABLE recoveryCodes MODIFY COLUMN codeHash BINARY(32);
4+
5+
ALTER TABLE recoveryCodes ADD COLUMN salt BINARY(32);
6+
7+
CREATE PROCEDURE `recoveryCodes_1` (
8+
IN `uidArg` BINARY(16)
9+
)
10+
BEGIN
11+
12+
SELECT * FROM recoveryCodes WHERE uid = uidArg;
13+
14+
END;
15+
16+
CREATE PROCEDURE `consumeRecoveryCode_2` (
17+
IN `uidArg` BINARY(16),
18+
IN `codeHashArg` BINARY(32)
19+
)
20+
BEGIN
21+
DECLARE EXIT HANDLER FOR SQLEXCEPTION
22+
BEGIN
23+
ROLLBACK;
24+
RESIGNAL;
25+
END;
26+
27+
START TRANSACTION;
28+
29+
SET @deletedCount = 0;
30+
31+
DELETE FROM `recoveryCodes` WHERE `uid` = `uidArg` AND `codeHash` = `codeHashArg`;
32+
33+
SELECT ROW_COUNT() INTO @deletedCount;
34+
IF @deletedCount = 0 THEN
35+
SIGNAL SQLSTATE '45000' SET MYSQL_ERRNO = 1643, MESSAGE_TEXT = 'Unknown recovery code.';
36+
END IF;
37+
38+
SELECT COUNT(*) AS count FROM `recoveryCodes` WHERE `uid` = `uidArg`;
39+
40+
COMMIT;
41+
END;
42+
43+
CREATE PROCEDURE `createRecoveryCode_2` (
44+
IN `uidArg` BINARY(16),
45+
IN `codeHashArg` BINARY(32),
46+
IN `saltArg` BINARY(32)
47+
)
48+
BEGIN
49+
50+
INSERT INTO recoveryCodes (uid, codeHash, salt, createdAt) VALUES (uidArg, codeHashArg, saltArg, NOW());
51+
52+
END;
53+
54+
UPDATE dbMetadata SET value = '79' WHERE name = 'schema-patch-level';
55+

lib/db/schema/patch-079-078.sql

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
-- SET NAMES utf8mb4 COLLATE utf8mb4_bin;
2+
3+
-- ALTER TABLE recoveryCodes MODIFY COLUMN codeHash BINARY(64),
4+
-- ALGORITHM = COPY, LOCK = SHARED;
5+
-- ALTER TABLE recoveryCodes DROP COLUMN salt BINARY(32),
6+
-- ALGORITHM = COPY, LOCK = SHARED;
7+
-- DROP recoveryCodes_1;
8+
-- DROP consumeRecoveryCode_2;
9+
-- DROP createRecoveryCode_2;
10+
11+
-- UPDATE dbMetadata SET value = '78' WHERE name = 'schema-patch-level';
12+

lib/db/util.js

Lines changed: 30 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
const crypto = require('crypto')
88
const P = require('../promise')
99
const randomBytes = P.promisify(require('crypto').randomBytes)
10+
const scryptHash = P.promisify(require('scrypt-hash'))
1011

1112
const BOUNCE_TYPES = new Map([
1213
['__fxa__unmapped', 0], // a bounce type we don't yet recognize
@@ -93,25 +94,44 @@ module.exports = {
9394
return hash.digest()
9495
},
9596

96-
createHashSha512 () {
97-
const hash = crypto.createHash('sha512')
98-
const args = [...arguments]
99-
args.forEach((arg) => {
100-
hash.update(arg)
101-
})
102-
return hash.digest()
97+
createHashScrypt(input) {
98+
// Creates an scrypt hash from string input with a randomly generated
99+
// salt. This matches process on auth-server.
100+
let salt
101+
return randomBytes(32)
102+
.then((result) => {
103+
salt = result
104+
const inputBuffer = Buffer.from(input)
105+
return scryptHash(inputBuffer, salt, 65536, 8, 1, 32)
106+
.then((hash) => {
107+
return {hash, salt}
108+
})
109+
})
110+
},
111+
112+
compareHashScrypt(input, verifyHash, salt) {
113+
const inputBuffer = Buffer.from(input)
114+
return scryptHash(inputBuffer, salt, 65536, 8, 1, 32)
115+
.then((hash) => crypto.timingSafeEqual(hash, verifyHash))
103116
},
104117

105-
generateRecoveryCodes(count) {
118+
generateRecoveryCodes(count, keyspace, length) {
106119
const randomByteCodes = []
107120
for (let i = 0; i < count; i++) {
108-
randomByteCodes.push(randomBytes(4))
121+
randomByteCodes.push(randomBytes(length))
109122
}
110123

111124
return P.all(randomByteCodes)
112125
.then((result) => {
113126
return result.map((randomCode) => {
114-
return randomCode.toString('hex')
127+
const charsLength = keyspace.length
128+
const result = []
129+
let currentIndex = 0
130+
for (let i = 0; i < 10; i++) {
131+
currentIndex += randomCode[i]
132+
result[i] = keyspace[currentIndex % charsLength]
133+
}
134+
return result.join('')
115135
})
116136
})
117137
}

0 commit comments

Comments
 (0)