TOTP: recovery by secret key

This commit is contained in:
yflory 2023-05-15 17:33:58 +02:00
parent be152fdaae
commit e893613b43
10 changed files with 434 additions and 15 deletions

View file

@ -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. <strong>If you forgot your recovery key, please copy the proof of ownership and send it to <a href="mailto:{0}">{0}</a></strong>';
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),
]),
])
])
]);
};
});

View file

@ -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;

View file

@ -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();
}
}
}
}

View file

@ -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)) {

View file

@ -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) {

View file

@ -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(); }
}
});

15
www/recovery/index.html Normal file
View file

@ -0,0 +1,15 @@
<!DOCTYPE html>
<html>
<!-- If this file is not called customize.dist/src/template.html, it is generated -->
<head>
<title data-localization="main_title">CryptPad: Collaboration suite, encrypted and open-source</title>
<meta content="text/html; charset=utf-8" http-equiv="content-type"/>
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no">
<link rel="icon" type="image/png" href="/customize/favicon/main-favicon.png" id="favicon"/>
<script src="/customize/pre-loading.js?ver=1.1"></script>
<link href="/customize/src/pre-loading.css?ver=1.0" rel="stylesheet" type="text/css">
<script async data-bootload="/customize/template.js" data-main="/common/boot.js?ver=1.0" src="/bower_components/requirejs/require.js?ver=2.3.5"></script>
</head>
<body class="html">
<noscript></noscript>

135
www/recovery/main.js Normal file
View file

@ -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);
});
});
});

View file

@ -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. <strong>To prevent this, please store the following recovery secret key.</strong> 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
]);

View file

@ -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('');
}