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/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/customize.dist/login.js b/customize.dist/login.js index 13fb6f40c..d34395e52 100644 --- a/customize.dist/login.js +++ b/customize.dist/login.js @@ -12,17 +12,22 @@ define([ '/common/common-feedback.js', '/common/outer/local-store.js', '/customize/messages.js', + '/bower_components/nthen/index.js', + '/common/outer/login-block.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) { 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 +46,12 @@ define([ // 32 more for a signing key var edSeed = opt.edSeed = dispense(32); + // 64 more bytes to seed an additional signing key + opt.blockSeed = new Uint8Array(dispense(64)); + + var blockKeys = opt.blockKeys = Block.genkeys(opt.blockSeed); + opt.blockHash = Block.getBlockHash(blockKeys); + // derive a private key from the ed seed var signingKeypair = Nacl.sign.keyPair.fromSeed(new Uint8Array(edSeed)); @@ -105,18 +116,32 @@ define([ return void cb('PASS_TOO_SHORT'); } - Cred.deriveFromPassphrase(uname, passwd, 128, function (bytes) { - // results... - var res = { - register: isRegister, - }; + // results... + var res = { + register: isRegister, + }; - // run scrypt to derive the user's keys - var opt = res.opt = allocateBytes(bytes); + var RT; + nThen(function (waitFor) { + Cred.deriveFromPassphrase(uname, passwd, Exports.requiredBytes, waitFor(function (bytes) { + // run scrypt to derive the user's keys + res.opt = allocateBytes(bytes); + })); + + + // TODO consider checking the block here + }).nThen(function (/* waitFor */) { + // check for blocks + Block = Block; // jshint + + + }).nThen(function (waitFor) { + var opt = res.opt; // use the derived key to generate an object - loadUserObject(opt, function (err, rt) { + loadUserObject(opt, waitFor(function (err, rt) { if (err) { return void cb(err); } + RT = rt; res.proxy = rt.proxy; res.realtime = rt.realtime; @@ -136,12 +161,14 @@ define([ // they tried to just log in but there's no such user 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(); return void cb('ALREADY_REGISTERED', res); } @@ -163,17 +190,17 @@ define([ if (shouldImport) { sessionStorage.migrateAnonDrive = 1; } - - // 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); }); - }); + })); + }).nThen(function () { + // 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); }); }); }); }); diff --git a/customize.dist/translations/messages.js b/customize.dist/translations/messages.js index 07fe95c7a..895df74f4 100644 --- a/customize.dist/translations/messages.js +++ b/customize.dist/translations/messages.js @@ -600,6 +600,11 @@ 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_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"; // 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 @@ -608,6 +613,7 @@ define(function () { out.settings_changePasswordNew = "New password"; // XXX out.settings_changePasswordNewConfirm = "Confirm new password"; // XXX out.settings_changePasswordConfirm = "Are you sure?"; // XXX + out.settings_changePasswordError = "Error {0}"; // XXX out.upload_title = "File upload"; out.upload_modal_title = "File upload options"; diff --git a/rpc.js b/rpc.js index 69d84b6dd..5f6288805 100644 --- a/rpc.js +++ b/rpc.js @@ -1297,6 +1297,160 @@ 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); + + // signature 64 bytes + // sign.detached(hash(decodeBase64_content(base64_content)), decodeBase64(publicKey)) + + // 1 byte version + // base64_content +}; + +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, verified_block) { + 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'); + } + + 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 + Fs.writeFile(path, new Buffer(verified_block), { 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) { + 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 +1507,8 @@ var isAuthenticatedCall = function (call) { 'CLEAR_OWNED_CHANNEL', 'REMOVE_OWNED_CHANNEL', 'REMOVE_PINS', + 'WRITE_LOGIN_BLOCK', + 'REMOVE_LOGIN_BLOCK', ].indexOf(call) !== -1; }; @@ -1423,6 +1579,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]); @@ -1692,6 +1849,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..02cb54029 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')); 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/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-util.js b/www/common/common-util.js index a67ba6673..072bfb851 100644 --- a/www/common/common-util.js +++ b/www/common/common-util.js @@ -83,6 +83,21 @@ define([], function () { }).join(''); }; + // given an array of Uint8Arrays, return a new Array with all their values + Util.uint8ArrayJoin = function (AA) { + var l = 0; + var i = 0; + for (; i < AA.length; i++) { l += AA[i].length; } + var C = new Uint8Array(l); + + i = 0; + for (var offset = 0; i < AA.length; i++) { + C.set(AA[i], offset); + offset += AA[i].length; + } + return C; + }; + Util.deduplicateString = function (array) { var a = array.slice(); for(var i=0; i= oneMegabyte) { return 'MB'; } }; + // given a path, asynchronously return an arraybuffer Util.fetch = function (src, cb) { - var done = false; - var CB = function (err, res) { - if (done) { return; } - done = true; - cb(err, res); - }; + var CB = Util.once(cb); var xhr = new XMLHttpRequest(); xhr.open("GET", src, true); xhr.responseType = "arraybuffer"; + xhr.onerror = function (err) { CB(err); }; xhr.onload = function () { if (/^4/.test(''+this.status)) { return CB('XHR_ERROR'); diff --git a/www/common/cryptpad-common.js b/www/common/cryptpad-common.js index 589ec0292..611207a22 100644 --- a/www/common/cryptpad-common.js +++ b/www/common/cryptpad-common.js @@ -8,11 +8,12 @@ define([ '/common/common-feedback.js', '/common/outer/local-store.js', '/common/outer/worker-channel.js', + '/common/outer/login-block.js', '/customize/application_config.js', '/bower_components/nthen/index.js', ], function (Config, Messages, Util, Hash, - Messaging, Constants, Feedback, LocalStore, Channel, + Messaging, Constants, Feedback, LocalStore, Channel, Block, AppConfig, Nthen) { /* This file exposes functionality which is specific to Cryptpad, but not to @@ -240,6 +241,12 @@ define([ }); }; + common.removeLoginBlock = function (data, cb) { + postMessage('REMOVE_LOGIN_BLOCK', data, function (obj) { + cb(obj); + }); + }; + // ANON RPC // SFRAME: talk to anon_rpc from the iframe @@ -692,6 +699,157 @@ define([ }); }; + common.changeUserPassword = function (Crypt, edPublic, data, cb) { + if (!edPublic) { + return void cb({ + error: 'E_NOT_LOGGED_IN' + }); + } + var accountName = LocalStore.getAccountName(); + var hash = LocalStore.getUserHash(); // To load your old drive + var password = data.password; // To remove your old block + var newPassword = data.newPassword; // To create your new block + var secret = Hash.getSecrets('drive', hash); + var newHash, newHref, newSecret, newBlockSeed; + var oldIsOwned = false; + + var blockHash = LocalStore.getBlockHash(); + var oldBlockKeys; + + var Cred, Block, Login; + Nthen(function (waitFor) { + require([ + '/customize/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); + oldBlockKeys = allocated.blockKeys; + if (blockHash) { + if (blockHash !== allocated.blockHash) { + // incorrect password probably + waitFor.abort(); + return void cb({ + error: 'INVALID_PASSWORD', + }); + } + // the user has already created a block, so you should compare against that + } else { + // otherwise they're a legacy user, and we should check against the User_hash + if (hash !== allocated.userHash) { + waitFor.abort(); + return void cb({ + error: 'INVALID_PASSWORD', + }); + } + } + })); + }).nThen(function (waitFor) { + // Check if our drive is already owned + common.anonRpcMsg('GET_METADATA', secret.channel, waitFor(function (err, obj) { + if (err || obj.error) { return; } + if (obj.owners && Array.isArray(obj.owners) && + obj.owners.indexOf(edPublic) !== -1) { + oldIsOwned = true; + } + })); + }).nThen(function (waitFor) { + // Create a new user hash + // Get the current content, store it in the new user file + // and make sure the new user drive is owned + newHash = Hash.createRandomHash('drive'); + newHref = '/drive/#' + newHash; + newSecret = Hash.getSecrets('drive', newHash); + + var optsPut = { + owners: [edPublic] + }; + + Crypt.get(hash, waitFor(function (err, val) { + if (err) { + waitFor.abort(); + return void cb({ error: err }); + } + Crypt.put(newHash, val, waitFor(function (err) { + if (err) { + waitFor.abort(); + return void cb({ error: err }); + } + }), optsPut); + })); + }).nThen(function (waitFor) { + // Drive content copied: get the new block location + Cred.deriveFromPassphrase(accountName, newPassword, Login.requiredBytes, waitFor(function (bytes) { + var allocated = Login.allocateBytes(bytes); + newBlockSeed = allocated.blockSeed; + })); + }).nThen(function (waitFor) { + // Write the new login block + var keys = Block.genkeys(newBlockSeed); + var content = Block.serialize(JSON.stringify({ + User_name: accountName, + User_hash: newHash + }), keys); + common.writeLoginBlock(content, waitFor(function (obj) { + var newBlockHash = Block.getBlockHash(keys); + LocalStore.setBlockHash(newBlockHash); + if (obj && obj.error) { + waitFor.abort(); + return void cb(obj); + } + })); + }).nThen(function (waitFor) { + // New drive hash is in login block, unpin the old one and pin the new one + common.unpinPads([secret.channel], waitFor()); + common.pinPads([newSecret.channel], waitFor()); + }).nThen(function (waitFor) { + // Remove block hash + if (blockHash) { + var removeData = Block.remove(oldBlockKeys); + common.removeLoginBlock(removeData, waitFor(function (obj) { + if (obj && obj.error) { return void console.error(obj.error); } + })); + } + }).nThen(function (waitFor) { + if (oldIsOwned) { + common.removeOwnedChannel(secret.channel, 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) { + postMessage("SET", { + 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 + window.location.reload(); + }); + }; + // Loading events common.loading = {}; common.loading.onDriveEvent = Util.mkEvent(); @@ -887,6 +1045,34 @@ define([ if (AppConfig.beforeLogin) { AppConfig.beforeLogin(LocalStore.isLoggedIn(), waitFor()); } + + }).nThen(function (waitFor) { + var blockHash = LocalStore.getBlockHash(); + if (blockHash) { + console.log(blockHash); + var parsed = Hash.parseBlockHash(blockHash); + + if (typeof(parsed) !== 'object') { + 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[Constants.userHashKey]) { LocalStore.setUserHash(block_info[Constants.userHashKey]); } + } catch (e) { + console.error(e); + return void console.error("failed to decrypt or decode block content"); + } + })); + } }).nThen(function (waitFor) { var cfg = { init: true, diff --git a/www/common/outer/async-store.js b/www/common/outer/async-store.js index 52c0efb9c..a5d74315f 100644 --- a/www/common/outer/async-store.js +++ b/www/common/outer/async-store.js @@ -13,7 +13,7 @@ define([ '/common/outer/network-config.js', '/customize/application_config.js', - '/bower_components/chainpad-crypto/crypto.js?v=0.1.5', + '/bower_components/chainpad-crypto/crypto.js', '/bower_components/chainpad/chainpad.dist.js', '/bower_components/chainpad-listmap/chainpad-listmap.js', '/bower_components/nthen/index.js', @@ -285,6 +285,15 @@ define([ }); }; + Store.removeLoginBlock = function (clientId, data, cb) { + store.rpc.removeLoginBlock(data, function (e, res) { + cb({ + error: e, + data: res + }); + }); + }; + Store.initRpc = function (clientId, data, cb) { if (store.rpc) { return void cb(account); } require(['/common/pinpad.js'], function (Pinpad) { diff --git a/www/common/outer/local-store.js b/www/common/outer/local-store.js index a40a3e6f5..645acb1b5 100644 --- a/www/common/outer/local-store.js +++ b/www/common/outer/local-store.js @@ -58,6 +58,14 @@ define([ localStorage[Constants.userHashKey] = sHash; }; + LocalStore.getBlockHash = function () { + return localStorage[Constants.blockHashKey]; + }; + + LocalStore.setBlockHash = function (hash) { + localStorage[Constants.blockHashKey] = hash; + }; + LocalStore.getAccountName = function () { return localStorage[Constants.userNameKey]; }; @@ -69,7 +77,7 @@ define([ - + // XXX update this to take into account blockHash values LocalStore.login = function (hash, name, cb) { if (!hash) { throw new Error('expected a user hash'); } if (!name) { throw new Error('expected a user name'); } @@ -96,6 +104,7 @@ define([ [ Constants.userNameKey, Constants.userHashKey, + Constants.blockHashKey, 'loginToken', 'plan', ].forEach(function (k) { diff --git a/www/common/outer/login-block.js b/www/common/outer/login-block.js new file mode 100644 index 000000000..36f387bdd --- /dev/null +++ b/www/common/outer/login-block.js @@ -0,0 +1,156 @@ +define([ + '/common/common-util.js', + '/api/config', + '/bower_components/tweetnacl/nacl-fast.min.js', +], function (Util, ApiConfig) { + var Nacl = window.nacl; + + var Block = {}; + + Block.join = Util.uint8ArrayJoin; + + // publickey + + // signature + + // block + + // [b64_public, b64_sig, b64_block [version, nonce, content]] + + Block.seed = function () { + return Nacl.hash(Nacl.util.decodeUTF8('pewpewpew')); + }; + + // should be deterministic from a seed... + Block.genkeys = function (seed) { + if (!(seed instanceof Uint8Array)) { + throw new Error('INVALID_SEED_FORMAT'); + } + if (!seed || typeof(seed.length) !== 'number' || seed.length < 64) { + throw new Error('INVALID_SEED_LENGTH'); + } + + var signSeed = seed.subarray(0, Nacl.sign.seedLength); + var symmetric = seed.subarray(Nacl.sign.seedLength, + Nacl.sign.seedLength + Nacl.secretbox.keyLength); + + console.log("symmetric key: ", Nacl.util.encodeBase64(symmetric)); + + return { + sign: Nacl.sign.keyPair.fromSeed(signSeed), // 32 bytes + symmetric: symmetric, // 32 bytes ... + }; + }; + + // (UTF8 content, keys object) => Uint8Array block + Block.encrypt = function (version, content, keys) { + var u8 = Nacl.util.decodeUTF8(content); + var nonce = Nacl.randomBytes(Nacl.secretbox.nonceLength); + return Block.join([ + [0], + nonce, + Nacl.secretbox(u8, nonce, keys.symmetric) + ]); + }; + + // (uint8Array block) => payload object + Block.decrypt = function (u8_content, keys) { + // version is currently ignored since there is only one + var nonce = u8_content.subarray(1, 1 + Nacl.secretbox.nonceLength); + var box = u8_content.subarray(1 + Nacl.secretbox.nonceLength); + + var plaintext = Nacl.secretbox.open(box, nonce, keys.symmetric); + try { + return JSON.parse(Nacl.util.encodeUTF8(plaintext)); + } catch (e) { + console.error(e); + return; + } + }; + + // (Uint8Array block) => signature + Block.sign = function (ciphertext, keys) { + return Nacl.sign.detached(Nacl.hash(ciphertext), keys.sign.secretKey); + }; + + Block.serialize = function (content, keys) { + // encrypt the content + var ciphertext = Block.encrypt(0, content, keys); + + // generate a detached signature + var sig = Block.sign(ciphertext, keys); + + // serialize {publickey, sig, ciphertext} + return { + publicKey: Nacl.util.encodeBase64(keys.sign.publicKey), + signature: Nacl.util.encodeBase64(sig), + ciphertext: Nacl.util.encodeBase64(ciphertext), + }; + }; + + 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), + }; + }; + + // FIXME don't spread the functions below across this file and common-hash + // find a permanent home for these hacks + var urlSafeB64 = function (u8) { + return Nacl.util.encodeBase64(u8).replace(/\//g, '-'); + }; + + Block.getBlockHash = function (keys) { + var publicKey = urlSafeB64(keys.sign.publicKey); + // '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 + var relative = 'block/' + publicKey.slice(0, 2) + '/' + publicKey; + var symmetric = urlSafeB64(keys.symmetric); + return ApiConfig.httpUnsafeOrigin + relative + '#' + symmetric; + }; + +/* + Block.createBlockHash = function (href, key) { + if (typeof(href) !== 'string') { return; } + if (!(key instanceof Uint8Array)) { return; } + + try { return href + '#' + Nacl.util.encodeBase64(key); } + catch (e) { return; } + }; +*/ + + var decodeSafeB64 = function (b64) { + try { + return Nacl.util.decodeBase64(b64.replace(/\-/g, '/')); + } catch (e) { + console.error(e); + return; + } + }; + + Block.parseBlockHash = function (hash) { + if (typeof(hash) !== 'string') { return; } + var parts = hash.split('#'); + if (parts.length !== 2) { return; } + + try { + return { + href: parts[0], + keys: { + symmetric: decodeSafeB64(parts[1]), + } + }; + } catch (e) { + console.error(e); + return; + } + }; + + return Block; +}); diff --git a/www/common/outer/store-rpc.js b/www/common/outer/store-rpc.js index 972776abe..2ed424610 100644 --- a/www/common/outer/store-rpc.js +++ b/www/common/outer/store-rpc.js @@ -24,6 +24,7 @@ define([ 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, diff --git a/www/common/pinpad.js b/www/common/pinpad.js index 1e4dd6046..721f8a94b 100644 --- a/www/common/pinpad.js +++ b/www/common/pinpad.js @@ -222,7 +222,34 @@ define([ }; exp.writeLoginBlock = function (data, cb) { - cb(); + if (!data) { return void cb('NO_DATA'); } + if (!data.publicKey || !data.signature || !data.ciphertext) { + console.log(data); + return void cb("MISSING_PARAMETERS"); + } + + rpc.send('WRITE_LOGIN_BLOCK', [ + data.publicKey, + data.signature, + data.ciphertext + ], 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); + }); }; cb(e, exp); diff --git a/www/common/sframe-common-outer.js b/www/common/sframe-common-outer.js index 8b30aa2e2..6009b35cc 100644 --- a/www/common/sframe-common-outer.js +++ b/www/common/sframe-common-outer.js @@ -661,10 +661,18 @@ define([ Cryptpad.changePadPassword(Cryptget, href, data.password, edPublic, cb); }); + sframeChan.on('Q_CHANGE_USER_PASSWORD', function (data, cb) { + 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); + }); + if (cfg.addRpc) { cfg.addRpc(sframeChan, Cryptpad, Utils); } diff --git a/www/common/sframe-protocol.js b/www/common/sframe-protocol.js index a23124a65..d65062fa8 100644 --- a/www/common/sframe-protocol.js +++ b/www/common/sframe-protocol.js @@ -77,6 +77,9 @@ define({ // Write/update the login block when the account password is changed 'Q_WRITE_LOGIN_BLOCK': true, + // Remove login blocks + 'Q_REMOVE_LOGIN_BLOCK': true, + // Check the pin limit to determine if we can store the pad in the drive or if we should. // display a warning 'Q_GET_PIN_LIMIT_STATUS': true, @@ -235,6 +238,9 @@ define({ // Change pad password 'Q_PAD_PASSWORD_CHANGE': true, + // Migrate drive to owned drive + 'Q_CHANGE_USER_PASSWORD': true, + // Loading events to display in the loading screen 'EV_LOADING_INFO': true, // Critical error outside the iframe during loading screen diff --git a/www/settings/inner.js b/www/settings/inner.js index 8d06b2da5..9cdda9d00 100644 --- a/www/settings/inner.js +++ b/www/settings/inner.js @@ -50,7 +50,7 @@ define([ 'cp-settings-resettips', 'cp-settings-thumbnails', 'cp-settings-userfeedback', - //'cp-settings-change-password', + 'cp-settings-change-password', 'cp-settings-delete' ], 'creation': [ @@ -404,12 +404,11 @@ define([ $(form).appendTo($div); var updateBlock = function (data, cb) { - sframeChan.query('Q_WRITE_LOGIN_BLOCK', data, function (err, obj) { + sframeChan.query('Q_CHANGE_USER_PASSWORD', data, function (err, obj) { if (err || obj.error) { return void cb ({error: err || obj.error}); } cb (obj); }); }; - updateBlock = updateBlock; // jshint.. var todo = function () { var oldPassword = $(form).find('#cp-settings-change-password-current').val(); @@ -432,8 +431,15 @@ define([ UI.confirm(Messages.settings_changePasswordConfirm, function (yes) { if (!yes) { return; } - // TODO - console.log(oldPassword, newPassword, newPasswordConfirm); + updateBlock({ + password: oldPassword, + newPassword: newPassword + }, function (obj) { + if (obj && obj.error) { + // TODO + UI.alert(Messages.settings_changePasswordError); + } + }); }, { ok: Messages.register_writtenPassword, cancel: Messages.register_cancel, @@ -461,6 +467,50 @@ define([ return $div; }; + create['migrate'] = function () { + if (true) { return; } // XXX js hint + // TODO + // if (!loginBlock) { return; } + // if (alreadyMigrated) { return; } + if (!common.isLoggedIn()) { return; } + + var $div = $('
', { 'class': 'cp-settings-migrate cp-sidebarlayout-element'}); + + $('', {'class': 'label'}).text(Messages.settings_ownDriveTitle).appendTo($div); + + $('', {'class': 'cp-sidebarlayout-description'}) + .append(Messages.settings_ownDriveHint).appendTo($div); + + var $ok = $('', {'class': 'fa fa-check', title: Messages.saved}); + var $spinner = $('', {'class': 'fa fa-spinner fa-pulse'}); + + var $button = $('