Skip to content

Commit

Permalink
Add rescue codes support in case of TOTP lost
Browse files Browse the repository at this point in the history
  • Loading branch information
agix committed Jan 25, 2017
1 parent 11b29d3 commit fe2ebac
Show file tree
Hide file tree
Showing 8 changed files with 109 additions and 7 deletions.
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@
"node-couchdb": "^1.1.0",
"node-forge": "^0.6.46",
"redis": "^2.6.3",
"secure-compare": "^3.0.1",
"speakeasy": "^2.0.0"
},
"devDependencies": {
Expand Down
2 changes: 1 addition & 1 deletion src/config.json
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
"port": 3000,
"redisConnection": "redis://127.0.0.1:6379",
"couchDBName": "secretin",
"couchDBAuth": true,
"couchDBAuth": false,
"couchDBConnection": {
"host": "127.0.0.1",
"protocol": "http",
Expand Down
2 changes: 2 additions & 0 deletions src/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ import unshare from './routes/Unshare';
import share from './routes/Share';
import testTotp from './routes/TestTotp';
import reset from './routes/Reset';
import getRescueCodes from './routes/GetRescueCodes';

const app = express();
app.server = http.createServer(app);
Expand All @@ -46,6 +47,7 @@ initializeDb(config, (couchdb, redis) => {
app.use('/user', updateUser({ couchdb, redis }));
app.use('/protectKey', getProtectKey({ couchdb, redis }));
app.use('/activateTotp', activateTotp({ couchdb, redis }));
app.use('/rescueCodes', getRescueCodes({ couchdb, redis }));
app.use('/deactivateTotp', deactivateTotp({ couchdb, redis }));
app.use('/activateShortLogin', activateShortLogin({ couchdb, redis }));
app.use('/secret', getSecret({ couchdb, redis }));
Expand Down
1 change: 1 addition & 0 deletions src/routes/ActivateTotp.js
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ export default ({ couchdb }) => {
};
doc.user[req.params.name].pass.totp = true;
doc.user[req.params.name].seed = jsonBody.seed;
doc.user[req.params.name].rescueCodes = Utils.generateRescueCodes();
return couchdb.update(couchdb.databaseName, doc);
})
.then(() => {
Expand Down
7 changes: 6 additions & 1 deletion src/routes/GetProtectKey.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { Router } from 'express';
import forge from 'node-forge';
import compare from 'secure-compare';

import Console from '../console';
import Utils from '../utils';
Expand All @@ -25,6 +26,9 @@ export default ({ redis, couchdb }) => {
})
.then((rIsBruteforce) => {
isBruteforce = rIsBruteforce;
if (isBruteforce) {
return Promise.resolve();
}
const key = `protectKey_${req.params.name}_${req.params.deviceId}`;
return redis.hgetallAsync(key);
})
Expand All @@ -35,7 +39,8 @@ export default ({ redis, couchdb }) => {
const md = forge.md.sha256.create();
md.update(req.params.hash);

if (!content || isBruteforce || md.digest().toHex() !== content.hash) {
const validHash = compare(md.digest().toHex(), content.hash);
if (!content || isBruteforce || !validHash) {
if (!content) {
content = {
salt: forge.util.bytesToHex(forge.random.getBytesSync(32)),
Expand Down
50 changes: 50 additions & 0 deletions src/routes/GetRescueCodes.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
import { Router } from 'express';
import url from 'url';

import Console from '../console';
import Utils from '../utils';

export default ({ couchdb }) => {
const route = Router();
route.get('/:name', (req, res) => {
let rescueCodes;
Utils.checkSignature({
couchdb,
name: req.params.name,
sig: req.query.sig,
data: `${req.baseUrl}${url.parse(req.url).pathname}`,
})
.then((rawUser) => {
const user = rawUser.data;

if (user.pass.totp) {
/* Retrocompatibility */
if (typeof user.rescueCodes === 'undefined') {
const doc = {
_id: rawUser.id,
_rev: rawUser.rev,
user: {
[req.params.name]: rawUser.data,
},
};
rescueCodes = Utils.generateRescueCodes();
doc.user[req.params.name].rescueCodes = rescueCodes;
return couchdb.update(couchdb.databaseName, doc);
}
/* End retrocompatibility */
rescueCodes = user.rescueCodes;
} else {
rescueCodes = [];
}
return Promise.resolve();
})
.then(() => {
res.json(rescueCodes);
})
.catch((error) => {
Console.error(res, error);
});
});

return route;
};
40 changes: 35 additions & 5 deletions src/routes/GetUser.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { Router } from 'express';
import forge from 'node-forge';
import speakeasy from 'speakeasy';
import url from 'url';
import compare from 'secure-compare';

import Console from '../console';
import Utils from '../utils';
Expand All @@ -26,6 +27,8 @@ export default ({ redis, couchdb }) => {
route.get('/:name/:hash', (req, res) => {
let rawUser;
let submitUser;
let totpValid;
let isBruteforce;
Utils.userExists({ couchdb, name: req.params.name })
.then((user) => {
rawUser = user;
Expand All @@ -40,10 +43,14 @@ export default ({ redis, couchdb }) => {
}
return Utils.checkBruteforce({ redis, ip });
})
.then((isBruteforce) => {
.then((rIsBruteforce) => {
submitUser = rawUser.data;
isBruteforce = rIsBruteforce;
if (isBruteforce) {
return Promise.resolve();
}

let totpValid = true;
totpValid = true;
if (submitUser.pass.totp && req.params.hash !== 'undefined') {
totpValid = false;
const protectedSeed = Utils.hexStringToUint8Array(submitUser.seed);
Expand All @@ -54,13 +61,34 @@ export default ({ redis, couchdb }) => {
encoding: 'hex',
token: req.query.otp,
});
}
if (!totpValid && typeof submitUser.rescueCodes !== 'undefined' && submitUser.rescueCodes.shift() === parseInt(req.query.otp, 10)) {
totpValid = true;
const doc = {
_id: rawUser.id,
_rev: rawUser.rev,
user: {
[req.params.name]: rawUser.data,
},
};

doc.user[req.params.name].rescueCodes = submitUser.rescueCodes;

if (submitUser.rescueCodes.length === 0) {
submitUser.pass.totp = false;
doc.user[req.params.name].pass.totp = false;
delete doc.user[req.params.name].seed;
delete doc.user[req.params.name].rescueCodes;
}
return couchdb.update(couchdb.databaseName, doc);
}
}
return Promise.resolve();
}).then(() => {
const md = forge.md.sha256.create();
md.update(req.params.hash);

// if something goes wrong, send fake private key
if (!totpValid || isBruteforce || md.digest().toHex() !== submitUser.pass.hash) {
const validHash = compare(md.digest().toHex(), submitUser.pass.hash);
if (!totpValid || isBruteforce || !validHash) {
submitUser.privateKey = {
privateKey: forge.util.bytesToHex(forge.random.getBytesSync(3232)),
iv: forge.util.bytesToHex(forge.random.getBytesSync(16)),
Expand All @@ -75,6 +103,7 @@ export default ({ redis, couchdb }) => {
.then((allMetadatas) => {
submitUser.metadatas = allMetadatas;
delete submitUser.seed;
delete submitUser.rescueCodes;
delete submitUser.pass.hash;
res.json(submitUser);
})
Expand All @@ -99,6 +128,7 @@ export default ({ redis, couchdb }) => {
const user = rawUser.data;
user.metadatas = allMetadatas;
delete user.seed;
delete user.rescueCodes;
delete user.pass.hash;
res.json(user);
})
Expand Down
13 changes: 13 additions & 0 deletions src/utils.js
Original file line number Diff line number Diff line change
Expand Up @@ -169,6 +169,18 @@ function checkSignature({ couchdb, name, sig, data }) {
});
}

function generateRescueCodes() {
const rescueCodes = [];
const randomBytes = forge.random.getBytesSync(6 * 2);
let rescueCode = 0;
for (let i = 0; i < randomBytes.length; i += 2) {
// eslint-disable-next-line
rescueCode = randomBytes[i].charCodeAt(0) + (randomBytes[i + 1].charCodeAt(0) << 8);
rescueCodes.push(rescueCode);
}
return rescueCodes;
}

const Utils = {
userExists,
reason,
Expand All @@ -178,6 +190,7 @@ const Utils = {
xorSeed,
checkSignature,
secretExists,
generateRescueCodes,
};

export default Utils;

0 comments on commit fe2ebac

Please sign in to comment.