diff --git a/config/config.example.js b/config/config.example.js index b78bf8fc3..4f289b56b 100644 --- a/config/config.example.js +++ b/config/config.example.js @@ -92,6 +92,19 @@ module.exports = { */ //httpSafePort: 3001, +/* Websockets need to be exposed on a separate port from the rest of + * the platform's HTTP traffic. Port 3003 is used by default. + * You can change this to a different port if it is in use by a + * different service, but under most circumstances you can leave this + * commented and it will work. + * + * In production environments, your reverse proxy (usually NGINX) + * will need to forward websocket traffic (/cryptpad_websocket) + * to this port. + * + */ + // websocketPort: 3003, + /* CryptPad will launch a child process for every core available * in order to perform CPU-intensive tasks in parallel. * Some host environments may have a very large number of cores available diff --git a/customize.dist/login.js b/customize.dist/login.js index 38c6a09b8..2dfc41fa4 100644 --- a/customize.dist/login.js +++ b/customize.dist/login.js @@ -15,11 +15,12 @@ define([ '/components/nthen/index.js', '/common/outer/login-block.js', '/common/common-hash.js', + '/common/outer/http-command.js', '/components/tweetnacl/nacl-fast.min.js', '/components/scrypt-async/scrypt-async.min.js', // better load speed ], function ($, Listmap, Crypto, Util, NetConfig, Cred, ChainPad, Realtime, Constants, UI, - Feedback, LocalStore, Messages, nThen, Block, Hash) { + Feedback, LocalStore, Messages, nThen, Block, Hash, ServerCommand) { var Exports = { Cred: Cred, Block: Block, @@ -99,7 +100,6 @@ define([ opt.channelHex = parsed.channel; opt.keys = parsed.keys; opt.edPublic = blockInfo.edPublic; - opt.User_name = blockInfo.User_name; return opt; }; @@ -135,7 +135,7 @@ define([ Exports.mergeAnonDrive = 1; }; - Exports.loginOrRegister = function (uname, passwd, isRegister, shouldImport, cb) { + Exports.loginOrRegister = function (uname, passwd, isRegister, shouldImport, onOTP, cb) { if (typeof(cb) !== 'function') { return; } // Usernames are all lowercase. No going back on this one @@ -173,26 +173,113 @@ define([ // determine where a block for your set of keys would be stored blockUrl = Block.getBlockUrl(res.opt.blockKeys); - // Check whether there is a block at that location - Util.fetch(blockUrl, waitFor(function (err, block) { - // if users try to log in or register, we must check - // whether there is a block. + var TOTP_prompt = function (err, cb) { + onOTP(function (code) { + ServerCommand(res.opt.blockKeys.sign, { + command: 'TOTP_VALIDATE', + code: code, + // TODO optionally allow the user to specify a lifetime for this session? + // this will require a little bit of server work + // and more UI/UX: + // ie. just a simple "remember me" checkbox? + // allow them to specify a lifetime for the session? + // "log me out after one day"? + }, cb); + }, false, err); + }; - // the block is only useful if it can be decrypted, though - if (err) { - console.log("no block found"); - return; - } + var done = waitFor(); + var responseToDecryptedBlock = function (response, cb) { + response.arrayBuffer().then(arraybuffer => { + arraybuffer = new Uint8Array(arraybuffer); + var decryptedBlock = Block.decrypt(arraybuffer, blockKeys); + if (!decryptedBlock) { + console.error("BLOCK DECRYPTION ERROR"); + return void cb("BLOCK_DECRYPTION_ERROR"); + } + cb(void 0, decryptedBlock); + }); + }; - var decryptedBlock = Block.decrypt(block, blockKeys); - if (!decryptedBlock) { - console.error("Found a login block but failed to decrypt"); - return; - } + var TOTP_response; + nThen(function (w) { + Util.getBlock(blockUrl, { + // request the block without credentials + }, w(function (err, response) { + if (err === 401) { + return void console.log("Block requires 2FA"); + } - //console.error(decryptedBlock); - res.blockInfo = decryptedBlock; - })); + // Some other error? + if (err) { + console.error(err); + w.abort(); + return void done(); + } + + // If the block was returned without requiring authentication + // then we can abort the subsequent steps of this nested nThen + w.abort(); + + // decrypt the response and continue the normal procedure with its payload + responseToDecryptedBlock(response, function (err, decryptedBlock) { + if (err) { + // if a block was present but you were not able to decrypt it... + console.error(err); + waitFor.abort(); + return void cb(err); + } + res.blockInfo = decryptedBlock; + done(); + }); + })); + }).nThen(function (w) { + // if you're here then you need to request a JWT + var done = w(); + var tries = 3; + var ask = function () { + if (!tries) { + w.abort(); + waitFor.abort(); + return void cb('TOTP_ATTEMPTS_EXHAUSTED'); + } + tries--; + TOTP_prompt(tries !== 2, function (err, response) { + // ask again until your number of tries are exhausted + if (err) { + console.error(err); + console.log("Normal failure. Asking again..."); + return void ask(); + } + if (!response || !response.bearer) { + console.log(response); + console.log("Unexpected failure. No bearer token. Asking again"); + return void ask(); + } + console.log("Successfully retrieved a bearer token"); + res.TOTP_token = TOTP_response = response; + done(); + }); + }; + ask(); + }).nThen(function (w) { + Util.getBlock(blockUrl, TOTP_response, function (err, response) { + if (err) { + w.abort(); + console.error(err); + return void cb('BLOCK_ERROR_3'); + } + + responseToDecryptedBlock(response, function (err, decryptedBlock) { + if (err) { + waitFor.abort(); + return void cb(err); + } + res.blockInfo = decryptedBlock; + done(); + }); + }); + }); }).nThen(function (waitFor) { // we assume that if there is a block, it was created in a valid manner // so, just proceed to the next block which handles that stuff @@ -275,7 +362,7 @@ define([ Realtime.whenRealtimeSyncs(rt.realtime, function () { // the following stages are there to initialize a new drive // if you are registering - LocalStore.login(res.userHash, res.userName, function () { + LocalStore.login(res.userHash, undefined, res.userName, function () { setTimeout(function () { cb(void 0, res); }); }); }); @@ -348,7 +435,6 @@ define([ } if (!isRegister && !isProxyEmpty(rt.proxy)) { - LocalStore.setBlockHash(blockHash); waitFor.abort(); if (shouldImport) { setMergeAnonDrive(); @@ -358,7 +444,11 @@ define([ if (l) { localStorage.setItem(LS_LANG, l); } - return void LocalStore.login(userHash, uname, function () { + + if (res.TOTP_token && res.TOTP_token.bearer) { + LocalStore.setSessionToken(res.TOTP_token.bearer); + } + return void LocalStore.login(undefined, blockHash, uname, function () { cb(void 0, res); }); } @@ -409,12 +499,19 @@ define([ // Finally, create the login block for the object you just created. var toPublish = {}; - toPublish[Constants.userNameKey] = uname; +// XXX I did some basic testing and searching and could not find this attribute +// actually being used anywhere. Including it means either supporting arbitrarily +// large blocks (a DoS vector) or having registration fail for large usernames. +// Can someone please double-check that removing this doesn't break anything? +// --Aaron + //toPublish[Constants.userNameKey] = uname; toPublish[Constants.userHashKey] = userHash; toPublish.edPublic = RT.proxy.edPublic; - var blockRequest = Block.serialize(JSON.stringify(toPublish), res.opt.blockKeys); - rpc.writeLoginBlock(blockRequest, waitFor(function (e) { + Block.writeLoginBlock({ + blockKeys: blockKeys, + content: toPublish + }, waitFor(function (e) { if (e) { console.error(e); waitFor.abort(); @@ -431,8 +528,7 @@ define([ } console.log("blockInfo available at:", blockHash); - LocalStore.setBlockHash(blockHash); - LocalStore.login(userHash, uname, function () { + LocalStore.login(undefined, blockHash, uname, function () { cb(void 0, res); }); })); @@ -458,7 +554,7 @@ define([ }; var hashing; - Exports.loginOrRegisterUI = function (uname, passwd, isRegister, shouldImport, testing, test) { + Exports.loginOrRegisterUI = function (uname, passwd, isRegister, shouldImport, onOTP, testing, test) { if (hashing) { return void console.log("hashing is already in progress"); } hashing = true; @@ -483,7 +579,7 @@ define([ // We need a setTimeout(cb, 0) otherwise the loading screen is only displayed // after hashing the password window.setTimeout(function () { - Exports.loginOrRegister(uname, passwd, isRegister, shouldImport, function (err, result) { + Exports.loginOrRegister(uname, passwd, isRegister, shouldImport, onOTP, function (err, result) { var proxy; if (result) { proxy = result.proxy; } @@ -537,11 +633,9 @@ define([ proxy[Constants.displayNameKey] = uname; } - if (result.blockHash) { - LocalStore.setBlockHash(result.blockHash); - } - - LocalStore.login(result.userHash, result.userName, function () { + var block = result.blockHash; + var user = block ? undefined : result.userHash; + LocalStore.login(user, block, result.userName, function () { setTimeout(function () { proceed(result); }); }); }); diff --git a/customize.dist/pages/recovery.js b/customize.dist/pages/recovery.js new file mode 100644 index 000000000..b34758bed --- /dev/null +++ b/customize.dist/pages/recovery.js @@ -0,0 +1,100 @@ +define([ + '/api/config', + 'jquery', + '/common/hyperscript.js', + '/common/common-interface.js', + '/customize/messages.js', + '/customize/pages.js' +], function (Config, $, h, UI, Msg, Pages) { + +Msg.recovery_header = "Account recovery"; // XXX +Msg.recovery_mfa_description = "If you have lost access to your Two-Factor Authentication method you can disable 2FA for your account using your recovery code. Please start by entering your login and password:"; +Msg.recovery_mfa_secret = "Please enter your recovery code to disable 2FA for your account:"; +Msg.recovery_mfa_secret_ph = "Recovery code"; + +Msg.mfa_disable = "Disable 2FA"; // XXX also in settings +Msg.continue = "Continue"; // XXX also in settings + +Msg.recovery_forgot = 'Forgot recovery code'; +Msg.recovery_forgot_text = 'Please copy the following information and email it toyour instance administrators'; + +Msg.recovery_mfa_wrong = "Invalid username or password"; +Msg.recovery_mfa_error = "Unknown error. Please reload and try again."; +Msg.recovery_mfa_disabled = "Multi-factor authentication is already disabled for this account."; + + return function () { + document.title = Msg.recovery_header; + + 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(), + ]), + ]; + }; + + return frame([ + h('div.row.cp-recovery-det', [ + h('div.hidden.col-md-3'), + h('div#userForm.form-group.hidden.col-md-6', [ + h('div.cp-recovery-step.step1', [ + h('p', Msg.recovery_mfa_description), + h('div.alert.alert-danger.wrong-cred.cp-hidden', Msg.recovery_mfa_wrong), + 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.continue) + ) + ]), + h('div.cp-recovery-step.step2', { style: 'display: none;' }, [ + h('label', Msg.recovery_mfa_secret), + h('input.form-control#mfarecovery', { + type: 'text', + autocomplete: 'off', + autocorrect: 'off', + autocapitalize: 'off', + spellcheck: false, + placeholder: Msg.recovery_mfa_secret_ph, + autofocus: true, + }), + h('div.cp-recovery-forgot', [ + h('i.fa.fa-caret-right'), + h('span', Msg.recovery_forgot) + ]), + h('div.cp-recovery-alt', { style: 'display: none;' }, [ + UI.setHTML(h('div'), + Msg._getKey('recovery_forgot_text', [Config.adminEmail || ''])), + h('textarea.cp-recover-email', {readonly: 'readonly'}), + h('button.btn.btn-secondary#mfacopyproof', Msg.copyToClipboard), + ]), + h('div.cp-recover-button', + h('button.btn.btn-primary#cp-recover', Msg.mfa_disable) + ) + ]), + h('div.cp-recovery-step.step-info', { style: 'display: none;' }, [ + h('div.alert.alert-info.cp-hidden.disabled', Msg.recovery_mfa_disabled), + h('div.alert.alert-danger.cp-hidden.unknown-error', Msg.recovery_mfa_error), + ]), + ]), + h('div.hidden.col-md-3'), + ]) + ]); + }; + +}); + diff --git a/customize.dist/src/less2/include/alertify.less b/customize.dist/src/less2/include/alertify.less index 3cd4c0318..260716aa0 100644 --- a/customize.dist/src/less2/include/alertify.less +++ b/customize.dist/src/less2/include/alertify.less @@ -487,5 +487,32 @@ overflow-x: auto; } } + // XXX this might not be the best place for this. + // I just put it next to other "share" styles + // --Aaron + #cp-qr-container { + position: relative; + background-color: white; + display: inline-flex; + padding: @alertify_padding-base; + border-radius: @variables_radius_L; + #cp-qr-blocker { + position: absolute; + height: 100%; + width: 100%; + border-radius: @variables_radius_L; + + margin: -@alertify_padding-base; + padding-top: @alertify_padding-base * 2; + text-align: center; + + background: @cryptpad_color_brand; + color: @cryptpad_text_col; + font-weight: bold; + &.hidden { + opacity: 0; + } + } + } } 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-login.less b/customize.dist/src/less2/pages/page-login.less index f6e417c19..b70ac0de5 100644 --- a/customize.dist/src/less2/pages/page-login.less +++ b/customize.dist/src/less2/pages/page-login.less @@ -53,6 +53,13 @@ } } } + + .cp-password-form { + flex-flow: row !important; + input:not(:last-child) { + margin-right: 10px; + } + } .cp-container { padding-top: 3em; min-height: 66vh; 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..872707fe7 --- /dev/null +++ b/customize.dist/src/less2/pages/page-recovery.less @@ -0,0 +1,106 @@ +@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: right; + } + .cp-recovery-forgot { + cursor: pointer; + i { + margin-right: 5px; + width: 10px; + } + } + .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 969c280f0..15168ee5d 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/docs/example.nginx.conf b/docs/example.nginx.conf index 3e546f765..4349433b5 100644 --- a/docs/example.nginx.conf +++ b/docs/example.nginx.conf @@ -79,6 +79,7 @@ server { add_header X-XSS-Protection "1; mode=block"; add_header X-Content-Type-Options nosniff; add_header Access-Control-Allow-Origin "${allowed_origins}"; + add_header Access-Control-Allow-Credentials true; # add_header X-Frame-Options "SAMEORIGIN"; # Opt out of Google's FLoC Network @@ -178,7 +179,12 @@ server { # We prefer to serve static content from nginx directly and to leave the API server to handle # the dynamic content that only it can manage. This is primarily an optimization location ^~ /cryptpad_websocket { - proxy_pass http://localhost:3000; + # XXX + # static assets like blobs and blocks are served by clustered workers in the API server + # Websocket traffic still needs to be handled by the main process, which means it needs + # to be hosted on a different port. By default 3003 will be used, though this is configurable + # via config.websocketPort + proxy_pass http://localhost:3003; proxy_set_header X-Real-IP $remote_addr; proxy_set_header Host $host; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; @@ -217,10 +223,15 @@ server { add_header Cross-Origin-Embedder-Policy require-corp; } - # encrypted blobs are immutable and are thus cached for a year - location ^~ /blob/ { + # Requests for blobs and blocks are now proxied to the API server + # This simplifies NGINX path configuration in the event they are being hosted in a non-standard location + # or with odd unexpected permissions. Serving blobs in this manner also means that it will be possible to + # enforce access control for them, though this is not yet implemented. + # Access control (via TOTP 2FA) has been added to blocks, so they can be handled with the same directives. + location ~ ^/(blob|block)/.*$ { if ($request_method = 'OPTIONS') { add_header 'Access-Control-Allow-Origin' "${allowed_origins}"; + add_header 'Access-Control-Allow-Credentials' true; add_header 'Access-Control-Allow-Methods' 'GET, POST, OPTIONS'; add_header 'Access-Control-Allow-Headers' 'DNT,X-CustomHeader,Keep-Alive,User-Agent,X-Requested-With,If-Modified-Since,Cache-Control,Content-Type,Content-Range,Range'; add_header 'Access-Control-Max-Age' 1728000; @@ -228,32 +239,15 @@ server { add_header 'Content-Length' 0; return 204; } - add_header X-Content-Type-Options nosniff; - add_header Cache-Control max-age=31536000; - add_header 'Access-Control-Allow-Origin' "${allowed_origins}"; - add_header 'Access-Control-Allow-Methods' 'GET, POST, OPTIONS'; - add_header 'Access-Control-Allow-Headers' 'DNT,X-CustomHeader,Keep-Alive,User-Agent,X-Requested-With,If-Modified-Since,Cache-Control,Content-Type,Content-Range,Range,Content-Length'; - add_header 'Access-Control-Expose-Headers' 'DNT,X-CustomHeader,Keep-Alive,User-Agent,X-Requested-With,If-Modified-Since,Cache-Control,Content-Type,Content-Range,Range,Content-Length'; - try_files $uri =404; + # Since we are proxying to the API server these headers can get duplicated + # so we hide them + proxy_hide_header 'X-Content-Type-Options'; + proxy_hide_header 'Access-Control-Allow-Origin'; + proxy_hide_header 'Permissions-Policy'; + proxy_hide_header 'X-XSS-Protection';^ + proxy_pass http://localhost:3000; } - # the "block-store" serves encrypted payloads containing users' drive keys - # these payloads are unlocked via login credentials. They are mutable - # and are thus never cached. They're small enough that it doesn't matter, in any case. - location ^~ /block/ { - add_header X-Content-Type-Options nosniff; - add_header Cache-Control max-age=0; - try_files $uri =404; - } - - # This block provides an alternative means of loading content - # otherwise only served via websocket. This is solely for debugging purposes, - # and is thus not allowed by default. - #location ^~ /datastore/ { - #add_header Cache-Control max-age=0; - #try_files $uri =404; - #} - # The nodejs server has some built-in forwarding rules to prevent # URLs like /pad from resulting in a 404. This simply adds a trailing slash # to a variety of applications. diff --git a/lib/api.js b/lib/api.js index 9a317be86..252edc1a4 100644 --- a/lib/api.js +++ b/lib/api.js @@ -6,6 +6,7 @@ const Decrees = require("./decrees"); const nThen = require("nthen"); const Fs = require("fs"); const Path = require("path"); +const Nacl = require("tweetnacl/nacl-fast"); module.exports.create = function (Env) { var log = Env.Log; @@ -21,6 +22,21 @@ nThen(function (w) { console.error(err); } })); +}).nThen(function (w) { + // we assume the server has generated a secret used to validate JWT tokens + if (typeof(Env.bearerSecret) === 'string') { return; } + // if one does not exist, then create one and remember it + // 256 bits + var bearerSecret = Nacl.util.encodeBase64(Nacl.randomBytes(32)); + Env.Log.info("GENERATING_BEARER_SECRET", {}); + Decrees.write(Env, [ + 'SET_BEARER_SECRET', + [bearerSecret], + 'INTERNAL', + +new Date() + ], w(function (err) { + if (err) { throw err; } + })); }).nThen(function (w) { var fullPath = Path.join(Env.paths.block, 'placeholder.txt'); Fs.writeFile(fullPath, 'PLACEHOLDER\n', w()); diff --git a/lib/challenge-commands/base.js b/lib/challenge-commands/base.js new file mode 100644 index 000000000..9efbc7d3b --- /dev/null +++ b/lib/challenge-commands/base.js @@ -0,0 +1,76 @@ +const Block = require("../commands/block"); +const MFA = require("../storage/mfa"); +const Util = require("../common-util"); + +const Commands = module.exports; + +var isValidBlockId = Block.isValidBlockId; + +// Read the MFA settings for the given public key +const checkMFA = (Env, publicKey, cb) => { + // Success if we can't get the MFA settings + MFA.read(Env, publicKey, function (err, content) { + if (err) { + if (err.code !== "ENOENT") { + Env.Log.error('TOTP_VALIDATE_MFA_READ', { + error: err, + publicKey: publicKey, + }); + } + return void cb(); + } + + var parsed = Util.tryParse(content); + if (!parsed) { return void cb(); } + + cb("NOT_ALLOWED"); + }); +}; + +// Make sure the block is not protected by MFA but don't do anything else +const check = Commands.MFA_CHECK = function (Env, body, cb) { + var { publicKey } = body; + if (!isValidBlockId(publicKey)) { return void cb("INVALID_KEY"); } + checkMFA(Env, publicKey, cb); +}; +check.complete = function (Env, body, cb) { cb(); }; + +// Write a login block IFF +// 1. You can sign for the block's public key +// 2. the block is not protected by MFA +// Note: the internal WRITE_LOGIN_BLOCK will check is you're allowed to create this block +const writeBlock = Commands.WRITE_BLOCK = function (Env, body, cb) { + const { publicKey, content } = body; + + // they must provide a valid block public key + if (!isValidBlockId(publicKey)) { return void cb("INVALID_KEY"); } + if (publicKey !== content.publicKey) { return void cb("INVALID_KEY"); } + + // check MFA + checkMFA(Env, publicKey, cb); +}; + +writeBlock.complete = function (Env, body, cb) { + const { content } = body; + Block.writeLoginBlock(Env, content, cb); +}; + +// Remove a login block IFF +// 1. You can sign for the block's public key +// 2. the block is not protected by MFA +const removeBlock = Commands.REMOVE_BLOCK = function (Env, body, cb) { + const { publicKey } = body; + + // they must provide a valid block public key + if (!isValidBlockId(publicKey)) { return void cb("INVALID_KEY"); } + + // check MFA + checkMFA(Env, publicKey, cb); +}; + +removeBlock.complete = function (Env, body, cb) { + const { publicKey } = body; + Block.removeLoginBlock(Env, publicKey, cb); +}; + + diff --git a/lib/challenge-commands/totp.js b/lib/challenge-commands/totp.js new file mode 100644 index 000000000..45f2cedd6 --- /dev/null +++ b/lib/challenge-commands/totp.js @@ -0,0 +1,534 @@ +/* globals Buffer */ +const B32 = require("thirty-two"); +const OTP = require("notp"); +const JWT = require("jsonwebtoken"); +const nThen = require("nthen"); +const Util = require("../common-util"); + +const MFA = require("../storage/mfa"); +const Sessions = require("../storage/sessions"); +const BlockStore = require("../storage/block"); +const Block = require("../commands/block"); + +const Commands = module.exports; + +var isString = s => typeof(s) === 'string'; + +// basic definition of what we'll accept as an OTP code +// exactly six numerical digits +var isValidOTP = otp => { + return isString(otp) && + // in the future this could be updated to support 8 digits + otp.length === 6 && + // \D is non-digit characters, so this tests that it is exclusively numeric + !/\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 +// any attempt relying on this should fail when we can't decode +// the id they provided. +var isValidBlockId = Block.isValidBlockId; + +// the base32 library can throw when decoding under various conditions. +// we have some basic requirements for the length of base32 as well, +// so we just do all the validation here. It either returns a buffer +// of length 20 or undefined, so the caller can just check whether it's +// falsey and otherwise assume it was well-formed +// Length === 20 comes from the recommendation of 160 bits of entropy +// in RFC4226 (https://www.rfc-editor.org/rfc/rfc4226#section-4) +var decode32 = S => { + let decoded; + try { + decoded = B32.decode(S); + } catch (err) { return; } + if (!(decoded instanceof Buffer) || decoded.length !== 20) { return; } + 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 + // which is used to look up whether it has been revoked. + 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 + algorithm: 'HS512', + // if you want it to expire you can set this for an arbitrary number of seconds in the future, but I won't assume that for now + //expiresIn: (60 * 60 * 24 * 7)), + }, function (err, token) { + if (err) { return void cb(err); } + cb(void 0, token); + }); +}; + +// Create a session with a token for the given public key +const makeSession = (Env, publicKey, cb) => { + const sessionId = Sessions.randomId(); + var token; + nThen(function (w) { + /*createJWT(Env, sessionId, publicKey, w(function (err, _token) { + if (err) { + Env.Log.error("TOTP_VALIDATE_JWT_SIGN_ERROR", { + error: Util.serializeError(err), + publicKey: publicKey, + }); + w.abort(); + return void cb("TOKEN_ERROR"); + } + token = _token; + }));*/ + }).nThen(function (w) { + // store the token + Sessions.write(Env, publicKey, sessionId, JSON.stringify({ + mfa: { + type: 'otp', + exp: (+new Date()) + EXPIRATION + } + }), w(function (err) { + if (err) { + Env.Log.error("TOTP_VALIDATE_SESSION_WRITE", { + error: Util.serializeError(err), + publicKey: publicKey, + sessionId: sessionId, + }); + w.abort(); + return void cb("SESSION_WRITE_ERROR"); + } + // else continue + })); + }).nThen(function () { + cb(void 0, { + bearer: sessionId, + }); + }); + +}; +// Read the MFA settings for the given public key +const readMFA = (Env, publicKey, cb) => { + // check that there is an MFA configuration for the given account + MFA.read(Env, publicKey, function (err, content) { + if (err) { + Env.Log.error('TOTP_VALIDATE_MFA_READ', { + error: err, + publicKey: publicKey, + }); + return void cb('NO_MFA_CONFIGURED'); + } + + var parsed = Util.tryParse(content); + if (!parsed) { return void cb("INVALID_CONFIGURATION"); } + cb(undefined, parsed); + }); +}; +// Check if an OTP code is valid against the provided secret +const checkCode = (Env, secret, code, publicKey, _cb) => { + const cb = Util.mkAsync(_cb); + + 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(); +}; + + +// This command allows clients to configure TOTP as a second factor protecting +// their login block IFF they: +// 1. provide a sufficiently strong TOTP secret +// 2. are able to produce a valid OTP code for that secret (indicating that their clock is sufficiently close to ours) +// 3. such a login block actually exists +// 4. are able to sign an arbitrary message for the login block's public key +// 5. have not already configured TOTP protection for this account +// (changing to a new secret can be done by disabling and re-enabling TOTP 2FA) +const TOTP_SETUP = Commands.TOTP_SETUP = function (Env, body, cb) { + const { publicKey, secret, code, contact } = body; + + + // the client MUST provide an OTP code of the expected format + // this doesn't check if it matches the secret and time, just that it's well-formed + if (!isValidOTP(code)) { return void cb("E_INVALID"); } + + // if they provide an (optional) point of contact as a recovery mechanism then it should be a string. + // the intent is to allow to specify some side channel for those who inevitably lock themselves out + // we should be able to use that to validate their identity. + // I don't want to assume email, but limiting its length to 254 (the maximum email length) seems fair. + if (contact && (!isString(contact) || contact.length > 254)) { return void cb("INVALID_CONTACT"); } + + // Check that the provided public key is the expected format for a block + if (!isValidBlockId(publicKey)) { + return void cb("INVALID_KEY"); + } + + // decode32 checks whether the secret decodes to a sufficiently long buffer + var decoded = decode32(secret); + if (!decoded) { return void cb('INVALID_SECRET'); } + + // Reject attempts to setup TOTP if a record of their preferences already exists + MFA.read(Env, publicKey, function (err) { + // There **should be** an error here, because anything else + // means that a record already exists + // This may need to be adjusted as other methods of MFA are added + if (!err) { return void cb("EEXISTS"); } + + // if no MFA settings exist then we expect ENOENT + // anything else indicates a problem and should result in rejection + if (err.code !== 'ENOENT') { return void cb(err); } + try { + // allow for 30s of clock drift in either direction + // returns an object ({ delta: 0 }) indicating the amount of clock drift + // if successful, otherwise `null` + var validated = OTP.totp.verify(code, decoded, { + window: 1, + }); + if (!validated) { return void cb("INVALID_OTP"); } + cb(); + } catch (err2) { + Env.Log.error('TOTP_SETUP_VERIFICATION_ERROR', { + error: err2, + }); + return void cb("INTERNAL_ERROR"); + } + }); +}; + +// The 'complete' step for TOTP_SETUP will only be called if the client +// passed earlier validation and successfully signed the server's challenge. +// There's still a little bit more to do and it could still fail. +TOTP_SETUP.complete = function (Env, body, cb) { + // the OTP code should have already been validated + var { publicKey, secret, contact } = body; + + // the device from which they configure MFA settings + // is assumed to be safe, so we'll respond with a JWT token + // the remainder of the setup is successfully completed. + // Otherwise they would have to reauthenticate. + // The session id is used as a reference to this particular session. + nThen(function (w) { + // confirm that the block exists + BlockStore.check(Env, publicKey, w(function (err) { + if (err) { + Env.Log.error("TOTP_SETUP_NO_BLOCK", { + publicKey, + }); + w.abort(); + return void cb("NO_BLOCK"); + } + // otherwise the block exists, continue + })); + }).nThen(function (w) { + // store the data you'll need in the future + var data = { + method: 'TOTP', // specify this so it's easier to add other methods later? + secret: secret, // the 160 bit, base32-encoded secret that is used for OTP validation + creation: new Date(), // the moment at which the MFA was configured + }; + + if (isString(contact)) { + // 'contact' is an arbitary (and optional) string for manual recovery from 2FA auth fails + // it should already be validated + data.contact = contact; + } + + // We attempt to store a record of the above preferences + // if it fails then we abort and inform the client of an error. + MFA.write(Env, publicKey, JSON.stringify(data), w(function (err) { + if (err) { + w.abort(); + Env.Log.error("TOTP_SETUP_STORAGE_FAILURE", { + publicKey: publicKey, + error: err, + }); + return void cb('STORAGE_FAILURE'); + } + // otherwise continue + })); + }).nThen(function () { + // we have already stored the MFA data, which will cause access to the resource to be restricted to the provided TOTP secret. + // we attempt to create a session as a matter of convenience - so if it fails + // that just means they'll be forced to authenticate + makeSession(Env, publicKey, cb); + }); +}; + +// This command is somewhat simpler than TOTP_SETUP +// Issue a client a JWT which will allow them to access 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 validate = Commands.TOTP_VALIDATE = 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 + readMFA(Env, publicKey, w(function (err, content) { + if (err) { + w.abort(); + return void cb(err); + } + secret = content.secret; + })); + }).nThen(function () { + checkCode(Env, secret, code, publicKey, cb); + }); +}; + +validate.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. instanciate a session for them by generating and storing a token for their public key +2. send them the token + +*/ + var { publicKey } = body; + makeSession(Env, publicKey, cb); +}; + +// Same as TOTP_VALIDATE but without making a session at the end +const check = Commands.TOTP_CHECK = function (Env, body, cb) { + var { publicKey, auth } = body; + const code = auth; + if (!isValidOTP(code)) { return void cb('E_INVALID'); } + if (!isValidBlockId(publicKey)) { return void cb("INVALID_KEY"); } + var secret; + nThen(function (w) { + readMFA(Env, publicKey, w(function (err, content) { + if (err) { + w.abort(); + return void cb(err); + } + secret = content.secret; + })); + }).nThen(function () { + checkCode(Env, secret, code, publicKey, cb); + }); +}; +check.complete = function (Env, body, cb) { cb(); }; + + +// 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, recoveryKey } = body; + + // they must provide a valid OTP code + 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, recoveryStored; + nThen(function (w) { + // check that there is an MFA configuration for the given account + readMFA(Env, publicKey, w(function (err, content) { + if (err) { + w.abort(); + return void cb(err); + } + secret = content.secret; + recoveryStored = content.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 () { + checkCode(Env, secret, code, publicKey, 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; + MFA.revoke(Env, publicKey, cb); +}; + + + +// Write a login block using an existing OTP block IFF +// 1. You can sign for the block's public key +// 2. You have a proof for the old block +// 3. The old block is OTP protected +// 4. The OTP code is valid +// Note: this is used when users change their password +const writeBlock = Commands.TOTP_WRITE_BLOCK = function (Env, body, cb) { + const { publicKey, content } = body; + const code = content.auth; + const registrationProof = content.registrationProof; + + // they must provide a valid block public key + if (!isValidBlockId(publicKey)) { return void cb("INVALID_KEY"); } + if (publicKey !== content.publicKey) { return void cb("INVALID_KEY"); } + if (!isValidOTP(code)) { return void cb('E_INVALID'); } + if (!registrationProof) { return void cb('MISSING_ANCESTOR'); } + + let secret; + let oldKey; + nThen(function (w) { + Block.validateAncestorProof(Env, registrationProof, w((err, provenKey) => { + if (err || !provenKey) { + w.abort(); + return void cb('INVALID_ANCESTOR'); + } + oldKey = provenKey; + })); + }).nThen(function (w) { + // check that there is an MFA configuration for the ancestor account + readMFA(Env, oldKey, w(function (err, content) { + if (err) { + w.abort(); + return void cb(err); + } + secret = content.secret; + })); + }).nThen(function () { + // check that the OTP code is valid + checkCode(Env, secret, code, oldKey, cb); + }); +}; + + + +writeBlock.complete = function (Env, body, cb) { + const { publicKey, content } = body; + nThen(function (w) { + // Write new block + Block.writeLoginBlock(Env, content, w((err) => { + if (err) { + w.abort(); + return void cb("BLOCK_WRITE_ERROR"); + } + })); + }).nThen(function (w) { + // Copy MFA settings + const proof = Util.tryParse(content.registrationProof); + const oldKey = proof && proof[0]; + if (!oldKey) { + w.abort(); + return void cb('INVALID_ANCESTOR'); + } + MFA.copy(Env, oldKey, publicKey, w()); + }).nThen(function () { + // Create a session for the current user + makeSession(Env, publicKey, cb); + }); +}; + +// Remove a login block IFF +// 1. You can sign for the block's public key +const removeBlock = Commands.TOTP_REMOVE_BLOCK = function (Env, body, cb) { + const { publicKey, auth } = body; + const code = auth; + + // they must provide a valid block public key + if (!isValidBlockId(publicKey)) { return void cb("INVALID_KEY"); } + if (!isValidOTP(code)) { return void cb('E_INVALID'); } + + let secret; + nThen(function (w) { + // check that there is an MFA configuration for this block + readMFA(Env, publicKey, w(function (err, content) { + if (err) { + w.abort(); + return void cb(err); + } + secret = content.secret; + })); + }).nThen(function () { + // check that the OTP code is valid + checkCode(Env, secret, code, publicKey, cb); + }); +}; + +removeBlock.complete = function (Env, body, cb) { + const { publicKey } = body; + nThen(function (w) { + // Remove the block + Block.removeLoginBlock(Env, publicKey, w((err) => { + if (err) { + w.abort(); + return void cb(err); + } + })); + }).nThen(() => { + // Delete the MFA settings and sessions + MFA.revoke(Env, publicKey, cb); + }); +}; + diff --git a/lib/commands/admin-rpc.js b/lib/commands/admin-rpc.js index 7200ce49b..071ce31f0 100644 --- a/lib/commands/admin-rpc.js +++ b/lib/commands/admin-rpc.js @@ -9,6 +9,7 @@ const Pinning = require("./pin-rpc"); const Core = require("./core"); const Channel = require("./channel"); const BlockStore = require("../storage/block"); +const MFA = require("../storage/mfa"); var Fs = require("fs"); @@ -498,6 +499,19 @@ var getDocumentStatus = function (Env, Server, cb, data) { } response.archived = result; })); + MFA.read(Env, id, w(function (err, v) { + if (err === 'ENOENT') { + response.totp = 'DISABLED'; + } else if (v) { + var parsed = Util.tryParse(v); + response.totp = { + enabled: true, + recovery: parsed.contact && parsed.contact.split(':')[0] + }; + } else { + response.totp = err; + } + })); }).nThen(function () { cb(void 0, response); }); @@ -539,6 +553,12 @@ var getDocumentStatus = function (Env, Server, cb, data) { }); }; +var disableMFA = function (Env, Server, cb, data) { + var id = Array.isArray(data) && data[1]; + if (typeof(id) !== 'string' || id.length !== 44) { return void cb("EINVAL"); } + MFA.revoke(Env, id, cb); +}; + var getPinList = function (Env, Server, cb, data) { var key = Array.isArray(data) && data[1]; if (!isValidKey(key)) { return void cb("EINVAL"); } @@ -746,6 +766,8 @@ var commands = { GET_LAST_CHANNEL_TIME: getLastChannelTime, GET_DOCUMENT_STATUS: getDocumentStatus, + DISABLE_MFA: disableMFA, + GET_PIN_LIST: getPinList, GET_PIN_HISTORY: getPinHistory, ARCHIVE_PIN_LOG: archivePinLog, diff --git a/lib/commands/block.js b/lib/commands/block.js index 4af32af70..bb7150b17 100644 --- a/lib/commands/block.js +++ b/lib/commands/block.js @@ -6,6 +6,11 @@ const nThen = require("nthen"); const Util = require("../common-util"); const BlockStore = require("../storage/block"); +var isString = s => typeof(s) === 'string'; +Block.isValidBlockId = id => { + return id && isString(id) && id.length === 44; +}; + /* We assume that the server is secured against MitM attacks via HTTPS, and that malicious actors do not have code execution @@ -98,33 +103,24 @@ Block.validateAncestorProof = function (Env, proof, _cb) { } }; -Block.writeLoginBlock = function (Env, safeKey, msg, _cb) { +Block.writeLoginBlock = function (Env, msg, _cb) { var cb = Util.once(Util.mkAsync(_cb)); - var publicKey = msg[0]; - var signature = msg[1]; - var block = msg[2]; - var registrationProof = msg[3]; - var previousKey; + const { publicKey, signature, ciphertext, registrationProof } = msg; + var previousKey; var validatedBlock, path; nThen(function (w) { - if (Util.escapeKeyCharacters(publicKey) !== safeKey) { - w.abort(); - return void cb("INCORRECT_KEY"); - } - }).nThen(function (w) { if (!Env.restrictRegistration) { return; } if (!registrationProof) { // we allow users with existing blocks to create new ones // call back with error if registration is restricted and no proof of an existing block was provided w.abort(); Env.Log.info("BLOCK_REJECTED_REGISTRATION", { - safeKey: safeKey, publicKey: publicKey, }); return cb("E_RESTRICTED"); } - Env.validateAncestorProof(registrationProof, w(function (err, provenKey) { + Block.validateAncestorProof(Env, registrationProof, w(function (err, provenKey) { if (err || !provenKey) { // double check that a key was validated w.abort(); Env.Log.warn('BLOCK_REJECTED_INVALID_ANCESTOR', { @@ -135,7 +131,7 @@ Block.writeLoginBlock = function (Env, safeKey, msg, _cb) { previousKey = provenKey; })); }).nThen(function (w) { - Env.validateLoginBlock(publicKey, signature, block, w(function (e, _validatedBlock) { + Block.validateLoginBlock(Env, publicKey, signature, ciphertext, w(function (e, _validatedBlock) { if (e) { w.abort(); return void cb(e); @@ -156,7 +152,6 @@ Block.writeLoginBlock = function (Env, safeKey, msg, _cb) { } BlockStore.write(Env, publicKey, buffer, function (err) { Env.Log.info('BLOCK_WRITE_BY_OWNER', { - safeKey: safeKey, blockId: publicKey, isChange: Boolean(registrationProof), previousKey: previousKey, @@ -167,8 +162,6 @@ Block.writeLoginBlock = function (Env, safeKey, msg, _cb) { }); }; -const DELETE_BLOCK = Nacl.util.encodeBase64(Nacl.util.decodeUTF8('DELETE_BLOCK')); - /* When users write a block, they upload the block, and provide a signature proving that they deserve to be able to write to @@ -179,28 +172,15 @@ const DELETE_BLOCK = Nacl.util.encodeBase64(Nacl.util.decodeUTF8('DELETE_BLOCK') information, we can just sign some constant and use that as proof. */ -Block.removeLoginBlock = function (Env, safeKey, msg, _cb) { +Block.removeLoginBlock = function (Env, publicKey, _cb) { var cb = Util.once(Util.mkAsync(_cb)); - var publicKey = msg[0]; - var signature = msg[1]; - - nThen(function (w) { - if (Util.escapeKeyCharacters(publicKey) !== safeKey) { - w.abort(); - return void cb("INCORRECT_KEY"); - } - }).nThen(function () { - Env.validateLoginBlock(publicKey, signature, DELETE_BLOCK, function (e) { - if (e) { return void cb(e); } - BlockStore.archive(Env, publicKey, function (err) { - Env.Log.info('ARCHIVAL_BLOCK_BY_OWNER_RPC', { - publicKey: publicKey, - status: err? String(err): 'SUCCESS', - }); - cb(err); - }); + BlockStore.archive(Env, publicKey, function (err) { + Env.Log.info('ARCHIVAL_BLOCK_BY_OWNER_RPC', { + publicKey: publicKey, + status: err? String(err): 'SUCCESS', }); + cb(err); }); }; diff --git a/lib/decrees.js b/lib/decrees.js index c7fd79f8a..8f4b53ad8 100644 --- a/lib/decrees.js +++ b/lib/decrees.js @@ -49,6 +49,9 @@ SET_INSTANCE_DESCRIPTION SET_INSTANCE_NAME SET_INSTANCE_NOTICE +// bearer secret +SET_BEARER_SECRET + NOT IMPLEMENTED: // RESTRICTED REGISTRATION @@ -348,6 +351,17 @@ commands.ADD_ADMIN_KEY = function (Env, args) { return true; }; +commands.SET_BEARER_SECRET = function (Env, args) { + if (!args_isString(args) || args.length !== 1 || !args[0]) { + throw new Error("INVALID_ARGS"); + } + + var secret = args[0]; + if (secret === Env.bearerSecret) { return false; } + Env.bearerSecret = secret; + return true; +}; + // [, , ,