Merge branch 'totp-ui' into 5.4-rc

This commit is contained in:
yflory 2023-07-11 10:30:36 +02:00
commit c10fc37645
62 changed files with 5334 additions and 982 deletions

View File

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

View File

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

View File

@ -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 <a href="mailto:{0}">email it</a> 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'),
])
]);
};
});

View File

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

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

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

View File

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

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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;
};
// [<command>, <args>, <author>, <time>]
var handleCommand = Decrees.handleCommand = function (Env, line) {
var command = line[0];
@ -357,7 +371,12 @@ var handleCommand = Decrees.handleCommand = function (Env, line) {
throw new Error("DECREE_UNSUPPORTED_COMMAND");
}
return commands[command](Env, args);
var outcome = commands[command](Env, args);
if (outcome) {
// trigger Env change event...
Env.envUpdated.fire(); // XXX
}
return outcome;
};
Decrees.createLineHandler = function (Env) {

View File

@ -69,6 +69,12 @@ Default.mainPages = function () {
];
};
/* The recommmended minimum Node.js version
* ideally managed using NVM and not your system's
* package manager, which usually provides a very outdated version
*/
Default.recommendedVersion = [16,14,2];
/* By default the CryptPad server will run scheduled tasks every five minutes
* If you want to run scheduled tasks in a separate process (like a crontab)
* you can disable this behaviour by setting the following value to true

View File

@ -11,6 +11,7 @@ const Core = require("./commands/core");
const Quota = require("./commands/quota");
const Util = require("./common-util");
const Package = require("../package.json");
const Default = require("./defaults");
const Path = require("path");
const Nacl = require("tweetnacl/nacl-fast");
@ -31,6 +32,7 @@ var deriveSandboxOrigin = function (unsafe, port) {
};
var isRecentVersion = function () {
var R = Default.recommendedVersion;
var V = process.version;
if (typeof(V) !== 'string') { return false; }
var parts = V.replace(/^v/, '').split('.').map(Number);
@ -38,13 +40,13 @@ var isRecentVersion = function () {
if (!parts.every(n => typeof(n) === 'number' && !isNaN(n))) {
return false;
}
if (parts[0] < 16) { return false; }
if (parts[0] > 16) { return true; }
if (parts[0] < R[0]) { return false; }
if (parts[0] > R[0]) { return true; }
// v16
if (parts[1] < 14) { return false; }
if (parts[1] > 14) { return true; }
if (parts[2] >= 2) { return true; }
if (parts[1] < R[1]) { return false; }
if (parts[1] > R[1]) { return true; }
if (parts[2] >= R[2]) { return true; }
return false;
};
@ -65,6 +67,10 @@ module.exports.create = function (config) {
httpSafeOrigin = canonicalizeOrigin(config.httpSafeOrigin);
}
if (typeof(config.websocketPort) !== 'number') {
config.websocketPort = 3003;
}
var permittedEmbedders = config.permittedEmbedders;
if (typeof(permittedEmbedders) === 'string') {
permittedEmbedders = permittedEmbedders.trim();
@ -73,11 +79,16 @@ module.exports.create = function (config) {
const curve = Nacl.box.keyPair();
const Env = {
logFeedback: Boolean(config.logFeedback),
mainPages: config.mainPages || Default.mainPages(),
protocol: new URL(httpUnsafeOrigin).protocol,
fileHost: config.fileHost || undefined,
NO_SANDBOX: NO_SANDBOX,
httpSafePort: httpSafePort,
websocketPort: config.websocketPort,
accounts_api: config.accounts_api || undefined, // this simplifies integration with an accounts page
shouldUpdateNode: !isRecentVersion(),
@ -106,15 +117,9 @@ module.exports.create = function (config) {
apiHeadersCache: undefined,
flushCache: function () {
Env.configCache = {};
Env.broadcastCache = {};
Env.officeHeadersCache = undefined;
Env.standardHeadersCache = undefined;
Env.apiHeadersCache = undefined;
Env.FRESH_KEY = +new Date();
if (!(Env.DEV_MODE || Env.FRESH_MODE)) { Env.FRESH_MODE = true; }
Env.cacheFlushed.fire();
if (!Env.Log) { return; }
Env.Log.info("UPDATING_FRESH_KEY", Env.FRESH_KEY);
},
@ -211,19 +216,29 @@ module.exports.create = function (config) {
// but it is referenced in Quota
domain: config.domain,
maxWorkers: config.maxWorkers,
maxWorkers: undefined,
disableIntegratedTasks: config.disableIntegratedTasks || false,
disableIntegratedEviction: typeof(config.disableIntegratedEviction) === 'undefined'? true: config.disableIntegratedEviction,
lastEviction: +new Date(),
evictionReport: {},
commandTimers: {},
// initialized as undefined
bearerSecret: void 0,
curvePrivate: curve.secretKey,
curvePublic: Nacl.util.encodeBase64(curve.publicKey),
selfDestructTo: {},
};
(function () {
var max = config.maxWorkers;
// if the supplied value is not a positive number, leave maxWorkers undefined
// one worker will be created for each CPU core
if (typeof(max) !== 'number' || isNaN(max) || max < 1) { return; }
Env.maxWorkers = max;
}());
(function () {
// mode can be FRESH (default), DEV, or PACKAGE
if (process.env.PACKAGE) {
@ -325,6 +340,7 @@ module.exports.create = function (config) {
paths.staging = keyOrDefaultString('blobStagingPath', './blobstage');
paths.blob = keyOrDefaultString('blobPath', './blob');
paths.decree = keyOrDefaultString('decreePath', './data/');
paths.base = keyOrDefaultString('base', './data');
paths.archive = keyOrDefaultString('archivePath', './data/archive');
paths.task = keyOrDefaultString('taskPath', './tasks');
@ -344,5 +360,45 @@ module.exports.create = function (config) {
console.error("Can't parse admin keys. Please update or fix your config.js file!");
}
Env.envUpdated = Util.mkEvent();
Env.cacheFlushed = Util.mkEvent();
return Env;
};
// don't serialize these things
const BAD = [
'Log',
'envUpdated',
'cacheFlushed',
'evictionReports',
'commandTimers',
'metadata_cache',
'channel_cache',
'cache_checks',
'intervals',
'Sessions',
'netfluxUsers',
'limits',
'customLimits',
'scheduleDecree',
'httpServer',
'pinStore',
'msgStore',
'store',
'blobStore',
];
module.exports.serialize = function (Env) {
return JSON.stringify(Env, function (key, value) {
if (value === Env) { return value; }
if (BAD.includes(key)) { return; }
if (typeof(value) === 'function') { return; }
//console.log('serializing', { key, value, });
if (Util.isCircular(value)) { return; }
return value;
});
};

323
lib/http-commands.js Normal file
View File

@ -0,0 +1,323 @@
var Nacl = require("tweetnacl/nacl-fast");
var Util = require('./common-util.js');
var Challenge = require("./storage/challenge.js");
// C.read(Env, id, cb)
// C.write(Env,id, data, cb)
// C.delete(Env, id, cb)
/*
The API for command definition consists of two stages:
Clients first send a command and its associated parameters.
The server validates that the command is supported, and that
the provided parameters are valid. If it fails validation for any reason,
the server responds with an error and the protocol is aborted.
COMMANDS[COMMAND_NAME] = function (Env, body, cb) {
// inspect parameters in the request body
if (!body.essential_parameter) {
return void cb('NO');
}
cb();
};
Commands whose parameters are successfully validated
have those parameters stored on the disk (or a relational DB in the future).
The server then requests that the client sign their well-formulated
command along with a server-generated transaction id ('txid': randomized to prevent replays)
and a date (so that it can ensure that the client responds within a reasonable window.
Clients then respond with a txid and a cryptographic signature
which matches the parameters of the command. The server loads the command
with the corresponding txid, checks that it was signed within a reasonable time window,
validates the signature, and attempts to complete the command's execution:
COMMAND[COMMAND_NAME].complete = function (Env, body, cb) {
doAThing(function (err, values) {
if (err) {
// Log the error and respond that the command was not successful
return void cb("SORRY_BUT_IM_NOT_OK");
}
cb(void 0, {
arbitrary: values,
});
});
};
In this second stage the protocol can be aborted if the client has done something wrong:
(ie. if it did not produce a valid signature for the command)
or it can can fail because the server was not able to complete the requested task
(ie. because of an I/O error or because an error was thrown and caught).
It is intended that the server will respond with an appropriate error if
the request cannot be completed, and it will respond OK if everything completed successfully.
*/
var COMMANDS = {};
// Methods allowing clients to configure Time-based One-Time Passwords for their login-block,
// and to authenticate new sessions once a TOTP secret has been associated with their account,
const NOAUTH = require("./challenge-commands/base.js");
COMMANDS.MFA_CHECK = NOAUTH.MFA_CHECK;
COMMANDS.WRITE_BLOCK = NOAUTH.WRITE_BLOCK;
COMMANDS.REMOVE_BLOCK = NOAUTH.REMOVE_BLOCK;
const TOTP = require("./challenge-commands/totp.js");
COMMANDS.TOTP_SETUP = TOTP.TOTP_SETUP;
COMMANDS.TOTP_VALIDATE = TOTP.TOTP_VALIDATE;
COMMANDS.TOTP_CHECK = TOTP.TOTP_CHECK;
COMMANDS.TOTP_REVOKE = TOTP.TOTP_REVOKE;
COMMANDS.TOTP_WRITE_BLOCK = TOTP.TOTP_WRITE_BLOCK;
COMMANDS.TOTP_REMOVE_BLOCK = TOTP.TOTP_REMOVE_BLOCK;
var randomToken = () => Nacl.util.encodeBase64(Nacl.randomBytes(24)).replace(/\//g, '-');
// this function handles the first stage of the protocol
// (the server's validation of the client's request and the generation of its challenge)
var handleCommand = function (Env, req, res) {
var body = req.body;
var command = body.command;
// reject if the command does not have a corresponding function
if (typeof(COMMANDS[command]) !== 'function') {
Env.Log.error('CHALLENGE_UNSUPPORTED_COMMAND', command);
return void res.status(500).json({
error: 'invalid command',
});
}
var publicKey = body.publicKey;
// reject if they did not provide a valid public key
if (!publicKey || typeof(publicKey) !== 'string' || publicKey.length !== 44) {
Env.Log.error('CHALLENGE_INVALID_KEY', publicKey);
return void res.status(500).json({
error: 'Invalid key',
});
}
try {
COMMANDS[command](Env, body, function (err) {
if (err) {
Env.Log.error('CHALLENGE_COMMAND_EXECUTION_ERROR', {
body: body,
error: Util.serializeError(err),
});
// errors returned from commands are passed back to the client
// as a weak precaution, we try to only send an error's message
// if one exists. This makes it less likely that we'll respond with any
// sensitive information in a stack trace. Ideally functions should
// only return error messages or codes in the form of a string or number,
// but mistakes happen.
return void res.status(500).json({
error: (err && err.message) || err,
});
}
var txid = randomToken();
var date = new Date().toISOString();
var copy = Util.clone(body);
copy.txid = txid;
copy.date = date;
// Write the command and challenge to disk, because the challenge protocol
// is interactive and the subsequent response might be handled by a different http worker
// this makes it so we can avoid holding state in memory
Challenge.write(Env, txid, JSON.stringify(copy), function (err) {
if (err) {
Env.Log.error('CHALLENGE_WRITE_ERROR', Util.serializeError(err));
return void res.status(500).json({
// arbitrary error message, only intended for debugging
error: 'Internal server error 6250',
});
}
// respond with challenge parameters
return void res.status(200).json({
txid: txid,
date: date,
});
});
});
} catch (err) {
Env.Log.error("CHALLENGE_COMMAND_THROWN_ERROR", {
error: Util.serializeError(err),
});
return void res.status(500).json({
// arbitrary error message, only intended for debugging
error: 'Internal server error 7692',
});
}
};
// this function handles the second stage of the protocol
// (the client's response to the server's challenge)
var handleResponse = function (Env, req, res) {
var body = req.body;
if (Object.keys(body).some(k => !/(sig|txid)/.test(k))) {
Env.Log.error("CHALLENGE_RESPONSE_DEBUGGING", body);
// we expect the response to only have two keys
// if any more are present then the response is malformed
return void res.status(500).json({
error: 'extraneous parameters',
});
}
// transaction ids are issued to the client by the server
// they allow it to recall the full details of the challenge
// to which the client is responding
var txid = body.txid;
// if no txid is present, then the server can't look up the corresponding challenge
// the response is definitely malformed, so reject it.
// Additionally, we expect txids to be 32 characters long (24 Uint8s as base64)
// reject txids of any other length
if (!txid || typeof(txid) !== 'string' || txid.length !== 32) {
Env.Log.error('CHALLENGE_RESPONSE_BAD_TXID', body);
return void res.status(500).json({
error: "Invalid txid",
});
}
var sig = body.sig;
if (!sig || typeof(sig) !== 'string' || sig.length !== 88) {
Env.Log.error("CHALLENGE_RESPONSE_BAD_SIG", body);
return void res.status(500).json({
error: "Missing signature",
});
}
Challenge.read(Env, txid, function (err, text) {
if (err) {
Env.Log.error("CHALLENGE_READ_ERROR", {
txid: txid,
error: Util.serializeError(err),
});
return void res.status(500).json({
error: "Unexpected response",
});
}
// garbage collection can clean this up later
Challenge.delete(Env, txid, function (err) {
if (err) {
Env.Log.error("CHALLENGE_DELETION_ERROR", {
txid: txid,
error: Util.serializeError(err),
});
}
});
var json = Util.tryParse(text);
if (!json) {
Env.Log.error("CHALLENGE_PARSE_ERROR", {
txid: txid,
});
return void res.status(500).json({
error: "Internal server error 129",
});
}
var publicKey = json.publicKey;
if (!publicKey || typeof(publicKey) !== 'string') {
// This shouldn't happen, as we expect that the server
// will have validated the key to an extent before storing the challenge
Env.Log.error('CHALLENGE_INVALID_PUBLICKEY', {
publicKey: publicKey,
});
return res.status(500).json({
error: "Invalid public key",
});
}
var action;
try {
action = COMMANDS[json.command].complete;
} catch (err2) {}
if (typeof(action) !== 'function') {
Env.Log.error("CHALLENGE_RESPONSE_ACTION_NOT_IMPLEMENTED", json.command);
return res.status(501).json({
error: 'Not implemented',
});
}
var u8_toVerify,
u8_sig,
u8_publicKey;
try {
u8_toVerify = Nacl.util.decodeUTF8(text);
u8_sig = Nacl.util.decodeBase64(sig);
u8_publicKey = Nacl.util.decodeBase64(publicKey);
} catch (err3) {
Env.Log.error('CHALLENGE_RESPONSE_DECODING_ERROR', {
text: text,
sig: sig,
publicKey: publicKey,
error: Util.serializeError(err3),
});
return res.status(500).json({
error: "decoding error"
});
}
// validate the response
var success = Nacl.sign.detached.verify(u8_toVerify, u8_sig, u8_publicKey);
if (success !== true) {
Env.Log.error("CHALLENGE_RESPONSE_SIGNATURE_FAILURE", {
publicKey,
});
return void res.status(500).json({
error: 'Failed signature validation',
});
}
// execute the command
action(Env, json, function (err, content) {
if (err) {
Env.Log.error("CHALLENGE_RESPONSE_ACTION_ERROR", {
error: Util.serializeError(err),
});
return res.status(500).json({
error: 'Execution error',
});
}
res.status(200).json(content);
});
});
};
module.exports.handle = function (Env, req, res /*, next */) {
var body = req.body;
// we expect that the client has posted some JSON data
if (!body) {
return void res.status(500).json({
error: 'invalid request',
});
}
// we only expect responses to challenges to have a 'txid' attribute
// further validation is performed in handleResponse
if (body.txid) {
return void handleResponse(Env, req, res);
}
// we only expect initial requests to have a 'command' attribute
// further validation is performed in handleCommand
if (body.command) {
return void handleCommand(Env, req, res);
}
// if a request is neither a command nor a response, then reject it with an error
res.status(500).json({
error: 'invalid request',
});
};

706
lib/http-worker.js Normal file
View File

@ -0,0 +1,706 @@
const process = require("node:process");
const Http = require("node:http");
const Default = require("./defaults");
const Path = require("node:path");
const Fs = require("node:fs");
const nThen = require("nthen");
const Util = require("./common-util");
const Logger = require("./log");
const AuthCommands = require("./http-commands");
const JWT = require("jsonwebtoken");
const MFA = require("./storage/mfa");
const Sessions = require("./storage/sessions");
const DEFAULT_QUERY_TIMEOUT = 5000;
const PID = process.pid;
var Env = JSON.parse(process.env.Env);
const response = Util.response(function (errLabel, info) {
if (!Env.Log) { return; }
Env.Log.error(errLabel, info);
});
const guid = () => {
return Util.guid(response._pending);
};
const sendMessage = (msg, cb, opt) => {
var txid = guid();
var timeout = (opt && opt.timeout) || DEFAULT_QUERY_TIMEOUT;
var obj = {
pid: PID,
txid: txid,
content: msg,
};
response.expect(txid, cb, timeout);
process.send(obj);
};
const Log = {};
Logger.levels.forEach(level => {
Log[level] = function (tag, info) {
sendMessage({
command: 'LOG',
level: level,
tag: tag,
info: info,
}, (err) => {
if (err) {
return void console.error(new Error(err));
}
});
};
});
Env.Log = Log;
Env.incrementBytesWritten = function () {};
const EVENTS = {};
EVENTS.ENV_UPDATE = function (data /*, cb */) {
try {
Env = JSON.parse(data);
Env.Log = Log;
Env.incrementBytesWritten = function () {};
} catch (err) {
Log.error('HTTP_WORKER_ENV_UPDATE', Util.serializeError(err));
}
};
EVENTS.FLUSH_CACHE = function (data) {
if (typeof(data) !== 'number') {
return Log.error('INVALID_FRESH_KEY', data);
}
Env.FRESH_KEY = data;
[ 'configCache', 'broadcastCache', ].forEach(key => {
Env[key] = {};
});
[ 'officeHeadersCache', 'standardHeadersCache', 'apiHeadersCache', ].forEach(key => {
Env[key] = undefined;
});
};
process.on('message', msg => {
if (!(msg && msg.txid)) { return; }
if (msg.type === 'REPLY') {
var txid = msg.txid;
return void response.handle(txid, [msg.error, msg.value]);
} else if (msg.type === 'EVENT') {
// response to event...
// ie. Update Env, flush cache, etc.
var ev = EVENTS[msg.command];
if (typeof(ev) === 'function') {
return void ev(msg.data, () => {});
}
}
console.error("UNHANDLED_MESSAGE", msg); // XXX
});
var applyHeaderMap = function (res, map) {
for (let header in map) {
if (typeof(map[header]) === 'string') { res.setHeader(header, map[header]); }
}
};
var EXEMPT = [
/^\/common\/onlyoffice\/.*\.html.*/,
/^\/(sheet|presentation|doc)\/inner\.html.*/,
/^\/unsafeiframe\/inner\.html.*$/,
];
var cacheHeaders = function (Env, key, headers) {
if (Env.DEV_MODE) { return; }
Env[key] = headers;
};
var getHeaders = function (Env, type) {
var key = type + 'HeadersCache';
if (Env[key]) { return Env[key]; }
var headers = Default.httpHeaders(Env);
var csp;
if (type === 'office') {
csp = Default.padContentSecurity(Env);
} else if (type === 'diagram') {
csp = Default.diagramContentSecurity(Env);
} else {
csp = Default.contentSecurity(Env);
}
headers['Content-Security-Policy'] = csp;
if (Env.NO_SANDBOX) { // handles correct configuration for local development
// https://stackoverflow.com/questions/11531121/add-duplicate-http-response-headers-in-nodejs
headers["Cross-Origin-Resource-Policy"] = 'cross-origin';
headers["Cross-Origin-Embedder-Policy"] = 'require-corp';
}
// Don't set CSP headers on /api/ endpoints
// because they aren't necessary and they cause problems
// when duplicated by NGINX in production environments
if (type === 'api') {
cacheHeaders(Env, key, headers);
return headers;
}
headers["Cross-Origin-Resource-Policy"] = 'cross-origin';
cacheHeaders(Env, key, headers);
return headers;
};
var setHeaders = function (req, res) {
var type;
if (EXEMPT.some(regex => regex.test(req.url))) {
type = 'office';
} else if (/^\/api\/(broadcast|config)/.test(req.url)) {
type = 'api';
} else if (/^\/components\/drawio\/src\/main\/webapp\/index.html.*$/.test(req.url)) {
type = 'diagram';
} else {
type = 'standard';
}
var h = getHeaders(Env, type);
applyHeaderMap(res, h);
};
const Express = require("express");
var app = Express();
(function () {
if (!Env.logFeedback) { return; }
const logFeedback = function (url) {
url.replace(/\?(.*?)=/, function (all, fb) {
Log.feedback(fb, '');
});
};
app.head(/^\/common\/feedback\.html/, function (req, res, next) {
logFeedback(req.url);
next();
});
}());
const { createProxyMiddleware } = require("http-proxy-middleware");
var proxyTarget = new URL('', 'ws:localhost');
proxyTarget.port = Env.websocketPort;
const wsProxy = createProxyMiddleware({
target: proxyTarget.href,
ws: true,
logLevel: 'error',
});
app.use('/cryptpad_websocket', wsProxy);
app.use('/blob', function (req, res, next) {
/* Head requests are used to check the size of a blob.
Clients can configure a maximum size to download automatically,
and can manually click to download blobs which exceed that limit. */
if (req.method === 'HEAD') {
Express.static(Path.resolve(Env.paths.blob), {
setHeaders: function (res /*, path, stat */) {
res.set('Access-Control-Allow-Origin', Env.enableEmbedding? '*': Env.permittedEmbedders);
res.set('Access-Control-Allow-Headers', 'Content-Length');
res.set('Access-Control-Expose-Headers', 'Content-Length');
}
})(req, res, next);
return;
}
/* Some GET requests concern the whole file,
others only target ranges, either:
1. a two octet prefix which encodes the length of the metadata in octets
2. the metadata itself, excluding the two preceding octets
*/
/*
// Example code to demonstrate the types of requests which are handled
if (req.method === 'GET') {
if (!req.headers.range) {
// metadata
} else {
// full request
}
}
*/
next();
});
app.use(function (req, res, next) {
/* These are pre-flight requests, through which the client
confirms with the server that it is permitted to make the
actual requests which will follow */
if (req.method === 'OPTIONS' && /\/blob\//.test(req.url)) {
res.setHeader('Access-Control-Allow-Origin', Env.enableEmbedding? '*': Env.permittedEmbedders);
res.setHeader('Access-Control-Allow-Methods', 'GET, OPTIONS');
res.setHeader('Access-Control-Allow-Headers', 'DNT,X-CustomHeader,Keep-Alive,User-Agent,X-Requested-With,If-Modified-Since,Cache-Control,Content-Type,Content-Range,Range,Access-Control-Allow-Origin');
res.setHeader('Access-Control-Max-Age', 1728000);
res.setHeader('Content-Type', 'application/octet-stream; charset=utf-8');
res.setHeader('Content-Length', 0);
res.statusCode = 204;
return void res.end();
}
setHeaders(req, res);
if (/[\?\&]ver=[^\/]+$/.test(req.url)) { res.setHeader("Cache-Control", "max-age=31536000"); }
else { res.setHeader("Cache-Control", "no-cache"); }
next();
});
// serve custom app content from the customize directory
// useful for testing pages customized with opengraph data
app.use(Express.static(Path.resolve('./customize/www')));
app.use(Express.static(Path.resolve('./www')));
var mainPages = Env.mainPages || Default.mainPages();
var mainPagePattern = new RegExp('^\/(' + mainPages.join('|') + ').html$');
app.get(mainPagePattern, Express.static('./customize'));
app.get(mainPagePattern, Express.static('./customize.dist'));
app.use("/blob", Express.static(Path.resolve(Env.paths.blob), {
maxAge: Env.DEV_MODE? "0d": "365d"
}));
app.head("/datastore", Express.static(Env.paths.data, {
maxAge: "0d"
}));
app.use('/block/', function (req, res, next) {
var parsed = Path.parse(req.url);
var name = parsed.name;
// block access control only applies to files
// identified by base64-encoded public keys
// skip everything else, ie. /block/placeholder.txt
if (typeof(name) !== 'string' || name.length !== 44) {
return void next();
}
var authorization = req.headers.authorization;
var mfa_params, jwt_payload;
nThen(function (w) {
// First, check whether the block id in question has any MFA settings stored
MFA.read(Env, name, w(function (err, content) {
// ENOENT means there are no settings configured
// it could be a 404 or an existing block without MFA protection
// in either case you can abort and fall through
// allowing the static webserver to handle either case
if (err && err.code === 'ENOENT') {
w.abort();
return void next();
}
// we're not expecting other errors. the sensible thing is to fail
// closed - meaning assume some protection is in place but that
// the settings couldn't be loaded for some reason. block access
// to the resource, logging for the admin and responding to the client
// with a vague error code
if (err) {
Log.error('GET_BLOCK_METADATA', err);
return void res.status(500).json({
code: 500,
error: "UNEXPECTED_ERROR",
});
}
// Otherwise, some settings were loaded correctly.
// We're expecting stringified JSON, so try to parse it.
// Log and respond with an error again if this fails.
// If it parses successfully then fall through to the next block.
try {
mfa_params = JSON.parse(content);
} catch (err2) {
w.abort();
Log.error("INVALID_BLOCK_METADATA", err2);
return res.status(500).json({
code: 500,
error: "UNEXPECTED_ERROR",
});
}
}));
}).nThen(function (w) {
// We should only be able to reach this logic
// if we successfully loaded and parsed some JSON
// representing the user's MFA settings.
// Failures at this point relate to insufficient or incorrect authorization.
// This function standardizes how we reject such requests.
// So far the only additional factor which is supported is TOTP.
// We specify what the method is to allow for future alternatives
// and inform the client so they can determine how to respond
// "401" means "Unauthorized"
var no = function () {
w.abort();
res.status(401).json({
method: mfa_params.method,
code: 401
});
};
// if you are here it is because this block is protected by MFA.
// they will need to provide a JSON Web Token, so we can reject them outright
// if one is not present in their authorization header
if (!authorization) { return void no(); }
// The authorization header should be of the form
// "Authorization: Bearer <JWT>"
// We can reject the request if it is malformed.
let token = authorization.replace(/^Bearer\s+/, '').trim();
if (!token) { return void no(); }
Sessions.read(Env, name, token, function (err, contentStr) {
if (err) {
Log.error('SESSION_READ_ERROR', err);
return res.status(401).json({
method: mfa_params.method,
code: 401,
});
}
let content = Util.tryParse(contentStr);
if (content.mfa && content.mfa.exp && ((+new Date()) > content.mfa.exp)) {
Log.error("OTP_SESSION_EXPIRED", payload);
Sessions.delete(Env, name, token, function (err) {
if (err) {
Log.error('SESSION_DELETE_EXPIRED_ERROR', err);
return;
}
Log.info('SESSION_DELETE_EXPIRED', err);
});
return void no();
}
// we could also check whether the content of the file matches the token,
// but clients don't have any influence over the reference and can only
// request to create tokens that are scoped to a public key they control.
// I don' think there's any practical benefit to such a check.
// So, interpret the existence of a file in that location as the continued
// validity of the session. Fall through and let the built-in webserver
// handle the 404 or serving the file.
next();
});
// Otherwise we attempt to validate the token
// Successful validation implies that the token was issued by the server
// since only the server should possess the current bearer secret (unless it has leaked).
// It is still possible that the token is not valid for this particular resource,
// so the algorithm (HMAC SHA512) only asserts its integrity, not its validity.
/*
JWT.verify(token, Env.bearerSecret, {
algorithm: 'HS512',
}, w(function (err, payload) {
if (err) {
// the token could not be validated for some reason.
// it might have expired, the server might have rotated secrets,
// it might not be well-formed, etc.
// log and respond.
Log.info('INVALID_JWT', {
error: err,
token: token,
});
return void no();
}
// Now that we have the payload we can inspect its properties
// and reject anything which is obviously wrong without requiring
// any async I/O
// Tokens are issued with a "reference" - a random id which is
// used alongside the block id to look up whether a given session
// is still valid
// reject if it does not provide a lookup reference
if (typeof(payload.ref) !== 'string') {
Log.error("JWT_NO_REFERENCE", payload);
return void no();
}
// A JWT can optionally indicate a finite lifetime.
// 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();
}
// A JWT indicates the subject (the block id) for which it is valid
// reject if it does not match the block the client is trying to access
if (payload.sub !== name) {
Log.error("JWT_SUBJECT_MISMATCH", payload);
return void no();
}
// otherwise, it seems basically correct.
Log.verbose("VALID_JWT", payload);
// remember the payload for subsequent asynchronous checks
jwt_payload = payload;
}));
*/
}).nThen(function () {
// Finally, even if the JWT itself seems valid, the database
// is the final authority as to whether the session is still valid,
// as it might have been revoked
/*
Sessions.read(Env, name, jwt_payload.ref, function (err) {
if (err) {
Log.error('JWT_SESSION_READ_ERROR', err);
return res.status(401).json({
method: mfa_params.method,
code: 401,
});
}
// we could also check whether the content of the file matches the token,
// but clients don't have any influence over the reference and can only
// request to create tokens that are scoped to a public key they control.
// I don' think there's any practical benefit to such a check.
// So, interpret the existence of a file in that location as the continued
// validity of the session. Fall through and let the built-in webserver
// handle the 404 or serving the file.
next();
});
*/
});
});
// TODO this would be a good place to update a block's atime
// in a manner independent of the filesystem. ie. for detecting and archiving
// inactive accounts in a way that will not be invalidated by other forms of access
// like filesystem backups.
app.use("/block", Express.static(Path.resolve(Env.paths.block), {
maxAge: "0d",
}));
app.use("/customize", Express.static('customize'));
app.use("/customize", Express.static('customize.dist'));
app.use("/customize.dist", Express.static('customize.dist'));
app.use(/^\/[^\/]*$/, Express.static('customize'));
app.use(/^\/[^\/]*$/, Express.static('customize.dist'));
// if dev mode: never cache
var cacheString = function () {
return (Env.FRESH_KEY? '-' + Env.FRESH_KEY: '') + (Env.DEV_MODE? '-' + (+new Date()): '');
};
var makeRouteCache = function (template, cacheName) {
var cleanUp = {};
var cache = Env[cacheName] = Env[cacheName] || {};
return function (req, res) {
var host = req.headers.host.replace(/\:[0-9]+/, '');
res.setHeader('Content-Type', 'text/javascript');
// don't cache anything if you're in dev mode
if (Env.DEV_MODE) {
return void res.send(template(host));
}
// generate a lookup key for the cache
var cacheKey = host + ':' + cacheString();
// FIXME mutable
// we must be able to clear the cache when updating any mutable key
// if there's nothing cached for that key...
if (!cache[cacheKey]) {
// generate the response and cache it in memory
cache[cacheKey] = template(host);
// and create a function to conditionally evict cache entries
// which have not been accessed in the last 20 seconds
cleanUp[cacheKey] = Util.throttle(function () {
delete cleanUp[cacheKey];
delete cache[cacheKey];
}, 20000);
}
// successive calls to this function
cleanUp[cacheKey]();
return void res.send(cache[cacheKey]);
};
};
var serveConfig = makeRouteCache(function () {
return [
'define(function(){',
'return ' + JSON.stringify({
requireConf: {
waitSeconds: 600,
urlArgs: 'ver=' + Env.version + cacheString(),
},
removeDonateButton: (Env.removeDonateButton === true),
allowSubscriptions: (Env.allowSubscriptions === true),
websocketPath: Env.websocketPath,
httpUnsafeOrigin: Env.httpUnsafeOrigin,
adminEmail: Env.adminEmail,
adminKeys: Env.admins,
inactiveTime: Env.inactiveTime,
supportMailbox: Env.supportMailbox,
defaultStorageLimit: Env.defaultStorageLimit,
maxUploadSize: Env.maxUploadSize,
premiumUploadSize: Env.premiumUploadSize,
restrictRegistration: Env.restrictRegistration,
httpSafeOrigin: Env.httpSafeOrigin,
enableEmbedding: Env.enableEmbedding,
fileHost: Env.fileHost,
shouldUpdateNode: Env.shouldUpdateNode || undefined,
listMyInstance: Env.listMyInstance,
accounts_api: Env.accounts_api,
}, null, '\t'),
'});'
].join(';\n');
}, 'configCache');
var serveBroadcast = makeRouteCache(function () {
var maintenance = Env.maintenance;
if (maintenance && maintenance.end && maintenance.end < (+new Date())) {
maintenance = undefined;
}
return [
'define(function(){',
'return ' + JSON.stringify({
lastBroadcastHash: Env.lastBroadcastHash,
surveyURL: Env.surveyURL,
maintenance: maintenance
}, null, '\t'),
'});'
].join(';\n');
}, 'broadcastCache');
app.get('/api/config', serveConfig);
app.get('/api/broadcast', serveBroadcast);
var Define = function (obj) {
return `define(function (){
return ${JSON.stringify(obj, null, '\t')};
});`;
};
app.get('/api/instance', function (req, res) {
res.setHeader('Content-Type', 'text/javascript');
res.send(Define({
name: Env.instanceName,
description: Env.instanceDescription,
location: Env.instanceJurisdiction,
notice: Env.instanceNotice,
}));
});
var four04_path = Path.resolve('./customize.dist/404.html');
var fivehundred_path = Path.resolve('./customize.dist/500.html');
var custom_four04_path = Path.resolve('./customize/404.html');
var custom_fivehundred_path = Path.resolve('./customize/500.html');
var send404 = function (res, path) {
if (!path && path !== four04_path) { path = four04_path; }
Fs.exists(path, function (exists) {
res.setHeader('Content-Type', 'text/html; charset=utf-8');
if (exists) { return Fs.createReadStream(path).pipe(res); }
send404(res);
});
};
var send500 = function (res, path) {
if (!path && path !== fivehundred_path) { path = fivehundred_path; }
Fs.exists(path, function (exists) {
res.setHeader('Content-Type', 'text/html; charset=utf-8');
if (exists) { return Fs.createReadStream(path).pipe(res); }
send500(res);
});
};
app.get('/api/updatequota', function (req, res) {
if (!Env.accounts_api) {
res.status(404);
return void send404(res);
}
sendMessage({
command: 'UPDATE_QUOTA',
}, (err) => {
if (err) {
res.status(500);
return void send500(res);
}
res.send();
});
});
app.get('/api/profiling', function (req, res) {
if (!Env.enableProfiling) { return void send404(res); }
sendMessage({
command: 'GET_PROFILING_DATA',
}, (err, value) => {
if (err) {
res.status(500);
return void send500(res);
}
res.setHeader('Content-Type', 'text/javascript');
res.send(JSON.stringify({
bytesWritten: value,
}));
});
});
// This endpoint handles authenticated RPCs over HTTP
// via an interactive challenge-response protocol
app.use(Express.json());
app.post('/api/auth', function (req, res, next) {
AuthCommands.handle(Env, req, res, next);
});
app.use(function (req, res /*, next */) {
if (/^(\/favicon\.ico\/|.*\.js\.map)$/.test(req.url)) {
// ignore common 404s
} else {
Log.info('HTTP_404', req.url);
}
res.status(404);
send404(res, custom_four04_path);
});
// default message for thrown errors in ExpressJS routes
app.use(function (err, req, res /*, next*/) {
Log.error('EXPRESSJS_ROUTING', {
error: err.stack || err,
});
res.status(500);
send500(res, custom_fivehundred_path);
});
var server = Http.createServer(app);
nThen(function (w) {
server.listen(Env.httpPort, w());
if (Env.httpSafePort) {
server.listen(Env.httpSafePort, w());
}
server.on('upgrade', function (req, socket, head) {
// TODO warn admins that websockets should only be proxied in this way in a dev environment
// in production it's more efficient to have your reverse proxy (NGINX) directly forward
// websocket traffic to the correct port (Env.websocketPort)
wsProxy.upgrade(req, socket, head);
});
}).nThen(function () {
// TODO inform the parent process that this worker is ready
});
process.on('uncaughtException', function (err) {
console.error('[%s] UNCAUGHT EXCEPTION IN HTTP WORKER', new Date());
console.error(err);
console.error("TERMINATING");
process.exit(1);
});

View File

@ -5,7 +5,6 @@ const Core = require("./commands/core");
const Admin = require("./commands/admin-rpc");
const Pinning = require("./commands/pin-rpc");
const Quota = require("./commands/quota");
const Block = require("./commands/block");
const Metadata = require("./commands/metadata");
const Channel = require("./commands/channel");
const Upload = require("./commands/upload");
@ -54,8 +53,6 @@ const AUTHENTICATED_USER_TARGETED = {
UPLOAD_COMPLETE: Upload.complete,
UPLOAD_CANCEL: Upload.cancel,
OWNED_UPLOAD_COMPLETE: Upload.complete_owned,
WRITE_LOGIN_BLOCK: Block.writeLoginBlock,
REMOVE_LOGIN_BLOCK: Block.removeLoginBlock,
ADMIN: Admin.command,
SET_METADATA: Metadata.setMetadata,
};

69
lib/storage/basic.js Normal file
View File

@ -0,0 +1,69 @@
/* Mulfi-factor auth requires some rudimentary storage methods
for a number of data types:
* "challenges" (described in challenge.js)
* account settings for MFA (described in mfa.js)
* session tokens (described in sessions.js)
Each data type requires the same three simple methods:
* read
* write
* delete
These could be implemented as tables in a relational database, but committing to a relational DB
is a big decision, so these methods are instead implemented using the filesystem, with each
file's path and naming convention implemented outside of this module.
Feel free to migrate all of these to a relational DB at some point in the future if you like.
*/
const Basic = module.exports;
const Fs = require("node:fs");
const Path = require("node:path");
var pathError = (cb) => {
setTimeout(function () {
cb(new Error("INVALID_PATH"));
});
};
Basic.read = function (Env, path, cb) {
if (!path) { return void pathError(cb); }
Fs.readFile(path, 'utf8', (err, content) => {
if (err) { return void cb(err); }
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); }
var dirpath = Path.dirname(path);
Fs.mkdir(dirpath, { recursive: true }, function (err) {
if (err) { return void cb(err); }
// the 'wx' flag causes writes to fail with EEXIST if a file is already present at the given path
// this could be overridden with options in the future if necessary, but it seems like a sensible default
Fs.writeFile(path, data, { flag: 'wx', }, cb);
});
};
// XXX I didn't bother implementing the usual "archive/restore/delete-from-archives" methods
// because they didn't seem particularly important for the data implemented with this module.
// They're still worth considering, though, so don't let my ommission stop you.
// Login blocks could probably be implemented with this module if these methods were supported.
// --Aaron
Basic.delete = function (Env, path, cb) {
if (!path) { return void pathError(cb); }
Fs.rm(path, cb);
};
Basic.deleteDir = function (Env, path, cb) {
if (!path) { return void pathError(cb); }
Fs.rm(path, { recursive: true, force: true }, cb);
};

View File

@ -116,6 +116,8 @@ Block.check = function (Env, publicKey, _cb) { // 'check' because 'exists' impli
Fs.access(path, Fs.constants.F_OK, cb);
};
Block.MAX_SIZE = 256; // XXX confirm that this is sufficient, prevent user inputs that would result in larger blocks
Block.write = function (Env, publicKey, buffer, _cb) {
var cb = Util.once(Util.mkAsync(_cb));
var path = Block.mkPath(Env, publicKey);

39
lib/storage/challenge.js Normal file
View File

@ -0,0 +1,39 @@
const Basic = require("./basic.js");
const Path = require("node:path");
const Challenge = module.exports;
/* This module manages storage used to implement a public-key authenticated
challenge-response protocol.
Each 'challenge' is only intended to be valid for a short period of time.
1. A client makes a request of the server
2. The server stores their request with a nonce and challenges them to sign for this request
3. If the client successfully signs for the request within a short window then the request is executed
4. Whether the signature is valid or not, the challenge is removed
Thus, we only expect challenges to remain in storage if the request was aborted or interrupted
for some unexpected reason.
Some form of garbage collection should be implemented in the future.
*/
const pathFromId = function (Env, id) {
if (!id || typeof(id) !== 'string') { return void console.error('CHALLENGE_BAD_ID', id); }
return Path.join(Env.paths.base, "challenges", id.slice(0, 2), id);
};
Challenge.read = function (Env, id, cb) {
var path = pathFromId(Env, id);
Basic.read(Env, path, cb);
};
Challenge.write = function (Env, id, data, cb) {
var path = pathFromId(Env, id);
Basic.write(Env, path, data, cb);
};
Challenge.delete = function (Env, id, cb) {
var path = pathFromId(Env, id);
Basic.delete(Env, path, cb);
};

88
lib/storage/mfa.js Normal file
View File

@ -0,0 +1,88 @@
const Basic = require("./basic");
const Path = require("node:path");
const Util = require("../common-util");
const Sessions = require("./sessions");
const nThen = require("nthen");
const MFA = module.exports;
/*
This module manages storage related to accounts' multi-factor authentication settings.
These settings are checked every time a block is accessed, so we do as little as possible
so that it can be accessed quickly.
*/
/* The path for a given account's settings is based on the public signing key
which identifies its "login block". We expect that any action to create or access
a block will be authenticated with a challenge-response protocol, so we
don't bother checking the validity of an identifier in here aside from
ensuring that it won't throw when using string methods.
*/
var pathFromId = function (Env, id) {
if (!id || typeof(id) !== 'string') { return; }
id = Util.escapeKeyCharacters(id);
return Path.join(Env.paths.base, "mfa", id.slice(0, 2), `${id}.json`);
};
MFA.read = function (Env, id, cb) {
var path = pathFromId(Env, id);
Basic.read(Env, path, cb);
};
// data should be a string
MFA.write = function (Env, id, data, cb) {
var path = pathFromId(Env, id);
Basic.write(Env, path, data, cb);
};
MFA.delete = function (Env, id, cb) {
var path = pathFromId(Env, id);
Basic.delete(Env, path, cb);
};
MFA.revoke = function (Env, publicKey, cb) {
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 error, 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
});
});
};
MFA.copy = function (Env, oldKey, newKey, cb) {
let content;
nThen(function (w) {
MFA.read(Env, oldKey, w(function (err, c) {
if (err) {
// No MFA configured, nothing to copy
w.abort();
return void cb();
}
content = c;
}));
}).nThen(function () {
MFA.write(Env, newKey, content, cb);
});
};

64
lib/storage/sessions.js Normal file
View File

@ -0,0 +1,64 @@
const Basic = require("./basic");
const Path = require("node:path");
const Nacl = require("tweetnacl/nacl-fast");
const Util = require("../common-util");
const Sessions = module.exports;
/* This module manages storage for per-acccount session tokens - currently assumed to be
JSON Web Tokens (JWTs).
Decisions about what goes into each of those JWTs happens upstream, so the storage
itself is relatively unopinionated.
The key things to understand are:
* valid sessions allow the holder of a given JWT to access a given "login block"
* JWTs are signed with a key held in the server's memory. If that key leaks then it should be rotated (with the SET_BEARER_SECRET decree) to invalidate all existing JWTs. Under these conditions then all tokens signed with the old key can be removed. Garbage collection of these older tokens is not implemented.
* it is expected that any given login-block can have multiple active sessions (for different devices, or if their browser clears its cache automatically). All sessions for a given block are stored in a per-user directory which is intended to make listing or iterating over them simple.
* It could be desirable to expose the list of sessions to the relevant user and allow them to revoke sessions individually or en-masse, though this is not currently implemented.
*/
var pathFromId = function (Env, id, ref) {
if (!id || typeof(id) !== 'string') { return; }
id = Util.escapeKeyCharacters(id);
return Path.join(Env.paths.base, "sessions", id.slice(0, 2), id, ref);
};
Sessions.randomId = () => Nacl.util.encodeBase64(Nacl.randomBytes(24)).replace(/\//g, '-');
Sessions.read = function (Env, id, ref, cb) {
var path = pathFromId(Env, id, ref);
Basic.read(Env, path, cb);
};
Sessions.write = function (Env, id, ref, data, cb) {
var path = pathFromId(Env, id, ref);
Basic.write(Env, path, data, cb);
};
Sessions.delete = function (Env, id, ref, cb) {
var path = pathFromId(Env, id, ref);
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:
// Fs.rm(dir, { recursive: true, force: true }, console.log)
// just be careful to validate the directory's path
// --Aaron

View File

@ -6,6 +6,7 @@ const OS = require("os");
const { fork } = require('child_process');
const Workers = module.exports;
const PID = process.pid;
const Block = require("../storage/block");
const DB_PATH = 'lib/workers/db-worker';
const MAX_JOBS = 16;
@ -260,22 +261,7 @@ Workers.initialize = function (Env, config, _cb) {
};
nThen(function (w) {
const max = config.maxWorkers;
var limit;
if (typeof(max) !== 'undefined') {
// the admin provided a limit on the number of workers
if (typeof(max) === 'number' && !isNaN(max)) {
if (max < 1) {
Log.info("INSUFFICIENT_MAX_WORKERS", max);
limit = 1;
}
limit = max;
} else {
Log.error("INVALID_MAX_WORKERS", '[' + max + ']');
}
}
var limit = Env.maxWorkers;
var logged;
OS.cpus().forEach(function (cpu, index) {
@ -472,6 +458,16 @@ Workers.initialize = function (Env, config, _cb) {
};
Env.validateLoginBlock = function (publicKey, signature, block, cb) {
if (!block || !block.length || block.length > Block.MAX_SIZE) {
return void setTimeout(function () {
Env.Log.error('E_INVALID_BLOCK_SIZE', {
size: block.length,
});
cb('E_INVALID_BLOCK_SIZE');
});
}
sendCommand({
command: 'VALIDATE_LOGIN_BLOCK',
publicKey: publicKey,

552
package-lock.json generated
View File

@ -29,14 +29,17 @@
"fs-extra": "^7.0.0",
"get-folder-size": "^2.0.1",
"html2canvas": "^1.4.0",
"http-proxy-middleware": "^2.0.6",
"hyper-json": "~1.4.0",
"jquery": "3.6.0",
"json.sortify": "~2.1.0",
"jsonwebtoken": "^9.0.0",
"jszip": "3.10.1",
"localforage": "^1.5.2",
"marked": "^4.3.0",
"mathjax": "3.0.5",
"netflux-websocket": "^1.0.0",
"notp": "^2.0.3",
"nthen": "0.1.8",
"open-sans-fontface": "^1.4.0",
"pako": "^2.1.0",
@ -50,6 +53,7 @@
"sortablejs": "^1.6.0",
"sortify": "^1.0.4",
"stream-to-pull-stream": "^1.7.2",
"thirty-two": "^1.0.2",
"tweetnacl": "~0.12.2",
"ulimit": "0.0.2",
"ws": "^3.3.1",
@ -101,6 +105,14 @@
"@types/node": "*"
}
},
"node_modules/@types/http-proxy": {
"version": "1.17.9",
"resolved": "https://registry.npmjs.org/@types/http-proxy/-/http-proxy-1.17.9.tgz",
"integrity": "sha512-QsbSjA/fSk7xB+UXlCT3wHBy5ai9wOcNDWwZAtud+jXhwOM3l+EYZh8Lng4+/6n8uar0J7xILzqftJdJ/Wdfkw==",
"dependencies": {
"@types/node": "*"
}
},
"node_modules/@types/minimatch": {
"version": "3.0.5",
"resolved": "https://registry.npmjs.org/@types/minimatch/-/minimatch-3.0.5.tgz",
@ -110,8 +122,7 @@
"node_modules/@types/node": {
"version": "17.0.33",
"resolved": "https://registry.npmjs.org/@types/node/-/node-17.0.33.tgz",
"integrity": "sha512-miWq2m2FiQZmaHfdZNcbpp9PuXg34W5JZ5CrJ/BaS70VuhoJENBEQybeiYSaPBRNq6KQGnjfEnc/F3PN++D+XQ==",
"dev": true
"integrity": "sha512-miWq2m2FiQZmaHfdZNcbpp9PuXg34W5JZ5CrJ/BaS70VuhoJENBEQybeiYSaPBRNq6KQGnjfEnc/F3PN++D+XQ=="
},
"node_modules/@xmldom/xmldom": {
"version": "0.8.8",
@ -794,6 +805,11 @@
"resolve": "0.6.3"
}
},
"node_modules/buffer-equal-constant-time": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz",
"integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA=="
},
"node_modules/bytes": {
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz",
@ -874,9 +890,9 @@
}
},
"node_modules/chainpad": {
"version": "5.2.6",
"resolved": "https://registry.npmjs.org/chainpad/-/chainpad-5.2.6.tgz",
"integrity": "sha512-jIlim4/4cTLpED3nYxAz1rNWzgX1ZwiDUWOfX3RMgVW2LWMdymYpIME7LRofMOwPfgTb8dcpl4q9F9mjo4/Lew==",
"version": "5.2.7",
"resolved": "https://registry.npmjs.org/chainpad/-/chainpad-5.2.7.tgz",
"integrity": "sha512-5+cf06B4mpHcfbDFGPwg6iqQypYgRhQjb+jqRxFIHFYNJtNvBLrInqlVY9HRhQyIbj/IxNSy2rE0gHfFuh7Tvw==",
"dependencies": {
"fast-diff": "^1.1.2",
"gluejs": "~2.4.0",
@ -894,14 +910,18 @@
}
},
"node_modules/chainpad-listmap": {
"version": "1.0.1",
"resolved": "git+ssh://git@github.com/xwiki-labs/chainpad-listmap.git#1ed90edf3c20ce9ba230d26c983b04de240f7b2a",
"license": "AGPL-3.0+"
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/chainpad-listmap/-/chainpad-listmap-1.0.2.tgz",
"integrity": "sha512-8DxiK7kkVAfXaFoTriLL360MImpX8ZwuuoISoZbizOgeacwOBVYBm4JecgVmXnft1UfjYUYBcXSl7HEI6EJHEQ==",
"dependencies": {
"chainpad-netflux": "^1.0.0",
"json.sortify": "~2.1.0"
}
},
"node_modules/chainpad-netflux": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/chainpad-netflux/-/chainpad-netflux-1.0.0.tgz",
"integrity": "sha512-WQcm9qNJYMR8PRVB5Lxu35BN+qL1pch0MUUb0uCcYnCttD2wq/mx1Yp7Fy6ZBKi0GJasJgfXQQKtOyuU09qzEg==",
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/chainpad-netflux/-/chainpad-netflux-1.0.1.tgz",
"integrity": "sha512-fOUiYD7790XhSFegZY2Yv0HT5YJxJV/WH3QhQYP1Qu/pu7I4XHQfDac0/cf22b8HbM6bsjFTxqcOvn4m63km9A==",
"dependencies": {
"netflux-websocket": "^1.0.0"
}
@ -941,9 +961,9 @@
},
"node_modules/ckeditor": {
"name": "ckeditor4",
"version": "4.21.0",
"resolved": "https://registry.npmjs.org/ckeditor4/-/ckeditor4-4.21.0.tgz",
"integrity": "sha512-OAMw68puJcrKFtsPZwIWVB/upYLgJpFw1yTuBBIhoreY+g/f0SttjQY0I/fUwxevVUHvgmRVNeJwNl8qkJPvyw=="
"version": "4.22.1",
"resolved": "https://registry.npmjs.org/ckeditor4/-/ckeditor4-4.22.1.tgz",
"integrity": "sha512-Yj4vTHX5YxHwc48gNqUqTm+KLkRr9tuyb4O2VIABu4oKHWRNVIdLdy6vUNe/XNx+RiTavMejfA1MVOU/MxLjqQ=="
},
"node_modules/class-utils": {
"version": "0.3.6",
@ -1079,9 +1099,9 @@
}
},
"node_modules/codemirror": {
"version": "5.65.11",
"resolved": "https://registry.npmjs.org/codemirror/-/codemirror-5.65.11.tgz",
"integrity": "sha512-Gp62g2eKSCHYt10axmGhKq3WoJSvVpvhXmowNq7pZdRVowwtvBR/hi2LSP5srtctKkRT33T6/n8Kv1UGp7JW4A=="
"version": "5.65.13",
"resolved": "https://registry.npmjs.org/codemirror/-/codemirror-5.65.13.tgz",
"integrity": "sha512-SVWEzKXmbHmTQQWaz03Shrh4nybG0wXx2MEu3FO4ezbPW8IbnZEd5iGHGEffSUaitKYa3i+pHpBsSvw8sPHtzg=="
},
"node_modules/collection-visit": {
"version": "1.0.0",
@ -1269,6 +1289,7 @@
"version": "0.0.2",
"resolved": "https://registry.npmjs.org/debuglog/-/debuglog-0.0.2.tgz",
"integrity": "sha512-R6Bl6JxzWVrquXohdoHkyGMgaL8esUXhSzB6WUfSX1N+1dFg30Yo9gshTu7ExV6ntKN4VoJFw8zVdPsVVYGmKg==",
"deprecated": "Package no longer supported. Contact Support at https://www.npmjs.com/support for more info.",
"optional": true,
"engines": {
"node": "*"
@ -1411,6 +1432,14 @@
"version": "21.5.2",
"resolved": "git+ssh://git@github.com/cryptpad/drawio-npm.git#c49df7017c6dc6cc55a18768b4fe4e3e030f8597"
},
"node_modules/ecdsa-sig-formatter": {
"version": "1.0.11",
"resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz",
"integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==",
"dependencies": {
"safe-buffer": "^5.0.1"
}
},
"node_modules/ee-first": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz",
@ -1473,6 +1502,11 @@
"node": ">= 0.6"
}
},
"node_modules/eventemitter3": {
"version": "4.0.7",
"resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-4.0.7.tgz",
"integrity": "sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw=="
},
"node_modules/exit": {
"version": "0.1.2",
"resolved": "https://registry.npmjs.org/exit/-/exit-0.1.2.tgz",
@ -1744,9 +1778,9 @@
}
},
"node_modules/fast-diff": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/fast-diff/-/fast-diff-1.2.0.tgz",
"integrity": "sha512-xJuoT5+L99XlZ8twedaRf6Ax2TgQVxvgZOYoPKqZufmJib0tL2tegPBOZb1pVNgIhlqDlA0eO0c3wBvQcmzx4w=="
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/fast-diff/-/fast-diff-1.3.0.tgz",
"integrity": "sha512-VxPP4NqbUjj6MaAOafWeUn2cXWLcCtljklUtZf0Ind4XQ+QPtmA0b18zZy0jIQx+ExRVCR/ZQpBmik5lXshNsw=="
},
"node_modules/fast-glob": {
"version": "2.2.7",
@ -1830,6 +1864,25 @@
"deprecated": "flatten is deprecated in favor of utility frameworks such as lodash.",
"dev": true
},
"node_modules/follow-redirects": {
"version": "1.15.2",
"resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.2.tgz",
"integrity": "sha512-VQLG33o04KaQ8uYi2tVNbdrWp1QWxNNea+nmIB4EVM28v0hmP17z7aG1+wAkNzVq4KeXTq3221ye5qTJP91JwA==",
"funding": [
{
"type": "individual",
"url": "https://github.com/sponsors/RubenVerborgh"
}
],
"engines": {
"node": ">=4.0"
},
"peerDependenciesMeta": {
"debug": {
"optional": true
}
}
},
"node_modules/for-in": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/for-in/-/for-in-1.0.2.tgz",
@ -2141,6 +2194,95 @@
"node": ">= 0.8"
}
},
"node_modules/http-proxy": {
"version": "1.18.1",
"resolved": "https://registry.npmjs.org/http-proxy/-/http-proxy-1.18.1.tgz",
"integrity": "sha512-7mz/721AbnJwIVbnaSv1Cz3Am0ZLT/UBwkC92VlxhXv/k/BBQfM2fXElQNC27BVGr0uwUpplYPQM9LnaBMR5NQ==",
"dependencies": {
"eventemitter3": "^4.0.0",
"follow-redirects": "^1.0.0",
"requires-port": "^1.0.0"
},
"engines": {
"node": ">=8.0.0"
}
},
"node_modules/http-proxy-middleware": {
"version": "2.0.6",
"resolved": "https://registry.npmjs.org/http-proxy-middleware/-/http-proxy-middleware-2.0.6.tgz",
"integrity": "sha512-ya/UeJ6HVBYxrgYotAZo1KvPWlgB48kUJLDePFeneHsVujFaW5WNj2NgWCAE//B1Dl02BIfYlpNgBy8Kf8Rjmw==",
"dependencies": {
"@types/http-proxy": "^1.17.8",
"http-proxy": "^1.18.1",
"is-glob": "^4.0.1",
"is-plain-obj": "^3.0.0",
"micromatch": "^4.0.2"
},
"engines": {
"node": ">=12.0.0"
},
"peerDependencies": {
"@types/express": "^4.17.13"
},
"peerDependenciesMeta": {
"@types/express": {
"optional": true
}
}
},
"node_modules/http-proxy-middleware/node_modules/braces": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/braces/-/braces-3.0.2.tgz",
"integrity": "sha512-b8um+L1RzM3WDSzvhm6gIz1yfTbBt6YTlcEKAvsmqCZZFw46z626lVj9j1yEPW33H5H+lBQpZMP1k8l+78Ha0A==",
"dependencies": {
"fill-range": "^7.0.1"
},
"engines": {
"node": ">=8"
}
},
"node_modules/http-proxy-middleware/node_modules/fill-range": {
"version": "7.0.1",
"resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.0.1.tgz",
"integrity": "sha512-qOo9F+dMUmC2Lcb4BbVvnKJxTPjCm+RRpe4gDuGrzkL7mEVl/djYSu2OdQ2Pa302N4oqkSg9ir6jaLWJ2USVpQ==",
"dependencies": {
"to-regex-range": "^5.0.1"
},
"engines": {
"node": ">=8"
}
},
"node_modules/http-proxy-middleware/node_modules/is-number": {
"version": "7.0.0",
"resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz",
"integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==",
"engines": {
"node": ">=0.12.0"
}
},
"node_modules/http-proxy-middleware/node_modules/micromatch": {
"version": "4.0.5",
"resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.5.tgz",
"integrity": "sha512-DMy+ERcEW2q8Z2Po+WNXuw3c5YaUSFjAO5GsJqfEl7UjvtIuFKO6ZrKvcItdy98dwFI2N1tg3zNIdKaQT+aNdA==",
"dependencies": {
"braces": "^3.0.2",
"picomatch": "^2.3.1"
},
"engines": {
"node": ">=8.6"
}
},
"node_modules/http-proxy-middleware/node_modules/to-regex-range": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz",
"integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==",
"dependencies": {
"is-number": "^7.0.0"
},
"engines": {
"node": ">=8.0"
}
},
"node_modules/hyper-json": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/hyper-json/-/hyper-json-1.4.0.tgz",
@ -2169,7 +2311,7 @@
"node_modules/immediate": {
"version": "3.0.6",
"resolved": "https://registry.npmjs.org/immediate/-/immediate-3.0.6.tgz",
"integrity": "sha1-nbHb0Pr43m++D13V5Wu2BigN5ps="
"integrity": "sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ=="
},
"node_modules/import-fresh": {
"version": "2.0.0",
@ -2292,7 +2434,6 @@
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz",
"integrity": "sha1-qIwCU1eR8C7TfHahueqXc8gz+MI=",
"dev": true,
"engines": {
"node": ">=0.10.0"
}
@ -2309,7 +2450,6 @@
"version": "4.0.3",
"resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz",
"integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==",
"dev": true,
"dependencies": {
"is-extglob": "^2.1.1"
},
@ -2339,6 +2479,17 @@
"node": ">=0.10.0"
}
},
"node_modules/is-plain-obj": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/is-plain-obj/-/is-plain-obj-3.0.0.tgz",
"integrity": "sha512-gwsOE28k+23GP1B6vFl1oVh/WOzmawBrKwo5Ev6wMKzPkaXaCDIQKzLnvsA42DRlbVTWorkgTKIviAKCWkfUwA==",
"engines": {
"node": ">=10"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/is-plain-object": {
"version": "2.0.4",
"resolved": "https://registry.npmjs.org/is-plain-object/-/is-plain-object-2.0.4.tgz",
@ -2430,6 +2581,26 @@
"graceful-fs": "^4.1.6"
}
},
"node_modules/jsonwebtoken": {
"version": "9.0.0",
"resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-9.0.0.tgz",
"integrity": "sha512-tuGfYXxkQGDPnLJ7SibiQgVgeDgfbPq2k2ICcbgqW8WxWLBAxKQM/ZCu/IT8SOSwmaYl4dpTFCW5xZv7YbbWUw==",
"dependencies": {
"jws": "^3.2.2",
"lodash": "^4.17.21",
"ms": "^2.1.1",
"semver": "^7.3.8"
},
"engines": {
"node": ">=12",
"npm": ">=6"
}
},
"node_modules/jsonwebtoken/node_modules/ms": {
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="
},
"node_modules/jszip": {
"version": "3.10.1",
"resolved": "https://registry.npmjs.org/jszip/-/jszip-3.10.1.tgz",
@ -2444,7 +2615,7 @@
"node_modules/jszip/node_modules/isarray": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz",
"integrity": "sha1-u5NdSFgsuhaMBoNJV6VKPgcSTxE="
"integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ=="
},
"node_modules/jszip/node_modules/pako": {
"version": "1.0.11",
@ -2452,9 +2623,9 @@
"integrity": "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw=="
},
"node_modules/jszip/node_modules/readable-stream": {
"version": "2.3.7",
"resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.7.tgz",
"integrity": "sha512-Ebho8K4jIbHAxnuxi7o42OrZgF/ZTNcsZj6nRKyUmkhLFq8CHItp/fy6hQZuZmP/n3yZ9VBUbp4zz/mX8hmYPw==",
"version": "2.3.8",
"resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz",
"integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==",
"dependencies": {
"core-util-is": "~1.0.0",
"inherits": "~2.0.3",
@ -2473,6 +2644,25 @@
"safe-buffer": "~5.1.0"
}
},
"node_modules/jwa": {
"version": "1.4.1",
"resolved": "https://registry.npmjs.org/jwa/-/jwa-1.4.1.tgz",
"integrity": "sha512-qiLX/xhEEFKUAJ6FiBMbes3w9ATzyk5W7Hvzpa/SLYdxNtng+gcurvrI7TbACjIXlsJyr05/S1oUhZrc63evQA==",
"dependencies": {
"buffer-equal-constant-time": "1.0.1",
"ecdsa-sig-formatter": "1.0.11",
"safe-buffer": "^5.0.1"
}
},
"node_modules/jws": {
"version": "3.2.2",
"resolved": "https://registry.npmjs.org/jws/-/jws-3.2.2.tgz",
"integrity": "sha512-YHlZCB6lMTllWDtSPHz/ZXTsi8S00usEV6v1tjq8tOUZzw7DpSDWVXjXDre6ed1w/pd495ODpHZYSdkRTsa0HA==",
"dependencies": {
"jwa": "^1.4.1",
"safe-buffer": "^5.0.1"
}
},
"node_modules/kind-of": {
"version": "6.0.3",
"resolved": "https://registry.npmjs.org/kind-of/-/kind-of-6.0.3.tgz",
@ -2568,8 +2758,7 @@
"node_modules/lodash": {
"version": "4.17.21",
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz",
"integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==",
"dev": true
"integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg=="
},
"node_modules/lodash.merge": {
"version": "4.6.2",
@ -2617,6 +2806,17 @@
"resolved": "https://registry.npmjs.org/looper/-/looper-3.0.0.tgz",
"integrity": "sha1-LvpUw7HLq6m5Su4uWRSwvlf7t0k="
},
"node_modules/lru-cache": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz",
"integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==",
"dependencies": {
"yallist": "^4.0.0"
},
"engines": {
"node": ">=10"
}
},
"node_modules/map-cache": {
"version": "0.2.2",
"resolved": "https://registry.npmjs.org/map-cache/-/map-cache-0.2.2.tgz",
@ -2891,6 +3091,14 @@
"resolved": "https://registry.npmjs.org/netflux-websocket/-/netflux-websocket-1.0.0.tgz",
"integrity": "sha512-xU5AXzSne9yA7eC6jXTU7UGvcG1Al0IUSeNd7IrumJBIr70DLa0nQfV3TOpZsmytBJbefi6fcfVjkgclAgWuOQ=="
},
"node_modules/notp": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/notp/-/notp-2.0.3.tgz",
"integrity": "sha512-oBig/2uqkjQ5AkBuw4QJYwkEWa/q+zHxI5/I5z6IeP2NT0alpJFsP/trrfCC+9xOAgQSZXssNi962kp5KBmypQ==",
"engines": {
"node": "> v0.6.0"
}
},
"node_modules/nthen": {
"version": "0.1.8",
"resolved": "https://registry.npmjs.org/nthen/-/nthen-0.1.8.tgz",
@ -3115,6 +3323,17 @@
"integrity": "sha512-cMlDqaLEqfSaW8Z7N5Jw+lyIW869EzT73/F5lhtY9cLGoVxSXznfgfXMO0Z5K0o0Q2TkTXq+0KFsdnSe3jDViA==",
"dev": true
},
"node_modules/picomatch": {
"version": "2.3.1",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz",
"integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==",
"engines": {
"node": ">=8.6"
},
"funding": {
"url": "https://github.com/sponsors/jonschlinkert"
}
},
"node_modules/pify": {
"version": "4.0.1",
"resolved": "https://registry.npmjs.org/pify/-/pify-4.0.1.tgz",
@ -3553,6 +3772,11 @@
"resolved": "https://registry.npmjs.org/requirejs-plugins/-/requirejs-plugins-1.0.2.tgz",
"integrity": "sha512-bX1vGnjDd1iIod3hst47oM/Nv9JiYw4qodWNKgyeQw5ahzSvPI3j1K6hMcK0HtiGRKxebCf+QUrtWzbrZtzQog=="
},
"node_modules/requires-port": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/requires-port/-/requires-port-1.0.0.tgz",
"integrity": "sha512-KigOCHcocU3XODJxsu8i/j8T9tzT4adHiecwORRQ0ZZFcp7ahwXuRU1m+yuO90C5ZUyGeGfocHDI14M3L3yDAQ=="
},
"node_modules/resolve": {
"version": "0.6.3",
"resolved": "https://registry.npmjs.org/resolve/-/resolve-0.6.3.tgz",
@ -3612,6 +3836,20 @@
"resolved": "https://registry.npmjs.org/scrypt-async/-/scrypt-async-1.2.0.tgz",
"integrity": "sha512-BtT7+xPLdoeXsh6wOblaq6sRy/AsffFXgcwBNGtQJXeNgm9UD3HM3nWEmAERjdCNfE6jWglAtRbAPZ7rc2WtsQ=="
},
"node_modules/semver": {
"version": "7.5.4",
"resolved": "https://registry.npmjs.org/semver/-/semver-7.5.4.tgz",
"integrity": "sha512-1bCSESV6Pv+i21Hvpxp3Dx+pSD8lIPt8uVjRrxAUt/nbswYc+tK6Y2btiULjd4+fnq15PX+nqQDC7Oft7WkwcA==",
"dependencies": {
"lru-cache": "^6.0.0"
},
"bin": {
"semver": "bin/semver.js"
},
"engines": {
"node": ">=10"
}
},
"node_modules/send": {
"version": "0.18.0",
"resolved": "https://registry.npmjs.org/send/-/send-0.18.0.tgz",
@ -4187,6 +4425,14 @@
"utrie": "^1.0.2"
}
},
"node_modules/thirty-two": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/thirty-two/-/thirty-two-1.0.2.tgz",
"integrity": "sha512-OEI0IWCe+Dw46019YLl6V10Us5bi574EvlJEOcAkB29IzQ/mYD1A6RyNHLjZPiHCmuodxvgF6U+vZO1L15lxVA==",
"engines": {
"node": ">=0.2.6"
}
},
"node_modules/ticky": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/ticky/-/ticky-1.0.1.tgz",
@ -4277,7 +4523,7 @@
"node_modules/tweetnacl": {
"version": "0.12.2",
"resolved": "https://registry.npmjs.org/tweetnacl/-/tweetnacl-0.12.2.tgz",
"integrity": "sha512-daw2PNhLZNN5sqvT7NfRcI2uH25gHpbaHxzWJMrF8IiWRW+RkFnD3tr3N5F2tXw1vj7VHI54Hyed5WKgU6I58g=="
"integrity": "sha1-vVn4kFB4VvsKETasw6i0RUfinds="
},
"node_modules/type-is": {
"version": "1.6.18",
@ -4423,7 +4669,7 @@
"node_modules/util-deprecate": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
"integrity": "sha1-RQ1Nyfpw3nMnYvvS1KKJgUGaDM8="
"integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw=="
},
"node_modules/utils-merge": {
"version": "1.0.1",
@ -4522,6 +4768,11 @@
"engines": {
"node": "*"
}
},
"node_modules/yallist": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz",
"integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A=="
}
},
"dependencies": {
@ -4556,6 +4807,14 @@
"@types/node": "*"
}
},
"@types/http-proxy": {
"version": "1.17.9",
"resolved": "https://registry.npmjs.org/@types/http-proxy/-/http-proxy-1.17.9.tgz",
"integrity": "sha512-QsbSjA/fSk7xB+UXlCT3wHBy5ai9wOcNDWwZAtud+jXhwOM3l+EYZh8Lng4+/6n8uar0J7xILzqftJdJ/Wdfkw==",
"requires": {
"@types/node": "*"
}
},
"@types/minimatch": {
"version": "3.0.5",
"resolved": "https://registry.npmjs.org/@types/minimatch/-/minimatch-3.0.5.tgz",
@ -4565,8 +4824,7 @@
"@types/node": {
"version": "17.0.33",
"resolved": "https://registry.npmjs.org/@types/node/-/node-17.0.33.tgz",
"integrity": "sha512-miWq2m2FiQZmaHfdZNcbpp9PuXg34W5JZ5CrJ/BaS70VuhoJENBEQybeiYSaPBRNq6KQGnjfEnc/F3PN++D+XQ==",
"dev": true
"integrity": "sha512-miWq2m2FiQZmaHfdZNcbpp9PuXg34W5JZ5CrJ/BaS70VuhoJENBEQybeiYSaPBRNq6KQGnjfEnc/F3PN++D+XQ=="
},
"@xmldom/xmldom": {
"version": "0.8.8",
@ -5077,6 +5335,11 @@
"resolve": "0.6.3"
}
},
"buffer-equal-constant-time": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz",
"integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA=="
},
"bytes": {
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz",
@ -5139,9 +5402,9 @@
"dev": true
},
"chainpad": {
"version": "5.2.6",
"resolved": "https://registry.npmjs.org/chainpad/-/chainpad-5.2.6.tgz",
"integrity": "sha512-jIlim4/4cTLpED3nYxAz1rNWzgX1ZwiDUWOfX3RMgVW2LWMdymYpIME7LRofMOwPfgTb8dcpl4q9F9mjo4/Lew==",
"version": "5.2.7",
"resolved": "https://registry.npmjs.org/chainpad/-/chainpad-5.2.7.tgz",
"integrity": "sha512-5+cf06B4mpHcfbDFGPwg6iqQypYgRhQjb+jqRxFIHFYNJtNvBLrInqlVY9HRhQyIbj/IxNSy2rE0gHfFuh7Tvw==",
"requires": {
"fast-diff": "^1.1.2",
"gluejs": "~2.4.0",
@ -5166,13 +5429,18 @@
}
},
"chainpad-listmap": {
"version": "git+ssh://git@github.com/xwiki-labs/chainpad-listmap.git#1ed90edf3c20ce9ba230d26c983b04de240f7b2a",
"from": "chainpad-listmap@^1.0.0"
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/chainpad-listmap/-/chainpad-listmap-1.0.2.tgz",
"integrity": "sha512-8DxiK7kkVAfXaFoTriLL360MImpX8ZwuuoISoZbizOgeacwOBVYBm4JecgVmXnft1UfjYUYBcXSl7HEI6EJHEQ==",
"requires": {
"chainpad-netflux": "^1.0.0",
"json.sortify": "~2.1.0"
}
},
"chainpad-netflux": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/chainpad-netflux/-/chainpad-netflux-1.0.0.tgz",
"integrity": "sha512-WQcm9qNJYMR8PRVB5Lxu35BN+qL1pch0MUUb0uCcYnCttD2wq/mx1Yp7Fy6ZBKi0GJasJgfXQQKtOyuU09qzEg==",
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/chainpad-netflux/-/chainpad-netflux-1.0.1.tgz",
"integrity": "sha512-fOUiYD7790XhSFegZY2Yv0HT5YJxJV/WH3QhQYP1Qu/pu7I4XHQfDac0/cf22b8HbM6bsjFTxqcOvn4m63km9A==",
"requires": {
"netflux-websocket": "^1.0.0"
}
@ -5199,9 +5467,9 @@
}
},
"ckeditor": {
"version": "npm:ckeditor4@4.21.0",
"resolved": "https://registry.npmjs.org/ckeditor4/-/ckeditor4-4.21.0.tgz",
"integrity": "sha512-OAMw68puJcrKFtsPZwIWVB/upYLgJpFw1yTuBBIhoreY+g/f0SttjQY0I/fUwxevVUHvgmRVNeJwNl8qkJPvyw=="
"version": "npm:ckeditor4@4.22.1",
"resolved": "https://registry.npmjs.org/ckeditor4/-/ckeditor4-4.22.1.tgz",
"integrity": "sha512-Yj4vTHX5YxHwc48gNqUqTm+KLkRr9tuyb4O2VIABu4oKHWRNVIdLdy6vUNe/XNx+RiTavMejfA1MVOU/MxLjqQ=="
},
"class-utils": {
"version": "0.3.6",
@ -5312,9 +5580,9 @@
}
},
"codemirror": {
"version": "5.65.11",
"resolved": "https://registry.npmjs.org/codemirror/-/codemirror-5.65.11.tgz",
"integrity": "sha512-Gp62g2eKSCHYt10axmGhKq3WoJSvVpvhXmowNq7pZdRVowwtvBR/hi2LSP5srtctKkRT33T6/n8Kv1UGp7JW4A=="
"version": "5.65.13",
"resolved": "https://registry.npmjs.org/codemirror/-/codemirror-5.65.13.tgz",
"integrity": "sha512-SVWEzKXmbHmTQQWaz03Shrh4nybG0wXx2MEu3FO4ezbPW8IbnZEd5iGHGEffSUaitKYa3i+pHpBsSvw8sPHtzg=="
},
"collection-visit": {
"version": "1.0.0",
@ -5578,6 +5846,14 @@
"version": "git+ssh://git@github.com/cryptpad/drawio-npm.git#c49df7017c6dc6cc55a18768b4fe4e3e030f8597",
"from": "drawio@cryptpad/drawio-npm#npm"
},
"ecdsa-sig-formatter": {
"version": "1.0.11",
"resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz",
"integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==",
"requires": {
"safe-buffer": "^5.0.1"
}
},
"ee-first": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz",
@ -5624,6 +5900,11 @@
"resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz",
"integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg=="
},
"eventemitter3": {
"version": "4.0.7",
"resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-4.0.7.tgz",
"integrity": "sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw=="
},
"exit": {
"version": "0.1.2",
"resolved": "https://registry.npmjs.org/exit/-/exit-0.1.2.tgz",
@ -5834,9 +6115,9 @@
}
},
"fast-diff": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/fast-diff/-/fast-diff-1.2.0.tgz",
"integrity": "sha512-xJuoT5+L99XlZ8twedaRf6Ax2TgQVxvgZOYoPKqZufmJib0tL2tegPBOZb1pVNgIhlqDlA0eO0c3wBvQcmzx4w=="
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/fast-diff/-/fast-diff-1.3.0.tgz",
"integrity": "sha512-VxPP4NqbUjj6MaAOafWeUn2cXWLcCtljklUtZf0Ind4XQ+QPtmA0b18zZy0jIQx+ExRVCR/ZQpBmik5lXshNsw=="
},
"fast-glob": {
"version": "2.2.7",
@ -5906,6 +6187,11 @@
"integrity": "sha512-dVsPA/UwQ8+2uoFe5GHtiBMu48dWLTdsuEd7CKGlZlD78r1TTWBvDuFaFGKCo/ZfEr95Uk56vZoX86OsHkUeIg==",
"dev": true
},
"follow-redirects": {
"version": "1.15.2",
"resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.2.tgz",
"integrity": "sha512-VQLG33o04KaQ8uYi2tVNbdrWp1QWxNNea+nmIB4EVM28v0hmP17z7aG1+wAkNzVq4KeXTq3221ye5qTJP91JwA=="
},
"for-in": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/for-in/-/for-in-1.0.2.tgz",
@ -6153,6 +6439,68 @@
"toidentifier": "1.0.1"
}
},
"http-proxy": {
"version": "1.18.1",
"resolved": "https://registry.npmjs.org/http-proxy/-/http-proxy-1.18.1.tgz",
"integrity": "sha512-7mz/721AbnJwIVbnaSv1Cz3Am0ZLT/UBwkC92VlxhXv/k/BBQfM2fXElQNC27BVGr0uwUpplYPQM9LnaBMR5NQ==",
"requires": {
"eventemitter3": "^4.0.0",
"follow-redirects": "^1.0.0",
"requires-port": "^1.0.0"
}
},
"http-proxy-middleware": {
"version": "2.0.6",
"resolved": "https://registry.npmjs.org/http-proxy-middleware/-/http-proxy-middleware-2.0.6.tgz",
"integrity": "sha512-ya/UeJ6HVBYxrgYotAZo1KvPWlgB48kUJLDePFeneHsVujFaW5WNj2NgWCAE//B1Dl02BIfYlpNgBy8Kf8Rjmw==",
"requires": {
"@types/http-proxy": "^1.17.8",
"http-proxy": "^1.18.1",
"is-glob": "^4.0.1",
"is-plain-obj": "^3.0.0",
"micromatch": "^4.0.2"
},
"dependencies": {
"braces": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/braces/-/braces-3.0.2.tgz",
"integrity": "sha512-b8um+L1RzM3WDSzvhm6gIz1yfTbBt6YTlcEKAvsmqCZZFw46z626lVj9j1yEPW33H5H+lBQpZMP1k8l+78Ha0A==",
"requires": {
"fill-range": "^7.0.1"
}
},
"fill-range": {
"version": "7.0.1",
"resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.0.1.tgz",
"integrity": "sha512-qOo9F+dMUmC2Lcb4BbVvnKJxTPjCm+RRpe4gDuGrzkL7mEVl/djYSu2OdQ2Pa302N4oqkSg9ir6jaLWJ2USVpQ==",
"requires": {
"to-regex-range": "^5.0.1"
}
},
"is-number": {
"version": "7.0.0",
"resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz",
"integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng=="
},
"micromatch": {
"version": "4.0.5",
"resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.5.tgz",
"integrity": "sha512-DMy+ERcEW2q8Z2Po+WNXuw3c5YaUSFjAO5GsJqfEl7UjvtIuFKO6ZrKvcItdy98dwFI2N1tg3zNIdKaQT+aNdA==",
"requires": {
"braces": "^3.0.2",
"picomatch": "^2.3.1"
}
},
"to-regex-range": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz",
"integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==",
"requires": {
"is-number": "^7.0.0"
}
}
}
},
"hyper-json": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/hyper-json/-/hyper-json-1.4.0.tgz",
@ -6175,7 +6523,7 @@
"immediate": {
"version": "3.0.6",
"resolved": "https://registry.npmjs.org/immediate/-/immediate-3.0.6.tgz",
"integrity": "sha1-nbHb0Pr43m++D13V5Wu2BigN5ps="
"integrity": "sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ=="
},
"import-fresh": {
"version": "2.0.0",
@ -6273,8 +6621,7 @@
"is-extglob": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz",
"integrity": "sha1-qIwCU1eR8C7TfHahueqXc8gz+MI=",
"dev": true
"integrity": "sha1-qIwCU1eR8C7TfHahueqXc8gz+MI="
},
"is-fullwidth-code-point": {
"version": "2.0.0",
@ -6285,7 +6632,6 @@
"version": "4.0.3",
"resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz",
"integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==",
"dev": true,
"requires": {
"is-extglob": "^2.1.1"
}
@ -6308,6 +6654,11 @@
}
}
},
"is-plain-obj": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/is-plain-obj/-/is-plain-obj-3.0.0.tgz",
"integrity": "sha512-gwsOE28k+23GP1B6vFl1oVh/WOzmawBrKwo5Ev6wMKzPkaXaCDIQKzLnvsA42DRlbVTWorkgTKIviAKCWkfUwA=="
},
"is-plain-object": {
"version": "2.0.4",
"resolved": "https://registry.npmjs.org/is-plain-object/-/is-plain-object-2.0.4.tgz",
@ -6381,6 +6732,24 @@
"graceful-fs": "^4.1.6"
}
},
"jsonwebtoken": {
"version": "9.0.0",
"resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-9.0.0.tgz",
"integrity": "sha512-tuGfYXxkQGDPnLJ7SibiQgVgeDgfbPq2k2ICcbgqW8WxWLBAxKQM/ZCu/IT8SOSwmaYl4dpTFCW5xZv7YbbWUw==",
"requires": {
"jws": "^3.2.2",
"lodash": "^4.17.21",
"ms": "^2.1.1",
"semver": "^7.3.8"
},
"dependencies": {
"ms": {
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="
}
}
},
"jszip": {
"version": "3.10.1",
"resolved": "https://registry.npmjs.org/jszip/-/jszip-3.10.1.tgz",
@ -6395,7 +6764,7 @@
"isarray": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz",
"integrity": "sha1-u5NdSFgsuhaMBoNJV6VKPgcSTxE="
"integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ=="
},
"pako": {
"version": "1.0.11",
@ -6403,9 +6772,9 @@
"integrity": "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw=="
},
"readable-stream": {
"version": "2.3.7",
"resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.7.tgz",
"integrity": "sha512-Ebho8K4jIbHAxnuxi7o42OrZgF/ZTNcsZj6nRKyUmkhLFq8CHItp/fy6hQZuZmP/n3yZ9VBUbp4zz/mX8hmYPw==",
"version": "2.3.8",
"resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz",
"integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==",
"requires": {
"core-util-is": "~1.0.0",
"inherits": "~2.0.3",
@ -6426,6 +6795,25 @@
}
}
},
"jwa": {
"version": "1.4.1",
"resolved": "https://registry.npmjs.org/jwa/-/jwa-1.4.1.tgz",
"integrity": "sha512-qiLX/xhEEFKUAJ6FiBMbes3w9ATzyk5W7Hvzpa/SLYdxNtng+gcurvrI7TbACjIXlsJyr05/S1oUhZrc63evQA==",
"requires": {
"buffer-equal-constant-time": "1.0.1",
"ecdsa-sig-formatter": "1.0.11",
"safe-buffer": "^5.0.1"
}
},
"jws": {
"version": "3.2.2",
"resolved": "https://registry.npmjs.org/jws/-/jws-3.2.2.tgz",
"integrity": "sha512-YHlZCB6lMTllWDtSPHz/ZXTsi8S00usEV6v1tjq8tOUZzw7DpSDWVXjXDre6ed1w/pd495ODpHZYSdkRTsa0HA==",
"requires": {
"jwa": "^1.4.1",
"safe-buffer": "^5.0.1"
}
},
"kind-of": {
"version": "6.0.3",
"resolved": "https://registry.npmjs.org/kind-of/-/kind-of-6.0.3.tgz",
@ -6504,8 +6892,7 @@
"lodash": {
"version": "4.17.21",
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz",
"integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==",
"dev": true
"integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg=="
},
"lodash.merge": {
"version": "4.6.2",
@ -6547,6 +6934,14 @@
"resolved": "https://registry.npmjs.org/looper/-/looper-3.0.0.tgz",
"integrity": "sha1-LvpUw7HLq6m5Su4uWRSwvlf7t0k="
},
"lru-cache": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz",
"integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==",
"requires": {
"yallist": "^4.0.0"
}
},
"map-cache": {
"version": "0.2.2",
"resolved": "https://registry.npmjs.org/map-cache/-/map-cache-0.2.2.tgz",
@ -6759,6 +7154,11 @@
"resolved": "https://registry.npmjs.org/netflux-websocket/-/netflux-websocket-1.0.0.tgz",
"integrity": "sha512-xU5AXzSne9yA7eC6jXTU7UGvcG1Al0IUSeNd7IrumJBIr70DLa0nQfV3TOpZsmytBJbefi6fcfVjkgclAgWuOQ=="
},
"notp": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/notp/-/notp-2.0.3.tgz",
"integrity": "sha512-oBig/2uqkjQ5AkBuw4QJYwkEWa/q+zHxI5/I5z6IeP2NT0alpJFsP/trrfCC+9xOAgQSZXssNi962kp5KBmypQ=="
},
"nthen": {
"version": "0.1.8",
"resolved": "https://registry.npmjs.org/nthen/-/nthen-0.1.8.tgz",
@ -6938,6 +7338,11 @@
"integrity": "sha512-cMlDqaLEqfSaW8Z7N5Jw+lyIW869EzT73/F5lhtY9cLGoVxSXznfgfXMO0Z5K0o0Q2TkTXq+0KFsdnSe3jDViA==",
"dev": true
},
"picomatch": {
"version": "2.3.1",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz",
"integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA=="
},
"pify": {
"version": "4.0.1",
"resolved": "https://registry.npmjs.org/pify/-/pify-4.0.1.tgz",
@ -7281,6 +7686,11 @@
"resolved": "https://registry.npmjs.org/requirejs-plugins/-/requirejs-plugins-1.0.2.tgz",
"integrity": "sha512-bX1vGnjDd1iIod3hst47oM/Nv9JiYw4qodWNKgyeQw5ahzSvPI3j1K6hMcK0HtiGRKxebCf+QUrtWzbrZtzQog=="
},
"requires-port": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/requires-port/-/requires-port-1.0.0.tgz",
"integrity": "sha512-KigOCHcocU3XODJxsu8i/j8T9tzT4adHiecwORRQ0ZZFcp7ahwXuRU1m+yuO90C5ZUyGeGfocHDI14M3L3yDAQ=="
},
"resolve": {
"version": "0.6.3",
"resolved": "https://registry.npmjs.org/resolve/-/resolve-0.6.3.tgz",
@ -7333,6 +7743,14 @@
"resolved": "https://registry.npmjs.org/scrypt-async/-/scrypt-async-1.2.0.tgz",
"integrity": "sha512-BtT7+xPLdoeXsh6wOblaq6sRy/AsffFXgcwBNGtQJXeNgm9UD3HM3nWEmAERjdCNfE6jWglAtRbAPZ7rc2WtsQ=="
},
"semver": {
"version": "7.5.4",
"resolved": "https://registry.npmjs.org/semver/-/semver-7.5.4.tgz",
"integrity": "sha512-1bCSESV6Pv+i21Hvpxp3Dx+pSD8lIPt8uVjRrxAUt/nbswYc+tK6Y2btiULjd4+fnq15PX+nqQDC7Oft7WkwcA==",
"requires": {
"lru-cache": "^6.0.0"
}
},
"send": {
"version": "0.18.0",
"resolved": "https://registry.npmjs.org/send/-/send-0.18.0.tgz",
@ -7791,6 +8209,11 @@
"utrie": "^1.0.2"
}
},
"thirty-two": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/thirty-two/-/thirty-two-1.0.2.tgz",
"integrity": "sha512-OEI0IWCe+Dw46019YLl6V10Us5bi574EvlJEOcAkB29IzQ/mYD1A6RyNHLjZPiHCmuodxvgF6U+vZO1L15lxVA=="
},
"ticky": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/ticky/-/ticky-1.0.1.tgz",
@ -7862,7 +8285,7 @@
"tweetnacl": {
"version": "0.12.2",
"resolved": "https://registry.npmjs.org/tweetnacl/-/tweetnacl-0.12.2.tgz",
"integrity": "sha512-daw2PNhLZNN5sqvT7NfRcI2uH25gHpbaHxzWJMrF8IiWRW+RkFnD3tr3N5F2tXw1vj7VHI54Hyed5WKgU6I58g=="
"integrity": "sha1-vVn4kFB4VvsKETasw6i0RUfinds="
},
"type-is": {
"version": "1.6.18",
@ -7980,7 +8403,7 @@
"util-deprecate": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
"integrity": "sha1-RQ1Nyfpw3nMnYvvS1KKJgUGaDM8="
"integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw=="
},
"utils-merge": {
"version": "1.0.1",
@ -8057,6 +8480,11 @@
"version": "0.1.2",
"resolved": "https://registry.npmjs.org/xmhell/-/xmhell-0.1.2.tgz",
"integrity": "sha512-9JF9o/dM1PHifwtc7KE/Pcc8pp2Jfj7462TakZFPXNzVPBNglePITwj7bQj94aAaQ6ZGokIb3BFu4nToPYuoeA=="
},
"yallist": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz",
"integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A=="
}
}
}

View File

@ -19,12 +19,16 @@
"fs-extra": "^7.0.0",
"get-folder-size": "^2.0.1",
"netflux-websocket": "^1.0.0",
"http-proxy-middleware": "^2.0.6",
"jsonwebtoken": "^9.0.0",
"notp": "^2.0.3",
"nthen": "0.1.8",
"prompt-confirm": "^2.0.4",
"pull-stream": "^3.6.1",
"saferphore": "0.0.1",
"sortify": "^1.0.4",
"stream-to-pull-stream": "^1.7.2",
"thirty-two": "^1.0.2",
"tweetnacl": "~0.12.2",
"ulimit": "0.0.2",
"ws": "^3.3.1",

74
scripts/issue-jwt.js Normal file
View File

@ -0,0 +1,74 @@
/* globals process */
const jwt = require("jsonwebtoken");
const Sessions = require("../lib/storage/sessions.js");
/*
This script was created strictly for debugging purposes so that I could manually create an
authenticated session and a corresponding JWT to test whether the client and server would
correctly validate that JWT and allow access to the protected resource.
I'm including it in the scripts directory in case it's useful for future debugging,
but I don't expect it to be of any use to non-developers.
--Aaron
*/
var [
secret,
subject
] = process.argv.slice(2);
if (!(secret && subject)) {
return void console.error(`This script creates a JSON Web Token (JWT) for a given subject.
It expects:
1. a secret known only to the issuer which is used to authenticate the token's integrity
2. a subject which the JWT is intended to protect (ie. a block id)
Call this script like so:
node ./scripts/issue-jwt.js "my_secret" "my_subject"`);
}
// the session storage module uses a pair of (subject, reference) to look up a session
// subject refers to a block public key, reference refers to a session for that block
var reference = Sessions.randomId();
jwt.sign({
ref: reference,
sub: subject
}, secret, {
expiresIn: (60 * 60 * 24 * 3), // Expire three days from now
algorithm: 'HS512',
}, function (err, token) {
if (err) { return void console.error(err); }
console.log(`Token:
${token}
`);
jwt.verify(token, secret, {
algorithm: 'HS512',
}, function (err, result) {
if (err) {
return void console.error(err);
}
console.log("JSON Payload");
console.log(result);
console.log();
// these values are in seconds, not milliseconds
// https://www.npmjs.com/package/jsonwebtoken#token-expiration-exp-claim
if (result.iat) {
console.log(`Issued at ${new Date(result.iat * 1000)}`);
}
if (result.exp) {
console.log(`Expires at ${new Date(result.exp * 1000)}`);
}
});
});

533
server.js
View File

@ -4,24 +4,19 @@
var Express = require('express');
var Http = require('http');
var Fs = require('fs');
var Path = require("path");
var nThen = require("nthen");
var Util = require("./lib/common-util");
var Default = require("./lib/defaults");
var OS = require("node:os");
var Cluster = require("node:cluster");
var config = require("./lib/load-config");
var Env = require("./lib/env").create(config);
var Environment = require("./lib/env");
var Env = Environment.create(config);
var Default = require("./lib/defaults");
var app = Express();
var fancyURL = function (domain, path) {
try {
if (domain && path) { return new URL(path, domain).href; }
return new URL(domain);
} catch (err) {}
return false;
};
(function () {
// you absolutely must provide an 'httpUnsafeOrigin' (a truthy string)
if (typeof(Env.httpUnsafeOrigin) !== 'string' || !Env.httpUnsafeOrigin.trim()) {
@ -29,387 +24,171 @@ var fancyURL = function (domain, path) {
}
}());
var applyHeaderMap = function (res, map) {
for (let header in map) {
if (typeof(map[header]) === 'string') { res.setHeader(header, map[header]); }
}
var COMMANDS = {};
COMMANDS.LOG = function (msg, cb) {
var level = msg.level;
Env.Log[level](msg.tag, msg.info);
cb();
};
var EXEMPT = [
/^\/common\/onlyoffice\/.*\.html.*/,
/^\/(sheet|presentation|doc)\/inner\.html.*/,
/^\/unsafeiframe\/inner\.html.*$/,
];
var cacheHeaders = function (Env, key, headers) {
if (Env.DEV_MODE) { return; }
Env[key] = headers;
};
var getHeaders = function (Env, type) {
var key = type + 'HeadersCache';
if (Env[key]) { return Env[key]; }
var headers = {};
var custom = config.httpHeaders;
// if the admin provided valid http headers then use them
if (custom && typeof(custom) === 'object' && !Array.isArray(custom)) {
headers = Util.clone(custom);
} else {
// otherwise use the default
headers = Default.httpHeaders(Env);
}
var csp;
if (type === 'office') {
csp = Default.padContentSecurity(Env);
} else if (type === 'diagram') {
csp = Default.diagramContentSecurity(Env);
} else {
csp = Default.contentSecurity(Env);
}
headers['Content-Security-Policy'] = csp;
if (Env.NO_SANDBOX) { // handles correct configuration for local development
// https://stackoverflow.com/questions/11531121/add-duplicate-http-response-headers-in-nodejs
headers["Cross-Origin-Resource-Policy"] = 'cross-origin';
headers["Cross-Origin-Embedder-Policy"] = 'require-corp';
}
// Don't set CSP headers on /api/ endpoints
// because they aren't necessary and they cause problems
// when duplicated by NGINX in production environments
if (type === 'api') {
cacheHeaders(Env, key, headers);
return headers;
}
headers["Cross-Origin-Resource-Policy"] = 'cross-origin';
cacheHeaders(Env, key, headers);
return headers;
};
var setHeaders = function (req, res) {
var type;
if (EXEMPT.some(regex => regex.test(req.url))) {
type = 'office';
} else if (/^\/api\/(broadcast|config)/.test(req.url)) {
type = 'api';
} else if (/^\/components\/drawio\/src\/main\/webapp\/index.html.*$/.test(req.url)) {
type = 'diagram';
} else {
type = 'standard';
}
var h = getHeaders(Env, type);
//console.log('PEWPEW', type, h);
applyHeaderMap(res, h);
};
(function () {
if (!config.logFeedback) { return; }
const logFeedback = function (url) {
url.replace(/\?(.*?)=/, function (all, fb) {
if (!config.log) { return; }
config.log.feedback(fb, '');
});
};
app.head(/^\/common\/feedback\.html/, function (req, res, next) {
logFeedback(req.url);
next();
});
}());
const serveStatic = Express.static(Env.paths.blob, {
setHeaders: function (res) {
res.set('Access-Control-Allow-Origin', Env.enableEmbedding? '*': Env.permittedEmbedders);
res.set('Access-Control-Allow-Headers', 'Content-Length');
res.set('Access-Control-Expose-Headers', 'Content-Length');
}
});
app.use('/blob', function (req, res, next) {
if (req.method === 'HEAD') {
return void serveStatic(req, res, next);
}
next();
});
app.use(function (req, res, next) {
if (req.method === 'OPTIONS' && /\/blob\//.test(req.url)) {
res.setHeader('Access-Control-Allow-Origin', Env.enableEmbedding? '*': Env.permittedEmbedders);
res.setHeader('Access-Control-Allow-Methods', 'GET, OPTIONS');
res.setHeader('Access-Control-Allow-Headers', 'DNT,X-CustomHeader,Keep-Alive,User-Agent,X-Requested-With,If-Modified-Since,Cache-Control,Content-Type,Content-Range,Range,Access-Control-Allow-Origin');
res.setHeader('Access-Control-Max-Age', 1728000);
res.setHeader('Content-Type', 'application/octet-stream; charset=utf-8');
res.setHeader('Content-Length', 0);
res.statusCode = 204;
return void res.end();
}
setHeaders(req, res);
if (/[\?\&]ver=[^\/]+$/.test(req.url)) { res.setHeader("Cache-Control", "max-age=31536000"); }
else { res.setHeader("Cache-Control", "no-cache"); }
next();
});
// serve custom app content from the customize directory
// useful for testing pages customized with opengraph data
app.use(Express.static(Path.resolve('customize/www')));
app.use(Express.static(Path.resolve('www')));
// FIXME I think this is a regression caused by a recent PR
// correct this hack without breaking the contributor's intended behaviour.
var mainPages = config.mainPages || Default.mainPages();
var mainPagePattern = new RegExp('^\/(' + mainPages.join('|') + ').html$');
app.get(mainPagePattern, Express.static(Path.resolve('customize')));
app.get(mainPagePattern, Express.static(Path.resolve('customize.dist')));
app.use("/blob", Express.static(Env.paths.blob, {
maxAge: Env.DEV_MODE? "0d": "365d"
}));
app.head("/datastore", Express.static(Env.paths.data, {
maxAge: "0d"
}));
app.use("/datastore", Express.static(Env.paths.data, {
maxAge: "0d"
}));
app.use("/block", Express.static(Env.paths.block, {
maxAge: "0d",
}));
app.use("/customize", Express.static(Path.resolve('customize')));
app.use("/customize", Express.static(Path.resolve('customize.dist')));
app.use("/customize.dist", Express.static(Path.resolve('customize.dist')));
app.use(/^\/[^\/]*$/, Express.static(Path.resolve('customize')));
app.use(/^\/[^\/]*$/, Express.static(Path.resolve('customize.dist')));
// if dev mode: never cache
var cacheString = function () {
return (Env.FRESH_KEY? '-' + Env.FRESH_KEY: '') + (Env.DEV_MODE? '-' + (+new Date()): '');
};
var makeRouteCache = function (template, cacheName) {
var cleanUp = {};
var cache = Env[cacheName] = Env[cacheName] || {};
return function (req, res) {
var host = req.headers.host.replace(/\:[0-9]+/, '');
res.setHeader('Content-Type', 'text/javascript');
// don't cache anything if you're in dev mode
if (Env.DEV_MODE) {
return void res.send(template(host));
}
// generate a lookup key for the cache
var cacheKey = host + ':' + cacheString();
// FIXME mutable
// we must be able to clear the cache when updating any mutable key
// if there's nothing cached for that key...
if (!cache[cacheKey]) {
// generate the response and cache it in memory
cache[cacheKey] = template(host);
// and create a function to conditionally evict cache entries
// which have not been accessed in the last 20 seconds
cleanUp[cacheKey] = Util.throttle(function () {
delete cleanUp[cacheKey];
delete cache[cacheKey];
}, 20000);
}
// successive calls to this function
cleanUp[cacheKey]();
return void res.send(cache[cacheKey]);
};
};
var serveConfig = makeRouteCache(function () {
return [
'define(function(){',
'return ' + JSON.stringify({
requireConf: {
waitSeconds: 600,
urlArgs: 'ver=' + Env.version + cacheString(),
},
removeDonateButton: (Env.removeDonateButton === true),
allowSubscriptions: (Env.allowSubscriptions === true),
websocketPath: Env.websocketPath,
httpUnsafeOrigin: Env.httpUnsafeOrigin,
adminEmail: Env.adminEmail,
adminKeys: Env.admins,
inactiveTime: Env.inactiveTime,
supportMailbox: Env.supportMailbox,
defaultStorageLimit: Env.defaultStorageLimit,
maxUploadSize: Env.maxUploadSize,
premiumUploadSize: Env.premiumUploadSize,
restrictRegistration: Env.restrictRegistration,
httpSafeOrigin: Env.httpSafeOrigin,
enableEmbedding: Env.enableEmbedding,
fileHost: Env.fileHost,
shouldUpdateNode: Env.shouldUpdateNode || undefined,
listMyInstance: Env.listMyInstance,
accounts_api: Env.accounts_api,
}, null, '\t'),
'});'
].join(';\n');
}, 'configCache');
var serveBroadcast = makeRouteCache(function () {
var maintenance = Env.maintenance;
if (maintenance && maintenance.end && maintenance.end < (+new Date())) {
maintenance = undefined;
}
return [
'define(function(){',
'return ' + JSON.stringify({
curvePublic: Env.curvePublic, // XXX could be in api/config but issue with static config
lastBroadcastHash: Env.lastBroadcastHash,
surveyURL: Env.surveyURL,
maintenance: maintenance
}, null, '\t'),
'});'
].join(';\n');
}, 'broadcastCache');
app.get('/api/config', serveConfig);
app.get('/api/broadcast', serveBroadcast);
var defineBlock = function (obj) {
return `define(function (){
return ${JSON.stringify(obj, null, '\t')};
});`;
};
app.get('/api/instance', function (req, res) { // XXX use caching?
res.setHeader('Content-Type', 'text/javascript');
res.send(defineBlock({
name: Env.instanceName,
description: Env.instanceDescription,
location: Env.instanceJurisdiction,
notice: Env.instanceNotice,
}));
});
var four04_path = Path.resolve('customize.dist/404.html');
var fivehundred_path = Path.resolve('customize.dist/500.html');
var custom_four04_path = Path.resolve('customize/404.html');
var custom_fivehundred_path = Path.resolve('/customize/500.html');
var send404 = function (res, path) {
if (!path && path !== four04_path) { path = four04_path; }
Fs.exists(path, function (exists) {
res.setHeader('Content-Type', 'text/html; charset=utf-8');
if (exists) { return Fs.createReadStream(path).pipe(res); }
send404(res);
});
};
var send500 = function (res, path) {
if (!path && path !== fivehundred_path) { path = fivehundred_path; }
Fs.exists(path, function (exists) {
res.setHeader('Content-Type', 'text/html; charset=utf-8');
if (exists) { return Fs.createReadStream(path).pipe(res); }
send500(res);
});
};
app.get('/api/updatequota', function (req, res) {
if (!Env.accounts_api) {
res.status(404);
return void send404(res);
}
COMMANDS.UPDATE_QUOTA = function (msg, cb) {
var Quota = require("./lib/commands/quota");
Quota.updateCachedLimits(Env, (e) => {
if (e) {
Env.Log.warn('UPDATE_QUOTA_ERR', e);
res.status(500);
return void send500(res);
Quota.updateCachedLimits(Env, (err) => {
if (err) {
Env.Log.warn('UPDATE_QUOTA_ERR', err);
return void cb(err);
}
Env.Log.info('QUOTA_UPDATED', {});
res.send();
cb();
});
});
};
app.get('/api/profiling', function (req, res) {
if (!Env.enableProfiling) { return void send404(res); }
res.setHeader('Content-Type', 'text/javascript');
res.send(JSON.stringify({
bytesWritten: Env.bytesWritten,
}));
});
app.use(function (req, res) {
res.status(404);
send404(res, custom_four04_path);
});
// default message for thrown errors in ExpressJS routes
app.use(function (err, req, res) {
Env.Log.error('EXPRESSJS_ROUTING', {
error: err.stack || err,
});
res.status(500);
send500(res, custom_fivehundred_path);
});
var httpServer = Env.httpServer = Http.createServer(app);
COMMANDS.GET_PROFILING_DATA = function (msg, cb) {
cb(void 0, Env.bytesWritten);
};
nThen(function (w) {
Fs.exists(Path.resolve("customize"), w(function (e) {
if (e) { return; }
console.log("CryptPad is customizable, see customize.dist/readme.md for details");
}));
}).nThen(function (w) {
httpServer.listen(Env.httpPort, Env.httpAddress, function(){
var host = Env.httpAddress;
var hostName = !host.indexOf(':') ? '[' + host + ']' : host;
var port = Env.httpPort;
var ps = port === 80? '': ':' + port;
var roughAddress = 'http://' + hostName + ps;
var betterAddress = fancyURL(Env.httpUnsafeOrigin);
if (betterAddress) {
console.log('Serving content for %s via %s.\n', betterAddress, roughAddress);
} else {
console.log('Serving content via %s.\n', roughAddress);
}
if (!Env.admins.length) {
console.log("Your instance is not correctly configured for safe use in production.\nSee %s for more information.\n",
fancyURL(Env.httpUnsafeOrigin, '/checkup/') || 'https://your-domain.com/checkup/'
);
}
});
if (Env.httpSafePort) {
Http.createServer(app).listen(Env.httpSafePort, Env.httpAddress, w());
}
}).nThen(function () {
//var wsConfig = { server: httpServer };
// Initialize logging then start the API server
require("./lib/log").create(config, function (_log) {
require("./lib/log").create(config, w(function (_log) {
Env.Log = _log;
config.log = _log;
}));
}).nThen(function (w) {
Fs.exists("customize", w(function (e) {
if (e) { return; }
Env.Log.info('NO_CUSTOMIZE_FOLDER', {
message: "CryptPad is customizable, see customize.dist/readme.md for details",
});
}));
}).nThen(function (w) {
// check that a valid origin was provided in the config
try {
var url = new URL('', Env.httpUnsafeOrigin).href;
Env.Log.info("WEBSERVER_LISTENING", {
origin: url,
});
if (Env.shouldUpdateNode) {
Env.Log.warn("NODEJS_OLD_VERSION", {
message: `The CryptPad development team recommends using at least NodeJS v16.14.2`,
currentVersion: process.version,
if (!Env.admins.length) {
Env.Log.info('NO_ADMIN_CONFIGURED', {
message: `Your instance is not correctly configured for production usage. Review its checkup page for more information.`,
details: new URL('/checkup/', Env.httpUnsafeOrigin).href,
});
}
} catch (err) {
Env.Log.error("INVALID_ORIGIN", {
httpUnsafeOrigin: Env.httpUnsafeOrigin,
});
process.exit(1);
}
Env.httpServer = Http.createServer(app);
Env.httpServer.listen(Env.websocketPort, 'localhost', w(function () {
Env.Log.info('WEBSOCKET_LISTENING', {
port: Env.websocketPort,
});
}));
}).nThen(function (w) {
var limit = Env.maxWorkers;
var workerState = {
Env: Environment.serialize(Env),
};
if (Env.OFFLINE_MODE) { return; }
if (Env.websocketPath) { return; }
require("./lib/api").create(Env);
Cluster.setupPrimary({
exec: './lib/http-worker.js',
args: [],
});
var launchWorker = (online) => {
var worker = Cluster.fork(workerState);
worker.on('online', () => {
online();
});
worker.on('message', msg => {
if (!msg) { return; }
var txid = msg.txid;
var content = msg.content;
if (!content) { return; }
var command = COMMANDS[content.command];
if (typeof(command) !== 'function') {
return void Env.Log.error('UNHANDLED_HTTP_WORKER_COMMAND', msg);
}
const cb = Util.once(Util.mkAsync(function (err, value) {
worker.send({
type: 'REPLY',
error: Util.serializeError(err),
txid: txid,
pid: msg.pid,
value: value,
});
}));
command(content, cb);
});
worker.on('exit', (code, signal) => {
if (!signal && code === 0) { return; }
// relaunch http workers if they crash
Env.Log.error('HTTP_WORKER_EXIT', {
signal,
code,
});
// update the environment with the latest state before relaunching
workerState.Env = Environment.serialize(Env);
launchWorker(function () {
Env.Log.info('HTTP_WORKER_RELAUNCH', {});
});
});
};
var txids = {};
var sendCommand = (worker, command, data /*, cb */) => {
worker.send({
type: 'EVENT',
txid: Util.guid(txids),
command: command,
data: data,
});
};
var broadcast = (command, data, cb) => {
cb = cb; // XXX nThen/concurrency
for (const worker of Object.values(Cluster.workers)) {
sendCommand(worker, command, data /*, cb */);
}
};
var throttledEnvChange = Util.throttle(function () {
Env.Log.info('WORKER_ENV_UPDATE', 'Updating HTTP workers with latest state');
broadcast('ENV_UPDATE', Environment.serialize(Env));
}, 250);
var throttledCacheFlush = Util.throttle(function () {
Env.Log.info('WORKER_CACHE_FLUSH', 'Instructing HTTP workers to flush cache');
broadcast('FLUSH_CACHE', Env.FRESH_KEY);
}, 250);
Env.envUpdated.reg(throttledEnvChange);
Env.cacheFlushed.reg(throttledCacheFlush);
OS.cpus().forEach((cpu, index) => {
if (limit && index >= limit) {
return;
}
launchWorker(w());
});
}).nThen(function () {
if (Env.shouldUpdateNode) {
Env.Log.warn("NODEJS_OLD_VERSION", {
message: `The CryptPad development team recommends using at least NodeJS v${Default.recommendedVersion.join('.')}`,
currentVersion: process.version,
});
}
if (Env.OFFLINE_MODE) { return; }
//if (Env.websocketPath) { return; } // XXX
require("./lib/api").create(Env);
});

View File

@ -15,6 +15,7 @@ define([
'/common/common-signing-keys.js',
'/support/ui.js',
'/common/clipboard.js',
'json.sortify',
'/lib/datepicker/flatpickr.js',
'/components/tweetnacl/nacl-fast.min.js',
@ -40,6 +41,7 @@ define([
Keys,
Support,
Clipboard,
Sortify,
Flatpickr
)
{
@ -75,6 +77,7 @@ define([
'cp-admin-account-metadata',
'cp-admin-document-metadata',
'cp-admin-block-metadata',
'cp-admin-totp-recovery',
],
'stats': [ // Msg.admin_cat_stats
'cp-admin-refresh-stats',
@ -1071,6 +1074,7 @@ define([
}
data.live = res[0].live;
data.archived = res[0].archived;
data.totp = res[0].totp;
}));
}).nThen(function () {
try {
@ -1094,6 +1098,9 @@ define([
row(Messages.admin_blockAvailable, localizeState(data.live));
row(Messages.admin_blockArchived, localizeState(data.archived));
row(Messages.admin_totpEnabled, localizeState(data.totp.enabled));
row(Messages.admin_totpRecoveryMethod, data.totp.recovery); // XXX localize?
if (data.live) {
var archiveButton = danger(Messages.ui_archive, function () {
justifyArchivalDialog('', reason => {
@ -1228,6 +1235,152 @@ define([
return $div;
};
Messages.admin_totpEnabled = "TOTP is enabled"; // XXX
Messages.admin_totpRecoveryMethod = "TOTP recovery method"; // XXX
Messages.admin_totpFailed = "Signature verification failed";
Messages.admin_totpCheck = "Signature verification success";
Messages.admin_totpDisable = "Disable TOTP for this account";
Messages.admin_totpDisableButton = "Disable";
var renderTOTPData = function (data) {
var tableObj = makeMetadataTable('cp-block-stats');
var row = tableObj.row;
row(Messages.admin_generatedAt, maybeDate(data.generated));
row(Messages.admin_blockKey, h('code', data.key));
row(Messages.admin_blockAvailable, localizeState(data.live));
if (!data.live || !data.totp) { return tableObj.table; }
row(Messages.admin_totpCheck, localizeState(data.totpCheck));
if (!data.totpCheck) { return tableObj.table; }
row(Messages.admin_totpEnabled, localizeState(Boolean(data.totp.enabled)));
if (data.totp && data.totp.enabled) {
row(Messages.admin_totpRecoveryMethod, data.totp.recovery); // XXX localize?
}
if (!data.totpCheck || !data.totp.enabled) { return tableObj.table; }
// TOTP is enabled and the signature is correct: display "disable TOTP" button
var disableButton = h('button.btn.btn-danger', Messages.admin_totpDisableButton);
UI.confirmButton(disableButton, { classes: 'btn-danger' }, function () {
sframeCommand('DISABLE_MFA', data.key, (err, res) => {
if (err) {
console.error(err);
return void UI.warn(Messages.error);
}
if (!Array.isArray(res) || !res[0] || !res[0].success) {
return UI.warn(Messages.error);
}
UI.log(Messages.ui_success);
});
});
row(Messages.admin_totpDisable, disableButton);
return tableObj.table;
};
var checkTOTPRequest = function (json) {
var clone = Util.clone(json);
delete clone.proof;
var msg = Nacl.util.decodeUTF8(Sortify(clone));
var sig = Nacl.util.decodeBase64(json.proof);
var pub = Nacl.util.decodeBase64(json.blockId);
return Nacl.sign.detached.verify(msg, sig, pub);
};
create['totp-recovery'] = function () {
var key = 'totp-recovery';
// XXX translation keys
var $div = makeBlock(key, true); // Msg.admin_totpRecoveryHint.totpRecoveryTitle
var textarea = h('textarea', {});
var $input = $(textarea);
var box = h('div.cp-admin-setter', [
textarea,
]);
$div.find('.cp-sidebarlayout-description').after(box);
var results = h('span');
$div.append(results);
var $btn = $div.find('.btn');
$btn.text(Messages.ui_generateReport);
disable($btn);
var pending = false;
var getInputState = function () {
var val = $input.val().trim();
var state = {
pending: pending,
value: undefined,
key: '',
};
var json;
try { json = JSON.parse(val); } catch (err) { }
/*
Example
{
"intent": "Disable TOTP",
"date": "2023-05-15T15:38:40.916Z",
"blockId": "+0PdpTuQi9/O2qjoJ8FLcvPEwChLfDWJrYXyPdVGzOo=",
"proof": "iDcHy6+ymiyWzK/oYNPQ1ItFNCiTmmJuAyYmcEXNha2U1nUxyBWAf0o7ZXWhygS6XI5BLrjH+DDcbWitfO3bCg=="
}
*/
if (!json || json.intent !== "Disable TOTP" || !json.blockId || json.blockId.length !== 44 ||
!json.date || !json.proof) { return state; }
state.value = json;
state.key = json.blockId.replace(/\//g, '-');
return state;
};
var setInterfaceState = function () {
var state = getInputState();
var all = [$btn, $input];
if (state.pending) {
all.forEach(disable);
} else {
all.forEach(enable);
}
};
setInterfaceState();
$btn.click(function () {
if (pending) { return; }
var state = getInputState();
if (!state.value) { return; }
pending = true;
setInterfaceState();
getBlockData(state.key, (err, data) => {
pending = false;
setInterfaceState();
console.warn(data);
if (err || !data) {
results.innerHTML = '';
console.log(err, data);
return UI.warn(Messages.error);
}
var check = checkTOTPRequest(state.value);
if (!check) { UI.warn(Messages.admin_totpFailed); }
data.totpCheck = check;
var table = renderTOTPData(data);
results.innerHTML = '';
results.appendChild(table);
});
});
return $div;
};
var makeAdminCheckbox = function (data) {
return function () {
var state = data.getState();

90
www/auth/app-auth.less Normal file
View File

@ -0,0 +1,90 @@
@import (reference) "../../customize/src/less2/include/colortheme-all.less";
@import (reference) "../../customize/src/less2/include/font.less";
@import (reference) "../../customize/src/less2/include/alertify.less";
@import (reference) "../../customize/src/less2/include/charts.less";
html, body {
.font_main();
.alertify_main();
.charts_main();
height: 100%;
margin: 0px;
padding: 0px;
background-color: @cp_static-bg !important;
color: @cryptpad_text_col;
font-family: "IBM Plex Mono";
width: 100%;
.centered {
width: 100%;
max-width: 50em;
margin: auto;
box-sizing: border-box;
}
h1 {
.centered;
text-align: center;
margin-bottom: 15px;
}
--height: 0;
@brand: #0087ff;
div.container {
height: 20em;
.centered;
//overflow: auto;
margin-bottom: 15px;
border: 1px solid white;
}
div.wrapper {
max-width: 50em;
margin: auto;
//margin-top: 5em !important;
}
p {
margin-top: 45px !important;
}
a, a:visited {
color: #ddf;
}
.bordered {
border: 1px solid #777;
padding: 15px;
margin: 15px;
}
#qr-target {
display: inline-block;
min-height: 256px;
min-width: 256px;
//height: 350px;
//width: 350px;
background: white;
padding: 5px;
margin-top: 15px;
}
input {
box-sizing: border-box;
}
blockquote {
border-left: 3px solid #999;
background: #77777777;
width: 100%;
padding: 15px;
box-sizing: border-box;
margin-left: 0px;
white-space: pre-wrap;
}
button {
border: 0px;
border-radius: 5px;
padding: 15px;
}
}

60
www/auth/base32.js Normal file
View File

@ -0,0 +1,60 @@
/* jshint esversion: 7 */
define([], function () {
// Based on https://gist.github.com/bellbind/871b145110c458e83077a718aef9fa0e
// base32 elements
//RFC4648: why include 2? Z and 2 looks similar than 8 and O
const b32 = "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567";
console.assert(b32.length === 32, b32.length);
const b32r = new Map(Array.from(b32, (ch, i) => [ch, i])).set("=", 0);
//[constants derived from character table size]
//cbit = 5 (as 32 == 2 ** 5), ubit = 8 (as byte)
//ccount = 8 (= cbit / gcd(cbit, ubit)), ucount = 5 (= ubit / gcd(cbit, ubit))
//cmask = 0x1f (= 2 ** cbit - 1), umask = 0xff (= 2 ** ubit - 1)
//const b32pad = [0, 6, 4, 3, 1];
const b32pad = Array.from(Array(5), (_, i) => (8 - i * 8 / 5 | 0) % 8);
function b32e5(u1, u2 = 0, u3 = 0, u4 = 0, u5 = 0) {
const u40 = u1 * 2 ** 32 + u2 * 2 ** 24 + u3 * 2 ** 16 + u4 * 2 ** 8 + u5;
return [b32[u40 / 2 ** 35 & 0x1f], b32[u40 / 2 ** 30 & 0x1f],
b32[u40 / 2 ** 25 & 0x1f], b32[u40 / 2 ** 20 & 0x1f],
b32[u40 / 2 ** 15 & 0x1f], b32[u40 / 2 ** 10 & 0x1f],
b32[u40 / 2 ** 5 & 0x1f], b32[u40 & 0x1f]];
}
function b32d8(b1, b2, b3, b4, b5, b6, b7, b8) {
const u40 = b32r.get(b1) * 2 ** 35 + b32r.get(b2) * 2 ** 30 +
b32r.get(b3) * 2 ** 25 + b32r.get(b4) * 2 ** 20 +
b32r.get(b5) * 2 ** 15 + b32r.get(b6) * 2 ** 10 +
b32r.get(b7) * 2 ** 5 + b32r.get(b8);
return [u40 / 2 ** 32 & 0xff, u40 / 2 ** 24 & 0xff, u40 / 2 ** 16 & 0xff,
u40 / 2 ** 8 & 0xff, u40 & 0xff];
}
// base32 encode/decode: Uint8Array <=> string
function b32e(u8a) {
console.assert(u8a instanceof Uint8Array, u8a.constructor);
const len = u8a.length, rem = len % 5;
const u5s = Array.from(Array((len - rem) / 5),
(_, i) => u8a.subarray(i * 5, i * 5 + 5));
const pad = b32pad[rem];
const br = rem === 0 ? [] : b32e5(...u8a.subarray(-rem)).slice(0, 8 - pad);
return [].concat(...u5s.map(u5 => b32e5(...u5)),
br, ["=".repeat(pad)]).join("");
}
function b32d(bs) {
const len = bs.length;
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);
console.assert(/^[A-Z2-7+\/]*$/.test(bs.slice(0, len - pad)), bs);
const u8s = [].concat(...bs.match(/.{8}/g).map(b8 => b32d8(...b8)));
return new Uint8Array(rem > 0 ? u8s.slice(0, rem - 5) : u8s);
}
return {
encode: b32e,
decode: b32d,
characters: b32,
};
});

8
www/auth/index.html Normal file
View File

@ -0,0 +1,8 @@
<!DOCTYPE html>
<html>
<head>
<meta content="text/html; charset=utf-8" http-equiv="content-type"/>
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
<script data-bootload="main.js" data-main="/common/boot.js" src="/components/requirejs/require.js"></script>
</head>
<body>

359
www/auth/main.js Normal file
View File

@ -0,0 +1,359 @@
define([
'jquery',
'/common/common-util.js',
'/common/hyperscript.js',
'/common/common-interface.js',
'/common/outer/http-command.js',
'/auth/base32.js',
'/customize.dist/login.js',
'/common/outer/login-block.js',
'/common/outer/local-store.js',
'/lib/qrcode.min.js',
'/components/tweetnacl/nacl-fast.min.js',
'less!/auth/app-auth.less',
], function ($, Util, h, UI, ServerCommand, Base32, Login, Block, LocalStore) {
var QRCode = window.QRCode;
var Nacl = window.nacl;
var main = h('div.centered', [
h('h1', 'Auth prototype'),
h('div.bordered', [
h('h2#keys', "Key derivation"),
h('blockquote',
`A user's name and password are used to derive:
1. a symmetric key which decrypts their "login block" and provides access to the rest of their account's credentials
2. an asymmetric signing keypair which proves they own the block (the public key is used as its identifier), allowing them to create new blocks, and overwrite or delete old ones.
With the introduction of TOTP as a second factor of authentication, the signing keypair is also used to setup multi-factor auth parameters and other actions that requrire authentication, such as:
1. configuration of the TOTP secret, optional contact field, and future parameters for other MFA mechanisms
2. authentication of new sessions
3. revoking existing sessions
Because these two cryptographic keys provide access to and control of the user's entire account, it is prudent to treat them (and the credentials from which they are derived) very carefully.
The symmetric key is kept in localStorage until logging out because it is needed to access the rest of their account, however, the signing keys should be forgotten as soon as they are no longer necessary (ie. once a session is authenticated, or once sensitive operations like password change have been completed).
Note: The login process performs many checks, confirming that crentials point to a valid block and that it yields access to a valid account, falling back to legacy methods of login where necessary. This prototype ignnores all those edge cases and is here only to derive a valid signing keypair.
`),
h('p', h('input#username', {
type: 'text',
placeholder: "Username",
})),
h('p', h('input#password', {
type: 'password',
placeholder: "Password",
})),
h('button#derive-keys', "Derive keys"),
h('hr'),
h('p', [
'Block id:',
h('div#block-id', '???'),
]),
]),
h('div.bordered#totp-app-config', [
h('h2#app', "TOTP app configuration"),
h('blockquote', `// XXX TOTP app configuration notes
Time-based One-Time Passwords are generated using a relatively simple algorithm which uses:
1. a hash function
2. a secret known to the client and the service authenticating them
3. the current time, upon which both parties must agree
Both parties should then be able to derive the same code which is valid within a 30 second window.
The server expects the client to provide a valid code in order to configure their account for TOTP 2FA. This ensures that the client's clock matches the server's, and avoids unfortunate situations in which the client enables TOTP authentication and but is then unable to authenticate.
The secret should consist of not less than 160 bits of entropy (20 Uint8s). When encoded as base32 this should result in a 32 character string.
Some authenticator apps can be configured with manual entry of the secret, but there are additional parameters indicating the name of the service and the account or resource to which it corresponds. These parameters can all be represented with a standardized URI, which can then be represented as a QR code.
It is possible to specify a variety of other values (code length, issuer, stronger hashing algorithms) through query parameters in the URI, but not all authenticator apps will support them. The ones specified below should work basically everywhere. Note that longer URIs produce more complex QR codes, which may be more difficult to scan.
Scan the generated code with your preferred app so that you can generate a code and configure your block with TOTP 2FA.
`),
h('p', [
"Base32 secret",
h('input#base32-secret', {
type: 'text',
placeholder: 'secret',
}),
]),
h('button#generate-secret', "Generate new TOTP secret"),
h('hr'),
h('p', [
"Label",
h('input#totp-label', {
type: 'text',
placeholder: 'Label',
}),
]),
h('p', [
"Hostname",
h('input#totp-hostname', {
type: 'text',
placeholder: 'Hostname',
}),
]),
h('p', [
"TOTP URI",
h('input#totp-uri', {
type: 'text',
//disabled: 'disabled',
placeholder: 'URI',
}),
]),
h('p', [
'QR Code',
h('br'),
h('div#qr-target', ''),
]),
]),
h('div.bordered', [
h('h2#setup', "MFA account settings"),
h('blockquote',
`// XXX MFA account settings notes
Once you have:
1. derived your block signing keypair
2. generated a secret
3. configured your authenticator app to generate codes using that secret
...then you can try entering a one-time password (OTP). This will be used in a request to the server to configure your account such that your block can only be requested with a valid token.
Note: This must currently be reversed manually (by deleting the mfa config file) because block removal of these settings is not yet implemented.
`),
h('p', [
h('input#otp-entry', {
type: 'text',
inputmode: 'numeric',
autocomplete: 'one-time-code',
pattern: '[0-9]{6}',
maxlength: "6",
placeholder: 'One-Time Password',
}),
]),
h('button#submit-otp', 'Submit OTP'),
]),
]);
document.body.appendChild(main);
// XXX hack to make the page jump to a given element once the content has been rendered
window.location.hash = window.location.hash;
// Key derivation
var $username = $('#username');
var $password = $('#password');
var $deriveKeys = $('#derive-keys');
var $blockId = $('#block-id');
var BUSY = false;
var blockKeys;
var blockId;
$deriveKeys.click(function () {
if (BUSY) { return; }
var name = $username.val().trim();
var password = $password.val();
if (!name) { return void window.alert("Invalid name"); }
if (!password) { return void window.alert("Invalid password"); }
UI.log("Deriving keys..");
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) {
BUSY = false;
UI.log("DONE");
console.log(bytes);
var result = Login.allocateBytes(bytes);
console.log(result);
blockKeys = result.blockKeys;
var blockURL = Block.getBlockUrl(blockKeys);
console.log('block URL', blockURL);
blockId = blockURL.replace(/.*\//, '');
$blockId.html(blockId);
});
}, 1500);
});
// TOTP app configuration
var $generateSecret = $('#generate-secret');
var $b32Secret = $('#base32-secret');
var randomSecret = () => {
var U8 = Nacl.randomBytes(20);
return Base32.encode(U8);
};
var isValidBase32 = input => {
if (typeof(input) !== 'string') { return false; }
try {
var output = Base32.decode(input);
if (!(output instanceof Uint8Array)) { return false; }
} catch (err) {
console.error(err);
return false;
}
return true;
};
// use the same base32 secret across page reloads
// by trying to read the hash and interpret it as a secret
// otherwise use a new, random secret, and store it in the hash
var hash = window.location.hash.slice(1);
console.log(hash);
console.log('isValid', isValidBase32(hash));
if (hash && hash.length >= 32 && isValidBase32(hash)) {
console.log("Reusing existing secret");
$b32Secret.val(hash);
} else {
console.log("Generating new secret");
let secret = randomSecret();
$b32Secret.val(secret);
window.location.hash = secret;
}
var $hostname = $('#totp-hostname');
$hostname.val(new URL(window.location.href).hostname);
var $label = $('#totp-label');
$label.val('CryptPad');
var $uri = $('#totp-uri');
var valueOrPlaceholder = $e => {
return $e.val().trim() || ($e.attr('placeholder') || '').trim();
};
var $qrTarget = $('#qr-target');
var updateQR = Util.throttle(function () {
var uri = $uri.val();
$qrTarget.html("");
new QRCode($qrTarget[0], uri);
}, 400);
updateQR();
$uri.on("change keyup keydown", updateQR);
var updateURI = Util.throttle(function () {
var username = valueOrPlaceholder($username);
var hostname = valueOrPlaceholder($hostname);
var label = valueOrPlaceholder($label);
var secret = valueOrPlaceholder($b32Secret);
var uri = `otpauth://totp/${label}:${username}@${hostname}?secret=${secret}`;
$uri.val(uri);
updateQR();
}, 400);
updateURI();
[$username, $b32Secret, $hostname, $label].forEach($el => {
$el.on('change keydown keyup', updateURI);
});
$generateSecret.click(function () {
//UI.log('gen secret');
var secret = randomSecret();
$b32Secret.val(secret);
window.location.hash = secret;
updateURI();
});
// MFA Account settings
var $OTPEntry = $('#otp-entry');
var $submitOTP = $('#submit-otp');
var OTP_LOCK;
$submitOTP.click(function () {
if (OTP_LOCK) {
return void window.alert("Server request already in progress");
}
console.log("OTP submission clicked");
// Double-check that the secret is OK
var secret = $b32Secret.val();
if (!isValidBase32(secret)) {
return void window.alert("Your base32 secret is not valid");
}
// Check block keys last, since they're the most expensive to derive
// The user can't set up 2FA unless they have a signing key which corresponds to an existing block
if (!blockKeys || !blockId) {
return void window.alert("Derive block keys first");
}
var code = $OTPEntry.val();
if (code.length !== 6 || /\D/.test(code)) {
return void window.alert("Invalid code");
}
OTP_LOCK = true;
ServerCommand(blockKeys.sign, {
command: 'TOTP_SETUP',
secret: secret,
code: code,
}, function (err, response) {
OTP_LOCK = false;
$OTPEntry.val("");
if (err) {
console.error(err);
console.log(response);
return void UI.warn("Error: see console");
}
if (!response || !response.bearer) {
console.log(response);
return void window.alert("Unexpected response");
}
// the server responded with a bearer token
// remember it so that you aren't redirected back to the login page
// when you access a page that enforces session persistence
console.log(response);
LocalStore.setSessionToken(response.bearer);
window.alert(`Success! This device's session should already be authenticated. Try accessing this account from a different device or browser to confirm that a TOTP code is required`);
});
});
});

View File

@ -75,6 +75,10 @@ html, body {
padding: 5px;
//font-size: 16px;
border-radius: @corner;
max-width: 100%;
box-sizing: border-box;
&.cp-danger {
border: 1px solid @cp_alerts-danger-bg;
background-color: @cp_alerts-danger-bg;
@ -85,6 +89,11 @@ html, body {
background-color: @cp_alerts-warning-bg;
color: @cp_alerts-warning-text;
}
pre.cp-raw-text {
overflow: auto;
background-color: #2222;
}
code {
word-break: keep-all;
font-style: italic;

View File

@ -12,6 +12,7 @@ define([
'/common/common-util.js',
'/common/pinpad.js',
'/common/outer/network-config.js',
'/common/outer/login-block.js',
'/customize/pages.js',
'/checkup/checkup-tools.js',
'/customize/application_config.js',
@ -21,7 +22,7 @@ define([
'less!/checkup/app-checkup.less',
], function ($, ApiConfig, Assertions, h, Messages, DomReady,
nThen, SFCommonO, Login, Hash, Util, Pinpad,
NetConfig, Pages, Tools, AppConfig) {
NetConfig, Block, Pages, Tools, AppConfig) {
window.CHECKUP_MAIN_LOADED = true;
var Assert = Assertions();
@ -306,9 +307,8 @@ define([
var opt = Login.allocateBytes(bytes);
var blockKeys = opt.blockKeys;
var blockUrl = Login.Block.getBlockUrl(opt.blockKeys);
var blockRequest = Login.Block.serialize("{}", opt.blockKeys);
var removeRequest = Login.Block.remove(opt.blockKeys);
console.warn('Testing block URL (%s). One 404 is normal.', blockUrl);
var userHash = '/2/drive/edit/000000000000000000000000';
@ -319,7 +319,7 @@ define([
var RT, rpc, exists, restricted;
nThen(function (waitFor) {
Util.fetch(blockUrl, waitFor(function (err) {
Util.getBlock(blockUrl, {}, waitFor(function (err) {
if (err) { return; } // No block found
exists = true;
}));
@ -344,20 +344,13 @@ define([
proxy.curvePrivate = opt.curvePrivate;
rt.realtime.onSettle(waitFor());
}));
}).nThen(function (waitFor) {
// Init RPC
Pinpad.create(RT.network, RT.proxy, waitFor(function (e, _rpc) {
if (e) {
waitFor.abort();
console.error("Can't initialize RPC", e); // INVALID_KEYS
return void cb(false);
}
rpc = _rpc;
}));
}).nThen(function (waitFor) {
// Write block
if (exists) { return; }
rpc.writeLoginBlock(blockRequest, waitFor(function (e) {
Block.writeLoginBlock({
blockKeys: blockKeys,
content: {}
}, waitFor(function (e) {
// we should tolerate restricted registration
// and proceed to clean up after any data we've created
if (e === 'E_RESTRICTED') {
@ -373,7 +366,7 @@ define([
}).nThen(function (waitFor) {
if (restricted) { return; }
// Read block
Util.fetch(blockUrl, waitFor(function (e) {
Util.getBlock(blockUrl, {}, waitFor(function (e) {
if (e) {
waitFor.abort();
console.error("Can't read login block", e);
@ -382,15 +375,26 @@ define([
}));
}).nThen(function (waitFor) {
// Remove block
rpc.removeLoginBlock(removeRequest, waitFor(function (e) {
Block.removeLoginBlock({
blockKeys: blockKeys,
}, waitFor(function (e) {
if (restricted) { return; } // an ENOENT is expected in the case of restricted registration, but we call this anyway to clean up any mess from previous tests.
if (e) {
waitFor.abort();
console.error("Can't remove login block", e);
console.error(blockRequest);
return void cb(false);
}
}));
}).nThen(function (waitFor) {
// Init RPC
Pinpad.create(RT.network, RT.proxy, waitFor(function (e, _rpc) {
if (e) {
waitFor.abort();
console.error("Can't initialize RPC", e); // INVALID_KEYS
return void cb(false);
}
rpc = _rpc;
}));
}).nThen(function (waitFor) {
rpc.removeOwnedChannel(secret.channel, waitFor(function (e) {
if (e) {
@ -913,7 +917,7 @@ define([
'child-src': '',
'frame-src': '',
'script-src': '',
'connect-src': "This rule restricts which URLs can be loaded by scripts. Overly permissive settings can allow users to be tracking using external resources, while overly restrictive settings may block pages from loading entirely.",
'connect-src': " This rule restricts which URLs can be loaded by scripts. Overly permissive settings can allow users to be tracked using external resources, while overly restrictive settings may block pages from loading entirely.",
'img-src': '',
'media-src': '',
'worker-src': '',
@ -1091,12 +1095,14 @@ define([
});
};
assert(function (cb, msg) {
var header = 'Access-Control-Allow-Origin';
var url = new URL('/', trimmedUnsafe).href;
Tools.common_xhr(url, function (xhr) {
var raw = xhr.getResponseHeader(header);
checkAllowedOrigins(raw, url, msg, cb);
['/', '/blob/placeholder.txt', '/block/placeholder.txt'].forEach(relativeURL => {
assert(function (cb, msg) {
var header = 'Access-Control-Allow-Origin';
var url = new URL(relativeURL, trimmedUnsafe).href;
Tools.common_xhr(url, function (xhr) {
var raw = xhr.getResponseHeader(header);
checkAllowedOrigins(raw, url, msg, cb);
});
});
});
@ -1175,6 +1181,45 @@ define([
});
});
var COMMONLY_DUPLICATED_HEADERS = [
'X-Content-Type-Options',
'Access-Control-Allow-Origin',
'Permissions-Policy',
'X-XSS-Protection',
];
['/', '/blob/placeholder.txt', '/block/placeholder.txt'].forEach(relativeURL => {
assert(function (cb, msg) {
var url = new URL(relativeURL, trimmedUnsafe).href;
Tools.common_xhr(url, xhr => {
var span = h('span', h('p', '// XXX DEBUGGING DUPLICATED HEADERS'));
var duplicated = false;
var pre = [];
COMMONLY_DUPLICATED_HEADERS.forEach(h => {
var value = xhr.getResponseHeader(h);
if (/,/.test(value)) {
pre.push(`${h}: ${value}`);
duplicated = true;
}
});
if (duplicated) {
span.appendChild(h('pre', pre.join('\n')));
}
// none of the headers should include a comma
// as that indicates they are duplicated
if (!duplicated) { return void cb(true); }
msg.appendChild(span);
cb({
duplicated,
url,
});
});
});
});
var POLICY_ADVISORY = " This link will be included in the home page footer and 'About CryptPad' menu. It's advised that you either provide one or disable registration.";
var APPCONFIG_DOCS_LINK = function (key, href) {
return h('span', [

View File

@ -5,6 +5,8 @@ define(['/customize/application_config.js'], function (AppConfig) {
userNameKey: 'User_name',
blockHashKey: 'Block_hash',
fileHashKey: 'FS_hash',
sessionJWT: 'Session_JWT',
// Store
displayNameKey: 'cryptpad.username',
oldStorageKey: 'CryptPad_RECENTPADS',

View File

@ -3,7 +3,10 @@ var factory = function (AppConfig, Scrypt) {
var Cred = {};
Cred.MINIMUM_PASSWORD_LENGTH = typeof(AppConfig.minimumPasswordLength) === 'number'?
AppConfig.minimumPasswordLength: 8;
AppConfig.minimumPasswordLength: 8; // TODO 14 or higher is a decent default for 2023
Cred.MINIMUM_NAME_LENGTH = 1;
Cred.MAXIMUM_NAME_LENGTH = 64;
// https://stackoverflow.com/questions/46155/how-to-validate-an-email-address-in-javascript
Cred.isEmail = function (email) {
@ -19,8 +22,12 @@ var factory = function (AppConfig, Scrypt) {
return typeof(x) === 'string';
};
// Maximum username length is enforced at registration time
// rather than in this function
// in order to maintain backwards compatibility with accounts
// that might have already registered with a longer name.
Cred.isValidUsername = function (name) {
return !!(name && isString(name));
return !!(isString(name) && name.length >= Cred.MINIMUM_NAME_LENGTH);
};
Cred.isValidPassword = function (passwd) {

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(); }
}
});
@ -1490,5 +1493,60 @@ define([
});
};
/* QR code generation is synchronous once the library is loaded
so this could be syncronous if we load the library separately. */
UI.createQRCode = function (data, _cb) {
var cb = Util.once(Util.mkAsync(_cb || Util.noop));
require(['/lib/qrcode.min.js'], function () {
var div = h('div');
/*var code =*/ new window.QRCode(div, data);
cb(void 0, div);
}, function (err) {
cb(err);
});
};
Messages.settings_otp_code = "OTP code"; // XXX KEY ALREADY ADDED IN www/settings/inner.js
Messages.settings_otp_invalid = "Invalid OTP code"; // Same
Messages.loading_enter_otp = "This account is protected with Two-Factor Authentication. Please enter your verification code."; // XXX
Messages.loading_recover = 'Unable to get a code? <a href="/recovery/">Recover your account</a>';
UI.getOTPScreen = function (cb, exitable, err) {
var btn, input;
var error;
if (err) {
error = h('p.cp-password-error', Messages.settings_otp_invalid);
}
var block = h('div#cp-loading-password-prompt', [
error,
h('p.cp-password-info', Messages.loading_enter_otp),
h('p.cp-password-form', [
input = h('input', {
placeholder: Messages.settings_otp_code,
autocomplete: 'off',
autocorrect: 'off',
autocapitalize: 'off',
spellcheck: false,
}),
btn = h('button.btn.btn-primary', Messages.ui_confirm)
]),
UI.setHTML(h('p.cp-password-recovery'), Messages.loading_recover)
]);
var $input = $(input);
var $btn = $(btn).click(function () {
var val = $input.val();
if (!val) { return void UI.getOTPScreen(cb, exitable, 'INVALID_CODE'); }
cb(val);
});
$(input).on('keydown', function (e) {
if (e.which !== 13) { return; } // enter
$btn.click();
});
UI.errorLoadingScreen(block, false, exitable);
};
return UI;
});

View File

@ -161,6 +161,7 @@
},
expect: expect,
handle: handle,
_pending: pending,
};
};
@ -188,6 +189,14 @@
.toString(32).replace(/\./g, '');
};
Util.guid = function (map) {
var id = Util.uid();
// the guid (globally unique id) is valid if it does already exist in the map
if (typeof(map[id]) === 'undefined') { return id; }
// otherwise try again
return Util.guid(map);
};
Util.fixHTML = function (str) {
if (!str) { return ''; }
return str.replace(/[<>&"']/g, function (x) {
@ -303,6 +312,44 @@
if (!/^[a-f0-9]{48}$/.test(cacheKey)) { cacheKey = undefined; }
return cacheKey;
};
Util.getBlock = function (src, opt, cb) {
var CB = Util.once(Util.mkAsync(cb));
var headers = {};
if (typeof(opt.bearer) === 'string' && opt.bearer) {
headers.authorization = `Bearer ${opt.bearer}`;
}
fetch(src, {
method: 'GET',
credentials: 'include',
headers: headers,
}).then(response => {
if (response.ok) {
// TODO this should probably be returned as an arraybuffer or something rather than a promise
// this is resulting in some code duplication
return void CB(void 0, response);
}
if (response.status === 401) {
response.json().then((data) => {
CB(401, data);
}).catch(() => {
CB(401);
});
return;
}
CB(response.status, response);
}).catch(error => {
CB(error);
});
};
Util.fetch = function (src, cb, progress, cache) {
var CB = Util.once(Util.mkAsync(cb));

View File

@ -12,12 +12,14 @@ define([
'/common/outer/local-store.js',
'/common/outer/worker-channel.js',
'/common/outer/login-block.js',
'/common/common-credential.js',
'/customize/login.js',
'/customize/application_config.js',
'/components/nthen/index.js',
], function (Config, Messages, Util, Hash, Cache,
Messaging, Constants, Feedback, Visible, UserObject, LocalStore, Channel, Block,
AppConfig, Nthen) {
Cred, Login, AppConfig, Nthen) {
/* This file exposes functionality which is specific to Cryptpad, but not to
any particular pad type. This includes functions for committing metadata
@ -616,18 +618,6 @@ define([
});
};
common.writeLoginBlock = function (data, cb) {
postMessage('WRITE_LOGIN_BLOCK', data, function (obj) {
cb(obj);
});
};
common.removeLoginBlock = function (data, cb) {
postMessage('REMOVE_LOGIN_BLOCK', data, function (obj) {
cb(obj);
});
};
// ANON RPC
// SFRAME: talk to anon_rpc from the iframe
@ -1892,80 +1882,29 @@ define([
});
};
var getBlockKeys = function (data, cb) {
var accountName = LocalStore.getAccountName();
var password = data.password;
var Cred, Block, Login;
var blockKeys;
var hash = LocalStore.getUserHash();
if (!hash) { return void cb({ error: 'E_NOT_LOGGED_IN' }); }
var blockHash = LocalStore.getBlockHash();
Nthen(function (waitFor) {
require([
'/common/common-credential.js',
'/common/outer/login-block.js',
'/customize/login.js'
], waitFor(function (_Cred, _Block, _Login) {
Cred = _Cred;
Block = _Block;
Login = _Login;
}));
}).nThen(function (waitFor) {
// confirm that the provided password is correct
Cred.deriveFromPassphrase(accountName, password, Login.requiredBytes,
waitFor(function (bytes) {
var allocated = Login.allocateBytes(bytes);
blockKeys = allocated.blockKeys;
if (blockHash) {
if (blockHash !== allocated.blockHash) {
// incorrect password
console.log("provided password did not yield the correct blockHash");
waitFor.abort();
return void cb({ error: 'INVALID_PASSWORD', });
}
} else {
// otherwise they're a legacy user, and we should check against the User_hash
if (hash !== allocated.userHash) {
// incorrect password
console.log("provided password did not yield the correct userHash");
waitFor.abort();
return void cb({ error: 'INVALID_PASSWORD', });
}
}
}));
}).nThen(function () {
cb({
Cred: Cred,
Block: Block,
Login: Login,
blockKeys: blockKeys
});
});
};
common.deleteAccount = function (data, cb) {
data = data || {};
// Confirm that the provided password is corrct and get the block keys
getBlockKeys(data, function (obj) {
if (obj && obj.error) { return void cb(obj); }
var blockKeys = obj.blockKeys;
var removeData = obj.Block.remove(blockKeys);
var bytes = data.bytes; // From Scrypt
var auth = data.auth; // MFA data
postMessage("DELETE_ACCOUNT", {
keys: Block.keysToRPCFormat(blockKeys),
removeData: removeData
}, function (obj) {
if (obj.state) {
Feedback.send('DELETE_ACCOUNT_AUTOMATIC');
} else {
Feedback.send('DELETE_ACCOUNT_MANUAL');
}
cb(obj);
});
});
var allocated = Login.allocateBytes(bytes);
var blockKeys = allocated.blockKeys;
postMessage("DELETE_ACCOUNT", {
keys: blockKeys,
auth: auth
}, function (obj) {
if (obj.state) {
Feedback.send('DELETE_ACCOUNT_AUTOMATIC');
} else {
Feedback.send('DELETE_ACCOUNT_MANUAL');
}
cb(obj);
}, {raw: true});
};
common.removeOwnedPads = function (data, cb) {
postMessage("REMOVE_OWNED_PADS", data, cb);
};
common.changeUserPassword = function (Crypt, edPublic, data, cb) {
if (!edPublic) {
@ -1973,36 +1912,28 @@ define([
error: 'E_NOT_LOGGED_IN'
});
}
var accountName = LocalStore.getAccountName();
var hash = LocalStore.getUserHash();
var hash = common.userHash;
if (!hash) {
return void cb({
error: 'E_NOT_LOGGED_IN'
});
}
var password = data.password; // To remove your old block
var newPassword = data.newPassword; // To create your new block
var oldBytes = data.oldBytes; // From Scrypt
var newBytes = data.newBytes; // From Scrypt
var secret = Hash.getSecrets('drive', hash);
var newHash, newHref, newSecret, blockKeys;
var newHash, newHref, newSecret;
var oldIsOwned = false;
var blockHash = LocalStore.getBlockHash();
var oldBlockKeys;
var Cred, Block, Login;
var oldAllocated = Login.allocateBytes(oldBytes);
var newAllocated = Login.allocateBytes(newBytes);
var oldBlockKeys = oldAllocated.blockKeys;
var blockKeys = newAllocated.blockKeys;
var auth = data.auth;
Nthen(function (waitFor) {
getBlockKeys(data, waitFor(function (obj) {
if (obj && obj.error) {
waitFor.abort();
return void cb(obj);
}
oldBlockKeys = obj.blockKeys;
Cred = obj.Cred;
Login = obj.Login;
Block = obj.Block;
}));
}).nThen(function (waitFor) {
// Check if our drive is already owned
console.log("checking if old drive is owned");
common.anonRpcMsg('GET_METADATA', secret.channel, waitFor(function (err, obj) {
@ -2012,6 +1943,17 @@ define([
oldIsOwned = true;
}
}));
}).nThen(function (waitFor) {
Block.checkRights({
auth: auth,
blockKeys: oldBlockKeys,
}, waitFor(function (err) {
if (err) {
waitFor.abort();
console.error(err);
return void cb({ error: 'INVALID_CODE' });
}
}));
}).nThen(function (waitFor) {
// Create a new user hash
// Get the current content, store it in the new user file
@ -2040,19 +1982,13 @@ define([
}
}), optsPut);
}));
}).nThen(function (waitFor) {
// Drive content copied: get the new block location
console.log("deriving new credentials from passphrase");
Cred.deriveFromPassphrase(accountName, newPassword, Login.requiredBytes, waitFor(function (bytes) {
var allocated = Login.allocateBytes(bytes);
blockKeys = allocated.blockKeys;
}));
}).nThen(function (waitFor) {
var blockUrl = Block.getBlockUrl(blockKeys);
// Check whether there is a block at that location
// Check whether there is a block at that new location
Util.fetch(blockUrl, waitFor(function (err, block) {
// If there is no block or the block is invalid, continue.
if (err) {
// error 401 means protected block
if (err && err !== 401) {
console.log("no block found");
return;
}
@ -2069,31 +2005,31 @@ define([
}));
}).nThen(function (waitFor) {
// Write the new login block
var temp = {
User_name: accountName,
var content = {
User_hash: newHash,
edPublic: edPublic,
};
var content = Block.serialize(JSON.stringify(temp), blockKeys);
console.error("OLD AND NEW BLOCK KEYS", oldBlockKeys, blockKeys);
content.registrationProof = Block.proveAncestor(oldBlockKeys);
console.log("writing new login block");
var data = {
keys: Block.keysToRPCFormat(blockKeys),
content: content,
};
common.writeLoginBlock(data, waitFor(function (obj) {
if (obj && obj.error) {
Block.writeLoginBlock({
auth: auth,
blockKeys: blockKeys,
oldBlockKeys: oldBlockKeys,
content: content
}, waitFor(function (err, data) {
if (err) {
waitFor.abort();
return void cb(obj);
return void cb({error: err});
}
if (data && data.bearer) {
LocalStore.setSessionToken(data.bearer);
}
}));
}).nThen(function (waitFor) {
var blockUrl = Block.getBlockUrl(blockKeys);
Util.fetch(blockUrl, waitFor(function (err /* block */) {
var sessionToken = LocalStore.getSessionToken() || undefined;
Util.getBlock(blockUrl, {
bearer: sessionToken,
}, waitFor((err) => {
if (err) {
console.error(err);
waitFor.abort();
@ -2112,53 +2048,49 @@ define([
common.pinPads([newSecret.channel], waitFor());
}).nThen(function (waitFor) {
// Remove block hash
if (blockHash) {
console.log('removing old login block');
var data = {
keys: Block.keysToRPCFormat(oldBlockKeys), // { edPrivate, edPublic }
content: Block.remove(oldBlockKeys),
};
common.removeLoginBlock(data, waitFor(function (obj) {
if (obj && obj.error) { return void console.error(obj.error); }
}));
}
if (!blockHash) { return; }
console.log('removing old login block');
Block.removeLoginBlock({
auth: auth,
blockKeys: oldBlockKeys,
}, waitFor(function (err) {
if (err) { return void console.error(err); }
}));
}).nThen(function (waitFor) {
if (oldIsOwned) {
console.log('removing old drive');
common.removeOwnedChannel({
channel: secret.channel,
teamId: null,
force: true
}, waitFor(function (obj) {
if (obj && obj.error) {
// Deal with it as if it was not owned
oldIsOwned = false;
return;
}
common.logoutFromAll(waitFor(function () {
postMessage("DISCONNECT");
}));
if (!oldIsOwned) { return; }
console.log('removing old drive');
common.removeOwnedChannel({
channel: secret.channel,
teamId: null,
force: true
}, waitFor(function (obj) {
if (obj && obj.error) {
// Deal with it as if it was not owned
oldIsOwned = false;
return;
}
common.logoutFromAll(waitFor(function () {
postMessage("DISCONNECT");
}));
}
}));
}).nThen(function (waitFor) {
if (!oldIsOwned) {
console.error('deprecating old drive.');
postMessage("SET", {
teamId: data.teamId,
key: [Constants.deprecatedKey],
value: true
}, waitFor(function (obj) {
if (obj && obj.error) {
console.error(obj.error);
}
common.logoutFromAll(waitFor(function () {
postMessage("DISCONNECT");
}));
if (oldIsOwned) { return; }
console.error('deprecating old drive.');
postMessage("SET", {
teamId: data.teamId,
key: [Constants.deprecatedKey],
value: true
}, waitFor(function (obj) {
if (obj && obj.error) {
console.error(obj.error);
}
common.logoutFromAll(waitFor(function () {
postMessage("DISCONNECT");
}));
}
}));
}).nThen(function () {
// We have the new drive, with the new login block
var feedbackKey = (password === newPassword)?
var feedbackKey = (data.password === data.newPassword)?
'OWNED_DRIVE_MIGRATION': 'PASSWORD_CHANGED';
Feedback.send(feedbackKey, undefined, function () {
@ -2486,6 +2418,7 @@ define([
}
}).nThen(function (waitFor) {
// if a block URL is present then the user is probably logged in with a modern account
var blockHash = LocalStore.getBlockHash();
if (blockHash) {
console.debug("Block hash is present");
@ -2495,28 +2428,68 @@ define([
console.error("Failed to parse blockHash");
console.log(parsed);
return;
} else {
//console.log(parsed);
}
Util.fetch(parsed.href, waitFor(function (err, arraybuffer) {
if (err) { return void console.log(err); }
// use the results to load your user hash and
// put your userhash into localStorage
try {
var block_info = Block.decrypt(arraybuffer, parsed.keys);
if (!block_info) {
console.error("Failed to decrypt !");
return;
}
userHash = block_info[Constants.userHashKey];
if (!userHash || userHash !== LocalStore.getUserHash()) {
return void requestLogin();
}
} catch (e) {
console.error(e);
return void console.error("failed to decrypt or decode block content");
// they might also have a "session token", which is a JWT.
// this indicates that their login block is protected with 2FA
var sessionToken = LocalStore.getSessionToken() || undefined;
var done = waitFor();
// request the login block, providing credentials if available
Util.getBlock(parsed.href, {
bearer: sessionToken,
}, waitFor((err, response) => {
if (err === 401) {
// a 401 error indicates insufficient authentication
// either their JWT is invalid, or they didn't provide one
// 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();
});
}
if (err) {
// TODO
// it seems wrong that errors here aren't reported or handled
// but it's consistent with other failure cases in the rest of this process
// that probably justifies some more thorough review.
// In particular, it should not be possible to be "half-logged-in"
// behaving like a guest after trying to authenticate as a registered user
return void console.error(err);
}
// if no errors occurred then we can try to convert the response
// to an arraybuffer and decrypt its payload
response.arrayBuffer().then(arraybuffer => {
arraybuffer = new Uint8Array(arraybuffer);
// use the results to load your user hash and
// put your userhash into localStorage
try {
var block_info = Block.decrypt(arraybuffer, parsed.keys);
if (!block_info) {
console.error("Failed to decrypt !");
return;
}
userHash = block_info[Constants.userHashKey];
if (!userHash) {
return void LocalStore.logout(function () {
requestLogin();
});
}
} catch (e) {
console.error(e);
return void console.error("failed to decrypt or decode block content");
}
done();
});
}));
}
}).nThen(function (waitFor) {

View File

@ -12,6 +12,9 @@ define([
'/customize/messages.js',
'/components/nthen/index.js',
'/customize/pages.js',
'/components/file-saver/FileSaver.min.js',
'/lib/qrcode.min.js',
], function ($, ApiConfig, Util, Hash, UI, UIElements, Feedback, Modal, h, Clipboard,
Messages, nThen, Pages) {
var Share = {};
@ -486,6 +489,60 @@ define([
});
};
var getQRCode = function (link) {
var blocker = h('div#cp-qr-blocker', Messages.share_toggleQR);
var $blocker = $(blocker).click(function () {
$blocker.toggleClass("hidden");
});
var qrDiv = h('div');
var container = h('div#cp-qr-container', [
blocker,
h('div#cp-qr-link-preview', qrDiv),
]);
new window.QRCode(qrDiv, link);
return container;
};
Messages.share_toggleQR = "Click to toggle QR code visibility"; // XXX
var getQRTab = function (Env, data, opts, _cb) {
var qr = getQRCode(opts.getLinkValue());
var link = h('div.cp-share-modal', [
h('span#cp-qr-target', qr),
]);
var buttons = [
makeCancelButton(),
{
className: 'primary cp-bar',
name: Messages.share_bar,
onClick: function () {
UI.warn("OOPS: NOT IMPLEMENTED"); // XXX
return true;
},
},
{
className: 'primary cp-nobar',
name: Messages.download_dl,
iconClass: '.fa.fa-download',
onClick: function () {
qr.querySelector('canvas').toBlob(blob => {
var name = Util.fixFileName((opts.title || 'document') + '-qr.png');
window.saveAs(blob, name);
});
},
},
];
return _cb(void 0, {
content: link,
buttons: buttons,
});
};
var getEmbedTab = function (Env, data, opts, _cb) {
var cb = Util.once(Util.mkAsync(_cb));
@ -666,12 +723,17 @@ define([
var getEmbed = function () {
return $rights.parent().find('#cp-embed-link-preview');
};
var getQR = function () {
return $rights.parent().find('#cp-qr-target');
};
// update values for link and embed preview when radio btns change
$rights.find('input[type="radio"]').on('change', function () {
getLink().val(opts.getLinkValue({
var link = opts.getLinkValue({
embed: Util.isChecked($('.alertify').find('#cp-share-embed'))
}));
});
getLink().val(link);
// Hide or show the burn after reading alert
if (Util.isChecked($rights.find('#cp-share-bar')) && !opts.burnAfterReadingUrl) {
$('.cp-alertify-bar-selected').show();
@ -681,6 +743,10 @@ define([
return;
}
getEmbed().val(opts.getEmbedValue());
var qr = getQRCode(opts.getLinkValue());
getQR().html('').append(qr);
// Hide burn after reading button
$('.alertify').find('.cp-nobar').show();
$('.alertify').find('.cp-bar').hide();
@ -722,6 +788,8 @@ define([
return $rights;
};
Messages.share_QRCategory = "QR"; // XXX
// In the share modal, tabs need to share data between themselves.
// To do so we're using "opts" to store data and functions
Share.getShareModal = function (common, opts, cb) {
@ -780,6 +848,10 @@ define([
title: Messages.share_linkCategory,
icon: "fa fa-link",
active: !contactsActive,
}, {
getTab: getQRTab,
title: Messages.share_QRCategory,
icon: 'fa fa-qrcode',
}];
if (!opts.static && ApiConfig.enableEmbedding && embeddableApps.includes(pathname)) {
tabs.push({
@ -977,6 +1049,7 @@ define([
active: !hasFriends,
}];
// XXX add QR code generation for files
if (ApiConfig.enableEmbedding) {
tabs.push({
getTab: getFileEmbedTab,

View File

@ -3,6 +3,7 @@ var factory = function () {
var Promise = window.Promise;
var cache;
var cypherChunkLength = 131088;
var sendCredentials = window.sendCredentials || false; // XXX find a logical place to infer whether this should be set
// Save a blob on the file system
var saveFile = function (blob, url, fileName) {
@ -244,6 +245,7 @@ var factory = function () {
var check = function () {
var xhr = new XMLHttpRequest();
xhr.open("HEAD", src);
if (sendCredentials) { xhr.withCredentials = true; }
xhr.onerror = function () { return void cb("XHR_ERROR"); };
xhr.onreadystatechange = function() {
if (this.readyState === this.DONE) {
@ -276,6 +278,7 @@ var factory = function () {
var fetch = function () {
var xhr = new XMLHttpRequest();
xhr.open('GET', src, true);
if (sendCredentials) { xhr.withCredentials = true; }
xhr.responseType = 'arraybuffer';
var progress = function (offset) {
@ -387,6 +390,7 @@ var factory = function () {
var fetch = function () {
var xhr = new XMLHttpRequest();
xhr.open('GET', src, true);
if (sendCredentials) { xhr.withCredentials = true; }
xhr.setRequestHeader('Range', 'bytes=0-1');
xhr.responseType = 'arraybuffer';
@ -399,6 +403,7 @@ var factory = function () {
var xhr2 = new XMLHttpRequest();
xhr2.open("GET", src, true);
if (sendCredentials) { xhr2.withCredentials = true; }
xhr2.setRequestHeader('Range', 'bytes=2-' + (size + 2));
xhr2.responseType = 'arraybuffer';
xhr2.onload = function () {

View File

@ -685,6 +685,7 @@ define([
var key = secret.keys && secret.keys.cryptKey;
var xhr = new XMLHttpRequest();
xhr.open('GET', src, true);
if (window.sendCredentials) { xhr.withCredentials = true; }
xhr.responseType = 'arraybuffer';
xhr.onload = function () {
if (/^4/.test('' + this.status)) {
@ -848,6 +849,10 @@ define([
lastCpHash: getLastCp().hash
}, function (err, obj) {
if (err || (obj && obj.error)) { console.error(err || (obj && obj.error)); }
// XXX an error loading a checkpoint was ignored, causing a sheet
// to load incorrectly. There's a risk of a new checkpoint being created
// with the resulting (incorrect) state. Errors like this should be reported
// to the user so they realize something is wrong.
});
sframeChan.on('EV_OO_EVENT', function (obj) {
switch (obj.ev) {

View File

@ -22,6 +22,7 @@ define([
'/common/outer/messenger.js',
'/common/outer/history.js',
'/common/outer/calendar.js',
'/common/outer/login-block.js',
'/common/outer/network-config.js',
'/customize/application_config.js',
@ -35,7 +36,7 @@ define([
], function (ApiConfig, Sortify, UserObject, ProxyManager, Migrate, Hash, Util, Constants, Feedback,
Realtime, Messaging, Pinpad, Cache,
SF, Cursor, Integration, OnlyOffice, Mailbox, Profile, Team, Messenger, History,
Calendar, NetConfig, AppConfig,
Calendar, Block, NetConfig, AppConfig,
Crypto, ChainPad, CpNetflux, Listmap, Netflux, nThen, Saferphore) {
var onReadyEvt = Util.mkEvent(true);
@ -454,38 +455,6 @@ define([
});
};
Store.writeLoginBlock = function (clientId, data, cb) {
Pinpad.create(store.network, data && data.keys, function (err, rpc) {
if (err) {
return void cb({
error: err,
});
}
rpc.writeLoginBlock(data && data.content, function (e, res) {
cb({
error: e,
data: res
});
});
});
};
Store.removeLoginBlock = function (clientId, data, cb) {
Pinpad.create(store.network, data && data.keys, function (err, rpc) {
if (err) {
return void cb({
error: err,
});
}
rpc.removeLoginBlock(data && data.content, function (e, res) {
cb({
error: e,
data: res
});
});
});
};
var initRpc = function (clientId, data, cb) {
if (!store.loggedIn) { return cb(); }
if (store.rpc) { return void cb(account); }
@ -752,43 +721,62 @@ define([
});
};
var getOwnedPads = function () {
var list = store.manager.getChannelsList('owned');
if (store.proxy.todo) {
// No password for todo
list.push(Hash.hrefToHexChannelId('/todo/#' + store.proxy.todo, null));
}
if (store.proxy.profile && store.proxy.profile.edit) {
// No password for profile
list.push(Hash.hrefToHexChannelId('/profile/#' + store.proxy.profile.edit, null));
}
if (store.proxy.mailboxes) {
Object.keys(store.proxy.mailboxes || {}).forEach(function (id) {
if (id === 'supportadmin') { return; }
var m = store.proxy.mailboxes[id];
list.push(m.channel);
});
}
if (store.proxy.teams) {
Object.keys(store.proxy.teams || {}).forEach(function (id) {
var t = store.proxy.teams[id];
if (t.owner) {
list.push(t.channel);
list.push(t.keys.roster.channel);
list.push(t.keys.chat.channel);
}
});
var getOwnedPads = function (account) {
var list = [];
if (account) {
if (store.proxy.todo) {
// No password for todo
list.push(Hash.hrefToHexChannelId('/todo/#' + store.proxy.todo, null));
}
if (store.proxy.profile && store.proxy.profile.edit) {
// No password for profile
list.push(Hash.hrefToHexChannelId('/profile/#' + store.proxy.profile.edit, null));
}
if (store.proxy.mailboxes) {
Object.keys(store.proxy.mailboxes || {}).forEach(function (id) {
if (id === 'supportadmin') { return; }
var m = store.proxy.mailboxes[id];
list.push(m.channel);
});
}
// XXX calendars
} else {
list = store.manager.getChannelsList('owned');
/*
if (store.proxy.teams) {
Object.keys(store.proxy.teams || {}).forEach(function (id) {
var t = store.proxy.teams[id];
if (t.owner) {
list.push(t.channel);
list.push(t.keys.roster.channel);
list.push(t.keys.chat.channel);
}
});
}
*/
}
return list.filter(function (channel) {
if (typeof(channel) !== 'string') { return; }
return [32, 48].indexOf(channel.length) !== -1;
});
};
var removeOwnedPads = function (waitFor) {
var removeOwnedPads = function (account, waitFor) {
// Delete owned pads
var edPublic = Util.find(store, ['proxy', 'edPublic']);
var ownedPads = getOwnedPads();
var ownedPads = getOwnedPads(account);
var sem = Saferphore.create(10);
var deleteChannel = function (c) {
// When deleting the whole account, no need to remove from drive
if (account) { return; }
var all = store.manager.findChannel(c);
all.forEach(function (d) {
var p = store.manager.findFile(d.id);
store.manager.delete({paths:p});
});
};
ownedPads.forEach(function (c) {
var w = waitFor();
sem.take(function (give) {
@ -806,6 +794,14 @@ define([
return void _w.abort();
}
var md = obj[0];
if (!Object.keys(md || {}).length) {
deleteChannel(c);
give();
w();
return void _w.abort();
}
var isOwner = md && Array.isArray(md.owners) && md.owners.indexOf(edPublic) !== -1;
if (!isOwner) {
give();
@ -825,7 +821,8 @@ define([
}
// We're the only owner: delete the pad
store.rpc.removeOwnedChannel(c, _w(function (err) {
if (err) { console.error(err); }
if (err) { return void console.error(err); }
deleteChannel(c);
}));
}).nThen(function () {
give();
@ -835,10 +832,17 @@ define([
});
};
Store.removeOwnedPads = function (clientId, data, cb) {
var edPublic = store.proxy.edPublic;
if (!edPublic) { return void cb({ error: 'NOT_LOGGED_IN' }); }
nThen(function (waitFor) {
removeOwnedPads(false, waitFor);
}).nThen(cb);
};
Store.deleteAccount = function (clientId, data, cb) {
var edPublic = store.proxy.edPublic;
var removeData = data && data.removeData;
var rpcKeys = data && data.keys;
var blockKeys = data && data.keys;
var auth = data && data.auth;
Store.anonRpcMsg(clientId, {
msg: 'GET_METADATA',
data: store.driveChannel
@ -849,13 +853,22 @@ define([
metadata.owners.indexOf(edPublic) !== -1) {
var token;
nThen(function (waitFor) {
Block.checkRights({
auth: auth,
blockKeys: blockKeys,
}, waitFor(function (err) {
if (err) {
waitFor.abort();
console.error(err);
return void cb({ error: 'INVALID_CODE' });
}
}));
}).nThen(function (waitFor) {
self.accountDeletion = clientId;
// Log out from other workers
var token = Math.floor(Math.random()*Number.MAX_SAFE_INTEGER);
store.proxy[Constants.tokenKey] = token;
onSync(null, waitFor());
}).nThen(function (waitFor) {
removeOwnedPads(waitFor);
}).nThen(function (waitFor) {
// Delete Pin Store
store.rpc.removePins(waitFor(function (err) {
@ -868,16 +881,15 @@ define([
force: true
}, waitFor());
}).nThen(function (waitFor) {
if (!removeData) { return; }
var done = waitFor();
Pinpad.create(store.network, rpcKeys, function (err, rpc) {
if (err) {
console.error(err);
return void done();
}
// Delete the block. Don't abort if it fails, it doesn't leak any data.
rpc.removeLoginBlock(removeData, done);
});
if (!blockKeys) { return; }
Block.removeLoginBlock({
auth: auth,
blockKeys: blockKeys,
}, waitFor(function (err) {
if (err) { console.error(err); }
}));
}).nThen(function (waitFor) {
removeOwnedPads(true, waitFor);
}).nThen(function () {
// Log out current worker
postMessage(clientId, "DELETE_ACCOUNT", token, function () {});
@ -2933,7 +2945,7 @@ define([
readOnly: false,
validateKey: secret.keys.validateKey || undefined,
crypto: Crypto.createEncryptor(secret.keys),
Cache: Cache, // ICE drive cache
Cache: Cache,
userName: 'fs',
logLevel: 1,
ChainPad: ChainPad,

View File

@ -0,0 +1,99 @@
define([
'/components/nthen/index.js',
'/common/common-util.js',
'/api/config',
'/components/tweetnacl/nacl-fast.min.js',
], function (nThen, Util, ApiConfig) {
var Nacl = window.nacl;
var clone = o => JSON.parse(JSON.stringify(o));
var randomToken = () => Nacl.util.encodeBase64(Nacl.randomBytes(24));
var postData = function (url, data, cb) {
var CB = Util.once(Util.mkAsync(cb));
fetch(url, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(data),
}).then(response => {
if (response.ok) {
return void response.text().then(result => { CB(void 0, Util.tryParse(result)); }); // XXX checkup error when using .json()
//return void response.json().then(result => { CB(void 0, result); });
}
response.json().then().then(result => {
CB(response.status, result);
});
//CB(response.status, response);
}).catch(error => {
CB(error);
});
};
var API_ORIGIN = (function () {
var url;
var unsafeOriginURL = new URL(ApiConfig.httpUnsafeOrigin);
try {
url = new URL(ApiConfig.websocketPath, ApiConfig.httpUnsafeOrigin);
url.protocol = unsafeOriginURL.protocol;
return url.origin;
} catch (err) {
console.error(err);
return ApiConfig.httpUnsafeOrigin;
}
}());
var serverCommand = function (keypair, my_data, cb) {
var obj = clone(my_data);
obj.publicKey = Nacl.util.encodeBase64(keypair.publicKey);
obj.nonce = randomToken();
var href = new URL('/api/auth/', API_ORIGIN);
var txid, date;
nThen(function (w) {
// Tell the server we want to do some action
postData(href, obj, w((err, data) => {
if (err) {
w.abort();
console.error(err);
// there might be more info here
if (data) { console.error(data); }
return void cb(err);
}
// if the requested action is valid, it responds with a txid and a nonce
// bundle all that up into an object, stringify it, and sign it.
// respond with an object: {sig, txid}
if (!data.date || !data.txid) {
w.abort();
return void cb('REQUEST_REJECTED');
}
txid = data.txid;
date = data.date;
}));
}).nThen(function (w) {
var copy = clone(obj);
copy.txid = txid;
copy.date = date;
var toSign = Nacl.util.decodeUTF8(JSON.stringify(copy));
var sig = Nacl.sign.detached(toSign, keypair.secretKey);
var encoded = Nacl.util.encodeBase64(sig);
var obj2 = {
sig: encoded,
txid: txid,
};
postData(href, obj2, w((err, data) => {
if (err) {
w.abort();
console.error(err);
// there might be more info here
if (data) { console.error(data); }
return void cb("RESPONSE_REJECTED");
}
cb(void 0, data);
}));
});
};
return serverCommand;
});

View File

@ -76,6 +76,14 @@ define([
safeSet(Constants.blockHashKey, hash);
};
LocalStore.getSessionToken = function () {
return localStorage[Constants.sessionJWT];
};
LocalStore.setSessionToken = function (token) {
safeSet(Constants.sessionJWT, token);
};
LocalStore.getAccountName = function () {
return localStorage[Constants.userNameKey];
};
@ -107,11 +115,11 @@ define([
safeSet(Constants.isPremiumKey, Boolean(bool));
};
LocalStore.login = function (hash, name, cb) {
if (!hash) { throw new Error('expected a user hash'); }
LocalStore.login = function (userHash, blockHash, name, cb) {
if (!userHash && !blockHash) { throw new Error('expected a user hash'); }
if (!name) { throw new Error('expected a user name'); }
hash = Hash.serializeHash(hash);
safeSet(Constants.userHashKey, hash);
if (userHash) { LocalStore.setUserHash(userHash); }
if (blockHash) { LocalStore.setBlockHash(blockHash); }
safeSet(Constants.userNameKey, name);
if (cb) { cb(); }
};
@ -121,6 +129,7 @@ define([
Constants.userNameKey,
Constants.userHashKey,
Constants.blockHashKey,
Constants.sessionJWT,
'loginToken',
'plan',
].forEach(function (k) {

View File

@ -1,8 +1,9 @@
define([
'/common/common-util.js',
'/api/config',
'/common/outer/http-command.js',
'/components/tweetnacl/nacl-fast.min.js',
], function (Util, ApiConfig) {
], function (Util, ApiConfig, ServerCommand) {
var Nacl = window.nacl;
var Block = {};
@ -113,17 +114,6 @@ define([
}
};
Block.remove = function (keys) {
// sign the hash of the text 'DELETE_BLOCK'
var sig = Nacl.sign.detached(Nacl.hash(
Nacl.util.decodeUTF8('DELETE_BLOCK')), keys.sign.secretKey);
return {
publicKey: Nacl.util.encodeBase64(keys.sign.publicKey),
signature: Nacl.util.encodeBase64(sig),
};
};
var urlSafeB64 = function (u8) {
return Nacl.util.encodeBase64(u8).replace(/\//g, '-');
};
@ -133,7 +123,7 @@ define([
// 'block/' here is hardcoded because it's hardcoded on the server
// if we want to make CryptPad work in server subfolders, we'll need
// to update this path derivation
return (ApiConfig.fileHost || window.location.origin)
return (ApiConfig.fileHost || ApiConfig.httpUnsafeOrigin || window.location.origin)
+ '/block/' + publicKey.slice(0, 2) + '/' + publicKey;
};
@ -170,5 +160,50 @@ define([
}
};
Block.checkRights = function (data, _cb) {
const cb = Util.mkAsync(_cb);
const { blockKeys, auth } = data;
var command = 'MFA_CHECK';
if (auth && auth.type === 'TOTP') {
command = 'TOTP_CHECK';
}
ServerCommand(blockKeys.sign, {
command: command,
auth: auth && auth.data
}, cb);
};
Block.writeLoginBlock = function (data, cb) {
const { content, blockKeys, oldBlockKeys, auth } = data;
var command = 'WRITE_BLOCK';
if (auth && auth.type === 'TOTP') {
command = 'TOTP_WRITE_BLOCK';
}
var block = Block.serialize(JSON.stringify(content), blockKeys);
block.auth = auth && auth.data;
block.registrationProof = oldBlockKeys && Block.proveAncestor(oldBlockKeys);
ServerCommand(blockKeys.sign, {
command: command,
content: block
}, cb);
};
Block.removeLoginBlock = function (data, cb) {
const { blockKeys, auth } = data;
var command = 'REMOVE_BLOCK';
if (auth && auth.type === 'TOTP') {
command = 'TOTP_REMOVE_BLOCK';
}
ServerCommand(blockKeys.sign, {
command: command,
auth: auth && auth.data
}, cb);
};
return Block;
});

View File

@ -24,8 +24,6 @@ define([
UPLOAD_COMPLETE: Store.uploadComplete,
UPLOAD_STATUS: Store.uploadStatus,
UPLOAD_CANCEL: Store.uploadCancel,
WRITE_LOGIN_BLOCK: Store.writeLoginBlock,
REMOVE_LOGIN_BLOCK: Store.removeLoginBlock,
PIN_PADS: Store.pinPads,
UNPIN_PADS: Store.unpinPads,
GET_DELETED_PADS: Store.getDeletedPads,
@ -93,6 +91,7 @@ define([
DRIVE_USEROBJECT: Store.userObjectCommand,
// Settings,
DELETE_ACCOUNT: Store.deleteAccount,
REMOVE_OWNED_PADS: Store.removeOwnedPads,
// Admin
ADMIN_RPC: Store.adminRpc,
ADMIN_ADD_MAILBOX: Store.addAdminMailbox,

View File

@ -205,41 +205,6 @@ var factory = function (Util, Rpc) {
});
};
exp.writeLoginBlock = function (data, cb) {
if (!data) { return void cb('NO_DATA'); }
if (!data.publicKey || !data.signature || !data.ciphertext) {
console.log(data);
return void cb("MISSING_PARAMETERS");
}
if (['string', 'undefined'].indexOf(typeof(data.registrationProof)) === -1) {
return void cb("INVALID_REGISTRATION_PROOF");
}
rpc.send('WRITE_LOGIN_BLOCK', [
data.publicKey,
data.signature,
data.ciphertext,
data.registrationProof || undefined,
], function (e) {
cb(e);
});
};
exp.removeLoginBlock = function (data, cb) {
if (!data) { return void cb('NO_DATA'); }
if (!data.publicKey || !data.signature) {
console.log(data);
return void cb("MISSING_PARAMETERS");
}
rpc.send('REMOVE_LOGIN_BLOCK', [
data.publicKey, // publicKey
data.signature, // signature
], function (e) {
cb(e);
});
};
// Get data for the admin panel
exp.setMetadata = function (obj, cb) {
rpc.send('SET_METADATA', {

View File

@ -1750,14 +1750,6 @@ define([
Cryptpad.changeUserPassword(Cryptget, edPublic, data, cb);
});
sframeChan.on('Q_WRITE_LOGIN_BLOCK', function (data, cb) {
Cryptpad.writeLoginBlock(data, cb);
});
sframeChan.on('Q_REMOVE_LOGIN_BLOCK', function (data, cb) {
Cryptpad.removeLoginBlock(data, cb);
});
// It seems we have performance issues when we open and close a lot of channels over
// the same network, maybe a memory leak. To fix this, we kill and create a new
// network every 30 cryptget calls (1 call = 1 channel)

View File

@ -32,19 +32,23 @@ define([
}).nThen(function (waitFor) {
SFCommonO.initIframe(waitFor);
}).nThen(function (/*waitFor*/) {
var hash = localStorage[Constants.userHashKey] || localStorage[Constants.fileHashKey];
var drive = hash && ('#'+hash === window.location.hash);
var isDrive = false;
var isMyDrive = false;
if (!window.location.hash) {
drive = true;
window.location.hash = hash;
isDrive = true;
isMyDrive = true;
} else {
var p = Hash.parsePadUrl('/debug/'+window.location.hash);
if (p && p.hashData && p.hashData.app === 'drive') {
drive = true;
isDrive = true;
}
}
var addData = function (meta) {
meta.debugDrive = drive;
var addData = function (meta, Cryptpad) {
if (isMyDrive) { window.location.hash = Cryptpad.userHash; }
window.CryptPad_location.app = "debug";
window.CryptPad_location.hash = Cryptpad.userHash;
window.CryptPad_location.href = '/debug/#'+Cryptpad.userHash;
meta.debugDrive = isDrive;
};
SFCommonO.start({
noDrive: true,

View File

@ -10,6 +10,7 @@ This file is intended to be used as a log of what third-party source we have ven
* [pdfjs](https://mozilla.github.io/pdf.js/) with some minor modifications to prevent CSP errors
* [mermaid 9.1.7](https://github.com/mermaid-js/mermaid/releases/tag/8.13.4) extends our markdown integration to support a variety of diagram types
* [Fabricjs 4.6.0](https://github.com/fabricjs/fabric.js) and [Fabric-history](https://github.com/lyzerk/fabric-history) for the whiteboard app
* [qrcode.js](https://github.com/davidshimjs/qrcodejs) from [this commit](https://github.com/davidshimjs/qrcodejs/commit/06c7a5e134f116402699f03cda5819e10a0e5787) since the repo doesn't use tags
* [Requirejs optional module plugin](https://stackoverflow.com/a/27422370)
* [asciidoc.js 2.0.0](https://github.com/asciidoctor/codemirror-asciidoc/releases/tag/2.0.0) with slight changes to match the format of other codemirror modes
* [Asciidoctor.js 2.2.6](https://github.com/asciidoctor/asciidoctor.js/releases/tag/v2.2.6) for AsciiDoc rendering

1
www/lib/qrcode.min.js vendored Normal file

File diff suppressed because one or more lines are too long

View File

@ -49,7 +49,8 @@ define([
var shouldImport = $checkImport[0].checked;
var uname = $uname.val();
var passwd = $passwd.val();
Login.loginOrRegisterUI(uname, passwd, false, shouldImport, /*Test.testing */ false, function () {
Login.loginOrRegisterUI(uname, passwd, false, shouldImport,
UI.getOTPScreen, /*Test.testing */ false, function () {
/*
if (test) {
localStorage.clear();

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="/components/requirejs/require.js?ver=2.3.5"></script>
</head>
<body class="html">
<noscript></noscript>

156
www/recovery/main.js Normal file
View File

@ -0,0 +1,156 @@
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/clipboard.js',
'/common/outer/local-store.js',
'/common/outer/login-block.js',
'/common/outer/http-command.js',
'/components/tweetnacl/nacl-fast.min.js',
'css!/components/components-font-awesome/css/font-awesome.min.css',
], function ($, Sortify, Login, Cryptpad, /*Test,*/ Cred, UI, Util, Realtime, Constants, Feedback,
Clipboard, 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 = $('#mfarecovery');
var $copyProof = $('#mfacopyproof');
var $step1 = $('.cp-recovery-step.step1');
var $step2 = $('.cp-recovery-step.step2');
var $stepInfo = $('.cp-recovery-step.step-info');
var $mfaProof = $('textarea.cp-recover-email');
var $forgot = $('.cp-recovery-forgot');
var $alt = $('.cp-recovery-alt');
[ $uname, $passwd]
.some(function ($el) { if (!$el.val()) { $el.focus(); return true; } });
var mfaStep2 = function () {
$step1.hide();
$step2.show();
};
var mfaStepInfo = function (cls) {
$step1.hide();
$stepInfo.find('.alert').toggleClass('cp-hidden', true);
$stepInfo.find(cls).toggleClass('cp-hidden', false);
$stepInfo.show();
};
$forgot.click(function () {
$alt.toggle();
if ($alt.is(':visible')) { $forgot.find('i').attr('class', 'fa fa-caret-down'); }
else { $forgot.find('i').attr('class', 'fa fa-caret-right'); }
});
var proofStr;
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);
proofStr = JSON.stringify(toSign, 0, 2);
$mfaProof.html(proofStr);
};
$copyProof.click(function () {
if (!proofStr) { return; }
if (Clipboard.copy.multiline(proofStr)) {
UI.log(Messages.genericCopySuccess);
} else {
UI.warn(Messages.error);
}
});
var blockKeys, blockHash, uname;
var revokeTOTP = function () {
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?
UI.log(Messages.ui_success);
LocalStore.login(undefined, blockHash, uname, function () {
Login.redirect();
});
});
};
var $recoverLogin = $('button#cp-recover-login');
var $recoverConfirm = $('button#cp-recover');
$recoverLogin.click(function () {
UI.addLoadingScreen({
loadingText: Messages.login_hashing
});
uname = $uname.val();
var pw = $passwd.val();
setTimeout(function () {
Login.Cred.deriveFromPassphrase(uname, pw, Login.requiredBytes, function (bytes) {
var result = Login.allocateBytes(bytes);
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 mfaStepInfo('.disabled');
}
if (err === 401) {
return mfaStep2(result.blockKeys);
}
if (err === 404) {
return $step1.find('.wrong-cred').toggleClass('cp-hidden', false);
}
mfaStepInfo('.unknown-error');
});
});
}, 100);
});
UI.confirmButton($recoverConfirm[0], {
multiple: true
}, function () {
if (!blockKeys) { return; }
revokeTOTP();
});
});
});

View File

@ -48,12 +48,19 @@ define([
var I_REALLY_WANT_TO_USE_MY_EMAIL_FOR_MY_USERNAME = false;
var br = function () { return h('br'); };
Messages.register_nameTooLong = "Usernames must be shorter than {0} characters"; // XXX
var registerClick = function () {
var uname = $uname.val().trim();
// trim whitespace surrounding the username since it is otherwise included in key derivation
// most people won't realize that its presence is significant
$uname.val(uname);
if (uname.length > Cred.MAXIMUM_NAME_LENGTH) {
let nameWarning = Messages._getKey('register_nameTooLong', [ Cred.MAXIMUM_NAME_LENGTH ]);
return void UI.alert(nameWarning, function () {
registering = false;
});
}
var passwd = $passwd.val();
var confirmPassword = $confirm.val();

View File

@ -86,13 +86,13 @@
}
}
.cp-settings-change-password, .cp-settings-own-drive, .cp-settings-delete {
.cp-password-container {
[type="password"], [type="text"] {
width: @sidebar_button-width;
flex: unset;
}
button {
margin-top: 5px;
margin-left: 10px;
}
}
.cp-settings-drive-backup {
@ -103,6 +103,50 @@
margin-right: 5px;
}
}
.cp-settings-mfa {
.cp-settings-qr {
img {
border: 10px solid white;
border-radius: 10px;
}
margin: 10px 20px 20px 0;
}
}
.cp-settings-mfa-hint {
color: @cp_sidebar-hint;
}
.cp-settings-mfa-status {
& > i {
margin-right: 5px;
}
margin: 1em 0;
}
.cp-settings-mfa {
.cp-password-container {
flex-wrap: wrap;
input {
flex-shrink: 1;
max-width: 400px;
}
label {
width: 100%;
font-weight: unset;
margin-bottom: 5px;
}
}
.cp-settings-qr-container {
display: flex;
align-items: center;
.cp-settings-qr-code {
input {
max-width: 250px;
}
button {
margin-top: 10px
}
}
}
}
}
}

View File

@ -15,6 +15,7 @@ define([
'/common/make-backup.js',
'/common/common-feedback.js',
'/common/common-constants.js',
'/customize.dist/login.js',
'/common/jscolor.js',
'/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,31 @@ define([
var privateData;
var sframeChan;
Messages.settings_mfaTitle = "Two-Factor Authentication (2FA)"; // XXX
Messages.settings_mfaHint = "Protect your account..."; // XXX
Messages.settings_cat_access = "Security"; // XXX
Messages.done = "Done";
Messages.continue = "Continue";
Messages.mfa_setup_label = "To enable 2FA, please begin by entering your account password"; // XXX
Messages.mfa_setup_button = "Begin 2FA setup"; // XXX
Messages.mfa_revoke_label = "To disable 2FA, please begin by entering your account password"; // XXX
Messages.mfa_revoke_button = "Start disable 2FA"; // XXX
Messages.mfa_revoke_code = "Please enter your verification code";
Messages.mfa_status_on = "2FA is active on this account";
Messages.mfa_status_off = "2FA is not active on this account";
Messages.mfa_recovery_title = "Save this recovery code now";
Messages.mfa_recovery_hint = "If you loose access to your authenticator...........";
Messages.mfa_recovery_warning = "This code will not be shown again...........";
Messages.mfa_enable = "Enable 2FA";
Messages.mfa_disable = "Disable 2FA";
Messages.settings_otp_code = "Verification code"; // XXX
Messages.settings_otp_invalid = "Invalid OTP code"; // XXX
Messages.settings_otp_tuto = "Please scan this QR code with your authenticator app and paste the verification code to confirm.";
Messages.settings_removeOwnedButton = "Destroy documents";
Messages.settings_removeOwnedText = "Please wait while your document are being destroyed...";
var categories = {
'account': [ // Msg.settings_cat_account
'cp-settings-own-drive',
@ -54,6 +81,10 @@ define([
'cp-settings-displayname',
'cp-settings-language-selector',
'cp-settings-mediatag-size',
],
'access': [ // Msg.settings_cat_access // XXX
'cp-settings-mfa',
'cp-settings-remove-owned',
'cp-settings-change-password',
'cp-settings-delete'
],
@ -477,6 +508,40 @@ define([
});
}, true);
var deriveBytes = function (name, password, cb) {
Cred.deriveFromPassphrase(name, password, Login.requiredBytes, cb);
};
makeBlock('remove-owned', function(cb) { // Msg.settings_removeOwnedHint, .settings_removeOwnedTitle
if (!common.isLoggedIn()) { return cb(false); }
var button = h('button.btn.btn-danger', Messages.settings_removeOwnedButton);
var form = h('div', [
button
]);
var $button = $(button);
UI.confirmButton(button, {
classes: 'btn-danger',
multiple: true
}, function() {
UI.addLoadingScreen({
hideTips: true,
loadingText: Messages.settings_removeOwnedText
});
sframeChan.query("Q_SETTINGS_REMOVE_OWNED_PADS", {}, function (err, data) {
UI.removeLoadingScreen();
$button.prop('disabled', '');
if (data && data.error) {
console.error(data.error);
return void UI.warn(Messages.error);
}
UI.log(Messages.success);
});
});
cb(form);
}, true);
makeBlock('delete', function(cb) { // Msg.settings_deleteHint, .settings_deleteTitle
if (!common.isLoggedIn()) { return cb(false); }
@ -490,7 +555,6 @@ define([
]);
var $form = $(form);
var $button = $(button);
var spinner = UI.makeSpinner($form);
UI.confirmButton(button, {
classes: 'btn-danger',
@ -528,36 +592,81 @@ define([
if (!password) {
return void UI.warn(Messages.error);
}
spinner.spin();
sframeChan.query("Q_SETTINGS_DELETE_ACCOUNT", {
password: password
}, function(err, data) {
if (data && data.error) {
spinner.hide();
$button.prop('disabled', '');
if (data.error === 'INVALID_PASSWORD') {
return void UI.warn(Messages.drive_sfPasswordError);
}
console.error(data.error);
return void UI.warn(Messages.error);
}
// Owned drive
if (data.state === true) {
return void sframeChan.query('Q_SETTINGS_LOGOUT_PROPERLY', null, function() {
UI.alert(Messages.settings_deleted, function() {
common.gotoURL('/');
});
spinner.done();
UI.addLoadingScreen({
hideTips: true,
loadingText: Messages.settings_deleteTitle
});
setTimeout(function () {
var name = privateData.accountName;
var bytes;
var auth = {};
nThen(function (w) {
deriveBytes(name, password, w(function (_bytes) {
bytes = _bytes;
}));
}).nThen(function (w) {
var result = Login.allocateBytes(bytes);
sframeChan.query("Q_SETTINGS_CHECK_PASSWORD", {
blockHash: result.blockHash,
userHash: result.userHash,
}, w(function (err, obj) {
if (!obj || !obj.correct) {
UI.warn(Messages.login_noSuchUser);
w.abort();
UI.removeLoadingScreen();
}
}));
}).nThen(function (w) {
// CHECK MFA
sframeChan.query('Q_SETTINGS_MFA_CHECK', {}, w(function (err, obj) {
// No block? no need for a code
if (err || !obj || (obj && obj.err === 'NOBLOCK')
|| !obj.mfa) { return; }
auth.type = obj.type;
if (auth.type === 'TOTP') {
UI.getOTPScreen(w(function (val) {
UI.addLoadingScreen({ loadingText: Messages.settings_deleteTitle });
auth.data = val;
}), function () {
w.abort(); // On exit OTP screen
});
}
}));
}).nThen(function () {
sframeChan.query("Q_SETTINGS_DELETE_ACCOUNT", {
bytes: bytes,
auth: auth
}, function(err, data) {
UI.removeLoadingScreen();
if (data && data.error) {
$button.prop('disabled', '');
if (data.error === 'INVALID_PASSWORD') {
return void UI.warn(Messages.drive_sfPasswordError);
}
if (data.error === 'INVALID_CODE') {
return void UI.warn(Messages.settings_otp_invalid);
}
return void UI.warn(Messages.error);
}
// Owned drive
if (data.state === true) {
return void sframeChan.query('Q_SETTINGS_LOGOUT_PROPERLY', null, function() {
UI.alert(Messages.settings_deleted, function() {
common.gotoURL('/');
});
});
}
// Not owned drive
var msg = h('div.cp-app-settings-delete-alert', [
h('p', Messages.settings_deleteModal),
h('pre', JSON.stringify(data, 0, 2))
]);
UI.alert(msg);
$button.prop('disabled', '');
});
}
// Not owned drive
var msg = h('div.cp-app-settings-delete-alert', [
h('p', Messages.settings_deleteModal),
h('pre', JSON.stringify(data, 0, 2))
]);
UI.alert(msg);
spinner.hide();
$button.prop('disabled', '');
});
});
});
});
@ -576,7 +685,6 @@ define([
.append(Messages.settings_changePasswordHint).appendTo($div);
// var publicKey = privateData.edPublic;
var form = h('div', [
UI.passwordInput({
id: 'cp-settings-change-password-current',
@ -601,7 +709,7 @@ define([
sframeChan.query('Q_CHANGE_USER_PASSWORD', data, function(err, obj) {
if (err || obj.error) { return void cb({ error: err || obj.error }); }
cb(obj);
});
}, {raw: true});
};
var todo = function() {
@ -630,19 +738,69 @@ define([
function(yes) {
if (!yes) { return; }
UI.addLoadingScreen({
hideTips: true,
loadingText: Messages.settings_changePasswordPending,
});
updateBlock({
password: oldPassword,
newPassword: newPassword
}, function(obj) {
UI.removeLoadingScreen();
if (obj && obj.error) {
// TODO more specific error message?
UI.alert(Messages.settings_changePasswordError);
}
UI.addLoadingScreen({ loadingText: Messages.settings_changePasswordPending });
// We're going to derive the bytes in inner in order to ask for the possible
// OTP code after the Scrypt execution. This will make it less likely to
// have the OTP code expire.
setTimeout(function () {
var oldBytes, newBytes;
var auth = {};
nThen(function (w) {
var name = privateData.accountName;
deriveBytes(name, oldPassword, w(function (bytes) {
oldBytes = bytes;
}));
deriveBytes(name, newPassword, w(function (bytes) {
newBytes = bytes;
}));
}).nThen(function (w) {
var result = Login.allocateBytes(oldBytes);
sframeChan.query("Q_SETTINGS_CHECK_PASSWORD", {
blockHash: result.blockHash,
userHash: result.userHash,
}, w(function (err, obj) {
if (!obj || !obj.correct) {
UI.warn(Messages.login_noSuchUser);
w.abort();
UI.removeLoadingScreen();
}
}));
}).nThen(function (w) {
// CHECK MFA
sframeChan.query('Q_SETTINGS_MFA_CHECK', {}, w(function (err, obj) {
// No block? no need for a code
if (err || !obj || (obj && obj.err === 'NOBLOCK')
|| !obj.mfa) { return; }
auth.type = obj.type;
if (auth.type === 'TOTP') {
UI.getOTPScreen(w(function (val) {
auth.data = val;
UI.addLoadingScreen({ loadingText: Messages.settings_changePasswordPending });
}), function () {
w.abort(); // On exit OTP screen
});
}
}));
}).nThen(function () {
updateBlock({
password: oldPassword,
newPassword: newPassword,
oldBytes: oldBytes,
newBytes: newBytes,
auth: auth
}, function(obj) {
UI.removeLoadingScreen();
if (obj && obj.error) {
if (obj.error === 'INVALID_CODE') {
return void UI.warn(Messages.settings_otp_invalid);
}
// TODO more specific error message?
console.error(obj.error);
UI.alert(Messages.settings_changePasswordError);
}
});
});
});
}, {
ok: Messages.register_writtenPassword,
@ -777,6 +935,291 @@ define([
cb($inputBlock);
}, true);
// Account access
var drawMfa = function (content, enabled) {
var $content = $(content).empty();
$content.append(h('div.cp-settings-mfa-hint.cp-settings-mfa-status', [
h('i.fa' + (enabled ? '.fa-check' : '.fa-times')),
h('span', enabled ? Messages.mfa_status_on : Messages.mfa_status_off)
]));
if (enabled) {
(function () {
var button = h('button.btn.btn-danger', Messages.mfa_revoke_button);
var $mfaRevokeBtn = $(button);
var pwInput;
var pwContainer = h('div.cp-password-container', [
h('label.cp-settings-mfa-hint', { for: 'cp-mfa-password' }, Messages.mfa_revoke_label),
pwInput = h('input#cp-mfa-password', {
type: 'password',
placeholder: Messages.login_password,
}),
button
]);
$content.append(pwContainer);
var spinner = UI.makeSpinner($mfaRevokeBtn);
$mfaRevokeBtn.click(function () {
var name = privateData.accountName;
var password = $(pwInput).val();
if (!password) { return void UI.warn(Messages.login_noSuchUser); }
spinner.spin();
$(pwInput).prop('disabled', 'disabled');
$mfaRevokeBtn.prop('disabled', 'disabled');
var blockKeys;
nThen(function (waitFor) {
var next = waitFor();
// 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_PASSWORD", {
blockHash: result.blockHash,
}, function (err, obj) {
if (!obj || !obj.correct) {
spinner.hide();
UI.warn(Messages.login_noSuchUser);
$mfaRevokeBtn.removeAttr('disabled');
$(pwInput).removeAttr('disabled');
waitFor.abort();
return;
}
spinner.done();
blockKeys = result.blockKeys;
next();
});
});
}, 100);
}).nThen(function () {
$(pwContainer).remove();
var OTPEntry;
var disable = h('button.btn.btn-danger', Messages.mfa_disable);
$content.append(h('div.cp-password-container', [
h('label.cp-settings-mfa-hint', { for: 'cp-mfa-password' }, Messages.mfa_revoke_code),
OTPEntry = h('input', {
placeholder: Messages.settings_otp_code
}),
disable
]));
var $OTPEntry = $(OTPEntry);
var $d = $(disable).click(function () {
$d.prop('disabled', 'disabled');
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) {
$d.removeAttr('disabled');
return void UI.warn(Messages.settings_otp_invalid);
}
drawMfa(content, false);
}, {raw: true});
});
});
});
})();
return;
}
var button = h('button.btn.btn-primary', Messages.mfa_setup_button);
var $mfaSetupBtn = $(button);
var pwInput;
$content.append(h('div.cp-password-container', [
h('label.cp-settings-mfa-hint', { for: 'cp-mfa-password' }, Messages.mfa_setup_label),
pwInput = h('input#cp-mfa-password', {
type: 'password',
placeholder: Messages.login_password,
}),
button
]));
var spinner = UI.makeSpinner($mfaSetupBtn);
$(button).click(function () {
var name = privateData.accountName;
var password = $(pwInput).val();
if (!password) { return void UI.warn(Messages.login_noSuchUser); }
spinner.spin();
$(pwInput).prop('disabled', 'disabled');
$mfaSetupBtn.prop('disabled', 'disabled');
var Base32, QRCode, Nacl;
var blockKeys;
var recoverySecret;
nThen(function (waitFor) {
require([
'/auth/base32.js',
'/lib/qrcode.min.js',
'/components/tweetnacl/nacl-fast.min.js',
], waitFor(function (_Base32) {
Base32 = _Base32;
QRCode = window.QRCode;
Nacl = window.nacl;
}));
}).nThen(function (waitFor) {
var next = waitFor();
// 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_PASSWORD", {
blockHash: result.blockHash,
}, function (err, obj) {
if (!obj || !obj.correct) {
spinner.hide();
UI.warn(Messages.login_noSuchUser);
$mfaSetupBtn.removeAttr('disabled');
$(pwInput).removeAttr('disabled');
waitFor.abort();
return;
}
spinner.done();
blockKeys = result.blockKeys;
next();
});
});
}, 100);
}).nThen(function (waitFor) {
$content.empty();
var next = waitFor();
recoverySecret = Nacl.util.encodeBase64(Nacl.randomBytes(24));
var button = h('button.btn.btn-primary', [
h('i.fa.fa-check'),
h('span', Messages.done)
]);
$content.append(h('div.alert.alert-danger', [
h('h2', Messages.mfa_recovery_title),
h('p', Messages.mfa_recovery_hint),
h('p', Messages.mfa_recovery_warning),
h('div.cp-password-container', [
UI.dialog.selectable(recoverySecret),
button
])
]));
var nextButton = h('button.btn.btn-primary', {
'disabled': 'disabled'
}, Messages.continue);
$(nextButton).click(function () {
next();
}).appendTo($content);
$(button).click(function () {
$content.find('.alert-danger').removeClass('alert-danger').addClass('alert-success');
$(button).prop('disabled', 'disabled');
$(nextButton).removeAttr('disabled');
});
}).nThen(function () {
var randomSecret = function () {
var U8 = Nacl.randomBytes(20);
return Base32.encode(U8);
};
$content.empty();
var updateQR = Util.mkAsync(function (uri, target) {
new QRCode(target, uri);
});
var updateURI = function (secret) {
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.cp-settings-qr');
var uriInput = UI.dialog.selectable(uri);
updateQR(uri, qr);
var OTPEntry = h('input', {
placeholder: Messages.settings_otp_code
});
var $OTPEntry = $(OTPEntry);
var description = h('p.cp-settings-mfa-hint', Messages.settings_otp_tuto);
var confirmOTP = h('button.btn.btn-primary', [
h('i.fa.fa-check'),
h('span', Messages.mfa_enable)
]);
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.settings_otp_invalid);
}
$confirmBtn.attr('disabled', 'disabled');
lock = true;
var data = {
command: 'TOTP_SETUP',
secret: secret,
contact: "secret:" + recoverySecret, // TODO other recovery options
code: code,
};
sframeChan.query("Q_SETTINGS_TOTP_SETUP", {
key: blockKeys.sign,
data: data
}, function (err, obj) {
lock = false;
$OTPEntry.val("");
if (err || !obj || !obj.success) {
$confirmBtn.removeAttr('disabled');
console.error(err);
return void UI.warn(Messages.error);
}
drawMfa(content, true);
}, {raw: true});
});
$content.append([
description,
h('div.cp-settings-qr-container', [
qr,
h('div.cp-settings-qr-code', [
OTPEntry,
h('br'),
confirmOTP
])
]),
uriInput
]);
};
var secret = randomSecret();
updateURI(secret);
});
});
};
makeBlock('mfa', function (cb) { // Msg.settings_mfaTitle, Msg.settings_mfaHint
if (!common.isLoggedIn()) { return void cb(false); }
var content = h('div');
sframeChan.query('Q_SETTINGS_MFA_CHECK', {}, function (err, obj) {
if (err || !obj || (obj && obj.err === 'NOBLOCK')) { return void cb(false); }
var enabled = obj && obj.mfa && obj.type === 'TOTP';
drawMfa(content, Boolean(enabled));
cb(content);
});
}, true);
// Security
makeBlock('safe-links', function(cb) { // Msg.settings_safeLinksTitle

View File

@ -70,6 +70,55 @@ define([
sframeChan.on('Q_SETTINGS_IMPORT_LOCAL', function (data, cb) {
Cryptpad.mergeAnonDrive(cb);
});
sframeChan.on('Q_SETTINGS_CHECK_PASSWORD', function (data, cb) {
var blockHash = Utils.LocalStore.getBlockHash();
var userHash = Utils.LocalStore.getUserHash();
var correct = (blockHash && blockHash === data.blockHash) ||
(!blockHash && userHash === data.userHash);
cb({correct: correct});
});
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) });
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) });
if (response && response.success) {
Utils.LocalStore.setSessionToken('');
}
});
});
});
sframeChan.on('Q_SETTINGS_MFA_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, data) {
cb({
mfa: err === 401,
type: data && data.method
});
});
});
});
sframeChan.on('Q_SETTINGS_REMOVE_OWNED_PADS', function (data, cb) {
Cryptpad.removeOwnedPads(data, cb);
});
sframeChan.on('Q_SETTINGS_DELETE_ACCOUNT', function (data, cb) {
Cryptpad.deleteAccount(data, cb);
});