diff --git a/.gitignore b/.gitignore index c95495f31..f78ce644a 100644 --- a/.gitignore +++ b/.gitignore @@ -14,6 +14,7 @@ data npm-debug.log pins/ blob/ +block/ blobstage/ block/ privileged.conf diff --git a/CHANGELOG.md b/CHANGELOG.md index 8a9a00a54..c93d79324 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,34 @@ +# Fossa release (v2.5.0) + +## Goals + +This release took longer than usual - three weeks instead of two - due to our plans involving a complete redesign of how login and registration function. +Any time we rework a critical system within CryptPad we're very cautious about deploying it, however, this update should bring considerable value for users. +From now on, users will be able to change their passwords without losing access to their old data, however, this is very different from _password recovery_. +While we will still be unable to help you if you have forgotten your password, this update will address our inability up until this point to change your password in the event that it has been compromised in some way. + +## Update notes + +* v2.5.0 uses newly released features in a clientside dependency ([chainpad-netflux](https://github.com/xwiki-labs/chainpad-netflux/releases/tag/0.7.2)). Run `bower update` to make sure you have the latest version. +* Update your server config to serve /block/ with maxAge 0d, if you are using a reverse proxy, or docker. `cryptpad/docs/example.nginx.conf` has been updated to include an example. +* Restart your server after updating. +* We have added a new feedback key, `NO_CSS_VARIABLES`, in order to diagnose how many of our clients support the CSS3 functionality. + +### Features + +* v2.5.0 introduces support for what we have called _modern users_. + * New registrations will use the new APIs that we've built to facillitate the ability to change your account password. + * _Legacy registrations_ will continue to function as they always have. + * Changing your password (via the settings page) will migrate old user accounts to the new system. + * We'll publish a blog post in the coming weeks to explain in depth how this functionality is implemented. +* The _kanban_ application now features support for export and import of your project data. +* This release features minor improvements to the _Deutsch_ translation + +### Bug fixes + +* We noticed that if you entered credentials for registration, and cancelled the displayed prompt informing you that such a user was already registered, the registration interface would not unlock for further interaction. This has been fixed. +* We found that on very slow connections, or when users opened pads in Firefox without focusing the tab, requirejs would fail to load dependencies before timing out. We've increased the timeout period by a factor of ten to address such cases. + # Echidna release (v2.4.0) ## Goals diff --git a/Dockerfile b/Dockerfile index da60937bf..5b22f39bf 100644 --- a/Dockerfile +++ b/Dockerfile @@ -8,13 +8,13 @@ RUN apk add --no-cache git tini \ && npm install -g bower \ && bower install --allow-root -EXPOSE 3000 +EXPOSE 3000 3001 VOLUME /cryptpad/datastore VOLUME /cryptpad/customize ENV USE_SSL=false -ENV STORAGE='./storage/file' +ENV STORAGE=\'./storage/file\' ENV LOG_TO_STDOUT=true CMD ["/sbin/tini", "--", "/cryptpad/container-start.sh"] diff --git a/Dockerfile.arm64 b/Dockerfile.arm64 new file mode 100644 index 000000000..ec11ddc55 --- /dev/null +++ b/Dockerfile.arm64 @@ -0,0 +1,25 @@ +FROM arm64v8/node:6 + +COPY . /cryptpad +WORKDIR /cryptpad + +RUN npm config set unsafe-perm true + +ADD https://github.com/krallin/tini/releases/download/v0.18.0/tini-static-arm64 /sbin/tini +RUN chmod a+x /sbin/tini + +RUN apt install -y git \ + && npm install --production \ + && npm install -g bower \ + && bower install --allow-root + +EXPOSE 3000 3001 + +VOLUME /cryptpad/datastore +VOLUME /cryptpad/customize + +ENV USE_SSL=false +ENV STORAGE=\'./storage/file\' +ENV LOG_TO_STDOUT=true + +CMD ["/sbin/tini", "--", "/cryptpad/container-start.sh"] diff --git a/config.example.js b/config.example.js index 166fd74a8..1d85d6c96 100644 --- a/config.example.js +++ b/config.example.js @@ -211,6 +211,11 @@ module.exports = { */ taskPath: './tasks', + /* if you would like users' authenticated blocks to be stored in + a custom location, change the path below: + */ + blockPath: './block', + /* * By default, CryptPad also contacts our accounts server once a day to check for changes in * the people who have accounts. This check-in will also send the version of your CryptPad diff --git a/container-start.sh b/container-start.sh index 990a83cd2..8e274b19a 100755 --- a/container-start.sh +++ b/container-start.sh @@ -24,5 +24,5 @@ sedeasy() { [ -n "$LOG_TO_STDOUT" ] && echo "Logging to stdout: $LOG_TO_STDOUT" \ && sedeasy "logToStdout: [^,]*," "logToStdout: ${LOG_TO_STDOUT}," customize/config.js - +export FRESH=1 exec node ./server.js diff --git a/customize.dist/login.js b/customize.dist/login.js index 13fb6f40c..ab9fabfa8 100644 --- a/customize.dist/login.js +++ b/customize.dist/login.js @@ -12,17 +12,23 @@ define([ '/common/common-feedback.js', '/common/outer/local-store.js', '/customize/messages.js', + '/bower_components/nthen/index.js', + '/common/outer/login-block.js', + '/common/common-hash.js', '/bower_components/tweetnacl/nacl-fast.min.js', '/bower_components/scrypt-async/scrypt-async.min.js', // better load speed ], function ($, Listmap, Crypto, Util, NetConfig, Cred, ChainPad, Realtime, Constants, UI, - Feedback, LocalStore, Messages) { + Feedback, LocalStore, Messages, nThen, Block, Hash) { var Exports = { Cred: Cred, + // this is depended on by non-customizable files + // be careful when modifying login.js + requiredBytes: 192, }; var Nacl = window.nacl; - var allocateBytes = function (bytes) { + var allocateBytes = Exports.allocateBytes = function (bytes) { var dispense = Cred.dispenser(bytes); var opt = {}; @@ -41,6 +47,10 @@ define([ // 32 more for a signing key var edSeed = opt.edSeed = dispense(32); + // 64 more bytes to seed an additional signing key + var blockKeys = opt.blockKeys = Block.genkeys(new Uint8Array(dispense(64))); + opt.blockHash = Block.getBlockHash(blockKeys); + // derive a private key from the ed seed var signingKeypair = Nacl.sign.keyPair.fromSeed(new Uint8Array(edSeed)); @@ -58,10 +68,24 @@ define([ // should never happen if (channelHex.length !== 32) { throw new Error('invalid channel id'); } - opt.channel64 = Util.hexToBase64(channelHex); + var channel64 = Util.hexToBase64(channelHex); + + // we still generate a v1 hash because this function needs to deterministically + // derive the same values as it always has. New accounts will generate their own + // userHash values + opt.userHash = '/1/edit/' + [channel64, opt.keys.editKeyStr].join('/') + '/'; + + return opt; + }; - opt.userHash = '/1/edit/' + [opt.channel64, opt.keys.editKeyStr].join('/'); + var loginOptionsFromBlock = function (blockInfo) { + var opt = {}; + var parsed = Hash.getSecrets('pad', blockInfo.User_hash); + opt.channelHex = parsed.channel; + opt.keys = parsed.keys; + opt.edPublic = blockInfo.edPublic; + opt.User_name = blockInfo.User_name; return opt; }; @@ -92,6 +116,14 @@ define([ return Object.keys(proxy).length === 0; }; + var setMergeAnonDrive = function () { + sessionStorage.migrateAnonDrive = 1; + }; + + var setCreateReadme = function () { + sessionStorage.createReadme = 1; + }; + Exports.loginOrRegister = function (uname, passwd, isRegister, shouldImport, cb) { if (typeof(cb) !== 'function') { return; } @@ -105,22 +137,101 @@ define([ return void cb('PASS_TOO_SHORT'); } - Cred.deriveFromPassphrase(uname, passwd, 128, function (bytes) { - // results... - var res = { - register: isRegister, - }; + // results... + var res = { + register: isRegister, + }; + + var RT, blockKeys, blockHash, Pinpad, rpc, userHash; + + nThen(function (waitFor) { + // derive a predefined number of bytes from the user's inputs, + // and allocate them in a deterministic fashion + Cred.deriveFromPassphrase(uname, passwd, Exports.requiredBytes, waitFor(function (bytes) { + res.opt = allocateBytes(bytes); + blockHash = res.opt.blockHash; + blockKeys = res.opt.blockKeys; + })); + }).nThen(function (waitFor) { + // the allocated bytes can be used either in a legacy fashion, + // or in such a way that a previously unused byte range determines + // the location of a layer of indirection which points users to + // an encrypted block, from which they can recover the location of + // the rest of their data + + // determine where a block for your set of keys would be stored + var 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. + + // the block is only useful if it can be decrypted, though + if (err) { + console.log("no block found"); + return; + } + + var decryptedBlock = Block.decrypt(block, blockKeys); + if (!decryptedBlock) { + console.error("Found a login block but failed to decrypt"); + return; + } - // run scrypt to derive the user's keys - var opt = res.opt = allocateBytes(bytes); + console.error(decryptedBlock); + res.blockInfo = decryptedBlock; + })); + }).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 + if (res.blockInfo) { return; } - // use the derived key to generate an object - loadUserObject(opt, function (err, rt) { + var opt = res.opt; + + // load the user's object using the legacy credentials + loadUserObject(opt, waitFor(function (err, rt) { if (err) { return void cb(err); } + // if a proxy is marked as deprecated, it is because someone had a non-owned drive + // but changed their password, and couldn't delete their old data. + // if they are here, they have entered their old credentials, so we should not + // allow them to proceed. In time, their old drive should get deleted, since + // it will should be pinned by anyone's drive. + if (rt.proxy[Constants.deprecatedKey]) { + return void cb('NO_SUCH_USER', res); + } + + if (isRegister && isProxyEmpty(rt.proxy)) { + // If they are trying to register, + // and the proxy is empty, then there is no 'legacy user' either + // so we should just shut down this session and disconnect. + rt.network.disconnect(); + return; // proceed to the next async block + } + + // they tried to just log in but there's no such user + // and since we're here at all there is no modern-block + if (!isRegister && isProxyEmpty(rt.proxy)) { + rt.network.disconnect(); // clean up after yourself + waitFor.abort(); + return void cb('NO_SUCH_USER', res); + } + + // they tried to register, but those exact credentials exist + if (isRegister && !isProxyEmpty(rt.proxy)) { + rt.network.disconnect(); + waitFor.abort(); + Feedback.send('LOGIN', true); + return void cb('ALREADY_REGISTERED', res); + } + + // if you are here, then there is no block, the user is trying + // to log in. The proxy is **not** empty. All values assigned here + // should have been deterministically created using their credentials + // so setting them is just a precaution to keep things in good shape res.proxy = rt.proxy; res.realtime = rt.realtime; - res.network = rt.network; // they're registering... res.userHash = opt.userHash; @@ -130,53 +241,168 @@ define([ res.edPrivate = opt.edPrivate; res.edPublic = opt.edPublic; + // export their encryption key res.curvePrivate = opt.curvePrivate; res.curvePublic = opt.curvePublic; - // they tried to just log in but there's no such user + if (shouldImport) { setMergeAnonDrive(); } + + // don't proceed past this async block. + waitFor.abort(); + + // We have to call whenRealtimeSyncs asynchronously here because in the current + // version of listmap, onLocal calls `chainpad.contentUpdate(newValue)` + // asynchronously. + // The following setTimeout is here to make sure whenRealtimeSyncs is called after + // `contentUpdate` so that we have an update userDoc in chainpad. + setTimeout(function () { + 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 () { + setTimeout(function () { cb(void 0, res); }); + }); + }); + }); + })); + }).nThen(function (waitFor) { // MODERN REGISTRATION / LOGIN + var opt; + if (res.blockInfo) { + opt = loginOptionsFromBlock(res.blockInfo); + userHash = res.blockInfo.User_hash; + console.error(opt, userHash); + } else { + console.log("allocating random bytes for a new user object"); + opt = allocateBytes(Nacl.randomBytes(Exports.requiredBytes)); + // create a random v2 hash, since we don't need backwards compatibility + userHash = opt.userHash = Hash.createRandomHash('drive'); + var secret = Hash.getSecrets('drive', userHash); + opt.keys = secret.keys; + opt.channelHex = secret.channel; + } + + // according to the location derived from the credentials which you entered + loadUserObject(opt, waitFor(function (err, rt) { + if (err) { + waitFor.abort(); + return void cb('MODERN_REGISTRATION_INIT'); + } + + console.error(JSON.stringify(rt.proxy)); + + // export the realtime object you checked + RT = rt; + + var proxy = rt.proxy; + if (isRegister && !isProxyEmpty(proxy) && (!proxy.edPublic || !proxy.edPrivate)) { + console.error("INVALID KEYS"); + console.log(JSON.stringify(proxy)); + return; + } + + res.proxy = rt.proxy; + res.realtime = rt.realtime; + + // they're registering... + res.userHash = userHash; + res.userName = uname; + + // somehow they have a block present, but nothing in the user object it specifies + // this shouldn't happen, but let's send feedback if it does if (!isRegister && isProxyEmpty(rt.proxy)) { + // this really shouldn't happen, but let's handle it anyway + Feedback.send('EMPTY_LOGIN_WITH_BLOCK'); + rt.network.disconnect(); // clean up after yourself + waitFor.abort(); return void cb('NO_SUCH_USER', res); } // they tried to register, but those exact credentials exist if (isRegister && !isProxyEmpty(rt.proxy)) { rt.network.disconnect(); + waitFor.abort(); + res.blockHash = blockHash; + if (shouldImport) { + setMergeAnonDrive(); + } + return void cb('ALREADY_REGISTERED', res); } - if (isRegister) { - var proxy = rt.proxy; - proxy.edPublic = res.edPublic; - proxy.edPrivate = res.edPrivate; - proxy.curvePublic = res.curvePublic; - proxy.curvePrivate = res.curvePrivate; + if (!isRegister && !isProxyEmpty(rt.proxy)) { + LocalStore.setBlockHash(blockHash); + waitFor.abort(); + if (shouldImport) { + setMergeAnonDrive(); + } + return void LocalStore.login(userHash, uname, function () { + cb(void 0, res); + }); + } + + if (isRegister && isProxyEmpty(rt.proxy)) { + proxy.edPublic = opt.edPublic; + proxy.edPrivate = opt.edPrivate; + proxy.curvePublic = opt.curvePublic; + proxy.curvePrivate = opt.curvePrivate; proxy.login_name = uname; proxy[Constants.displayNameKey] = uname; - sessionStorage.createReadme = 1; - if (!shouldImport) { proxy.version = 6; } + setCreateReadme(); + if (shouldImport) { + setMergeAnonDrive(); + } else { + proxy.version = 6; + } + Feedback.send('REGISTRATION', true); } else { Feedback.send('LOGIN', true); } - if (shouldImport) { - sessionStorage.migrateAnonDrive = 1; + setTimeout(waitFor(function () { + Realtime.whenRealtimeSyncs(rt.realtime, waitFor()); + })); + })); + }).nThen(function (waitFor) { + require(['/common/pinpad.js'], waitFor(function (_Pinpad) { + console.log("loaded rpc module"); + Pinpad = _Pinpad; + })); + }).nThen(function (waitFor) { + // send an RPC to store the block which you created. + console.log("initializing rpc interface"); + + Pinpad.create(RT.network, RT.proxy, waitFor(function (e, _rpc) { + if (e) { + waitFor.abort(); + console.error(e); // INVALID_KEYS + return void cb('RPC_CREATION_ERROR'); } + rpc = _rpc; + console.log("rpc initialized"); + })); + }).nThen(function (waitFor) { + console.log("creating request to publish a login block"); - // We have to call whenRealtimeSyncs asynchronously here because in the current - // version of listmap, onLocal calls `chainpad.contentUpdate(newValue)` - // asynchronously. - // The following setTimeout is here to make sure whenRealtimeSyncs is called after - // `contentUpdate` so that we have an update userDoc in chainpad. - setTimeout(function () { - Realtime.whenRealtimeSyncs(rt.realtime, function () { - LocalStore.login(res.userHash, res.userName, function () { - setTimeout(function () { cb(void 0, res); }); - }); - }); + // Finally, create the login block for the object you just created. + var toPublish = {}; + + 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) { + if (e) { return void console.error(e); } + + console.log("blockInfo available at:", blockHash); + LocalStore.setBlockHash(blockHash); + LocalStore.login(userHash, uname, function () { + cb(void 0, res); }); - }); + })); }); }; Exports.redirect = function () { @@ -255,7 +481,6 @@ define([ }); break; case 'ALREADY_REGISTERED': - // logMeIn should reset registering = false UI.removeLoadingScreen(function () { UI.confirm(Messages.register_alreadyRegistered, function (yes) { if (!yes) { @@ -268,6 +493,12 @@ define([ proxy[Constants.displayNameKey] = uname; } LocalStore.eraseTempSessionValues(); + + + if (result.blockHash) { + LocalStore.setBlockHash(result.blockHash); + } + LocalStore.login(result.userHash, result.userName, function () { setTimeout(function () { proceed(result); }); }); diff --git a/customize.dist/pages.js b/customize.dist/pages.js index 10ccb0824..152970f58 100644 --- a/customize.dist/pages.js +++ b/customize.dist/pages.js @@ -95,7 +95,7 @@ define([ ]) ]) ]), - h('div.cp-version-footer', "CryptPad v2.4.0 (Echidna)") + h('div.cp-version-footer', "CryptPad v2.5.0 (Fossa)") ]); }; diff --git a/customize.dist/translations/messages.de.js b/customize.dist/translations/messages.de.js index e6334812e..72ae6cb35 100644 --- a/customize.dist/translations/messages.de.js +++ b/customize.dist/translations/messages.de.js @@ -39,15 +39,13 @@ define(function () { out.padNotPinned = 'Dieses Dokument wird nach 3 Monaten ohne Zugang auslaufen, {0}logge Dich ein{1} or {2}registriere Dich{3}, um das Auslaufen zu verhindern.'; out.anonymousStoreDisabled = "Der Webmaster dieses CryptPad Server hat die anonyme Verwendung deaktiviert. Du muss Dich einloggen, um CryptDrive zu verwenden."; out.expiredError = 'Dieses Dokument ist abgelaufen und ist nicht mehr verfügbar.'; - out.deletedError = 'Dieses Dokument wurde von seinem Besitzer gelöscht und ist nicht mehr verfügbar.'; - out.inactiveError = 'Dieses Dokument ist wegen Inaktivität gelöscht worden. Drücke auf die Esc-Taste, um ein neues Dokument zu erstellen.'; - out.chainpadError = 'Ein kritischer Fehler ist beim Aktualisieren Deines Dokuments aufgetreten. Dieses Dokument ist schreibgeschützt, damit Du sicherstellen kannst, dass kein Inhalt verloren geht.
'+ - 'Drücke auf Esc, um das Dokument schreibgeschützt zu lesen, oder lade es neu, um das Editierien wieder aufzunehmen.'; - out.errorCopy = ' Du kannst noch den Inhalt woanders hin kopieren, nachdem Du Esc gedrückt hast.
Wenn Du die Seite verlässt, verschwindet der Inhalt für immer!'; - out.errorRedirectToHome = 'Drückee Esc um zu Deinem CryptDrive zurückzukehren.'; - out.newVersionError = "Eine neue Version von CryptPad ist verfügbar.
" + - "Lade die Seite neu um die neue version zu benutzen, oder drücke Esc um im Offline-Modus weiterzuarbeiten."; - + out.deletedError = 'Dieses Dokument wurde von seinem Besitzer gelöscht und nicht mehr verfügbar.'; + out.inactiveError = 'Dieses Dokument ist wegen Inaktivität gelöscht worden. Drucke auf die Esc-Taste, um ein neues Dokument zu gestalten.'; + out.chainpadError = 'Ein kritischer Fehler hat stattgefunden, bei den Updates deines Dokuments. Dieses Dokument ist schreibgeschützt, damit du sicher machen kannst, dass keine Inhalt verloren geht.
'+ + 'Druck auf Esc, um das Dokument schreibgeschützt zu lesen, oder lade es neu, um das Editierien wiederanzufangen.'; + out.errorCopy = ' Du kannst noch den Inhalt woanders kopieren, nachdem du Esc drucken.
Wenn du die Seite verlässt, verschwindet der Inhalt für immer!'; + out.errorRedirectToHome = 'Drucke Esc, um zu deinem CryptDrive zu gehen.'; + out.loading = "Laden..."; out.error = "Fehler"; out.saved = "Gespeichert"; diff --git a/customize.dist/translations/messages.fr.js b/customize.dist/translations/messages.fr.js index dc1a76040..851d3648b 100644 --- a/customize.dist/translations/messages.fr.js +++ b/customize.dist/translations/messages.fr.js @@ -49,7 +49,7 @@ define(function () { out.deleted = "Pad supprimé de votre CryptDrive"; out.deletedFromServer = "Pad supprimé du serveur"; - out.realtime_unrecoverableError = "Le moteur temps-réel a rencontré une erreur critique. Cliquez sur OK pour recharger la page."; + out.realtime_unrecoverableError = "Une erreur critique est survenue. Cliquez sur OK pour recharger la page."; out.disconnected = 'Déconnecté'; out.synchronizing = 'Synchronisation'; @@ -235,7 +235,6 @@ define(function () { out.history_next = "Version plus récente"; out.history_prev = "Version plus ancienne"; out.history_loadMore = "Charger davantage d'historique"; - out.history_close = "Retour"; out.history_closeTitle = "Fermer l'historique"; out.history_restoreTitle = "Restaurer la version du document sélectionnée"; out.history_restorePrompt = "Êtes-vous sûr de vouloir remplacer la version actuelle du document par la version affichée ?"; @@ -597,6 +596,17 @@ define(function () { out.settings_templateSkip = "Passer la fenêtre de choix d'un modèle"; out.settings_templateSkipHint = "Quand vous créez un nouveau pad, et si vous possédez des modèles pour ce type de pad, une fenêtre peut apparaître pour demander si vous souhaitez importer un modèle. Ici vous pouvez choisir de ne jamais montrer cette fenêtre et donc de ne jamais utiliser de modèle."; + out.settings_changePasswordTitle = "Changer de mot de passe"; + out.settings_changePasswordHint = "Pour modifier le mot de passe de votre compte utilisateur, entrez votre mot de passe actuel et confirmez le nouveau mot de passe en la tapant deux fois.
" + + "Nous ne pouvons pas réinitialiser votre mot de passe si vous le perdez, donc soyez très prudent !"; + out.settings_changePasswordButton = "Changer le mot de passe"; + out.settings_changePasswordCurrent = "Mot de passe actuel"; + out.settings_changePasswordNew = "Nouveau mot de passe"; + out.settings_changePasswordNewConfirm = "Confirmer le nouveau mot de passe"; + out.settings_changePasswordConfirm = "Êtes-vous sûr de vouloir changer votre mot de passe ? Vous devrez vous reconnecter sur tous vos appareils."; + out.settings_changePasswordError = "Une erreur est survenue. Si vous n'êtes plus en mesure de vous connecter à votre compte utilisateur ou de changer votre mot de passe, veuillez contacter l'administrateur de votre CryptPad."; + out.settings_changePasswordPending = "Votre mot de passe est en train d'être modifié. Veuillez ne pas fermer ou recharger cette page avant que le traitement soit terminé."; + out.upload_title = "Hébergement de fichiers"; out.upload_modal_title = "Options d'importation du fichier"; out.upload_modal_filename = "Nom (extension {0} ajoutée automatiquement)"; @@ -707,7 +717,7 @@ define(function () { out.whatis_drive_p2 = "Avec le glisser-déposer intuitif, vous pouvez déplacer vos pads dans votre drive tout en conservant les liens vers ces pads pour que vos collaborateurs n'en perdent pas l'accès"; out.whatis_drive_p3 = "Vous pouvez également importer des fichiers dans votre CryptDrive et les partager avec des collègues. Les fichiers importés peuvent être rangés de la même manière que vos pads collaboratifs."; out.whatis_business = 'CryptPad for Business'; - out.whatis_business_p1 = "Le chiffrement Zero Knowledge de CryptPad excelle pour multiplier l'efficacité des protocoles de sécurité existants en recréant les contrôles d'accès organisationnels de manière cryptographique. Puisque les données sensibles ne peuvent être déchiffrées qu'en utilisant les identifiants d'un employé, CryptPad empêche d'éventuels hackers ayant réussi à s'introduire dans le serveur d'avoir accès en clair à ces données. Découvrez-en plus sur la manière dont CryptPad peut aider votre entreprise en lisant le CryptPad Whitepaper."; + out.whatis_business_p1 = "Le chiffrement Zero Knowledge de CryptPad excelle pour accroître l'efficacité des protocoles de sécurité existants en les recréant de manière cryptographique. Puisque les données sensibles ne peuvent être déchiffrées qu'en utilisant les identifiants d'un utilisateur, CryptPad empêche d'éventuels hackers ayant réussi à s'introduire dans le serveur d'avoir accès en clair à ces données. Découvrez-en plus sur la manière dont CryptPad peut aider votre entreprise en lisant le CryptPad Whitepaper."; out.whatis_business_p2 = "CryptPad est déployable sur site et les développeurs CryptPad chez XWiki SAS peuvent effectuer du développement, des personnalisations et du support commercial. Contactez-nous à sales@cryptpad.fr pour plus d'informations."; // privacy.html @@ -802,6 +812,10 @@ define(function () { "Les pads existant dans votre CryptDrive peuvent être transformés en tant que modèle en les déplaçant dans la catégorie Modèles du CryptDrive.
" + "Il est également possible de créer une copie d'un pad en tant que modèle en cliquant sur le bouton (Sauver en tant que modèle) dans la barre d'outils des éditeurs." }, + abandoned: { + q: "Qu'est-ce qu'un pad abandonné?", + a: "Un pad abandonné est un pad qui n'est stocké dans le CryptDrive d'aucun utilisateur enregistré et qui n'a pas été modifié depuis 6 mois. Les documents abandonnées sont automatiquement supprimés du serveur." + }, }; out.faq.privacy = { title: 'Confidentialité', @@ -824,7 +838,7 @@ define(function () { "Les formulaires d'inscription et de connexion génèrent à la place un ensemble de clés uniques, créées à partir de vos identifiants, et le serveur ne connaît donc que votre signature cryptographique.
" + "Nous utilisons cette information principalement pour mesurer combien de données vous avez stocké sur nos serveurs, afin de pouvoir limiter chaque utilisateur à son quota.

" + "Nous utilisons également notre fonctionnalité de retour d'expérience pour indiquer au serveur que quelqu'un avec votre adresse IP a créé un compte utilisateur, bien que nous ne sachions pas lequel. Cela nous permet de mesurer le nombre d'inscriptions sur CryptPad mais aussi de voir dans quelles régions du monde se trouvent les utilisateurs, afin de déterminer les langues dans lesquelles traduire CryptPad.

" + - "Enfin, les clés générées à l'inscription permettent d'indiquer au serveur que les pads dans votre CryptDrive ne doivent pas être supprimés, même s'ils sont inactifs. Ce système a l'inconvénient de nous fournir davantage d'informations sur la façon dont vous utilisez CryptPad, mais il est nécessaire pour que nous puissions supprimer du serveur les pads inactifs dont personne n'a besoin." + "Enfin, les utilisateurs enregistrés indiquent au serveur quels pads sont dans leur CryptDrive, afin que ces pads ne soient pas considérés comme abandonnés et ne soient donc pas supprimés pour inactivité." }, other: { q: "Que peuvent apprendre les autres collaborateurs à mon sujet ?", @@ -1121,6 +1135,7 @@ define(function () { out.properties_changePassword = "Modifier le mot de passe"; out.properties_confirmNew = "Êtes-vous sûr ? Ajouter un mot de passe changera l'URL de ce pad et supprimera son historique. Les utilisateurs ne connaissant pas le nouveau mot de passe perdront l'accès au pad."; out.properties_confirmChange = "Êtes-vous sûr ? Changer le mot de passe supprimera l'historique de ce pad. Les utilisateurs ne connaissant pas le nouveau mot de passe perdront l'accès au pad."; + out.properties_passwordSame = "Le nouveau mot de passe doit être différent de celui existant."; out.properties_passwordError = "Une erreur est survenue lors de la modification du mot de passe. Veuillez réessayer."; out.properties_passwordWarning = "Le mot de passe a été modifié avec succès mais nous n'avons pas réussi à mettre à jour votre CryptDrive avec les nouvelles informations. Vous devrez peut-être supprimer manuellement l'ancienne version de ce pad.
Appuyez sur OK pour recharger le pad et mettre à jour vos droits d'accès."; out.properties_passwordSuccess = "Le mot de passe a été modifié avec succès.
Appuyez sur OK pour mettre à jour vos droits d'accès."; diff --git a/customize.dist/translations/messages.js b/customize.dist/translations/messages.js index 07fe95c7a..ac3ba63fd 100644 --- a/customize.dist/translations/messages.js +++ b/customize.dist/translations/messages.js @@ -50,7 +50,7 @@ define(function () { out.deleted = "Pad deleted from your CryptDrive"; out.deletedFromServer = "Pad deleted from the server"; - out.realtime_unrecoverableError = "The realtime engine has encountered an unrecoverable error. Click OK to reload."; + out.realtime_unrecoverableError = "An unrecoverable error has occured. Click OK to reload."; out.disconnected = 'Disconnected'; out.synchronizing = 'Synchronizing'; @@ -600,14 +600,21 @@ define(function () { out.settings_templateSkip = "Skip the template selection modal"; out.settings_templateSkipHint = "When you create a new empty pad, if you have stored templates for this type of pad, a modal appears to ask if you want to use a template. Here you can choose to never show this modal and so to never use a template."; - out.settings_changePasswordTitle = "Change your password"; // XXX - out.settings_changePasswordHint = "Change your account's password without losing its data. You have to enter your existing password once, and the new password you want twice.
" + - "We can't reset your password if you forget it so be very careful!"; // XXX - out.settings_changePasswordButton = "Change password"; // XXX - out.settings_changePasswordCurrent = "Existing password"; // XXX - out.settings_changePasswordNew = "New password"; // XXX - out.settings_changePasswordNewConfirm = "Confirm new password"; // XXX - out.settings_changePasswordConfirm = "Are you sure?"; // XXX + out.settings_ownDriveTitle = "Drive migration"; // XXX + out.settings_ownDriveHint = "Migrating your drive to the new version will give you access to new features..."; // XXX + out.settings_ownDriveButton = "Migrate"; // XXX + out.settings_ownDriveConfirm = "Are you sure?"; // XXX + + out.settings_changePasswordTitle = "Change your password"; + out.settings_changePasswordHint = "Change your account's password. Enter your current password, and confirm the new password by typing it twice.
" + + "We can't reset your password if you forget it, so be very careful!"; + out.settings_changePasswordButton = "Change password"; + out.settings_changePasswordCurrent = "Current password"; + out.settings_changePasswordNew = "New password"; + out.settings_changePasswordNewConfirm = "Confirm new password"; + out.settings_changePasswordConfirm = "Are you sure you want to change your password? You will need to log back in on all your devices."; + out.settings_changePasswordError = "An unexpected error occurred. If you are unable to login or change your password, contact your CryptPad administrators."; + out.settings_changePasswordPending = "Your password is being updated. Please do not close or reload this page until the process has completed."; out.upload_title = "File upload"; out.upload_modal_title = "File upload options"; @@ -720,7 +727,7 @@ define(function () { out.whatis_drive_p2 = 'With intuitive drag-and-drop, you can move pads around in your drive and the link to these pads will stay the same so your collaborators will never lose access.'; out.whatis_drive_p3 = 'You can also upload files in your CryptDrive and share them with colleagues. Uploaded files can be organized just like collaborative pads.'; out.whatis_business = 'CryptPad for Business'; - out.whatis_business_p1 = 'CryptPad\'s Zero Knowledge encryption is excellent for multiplying the effectiveness of existing security protocols by mirroring organizational access controls in cryptography. Because sensitive assets can only be decrypted using employee access credentials, CryptPad removes the hacker jackpot which exists in traditional IT servers. Read the CryptPad Whitepaper to learn more about how it can help your business.'; + out.whatis_business_p1 = "CryptPad\'s Zero Knowledge encryption multiplies the effectiveness of existing security protocols by mirroring organizational access controls in cryptography. Because sensitive assets can only be decrypted using user access credentials, CryptPad is less valuable as a target when compared to traditional cloud services. Read the CryptPad Whitepaper to learn more about how it can help your business."; out.whatis_business_p2 = 'CryptPad is deployable on premises and the CryptPad developers at XWiki SAS are able to offer commercial support, customization and development. Reach out to sales@cryptpad.fr for more information.'; // privacy.html @@ -817,6 +824,10 @@ define(function () { " Any existing pad can be turned into a template by moving it into the Templates section in your CryptDrive." + " You can also create a copy of a pad to be used as a template by clicking the template button () in the editor's toolbar." }, + abandoned: { + q: "What is an abandoned pad?", + a: "An abandoned pad is a pad that is not pinned in any registered user's CryptDrive and that hasn't been changed for six months. Abandoned documents will be automatically removed from the server." + }, }; out.faq.privacy = { title: 'Privacy', @@ -847,8 +858,7 @@ define(function () { "We use our feedback functionality to inform the server that someone with your IP has registered an account." + " We use this to measure how many people register for CryptPad accounts, and to see what regions they are in so that we can guess which languages may need better support.

" + - "When you register, you generate a public key which is used to tell the server that the pads in your CryptDrive should not be deleted even if they are not actively being used." + - " This information does reveal more about how you are using CryptPad, but the system allows us to remove pads from the server once nobody cares enough to keep them." + "Registered users inform the server which pads are in their CryptDrive so that such pads are not considered abandoned, and are removed from the server due to inactivity." }, other: { q: "What can other collaborators learn about me?", @@ -1174,6 +1184,7 @@ define(function () { out.properties_changePassword = "Change the password"; out.properties_confirmNew = "Are you sure? Adding a password will change this pad's URL and remove its history. Users without the password will lose access to this pad"; out.properties_confirmChange = "Are you sure? Changing the password will remove its history. Users without the new password will lose access to this pad"; + out.properties_passwordSame = "New passwords must differ from the current one."; out.properties_passwordError = "An error occured while trying to change the password. Please try again."; out.properties_passwordWarning = "The password was successfully changed but we were unable to update your CryptDrive with the new data. You may have to remove the old version of the pad manually.
Press OK to reload and update your acces rights."; out.properties_passwordSuccess = "The password was successfully changed.
Press OK to reload and update your access rights."; diff --git a/docs/example.nginx.conf b/docs/example.nginx.conf index afbd86846..e8454dc31 100644 --- a/docs/example.nginx.conf +++ b/docs/example.nginx.conf @@ -85,6 +85,11 @@ server { try_files $uri =404; } + location ^~ /block/ { + add_header Cache-Control max-age=0; + try_files $uri =404; + } + location ^~ /datastore/ { add_header Cache-Control max-age=0; try_files $uri =404; diff --git a/package.json b/package.json index e4bfa80fd..aa61b9f49 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "cryptpad", "description": "realtime collaborative visual editor with zero knowlege server", - "version": "2.4.0", + "version": "2.5.0", "license": "AGPL-3.0+", "repository": { "type": "git", diff --git a/rpc.js b/rpc.js index 69d84b6dd..54739b78e 100644 --- a/rpc.js +++ b/rpc.js @@ -25,7 +25,7 @@ var SUPPRESS_RPC_ERRORS = false; var WARN = function (e, output) { if (!SUPPRESS_RPC_ERRORS && e && output) { - console.error(new Date().toISOString() + ' [' + e + ']', output); + console.error(new Date().toISOString() + ' [' + String(e) + ']', output); console.error(new Error(e).stack); console.error(); } @@ -1297,6 +1297,159 @@ var upload_status = function (Env, publicKey, filesize, cb) { }); }; +/* + We assume that the server is secured against MitM attacks + via HTTPS, and that malicious actors do not have code execution + capabilities. If they do, we have much more serious problems. + + The capability to replay a block write or remove results in either + a denial of service for the user whose block was removed, or in the + case of a write, a rollback to an earlier password. + + Since block modification is destructive, this can result in loss + of access to the user's drive. + + So long as the detached signature is never observed by a malicious + party, and the server discards it after proof of knowledge, replays + are not possible. However, this precludes verification of the signature + at a later time. + + Despite this, an integrity check is still possible by the original + author of the block, since we assume that the block will have been + encrypted with xsalsa20-poly1305 which is authenticated. +*/ +var validateLoginBlock = function (Env, publicKey, signature, block, cb) { + // convert the public key to a Uint8Array and validate it + if (typeof(publicKey) !== 'string') { return void cb('E_INVALID_KEY'); } + + var u8_public_key; + try { + u8_public_key = Nacl.util.decodeBase64(publicKey); + } catch (e) { + return void cb('E_INVALID_KEY'); + } + + var u8_signature; + try { + u8_signature = Nacl.util.decodeBase64(signature); + } catch (e) { + console.error(e); + return void cb('E_INVALID_SIGNATURE'); + } + + // convert the block to a Uint8Array + var u8_block; + try { + u8_block = Nacl.util.decodeBase64(block); + } catch (e) { + return void cb('E_INVALID_BLOCK'); + } + + // take its hash + var hash = Nacl.hash(u8_block); + + // validate the signature against the hash of the content + var verified = Nacl.sign.detached.verify(hash, u8_signature, u8_public_key); + + // existing authentication ensures that users cannot replay old blocks + + // call back with (err) if unsuccessful + if (!verified) { return void cb("E_COULD_NOT_VERIFY"); } + + return void cb(null, u8_block); +}; + +var createLoginBlockPath = function (Env, publicKey) { + // prepare publicKey to be used as a file name + var safeKey = escapeKeyCharacters(publicKey); + + // validate safeKey + if (typeof(safeKey) !== 'string') { + return; + } + + // derive the full path + // /home/cryptpad/cryptpad/block/fg/fg32kefksjdgjkewrjksdfksjdfsdfskdjfsfd + return Path.join(Env.paths.block, safeKey.slice(0, 2), safeKey); +}; + +var writeLoginBlock = function (Env, msg, cb) { + //console.log(msg); + var publicKey = msg[0]; + var signature = msg[1]; + var block = msg[2]; + + validateLoginBlock(Env, publicKey, signature, block, function (e, validatedBlock) { + if (e) { return void cb(e); } + if (!(validatedBlock instanceof Uint8Array)) { return void cb('E_INVALID_BLOCK'); } + + // derive the filepath + var path = createLoginBlockPath(Env, publicKey); + + // make sure the path is valid + if (typeof(path) !== 'string') { + return void cb('E_INVALID_BLOCK_PATH'); + } + + var parsed = Path.parse(path); + if (!parsed || typeof(parsed.dir) !== 'string') { + return void cb("E_INVALID_BLOCK_PATH_2"); + } + + nThen(function (w) { + // make sure the path to the file exists + Mkdirp(parsed.dir, w(function (e) { + if (e) { + w.abort(); + cb(e); + } + })); + }).nThen(function () { + // actually write the block + + // flow is dumb and I need to guard against this which will never happen + /*:: if (typeof(validatedBlock) === 'undefined') { throw new Error('should never happen'); } */ + /*:: if (typeof(path) === 'undefined') { throw new Error('should never happen'); } */ + Fs.writeFile(path, new Buffer(validatedBlock), { encoding: "binary", }, function (err) { + if (err) { return void cb(err); } + cb(); + }); + }); + }); +}; + +/* + When users write a block, they upload the block, and provide + a signature proving that they deserve to be able to write to + the location determined by the public key. + + When removing a block, there is nothing to upload, but we need + to sign something. Since the signature is considered sensitive + information, we can just sign some constant and use that as proof. + +*/ +var removeLoginBlock = function (Env, msg, cb) { + var publicKey = msg[0]; + var signature = msg[1]; + var block = Nacl.util.decodeUTF8('DELETE_BLOCK'); // clients and the server will have to agree on this constant + + validateLoginBlock(Env, publicKey, signature, block, function (e /*::, validatedBlock */) { + if (e) { return void cb(e); } + // derive the filepath + var path = createLoginBlockPath(Env, publicKey); + + // make sure the path is valid + if (typeof(path) !== 'string') { + return void cb('E_INVALID_BLOCK_PATH'); + } + + Fs.unlink(path, function (err) { + if (err) { return void cb(err); } + cb(); + }); + }); +}; + var isNewChannel = function (Env, channel, cb) { if (!isValidId(channel)) { return void cb('INVALID_CHAN'); } if (channel.length !== 32) { return void cb('INVALID_CHAN'); } @@ -1353,6 +1506,8 @@ var isAuthenticatedCall = function (call) { 'CLEAR_OWNED_CHANNEL', 'REMOVE_OWNED_CHANNEL', 'REMOVE_PINS', + 'WRITE_LOGIN_BLOCK', + 'REMOVE_LOGIN_BLOCK', ].indexOf(call) !== -1; }; @@ -1423,6 +1578,7 @@ RPC.create = function ( var pinPath = paths.pin = keyOrDefaultString('pinPath', './pins'); var blobPath = paths.blob = keyOrDefaultString('blobPath', './blob'); var blobStagingPath = paths.staging = keyOrDefaultString('blobStagingPath', './blobstage'); + paths.block = keyOrDefaultString('blockPath', './block'); var isUnauthenticateMessage = function (msg) { return msg && msg.length === 2 && isUnauthenticatedCall(msg[0]); @@ -1559,7 +1715,7 @@ RPC.create = function ( var session = Sessions[safeKey]; var token = session? session.tokens.slice(-1)[0]: ''; var cookie = makeCookie(token).join('|'); - respond(e, [cookie].concat(typeof(msg) !== 'undefined' ?msg: [])); + respond(e ? String(e): e, [cookie].concat(typeof(msg) !== 'undefined' ?msg: [])); }; if (typeof(msg) !== 'object' || !msg.length) { @@ -1692,6 +1848,22 @@ RPC.create = function ( WARN(e, 'UPLOAD_CANCEL'); Respond(e); }); + case 'WRITE_LOGIN_BLOCK': + return void writeLoginBlock(Env, msg[1], function (e) { + if (e) { + WARN(e, 'WRITE_LOGIN_BLOCK'); + return void Respond(e); + } + Respond(e); + }); + case 'REMOVE_LOGIN_BLOCK': + return void removeLoginBlock(Env, msg[1], function (e) { + if (e) { + WARN(e, 'REMOVE_LOGIN_BLOCK'); + return void Respond(e); + } + Respond(e); + }); default: return void Respond('UNSUPPORTED_RPC_CALL', msg); } diff --git a/server.js b/server.js index aac8d7513..17e793f29 100644 --- a/server.js +++ b/server.js @@ -126,6 +126,9 @@ app.use("/blob", Express.static(Path.join(__dirname, (config.blobPath || './blob app.use("/datastore", Express.static(Path.join(__dirname, (config.filePath || './datastore')), { maxAge: "0d" })); +app.use("/block", Express.static(Path.join(__dirname, (config.blockPath || '/block')), { + maxAge: "0d", +})); app.use("/customize", Express.static(__dirname + '/customize')); app.use("/customize", Express.static(__dirname + '/customize.dist')); @@ -162,7 +165,7 @@ app.get('/api/config', function(req, res){ res.send('define(function(){\n' + [ 'var obj = ' + JSON.stringify({ requireConf: { - waitSeconds: 60, + waitSeconds: 600, urlArgs: 'ver=' + Package.version + (FRESH_KEY? '-' + FRESH_KEY: '') + (DEV_MODE? '-' + (+new Date()): ''), }, removeDonateButton: (config.removeDonateButton === true), diff --git a/www/assert/main.js b/www/assert/main.js index 4ff862511..e0b437f58 100644 --- a/www/assert/main.js +++ b/www/assert/main.js @@ -10,9 +10,13 @@ define([ '/common/wire.js', '/common/flat-dom.js', '/common/media-tag.js', -], function ($, Hyperjson, Sortify, Drive, Test, Hash, Util, Thumb, Wire, Flat, MediaTag) { + '/common/outer/login-block.js', + + '/bower_components/tweetnacl/nacl-fast.min.js', +], function ($, Hyperjson, Sortify, Drive, Test, Hash, Util, Thumb, Wire, Flat, MediaTag, Block) { window.Hyperjson = Hyperjson; window.Sortify = Sortify; + var Nacl = window.nacl; var assertions = 0; var failed = false; @@ -296,6 +300,15 @@ define([ !secret.hashData.present); }, "test support for ugly tracking query paramaters in url"); + assert(function (cb) { + var keys = Block.genkeys(Nacl.randomBytes(64)); + var hash = Block.getBlockHash(keys); + var parsed = Block.parseBlockHash(hash); + + cb(parsed && + parsed.keys.symmetric.length === keys.symmetric.length); + }, 'parse a block hash'); + assert(function (cb) { try { MediaTag(void 0).on('progress').on('decryption'); diff --git a/www/common/LessLoader.js b/www/common/LessLoader.js index 04fa68714..8846000f0 100644 --- a/www/common/LessLoader.js +++ b/www/common/LessLoader.js @@ -134,10 +134,15 @@ define([ }; module.exports.load = function (url /*:string*/, cb /*:()=>void*/) { + var btime = +new Date(); + var done = function () { + console.log("Compiling [" + url + "] took " + (+new Date() - btime) + "ms"); + cb(); + }; cacheGet(url, function (css) { if (css) { inject(css, url); - return void cb(); + return void done(); } console.log('CACHE MISS ' + url); ((/\.less([\?\#].*)?$/.test(url)) ? loadLess : loadCSS)(url, function (err, css) { @@ -145,7 +150,7 @@ define([ var output = fixAllURLs(css, url); cachePut(url, output); inject(output, url); - cb(); + done(); }); }); }; diff --git a/www/common/common-constants.js b/www/common/common-constants.js index 494267ed3..908134bec 100644 --- a/www/common/common-constants.js +++ b/www/common/common-constants.js @@ -3,6 +3,7 @@ define(function () { // localStorage userHashKey: 'User_hash', userNameKey: 'User_name', + blockHashKey: 'Block_hash', fileHashKey: 'FS_hash', // sessionStorage newPadPathKey: "newPadPath", @@ -11,6 +12,7 @@ define(function () { oldStorageKey: 'CryptPad_RECENTPADS', storageKey: 'filesData', tokenKey: 'loginToken', - displayPadCreationScreen: 'displayPadCreationScreen' + displayPadCreationScreen: 'displayPadCreationScreen', + deprecatedKey: 'deprecated' }; }); diff --git a/www/common/common-ui-elements.js b/www/common/common-ui-elements.js index d54540efa..16b45c4f8 100644 --- a/www/common/common-ui-elements.js +++ b/www/common/common-ui-elements.js @@ -147,7 +147,7 @@ define([ id: 'cp-app-prop-expire', })); - var hasPassword = typeof data.password !== "undefined"; + var hasPassword = data.password; if (hasPassword) { $('