From d789627920faea410599cd4b05ac31d012a136c1 Mon Sep 17 00:00:00 2001 From: yflory Date: Fri, 12 May 2023 18:21:15 +0200 Subject: [PATCH] TOTP setup and revocation in settings --- lib/challenge-commands/totp.js | 115 +++++++++++++++ lib/http-commands.js | 1 + lib/http-worker.js | 7 + lib/storage/basic.js | 9 ++ lib/storage/sessions.js | 14 ++ www/auth/base32.js | 3 +- www/auth/main.js | 4 +- www/common/cryptpad-common.js | 4 + www/settings/inner.js | 251 ++++++++++++++++++++++++++++++++- www/settings/main.js | 41 ++++++ 10 files changed, 445 insertions(+), 4 deletions(-) diff --git a/lib/challenge-commands/totp.js b/lib/challenge-commands/totp.js index f86797569..57b5257a6 100644 --- a/lib/challenge-commands/totp.js +++ b/lib/challenge-commands/totp.js @@ -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 + }); + }); +}; + diff --git a/lib/http-commands.js b/lib/http-commands.js index 82508b2dd..04ccf9101 100644 --- a/lib/http-commands.js +++ b/lib/http-commands.js @@ -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, '-'); diff --git a/lib/http-worker.js b/lib/http-worker.js index b11ad0077..e920a25e7 100644 --- a/lib/http-worker.js +++ b/lib/http-worker.js @@ -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(); } diff --git a/lib/storage/basic.js b/lib/storage/basic.js index be20d0cd9..92854a0fa 100644 --- a/lib/storage/basic.js +++ b/lib/storage/basic.js @@ -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); +}; + diff --git a/lib/storage/sessions.js b/lib/storage/sessions.js index cd52558d4..f31151ec8 100644 --- a/lib/storage/sessions.js +++ b/lib/storage/sessions.js @@ -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: diff --git a/www/auth/base32.js b/www/auth/base32.js index 0da9ab118..d9a15f5ea 100644 --- a/www/auth/base32.js +++ b/www/auth/base32.js @@ -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); diff --git a/www/auth/main.js b/www/auth/main.js index 30e9c0703..a15edfc85 100644 --- a/www/auth/main.js +++ b/www/auth/main.js @@ -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 = () => { diff --git a/www/common/cryptpad-common.js b/www/common/cryptpad-common.js index 237a258c9..135d522f6 100644 --- a/www/common/cryptpad-common.js +++ b/www/common/cryptpad-common.js @@ -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(); diff --git a/www/settings/inner.js b/www/settings/inner.js index 459257c93..e19ba65b9 100644 --- a/www/settings/inner.js +++ b/www/settings/inner.js @@ -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 diff --git a/www/settings/main.js b/www/settings/main.js index 189d94eb5..f9e00fbc7 100644 --- a/www/settings/main.js +++ b/www/settings/main.js @@ -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); });