From e893613b43c46b6aea51161e177261c752bc0151 Mon Sep 17 00:00:00 2001 From: yflory Date: Mon, 15 May 2023 17:33:58 +0200 Subject: [PATCH] TOTP: recovery by secret key --- customize.dist/pages/recovery.js | 128 +++++++++++++++++ .../src/less2/include/infopages.less | 7 +- .../src/less2/pages/page-recovery.less | 99 +++++++++++++ customize.dist/template.js | 2 + lib/challenge-commands/totp.js | 28 +++- www/common/common-interface.js | 3 + www/recovery/index.html | 15 ++ www/recovery/main.js | 135 ++++++++++++++++++ www/settings/inner.js | 30 ++-- www/settings/main.js | 2 - 10 files changed, 434 insertions(+), 15 deletions(-) create mode 100644 customize.dist/pages/recovery.js create mode 100644 customize.dist/src/less2/pages/page-recovery.less create mode 100644 www/recovery/index.html create mode 100644 www/recovery/main.js diff --git a/customize.dist/pages/recovery.js b/customize.dist/pages/recovery.js new file mode 100644 index 000000000..c5736f4ac --- /dev/null +++ b/customize.dist/pages/recovery.js @@ -0,0 +1,128 @@ +define([ + '/api/config', + 'jquery', + '/common/hyperscript.js', + '/common/common-interface.js', + '/customize/messages.js', + '/customize/pages.js' +], function (Config, $, h, UI, Msg, Pages) { + return function () { + document.title = Msg.register_header; + + var tos = $(UI.createCheckbox('accept-terms')).find('.cp-checkmark-label').append(Msg.register_acceptTerms).parent()[0]; + + var termsLink = Pages.customURLs.terms; + $(tos).find('a').attr({ + href: termsLink, + target: '_blank', + tabindex: '-1', + }); + +Msg.recovery_header = "Recover account"; // XXX +Msg.recovery_totp = "Disable TOTP on your account"; // XXX +Msg.recovery_totp_description = "If you've locked yourselves out of your CryptPad account because of a TOTP (Time based One Time Password) multi-factor authentication, you can use this form to disable this protection."; +Msg.recovery_totp_beta = 'The TOTP multi-factor authentication has just been released. To avoid accidentaly locking accounts, the support team will temporarily agree to disable the protection even if you forgot your secret recovery key. If you forgot your recovery key, please copy the proof of ownership and send it to {0}'; +Msg.recovery_totp_login = "Please enter your login credentials"; +Msg.recovery_totp_secret = "Please enter your secret recovery key"; +Msg.recovery_totp_secret_ph = "Secret recovery key"; +Msg.recovery_totp_proof = "Proof of ownership"; +Msg.recovery_totp_continue = "Continue"; +Msg.recovery_totp_disable = "Disable TOTP"; + +Msg.recovery_totp_method_email = "Manual recovery by email"; +Msg.recovery_totp_method_secret = "Automatic recovery by secret key"; + +Msg.recovery_totp_wrong = "Invalid username or password"; +Msg.recovery_totp_error = "Unknown error. Please reload and try again."; +Msg.recovery_totp_disabled = "Multi-factor authentication is already disabled for this account."; + + var frame = function (content) { + return [ + h('div#cp-main', [ + Pages.infopageTopbar(), + h('div.container.cp-container', [ + h('div.row.cp-page-title', h('h1', Msg.recovery_header)), + ].concat(content)), + Pages.infopageFooter(), + ]), + ]; + }; + + if (Config.restrictRegistration) { + return frame([ + h('div.cp-restricted-registration', [ + h('p', Msg.register_registrationIsClosed), + ]) + ]); + } + + var termsCheck; + if (termsLink) { + termsCheck = h('div.checkbox-container', tos); + } + + return frame([ + h('div.row.cp-recovery-det', [ + h('div#userForm.form-group.hidden.col-md-12', [ + h('h2', Msg.recovery_totp), + h('div.cp-recovery-desc', [ + Msg._getKey('recovery_totp_description', [ Pages.Instance.name ]), + ]), + h('div.cp-recovery-step.step1', [ + h('div.alert.alert-danger.wrong-cred.cp-hidden', Msg.recovery_totp_wrong), + h('label', Msg.recovery_totp_login), + h('input.form-control#username', { + type: 'text', + autocomplete: 'off', + autocorrect: 'off', + autocapitalize: 'off', + spellcheck: false, + placeholder: Msg.login_username, + autofocus: true, + }), + h('input.form-control#password', { + type: 'password', + placeholder: Msg.login_password, + }), + h('div.cp-recover-button', + h('button.btn.btn-primary#cp-recover-login', Msg.recovery_totp_continue) + ) + ]), + h('div.cp-recovery-step.step2', { style: 'display: none;' }, [ + h('div.cp-recovery-method', [ + h('h3', Msg.recovery_totp_method_secret), + h('label', Msg.recovery_totp_secret), + h('input.form-control#totprecovery', { + type: 'text', + autocomplete: 'off', + autocorrect: 'off', + autocapitalize: 'off', + spellcheck: false, + placeholder: Msg.recovery_totp_secret_ph, + autofocus: true, + }), + h('div.cp-recover-button', + h('button.btn.btn-primary#cp-recover', Msg.recovery_totp_disable) + ) + ]), + h('div.cp-recovery-desc', Msg.settings_kanbanTagsOr), + h('div.cp-recovery-method', [ + h('h3', Msg.recovery_totp_method_email), + Config.adminEmail ? UI.setHTML(h('div.alert.alert-warning'), + Msg._getKey('recovery_totp_beta', [Config.adminEmail])) : undefined, + h('label', Msg.recovery_totp_proof), + h('textarea.cp-recover-email', {readonly: 'readonly'}), + h('button.btn.btn-secondary', Msg.copyToClipboard), + ]), + ]), + h('div.cp-recovery-step.step-info', { style: 'display: none;' }, [ + h('div.alert.alert-info.cp-hidden.disabled', Msg.recovery_totp_disabled), + h('div.alert.alert-danger.cp-hidden.unknown-error', Msg.recovery_totp_error), + ]), + ]) + ]) + ]); + }; + +}); + diff --git a/customize.dist/src/less2/include/infopages.less b/customize.dist/src/less2/include/infopages.less index 1882ffd6d..d65e62868 100644 --- a/customize.dist/src/less2/include/infopages.less +++ b/customize.dist/src/less2/include/infopages.less @@ -20,6 +20,11 @@ .infopages_main () { --LessLoader_require: LessLoader_currentFile(); } + +.cp-loading-noscroll { + overflow: hidden; +} + body.html { .font_main(); @infopages_infobar-height: 64px; @@ -105,7 +110,7 @@ body.html { filter: @cp_static-img-invert-filter; } - button { + button:not(.btn) { outline: none; background-color: @cp_buttons-primary; color: @cp_buttons-primary-text; diff --git a/customize.dist/src/less2/pages/page-recovery.less b/customize.dist/src/less2/pages/page-recovery.less new file mode 100644 index 000000000..2fe41447b --- /dev/null +++ b/customize.dist/src/less2/pages/page-recovery.less @@ -0,0 +1,99 @@ +@import (reference) "../include/infopages.less"; +@import (reference) "../include/colortheme-all.less"; +@import (reference) "../include/alertify.less"; +@import (reference) "../include/checkmark.less"; +@import (reference) "../include/forms.less"; + +&.cp-page-recovery { + .infopages_main(); + .forms_main(); + + .alertify_main(); + .checkmark_main(20px); + + .cp-container { + .alert { + font-size: @colortheme_app-font-size; + } + .form-group { + .cp-recovery-desc { + margin-bottom: 10px; + } + .cp-recovery-desc, .cp-recovery-step { + width: 100%; + } + #register { + &.btn { + padding: .5rem .5rem; + } + margin-top: 16px; + font-size: 1.25em; + min-width: 30%; + } + } + padding-bottom: 3em; + min-height: 5vh; + .cp-hidden { + display: none; + } + } + .alertify { + // workaround for alertify making empty p + p:empty { + display: none; + } + + nav { + display: flex; + align-items: center; + justify-content: flex-end; + } + + @media screen and (max-width: 600px) { + nav .btn-danger { + line-height: inherit; + } + } + + } + + .cp-recovery-det { + .cp-recover-button { + text-align: center; + } + .cp-recovery-method { + padding: 5px; + border: 1px solid white; + border-radius: 5px; + &:not(:last-child) { + margin-bottom: 10px; + } + h3 { + margin-top: 0; + } + } + .cp-recover-email { + height: 164px; + } + #userForm { + padding: 15px; + background-color: @cp_static-card-bg; + position: relative; + z-index: 2; + margin-bottom: 100px; + border-radius: @infopages-radius-L; + .cp-shadow(); + .form-control { + border-radius: @infopages-radius; + color: @cryptpad_text_col; + background-color: @cp_forms-bg; + margin-bottom: 10px; + &:focus { + border-color: @cryptpad_color_brand; + } + .tools_placeholder-color(); + } + } + } +} + diff --git a/customize.dist/template.js b/customize.dist/template.js index 332471630..b5eca8b2f 100644 --- a/customize.dist/template.js +++ b/customize.dist/template.js @@ -55,6 +55,8 @@ $(function () { require([ '/register/main.js' ], function () {}); } else if (/^\/install\//.test(pathname)) { require([ '/install/main.js' ], function () {}); + } else if (/^\/recovery\//.test(pathname)) { + require([ '/recovery/main.js' ], function () {}); } else if (/^\/login\//.test(pathname)) { require([ '/login/main.js' ], function () {}); } else if (/^\/($|^\/index\.html$)/.test(pathname)) { diff --git a/lib/challenge-commands/totp.js b/lib/challenge-commands/totp.js index 57b5257a6..8fcef5c38 100644 --- a/lib/challenge-commands/totp.js +++ b/lib/challenge-commands/totp.js @@ -23,6 +23,16 @@ var isValidOTP = otp => { !/\D/.test(otp); }; +// basic definition of what we'll accept as a recovery key +// 24 bytes encoded as b64 ==> 32 characters +var isValidRecoveryKey = otp => { + return isString(otp) && + // in the future this could be updated to support 8 digits + otp.length === 32 && + // \D is non-digit characters, so this tests that it is exclusively numeric + /[A-Za-z0-9+\/]{32}/.test(otp); +}; + // we'll only allow users to set up multi-factor auth // for keypairs they control which already have blocks // this check doesn't confirm that their id is valid base64 @@ -349,15 +359,15 @@ So, we should: // 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; + var { publicKey, code, recoveryKey } = body; // they must provide a valid OTP code - if (!isValidOTP(code)) { return void cb('E_INVALID'); } + if (!isValidOTP(code) && !isValidRecoveryKey(recoveryKey)) { return void cb('E_INVALID'); } // they must provide a valid block public key if (!isValidBlockId(publicKey)) { return void cb("INVALID_KEY"); } - var secret; + var secret, recoveryStored; nThen(function (w) { // check that there is an MFA configuration for the given account MFA.read(Env, publicKey, w(function (err, content) { @@ -378,7 +388,19 @@ const revoke = Commands.TOTP_REVOKE = function (Env, body, cb) { } secret = parsed.secret; + recoveryStored = parsed.contact; })); + }).nThen(function (w) { + if (!recoveryKey) { return; } + w.abort(); + if (!/^secret:/.test(recoveryStored)) { + return void cb("E_NO_RECOVERY_KEY"); + } + recoveryStored = recoveryStored.slice(7); + if (recoveryKey !== recoveryStored) { + return void cb("E_WRONG_RECOVERY_KEY"); + } + cb(); }).nThen(function () { let decoded = decode32(secret); if (!decoded) { diff --git a/www/common/common-interface.js b/www/common/common-interface.js index bd13a1e69..ea9574f34 100644 --- a/www/common/common-interface.js +++ b/www/common/common-interface.js @@ -985,6 +985,7 @@ define([ todo(); } + $('html').toggleClass('cp-loading-noscroll', true); // Remove the inner placeholder (iframe) $('#placeholder').remove(); }; @@ -1004,6 +1005,7 @@ define([ $loading.find('.cp-loading-progress').remove(); // Remove the progress list setTimeout(cb, 750); $('head > link[href^="/customize/src/pre-loading.css"]').remove(); + $('html').toggleClass('cp-loading-noscroll', false); }; UI.errorLoadingScreen = function (error, transparent, exitable) { if (error === 'Error: XDR encoding failure') { @@ -1044,6 +1046,7 @@ define([ $(window).keydown(function (e) { // XXX what if they don't have a keyboard? if (e.which === 27) { $loading.hide(); + $('html').toggleClass('cp-loading-noscroll', false); if (typeof(exitable) === "function") { exitable(); } } }); diff --git a/www/recovery/index.html b/www/recovery/index.html new file mode 100644 index 000000000..0a4a2242f --- /dev/null +++ b/www/recovery/index.html @@ -0,0 +1,15 @@ + + + + + CryptPad: Collaboration suite, encrypted and open-source + + + + + + + + + + diff --git a/www/recovery/main.js b/www/recovery/main.js new file mode 100644 index 000000000..449752860 --- /dev/null +++ b/www/recovery/main.js @@ -0,0 +1,135 @@ +define([ + 'jquery', + 'json.sortify', + '/customize/login.js', + '/common/cryptpad-common.js', + //'/common/test.js', + '/common/common-credential.js', + '/common/common-interface.js', + '/common/common-util.js', + '/common/common-realtime.js', + '/common/common-constants.js', + '/common/common-feedback.js', + '/common/outer/local-store.js', + '/common/outer/login-block.js', + '/common/outer/http-command.js', + + '/bower_components/tweetnacl/nacl-fast.min.js', + + 'css!/bower_components/components-font-awesome/css/font-awesome.min.css', +], function ($, Sortify, Login, Cryptpad, /*Test,*/ Cred, UI, Util, Realtime, Constants, Feedback, + LocalStore, Block, ServerCommand) { + if (window.top !== window) { return; } + + var Messages = Cryptpad.Messages; + var Nacl = window.nacl; + + $(function () { + if (LocalStore.isLoggedIn()) { + // already logged in, redirect to drive + document.location.href = '/drive/'; + return; + } + + // text and password input fields + var $uname = $('#username'); + var $passwd = $('#password'); + var $recoveryKey = $('#totprecovery'); + + var $step1 = $('.cp-recovery-step.step1'); + var $step2 = $('.cp-recovery-step.step2'); + var $stepInfo = $('.cp-recovery-step.step-info'); + var $totpProof = $('textarea.cp-recover-email'); + + [ $uname, $passwd] + .some(function ($el) { if (!$el.val()) { $el.focus(); return true; } }); + + var totpStep2 = function () { + $step1.hide(); + $step2.show(); + }; + var totpStepInfo = function (cls) { + $step1.hide(); + $step2.hide(); + $stepInfo.find('.alert').toggleClass('cp-hidden', true); + $stepInfo.find(cls).toggleClass('cp-hidden', false); + $stepInfo.show(); + }; + + $step2.show(); // XXX debug + + var addProof = function (blockKeys) { + var pub = blockKeys.sign.publicKey; + var sec = blockKeys.sign.secretKey; + var toSign = { + intent: 'Disable TOTP', + date: new Date().toISOString(), + blockId: Nacl.util.encodeBase64(pub), + }; + var proof = Nacl.sign.detached(Nacl.util.decodeUTF8(Sortify(toSign)), sec); + toSign.proof = Nacl.util.encodeBase64(proof); + $totpProof.html(JSON.stringify(toSign, 0, 2)); + }; + + var revokeTOTP = function (blockKeys) { + var recoveryKey = $recoveryKey.val().trim(); + if (!recoveryKey || recoveryKey.length !== 32) { + return void UI.warn(Messages.error); // XXX error message? + } + ServerCommand(blockKeys.sign, { + command: 'TOTP_REVOKE', + recoveryKey: recoveryKey + }, function (err, response) { + var success = !err && response && response.success; + if (!success) { + console.error(err, response); + return void UI.warn(Messages.error); + } + // XXX redirect to login? + return void UI.log(Messages.ui_success); + }); + }; + + var $recoverLogin = $('button#cp-recover-login'); + var $recoverConfirm = $('button#cp-recover'); + var blockKeys; + $recoverLogin.click(function () { + UI.addLoadingScreen({ + loadingText: Messages.login_hashing + }); + var name = $uname.val(); + var pw = $passwd.val(); + setTimeout(function () { + Login.Cred.deriveFromPassphrase(name, pw, Login.requiredBytes, function (bytes) { + var result = Login.allocateBytes(bytes); + var blockHash = result.blockHash; + var parsed = Block.parseBlockHash(blockHash); + addProof(result.blockKeys); + blockKeys = result.blockKeys; + Util.getBlock(parsed.href, {}, function (err, v) { + UI.removeLoadingScreen(); + if (v && !err) { + return totpStepInfo('.disabled'); + } + if (err === 401) { + return totpStep2(result.blockKeys); + } + if (err === 404) { + return $step1.find('.wrong-cred').toggleClass('cp-hidden', false); + } + totpStepInfo('.unknown-error'); + }); + }); + }, 100); + }); + + UI.confirmButton($recoverConfirm[0], { + multiple: true + }, function () { + if (!blockKeys) { return; } + // XXX disable TOTP automatically + revokeTOTP(blockKeys); + }); + + }); +}); diff --git a/www/settings/inner.js b/www/settings/inner.js index e19ba65b9..9a599994b 100644 --- a/www/settings/inner.js +++ b/www/settings/inner.js @@ -60,6 +60,9 @@ define([ 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 + Messages.settings_totp_recovery_header = "Recovery code"; + Messages.settings_totp_recovery = "If you lose access to your authenticator app, you may lock yourselves out of your CryptPad account. To prevent this, please store the following recovery secret key. You'll be able to use it to disable the multi-factor authentication. Do not share this key."; + var categories = { 'account': [ // Msg.settings_cat_account 'cp-settings-own-drive', @@ -864,17 +867,15 @@ define([ var button = h('button.btn.btn-primary', Messages.settings_totp_enable); $(button).click(function () { $content.empty(); - var Base32, Block, QRCode, Nacl; + var Base32, 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) { + ], waitFor(function (_Base32) { Base32 = _Base32; - Block = _Block; QRCode = window.QRCode; Nacl = window.nacl; })); @@ -943,6 +944,7 @@ define([ var updateURI = function (secret) { $container.empty(); + var recoverySecret = Nacl.util.encodeBase64(Nacl.randomBytes(24)); var username = privateData.accountName; var hostname = new URL(privateData.origin).hostname; var label = "CryptPad"; @@ -972,13 +974,16 @@ define([ $confirmBtn.attr('disabled', 'disabled'); lock = true; + var data = { + command: 'TOTP_SETUP', + secret: secret, + contact: "secret:" + recoverySecret, // XXX add other recovery options + code: code, + }; + sframeChan.query("Q_SETTINGS_TOTP_SETUP", { key: blockKeys.sign, - data: { - command: 'TOTP_SETUP', - secret: secret, - code: code, - } + data: data }, function (err, obj) { lock = false; $OTPEntry.val(""); @@ -992,11 +997,18 @@ define([ }); + var recoveryBlock = h('div.alert.alert-danger', [ + h('h3', Messages.settings_totp_recovery_header), + UI.setHTML(h('p'), Messages.settings_totp_recovery), + UI.dialog.selectable(recoverySecret) + ]); + $container.append([ uriInput, qr, h('br'), description, + recoveryBlock, OTPEntry, confirmOTP ]); diff --git a/www/settings/main.js b/www/settings/main.js index f9e00fbc7..f962d639e 100644 --- a/www/settings/main.js +++ b/www/settings/main.js @@ -79,7 +79,6 @@ define([ ], 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); } @@ -92,7 +91,6 @@ define([ ], 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(''); }