TOTP setup and revocation in settings

This commit is contained in:
yflory 2023-05-12 18:21:15 +02:00
parent bf548c1022
commit d789627920
10 changed files with 445 additions and 4 deletions

View File

@ -48,6 +48,11 @@ var decode32 = S => {
return decoded;
};
// XXX Decide expire time
// Allow user settings?
var EXPIRATION = 7 * 24 * 3600 * 1000; // Sessions are valid 7 days
var createJWT = function (Env, sessionId, publicKey, cb) {
JWT.sign({
// this is a custom JWT field (not a standard) - we include a reference to the session
@ -55,6 +60,7 @@ var createJWT = function (Env, sessionId, publicKey, cb) {
ref: sessionId,
// we specify in the token for what resource the token should be valid (their block's public key)
sub: Util.escapeKeyCharacters(publicKey),
exp: (+new Date()) + EXPIRATION
}, Env.bearerSecret, {
// token integrity is ensured with HMAC SHA512 with the server's bearerSecret
// clients can inspect token parameters, but cannot modify them
@ -336,3 +342,112 @@ So, we should:
});
};
// This command is somewhat simpler than TOTP_SETUP
// Revoke a client TOTP secret which will allow them to disable TOTP for a login block IFF:
// 1. That login block exists
// 2. That login block is protected by TOTP 2FA
// 3. They can produce a valid OTP for that block's TOTP secret
// 4. They can sign for the block's public key
const revoke = Commands.TOTP_REVOKE = function (Env, body, cb) {
var { publicKey, code } = body;
// they must provide a valid OTP code
if (!isValidOTP(code)) { return void cb('E_INVALID'); }
// they must provide a valid block public key
if (!isValidBlockId(publicKey)) { return void cb("INVALID_KEY"); }
var secret;
nThen(function (w) {
// check that there is an MFA configuration for the given account
MFA.read(Env, publicKey, w(function (err, content) {
if (err) {
w.abort();
Env.Log.error('TOTP_VALIDATE_MFA_READ', {
error: err,
publicKey: publicKey,
});
return void cb('NO_MFA_CONFIGURED');
}
var parsed = Util.tryParse(content);
if (!parsed) {
w.abort();
return void cb("INVALID_CONFIGURATION");
}
secret = parsed.secret;
}));
}).nThen(function () {
let decoded = decode32(secret);
if (!decoded) {
Env.Log.error("TOTP_VALIDATE_INVALID_SECRET", {
publicKey, // log the public key so the admin can investigate further
// don't log the problematic secret directly as
// logs are likely to be pasted in random places
});
return void cb("E_INVALID_SECRET");
}
// validate the code
var validated = OTP.totp.verify(code, decoded, {
window: 1,
});
if (!validated) {
// I won't worry about logging these OTPs as they shouldn't leak any useful information
Env.Log.error("TOTP_VALIDATE_BAD_OTP", {
code,
});
return void cb("INVALID_OTP");
}
// call back to indicate that their request was well-formed and valid
cb();
});
};
revoke.complete = function (Env, body, cb) {
/*
if they are here then they:
1. have a valid block configured with TOTP-based 2FA
2. were able to provide a valid TOTP for that block's secret
3. were able to sign their messages for the block's public key
So, we should:
1. Revoke the TOTP authentication for their block
2. Remove all existing sessions
*/
var { publicKey } = body;
nThen(function (w) {
MFA.delete(Env, publicKey, w(function (err) {
if (!err) { return; }
w.abort();
Env.Log.error('TOTP_REVOKE_MFA_DELETE', {
error: err,
publicKey: publicKey,
});
cb('MFA_ERROR');
}));
}).nThen(function () {
Sessions.deleteUser(Env, publicKey, function (err) {
if (!err) { return; }
// If we can't delete the sessions, don't send an erorr, just log to the server.
// The MFA will still be correctly disabled as long as the first step is done.
Env.Log.error('TOTP_REVOKE_SESSIONS__DELETE', {
error: err,
publicKey: publicKey,
});
});
}).nThen(function () {
cb(void 0, {
success: true
});
});
};

View File

@ -63,6 +63,7 @@ var COMMANDS = {};
const TOTP = require("./challenge-commands/totp.js");
COMMANDS.TOTP_SETUP = TOTP.TOTP_SETUP;
COMMANDS.TOTP_VALIDATE = TOTP.TOTP_VALIDATE;
COMMANDS.TOTP_REVOKE = TOTP.TOTP_REVOKE;
var randomToken = () => Nacl.util.encodeBase64(Nacl.randomBytes(24)).replace(/\//g, '-');

View File

@ -380,6 +380,13 @@ app.use('/block/', function (req, res, next) {
// reject if it's too old
if (payload.exp && ((+new Date()) > payload.exp)) {
Log.error("JWT_EXPIRED", payload);
Sessions.delete(Env, name, payload.ref, function (err) {
if (err) {
Log.error('JWT_SESSION_DELETE_EXPIRED_ERROR', err);
return;
}
Log.info('JWT_SESSION_DELETE_EXPIRED', err);
});
return void no();
}

View File

@ -36,6 +36,10 @@ Basic.read = function (Env, path, cb) {
cb(void 0, content);
});
};
Basic.readDir = function (Env, path, cb) {
if (!path) { return void pathError(cb); }
Fs.readdir(path, cb);
};
Basic.write = function (Env, path, data, cb) {
if (!path) { return void pathError(cb); }
@ -57,4 +61,9 @@ Basic.delete = function (Env, path, cb) {
if (!path) { return void pathError(cb); }
Fs.rm(path, cb);
};
Basic.deleteDir = function (Env, path, cb) {
if (!path) { return void pathError(cb); }
Fs.rm(path, { recursive: true, force: true }, cb);
};

View File

@ -42,6 +42,20 @@ Sessions.delete = function (Env, id, ref, cb) {
Basic.delete(Env, path, cb);
};
Sessions.deleteUser = function (Env, id, cb) {
if (!id || typeof(id) !== 'string') { return; }
id = Util.escapeKeyCharacters(id);
var dirPath = Path.join(Env.paths.base, "sessions", id.slice(0, 2), id);
Basic.readDir(Env, dirPath, (err, files) => {
var checkContent = !files || (Array.isArray(files) && files.every((file) => {
return file && file.length === 32;
}));
if (!checkContent) { return void cb('INVALID_SESSIONS_DIR'); }
Basic.deleteDir(Env, dirPath, cb);
});
};
// XXX All of a user's sessions should be removed When a user deletes their account
// The fact that each user is given their own publicKey-scoped directory makes them easy
// to remove all at once. Nodejs provides an easy way to `rm -rf` since 14.14.0:

View File

@ -1,3 +1,4 @@
/* jshint esversion: 7 */
define([], function () {
// Based on https://gist.github.com/bellbind/871b145110c458e83077a718aef9fa0e
@ -42,7 +43,7 @@ define([], function () {
}
function b32d(bs) {
const len = bs.length;
if (len === 0) return new Uint8Array([]);
if (len === 0) { return new Uint8Array([]); }
//console.assert(len % 8 === 0, len);
const pad = len - bs.indexOf("="), rem = b32pad.indexOf(pad);
//console.assert(rem >= 0, pad);

View File

@ -175,7 +175,7 @@ Note: This must currently be reversed manually (by deleting the mfa config file)
$deriveKeys.click(function () {
if (BUSY) { return; }
var name = $username.val().trim()
var name = $username.val().trim();
var password = $password.val();
if (!name) { return void window.alert("Invalid name"); }
@ -212,7 +212,7 @@ Note: This must currently be reversed manually (by deleting the mfa config file)
// TOTP app configuration
var $generateSecret = $('#generate-secret')
var $generateSecret = $('#generate-secret');
var $b32Secret = $('#base32-secret');
var randomSecret = () => {

View File

@ -2513,6 +2513,10 @@ define([
// when it was expected. Log them out and redirect them to
// the login page, where they will be able to authenticate
// and request a new JWT
// XXX We may only require them to provid a new TOTP code here
// instead of redirecting them to the login page
waitFor.abort();
return void LocalStore.logout(function () {
requestLogin();

View File

@ -15,6 +15,7 @@ define([
'/common/make-backup.js',
'/common/common-feedback.js',
'/common/common-constants.js',
'/customize.dist/login.js',
'/common/jscolor.js',
'/bower_components/file-saver/FileSaver.min.js',
@ -37,7 +38,8 @@ define([
ApiConfig,
Backup,
Feedback,
Constants
Constants,
Login
) {
var saveAs = window.saveAs;
var APP = window.APP = {};
@ -47,6 +49,17 @@ define([
var privateData;
var sframeChan;
Messages.settings_totpTitle = "TOTP"; // XXX
Messages.settings_cat_access = "Security"; // XXX
Messages.settings_totp_enable = "Enable TOTP"; // XXX
Messages.settings_totp_disable = "Disable TOTP"; // XXX
Messages.settings_totp_generate = "Generate secret"; // XXX
Messages.settings_totp_code = "OTP code"; // XXX
Messages.settings_totp_code_invalid = "Invalid OTP code"; // XXX
Messages.settings_totp_tuto = "Scan this QR code with a authenticator application. Obtain a valid authentication code and confirm before it expires."; // XXX
Messages.settings_totp_confirm = "Enable TOTP with this secret"; // XXX
var categories = {
'account': [ // Msg.settings_cat_account
'cp-settings-own-drive',
@ -57,6 +70,10 @@ define([
'cp-settings-change-password',
'cp-settings-delete'
],
'access': [ // Msg.settings_cat_access // XXX
// XXX add password change and account deletion here?
'cp-settings-totp'
],
'security': [ // Msg.settings_cat_security
'cp-settings-logout-everywhere',
'cp-settings-autostore',
@ -777,6 +794,238 @@ define([
cb($inputBlock);
}, true);
// Account access
var drawTotp = function (content, enabled) {
var $content = $(content).empty();
if (enabled) {
(function () {
var disable = h('button.btn.btn-danger', Messages.settings_totp_disable);
var OTPEntry, pwInput;
$content.append(h('div', [
h('p', pwInput = h('input', {
type: 'password',
placeholder: Messages.login_password,
})),
OTPEntry = h('input', {
placeholder: Messages.settings_totp_code
}),
disable
]));
var $b = $(disable);
var $OTPEntry = $(OTPEntry);
UI.confirmButton(disable, {
classes: 'btn-danger',
multiple: true
}, function () {
$b.attr('disabled', 'disabled');
var name = privateData.accountName;
var password = $(pwInput).val();
// scrypt locks up the UI before the DOM has a chance
// to update (displaying logs, etc.), so do a set timeout
setTimeout(function () {
Login.Cred.deriveFromPassphrase(name, password, Login.requiredBytes, function (bytes) {
var result = Login.allocateBytes(bytes);
sframeChan.query("Q_SETTINGS_CHECK_BLOCK", {
blockHash: result.blockHash,
}, function (err, obj) {
if (!obj || !obj.correct) {
UI.warn(Messages.login_noSuchUser);
$b.removeAttr('disabled');
return;
}
var blockKeys = result.blockKeys;
var code = $OTPEntry.val();
sframeChan.query("Q_SETTINGS_TOTP_REVOKE", {
key: blockKeys.sign,
data: {
command: 'TOTP_REVOKE',
code: code,
}
}, function (err, obj) {
$OTPEntry.val("");
if (err || !obj || !obj.success) {
$b.removeAttr('disabled');
return void UI.warn(Messages.settings_totp_code_invalid);
}
drawTotp(content, false);
}, {raw: true});
});
});
}, 1000);
});
})();
return;
}
var button = h('button.btn.btn-primary', Messages.settings_totp_enable);
$(button).click(function () {
$content.empty();
var Base32, Block, QRCode, Nacl;
var blockKeys;
nThen(function (waitFor) {
require([
'/auth/base32.js',
'/common/outer/login-block.js',
'/lib/qrcode.min.js',
'/bower_components/tweetnacl/nacl-fast.min.js',
], waitFor(function (_Base32, _Login, _Block) {
Base32 = _Base32;
Block = _Block;
QRCode = window.QRCode;
Nacl = window.nacl;
}));
}).nThen(function (waitFor) {
var pwInput;
var button = h('button.btn.btn-secondary', Messages.ui_confirm);
$content.append(h('div', [
h('p', pwInput = h('input', {
type: 'password',
placeholder: Messages.login_password,
})),
button
]));
var next = waitFor();
var BUSY = false;
var spinner = UI.makeSpinner($content);
var $b = $(button).click(function () {
if (BUSY) { return; }
var name = privateData.accountName;
var password = $(pwInput).val();
if (!password) { return void UI.warn(Messages.login_noSuchUser); }
spinner.spin();
$b.attr('disabled', 'disabled');
BUSY = true;
// scrypt locks up the UI before the DOM has a chance
// to update (displaying logs, etc.), so do a set timeout
setTimeout(function () {
Login.Cred.deriveFromPassphrase(name, password, Login.requiredBytes, function (bytes) {
var result = Login.allocateBytes(bytes);
sframeChan.query("Q_SETTINGS_CHECK_BLOCK", {
blockHash: result.blockHash,
}, function (err, obj) {
BUSY = false;
if (!obj || !obj.correct) {
spinner.hide();
UI.warn(Messages.login_noSuchUser);
$b.removeAttr('disabled');
return;
}
spinner.done();
blockKeys = result.blockKeys;
next();
});
});
}, 1000);
});
}).nThen(function () {
var randomSecret = function () {
var U8 = Nacl.randomBytes(20);
return Base32.encode(U8);
};
$content.empty();
var generate = h('button.btn.btn-primary', Messages.settings_totp_generate);
var secretContainer = h('div');
var $container = $(secretContainer);
$content.append(secretContainer, h('p', generate));
var updateQR = Util.throttle(function (uri, target) {
new QRCode(target, uri);
}, 400);
var updateURI = function (secret) {
$container.empty();
var username = privateData.accountName;
var hostname = new URL(privateData.origin).hostname;
var label = "CryptPad";
var uri = `otpauth://totp/${label}:${username}@${hostname}?secret=${secret}`;
var qr = h('div');
var uriInput = UI.dialog.selectable(uri);
updateQR(uri, qr);
var OTPEntry = h('input', {
placeholder: Messages.settings_totp_code
});
var $OTPEntry = $(OTPEntry);
var description = h('p', Messages.settings_totp_tuto);
var confirmOTP = h('button.btn.btn-primary', Messages.settings_totp_confirm);
var $confirmBtn = $(confirmOTP);
var lock = false;
UI.confirmButton(confirmOTP, {
multiple: true
}, function () {
var code = $OTPEntry.val();
if (code.length !== 6 || /\D/.test(code)) {
return void UI.warn(Messages.error); // XXX
}
$confirmBtn.attr('disabled', 'disabled');
lock = true;
sframeChan.query("Q_SETTINGS_TOTP_SETUP", {
key: blockKeys.sign,
data: {
command: 'TOTP_SETUP',
secret: secret,
code: code,
}
}, function (err, obj) {
lock = false;
$OTPEntry.val("");
if (err || !obj || !obj.success) {
$confirmBtn.removeAttr('disabled');
console.error(err);
return void UI.warn(Messages.error);
}
drawTotp(content, true);
}, {raw: true});
});
$container.append([
uriInput,
qr,
h('br'),
description,
OTPEntry,
confirmOTP
]);
};
var $g = $(generate).click(function () {
$g.remove();
var secret = randomSecret();
updateURI(secret);
});
});
}).appendTo(content);
};
makeBlock('totp', function (cb) { // Msg.settings_totpTitle
if (!common.isLoggedIn()) { return void cb(false); }
var content = h('div');
sframeChan.query('Q_SETTINGS_TOTP_CHECK', {}, function (err, obj) {
if (err || !obj || (obj && obj.err === 'NOBLOCK')) { return void cb(false); }
var enabled = obj && obj.totp;
drawTotp(content, Boolean(enabled));
cb(content);
});
}, true);
// Security
makeBlock('safe-links', function(cb) { // Msg.settings_safeLinksTitle

View File

@ -70,6 +70,47 @@ define([
sframeChan.on('Q_SETTINGS_IMPORT_LOCAL', function (data, cb) {
Cryptpad.mergeAnonDrive(cb);
});
sframeChan.on('Q_SETTINGS_CHECK_BLOCK', function (data, cb) {
cb({correct: data.blockHash === Utils.LocalStore.getBlockHash() });
});
sframeChan.on('Q_SETTINGS_TOTP_SETUP', function (obj, cb) {
require([
'/common/outer/http-command.js',
], function (ServerCommand) {
ServerCommand(obj.key, obj.data, function (err, response) {
cb({ success: Boolean(!err && response && response.bearer) });
console.log(response);
if (response && response.bearer) {
Utils.LocalStore.setSessionToken(response.bearer);
}
});
});
});
sframeChan.on('Q_SETTINGS_TOTP_REVOKE', function (obj, cb) {
require([
'/common/outer/http-command.js',
], function (ServerCommand) {
ServerCommand(obj.key, obj.data, function (err, response) {
cb({ success: Boolean(!err && response && response.success) });
console.error(response);
if (response && response.success) {
Utils.LocalStore.setSessionToken('');
}
});
});
});
sframeChan.on('Q_SETTINGS_TOTP_CHECK', function (obj, cb) {
require([
'/common/outer/login-block.js',
], function (Block) {
var blockHash = Utils.LocalStore.getBlockHash();
if (!blockHash) { return void cb({ err: 'NOBLOCK' }); }
var parsed = Block.parseBlockHash(blockHash);
Utils.Util.getBlock(parsed.href, {}, function (err) {
cb({totp: err === 401});
});
});
});
sframeChan.on('Q_SETTINGS_DELETE_ACCOUNT', function (data, cb) {
Cryptpad.deleteAccount(data, cb);
});