diff --git a/www/common/cryptpad-common.js b/www/common/cryptpad-common.js index 69ed47587..dbcbcf8eb 100644 --- a/www/common/cryptpad-common.js +++ b/www/common/cryptpad-common.js @@ -76,7 +76,7 @@ define([ postMessage("GET", { key: ['edPrivate'], }, waitFor(function (obj) { - if (obj.error) { return; } + if (!obj || obj.error) { return; } try { keys.push({ edPrivate: obj, @@ -89,7 +89,7 @@ define([ postMessage("GET", { key: ['teams'], }, waitFor(function (obj) { - if (obj.error) { return; } + if (!obj || obj.error) { return; } Object.keys(obj || {}).forEach(function (id) { var t = obj[id]; var _keys = t.keys.drive || {}; @@ -104,13 +104,26 @@ define([ }; common.getFormKeys = function (cb) { - postMessage("GET", { - key: ['curvePrivate'], - }, function (obj) { - if (obj.error) { return void cb(); } + var curvePrivate; + var formSeed; + Nthen(function (waitFor) { + postMessage("GET", { + key: ['curvePrivate'], + }, waitFor(function (obj) { + if (!obj || obj.error) { return; } + curvePrivate = obj; + })); + postMessage("GET", { + key: ['form_seed'], + }, waitFor(function (obj) { + if (!obj || obj.error) { return; } + formSeed = obj; + })); + }).nThen(function () { cb({ - curvePrivate: obj, - curvePublic: Hash.getCurvePublicFromPrivate(obj) + curvePrivate: curvePrivate, + curvePublic: curvePrivate && Hash.getCurvePublicFromPrivate(curvePrivate), + formSeed: formSeed }); }); }; @@ -124,7 +137,8 @@ define([ key: ['forms', data.channel], value: { hash: data.hash, - curvePrivate: data.curvePrivate + curvePrivate: data.curvePrivate, + anonymous: data.anonymous } }, function (obj) { if (obj && obj.error) { console.error(obj.error); } diff --git a/www/common/outer/async-store.js b/www/common/outer/async-store.js index 2d08b52d4..1af15a9ed 100644 --- a/www/common/outer/async-store.js +++ b/www/common/outer/async-store.js @@ -629,6 +629,7 @@ define([ if (!proxy.uid) { store.noDriveUid = store.noDriveUid || Hash.createChannelId(); } + var metadata = { // "user" is shared with everybody via the userlist user: { @@ -655,7 +656,7 @@ define([ accountName: proxy.login_name || '', offline: store.proxy && store.offline, teams: teams, - plan: account.plan + plan: account.plan, } }; cb(JSON.parse(JSON.stringify(metadata))); @@ -2710,6 +2711,10 @@ define([ if (!proxy.settings) { proxy.settings = NEW_USER_SETTINGS; } if (!proxy.forms) { proxy.forms = {}; } if (!proxy.friends_pending) { proxy.friends_pending = {}; } + // Form seed is used to generate a box encryption keypair when + // answering a form anonymously + if (!proxy.form_seed) { proxy.form_seed = Hash.createChannelId(); } + // Call onCacheReady if the manager is not yet defined if (!manager) { diff --git a/www/form/inner.js b/www/form/inner.js index b2406fc93..33ddff44e 100644 --- a/www/form/inner.js +++ b/www/form/inner.js @@ -110,6 +110,12 @@ define([ Messages.form_isClosed = "This form was closed on {0}"; Messages.form_willClose = "This form will close on {0}"; + Messages.form_anonymous_on = "Anonymous answers are allowed"; + Messages.form_anonymous_off = "Anonymous answers are blocked"; + Messages.form_anonymous_button_on = "Block anonymous answers"; + Messages.form_anonymous_button_off = "Allow anonymous answers"; + Messages.form_anonymous_blocked = "Anonymous responses are blocked for this form. You must log in or register to submit answers."; + Messages.form_defaultOption = "Option {0}"; Messages.form_defaultItem = "Item {0}"; Messages.form_newOption = "New option"; @@ -119,6 +125,7 @@ define([ Messages.form_addMultiple = "Add all"; Messages.form_clear = "Clear"; + Messages.form_anonymousBox = "Answer anonymously"; var MAX_OPTIONS = 15; // XXX var MAX_ITEMS = 10; // XXX @@ -544,7 +551,6 @@ define([ }); var dayValues = Object.keys(_days).map(function (d) { return _days[d]; }); var minDay = Math.min.apply(null, dayValues); - console.log(_days, minDay); Object.keys(_days).forEach(function (day) { days.push(h('div.cp-poll-cell.cp-poll-time-day', { style: 'flex-grow:'+(_days[day]-1)+';' @@ -1061,13 +1067,10 @@ define([ return cell; }); // Name input - var nameInput = h('input', { value: username || '' }); + var nameInput = h('input', { value: username || Messages.anonymous }); addLine.unshift(h('div.cp-poll-cell', nameInput)); - // XXX Submit button here? lines.push(h('div', addLine)); - - var tag = h('div.cp-form-type-poll', lines); var $tag = $(tag); @@ -1150,6 +1153,20 @@ define([ return results; }; var makeFormControls = function (framework, content, update) { + var loggedIn = framework._.sfCommon.isLoggedIn(); + + if (!loggedIn && !content.answers.anonymous) { return; } + + var cbox; + if (loggedIn) { + cbox = UI.createCheckbox('cp-form-anonymous', + Messages.form_anonymousBox, false, { mark: { tabindex:1 } }); + if (!content.answers.anonymous) { + $(cbox).find('input').attr('disabled', 'disabled'); + } + + } + var send = h('button.cp-open.btn.btn-primary', update ? Messages.form_update : Messages.form_submit); var reset = h('button.cp-open.btn.btn-danger-alt', Messages.form_reset); $(reset).click(function () { @@ -1162,10 +1179,12 @@ define([ $send.attr('disabled', 'disabled'); var results = getFormResults(); if (!results) { return; } + var sframeChan = framework._.sfCommon.getSframeChannel(); sframeChan.query('Q_FORM_SUBMIT', { mailbox: content.answers, - results: results + results: results, + anonymous: !loggedIn || Util.isChecked($(cbox).find('input')) }, function (err, data) { $send.attr('disabled', 'disabled'); if (err || (data && data.error)) { @@ -1186,7 +1205,8 @@ define([ var sframeChan = framework._.sfCommon.getSframeChannel(); var $v = $(viewResults).click(function () { $v.attr('disabled', 'disabled'); - sframeChan.query("Q_FORM_FETCH_ANSWERS", content.answers, function (err, answers) { + sframeChan.query("Q_FORM_FETCH_ANSWERS", content.answers, function (err, obj) { + var answers = obj && obj.results; if (answers) { APP.answers = answers; } $v.removeAttr('disabled'); $('body').addClass('cp-app-form-results'); @@ -1200,7 +1220,10 @@ define([ reset = undefined; } - return h('div.cp-form-send-container', [send, reset, viewResults]); + return h('div.cp-form-send-container', [ + cbox ? h('div', cbox) : undefined, + send, reset, viewResults + ]); }; var updateForm = function (framework, content, editable, answers, temp) { var $container = $('div.cp-form-creator-content'); @@ -1401,6 +1424,7 @@ define([ var andThen = function (framework) { framework.start(); + var evOnChange = Util.mkEvent(); var content = {}; var sframeChan = framework._.sfCommon.getSframeChannel(); @@ -1416,26 +1440,54 @@ define([ $toolbarContainer.after(helpMenu.menu); + // XXX refresh form settings on remote change var makeFormSettings = function () { - var makePublic = h('button.btn.btn-primary', Messages.form_makePublic); - if (content.answers.privateKey) { makePublic = undefined; } - var publicText = content.answers.privateKey ? Messages.form_isPublic : Messages.form_isPrivate; - var resultsType = h('div.cp-form-results-type-container', [ - h('span.cp-form-results-type', publicText), - makePublic - ]); - var $makePublic = $(makePublic).click(function () { - UI.confirm(Messages.form_makePublicWarning, function (yes) { - if (!yes) { return; } - content.answers.privateKey = priv.form_private; + // Private / public status + var resultsType = h('div.cp-form-results-type-container'); + var $results = $(resultsType); + var refreshPublic = function () { + $results.empty(); + var makePublic = h('button.btn.btn-primary', Messages.form_makePublic); + if (content.answers.privateKey) { makePublic = undefined; } + var publicText = content.answers.privateKey ? Messages.form_isPublic : Messages.form_isPrivate; + $results.append(h('span.cp-form-results-type', publicText)); + $results.append(makePublic); + var $makePublic = $(makePublic).click(function () { + UI.confirm(Messages.form_makePublicWarning, function (yes) { + if (!yes) { return; } + $makePublic.attr('disabled', 'disabled'); + content.answers.privateKey = priv.form_private; + framework.localChange(); + framework._.cpNfInner.chainpad.onSettle(function () { + UI.log(Messages.saved); + refreshPublic(); + }); + }); + }); + }; + refreshPublic(); + + // Allow anonymous answers + var privacyContainer = h('div.cp-form-privacy-container'); + var $privacy = $(privacyContainer); + var refreshPrivacy = function () { + $privacy.empty(); + var anonymous = content.answers.anonymous; + var key = anonymous ? 'on' : 'off'; + var button = h('button.btn.btn-secondary', Messages['form_anonymous_button_'+key]); + var $b = $(button).click(function () { + $b.attr('disabled', 'disabled'); + content.answers.anonymous = !anonymous; framework.localChange(); framework._.cpNfInner.chainpad.onSettle(function () { UI.log(Messages.saved); - $makePublic.remove(); - $(resultsType).find('.cp-form-results-type').text(Messages.form_isPublic); + refreshPrivacy(); }); }); - }); + $privacy.append(h('div.cp-form-status', Messages['form_anonymous_'+key])); + $privacy.append(h('div.cp-form-actions', button)); + }; + refreshPrivacy(); // End date / Closed state var endDateContainer = h('div.cp-form-status-container'); @@ -1511,7 +1563,8 @@ define([ channel: content.answers.channel, validateKey: content.answers.validateKey, publicKey: content.answers.publicKey - }, function (err, answers) { + }, function (err, obj) { + var answers = obj && obj.results; if (answers) { APP.answers = answers; } $v.removeAttr('disabled'); $body.addClass('cp-app-form-results'); @@ -1519,8 +1572,14 @@ define([ }); }); + + evOnChange.reg(refreshPublic); + evOnChange.reg(refreshPrivacy); + evOnChange.reg(refreshEndDate); + return [ endDateContainer, + privacyContainer, resultsType, viewResults, ]; @@ -1665,9 +1724,10 @@ define([ publicKey: content.answers.publicKey, privateKey: priv.form_auditorKey }, function (err, obj) { - if (obj) { APP.answers = obj; } + var answers = obj && obj.results; + if (answers) { APP.answers = answers; } $body.addClass('cp-app-form-results'); - renderResults(content, obj); + renderResults(content, answers); }); return; } @@ -1678,16 +1738,21 @@ define([ validateKey: content.answers.validateKey, publicKey: content.answers.publicKey }, function (err, obj) { - if (obj) { APP.answers = obj; } + var answers = obj && obj.results; + if (answers) { APP.answers = answers; } checkIntegrity(false); updateForm(framework, content, true); - }); return; } refreshEndDateBanner(); + var loggedIn = framework._.sfCommon.isLoggedIn(); + if (!loggedIn && !content.answers.anonymous) { + UI.alert(Messages.form_anonymous_blocked); + } + // If the results are public and there is at least one doodle, fetch the results now if (content.answers.privateKey && Object.keys(content.form).some(function (uid) { return content.form[uid].type === "poll"; @@ -1698,12 +1763,19 @@ define([ publicKey: content.answers.publicKey, privateKey: content.answers.privateKey, }, function (err, obj) { - if (obj) { APP.answers = obj; } + var answers = obj && obj.results; + if (answers) { APP.answers = answers; } checkIntegrity(false); var myAnswers; - if (user.curvePublic && obj && obj[user.curvePublic]) { // XXX ANONYMOUS - myAnswers = obj[user.curvePublic].msg; + var curve1 = user.curvePublic + var curve2 = obj && obj.myKey; // Anonymous answer key + if (answers) { + var myAnswersObj = answers[curve1] || answers[curve2] || undefined; + if (myAnswersObj) { + myAnswers = myAnswersObj.msg; + } } + console.warn(obj); updateForm(framework, content, false, myAnswers); }); return; @@ -1726,8 +1798,8 @@ define([ }); framework.onContentUpdate(function (newContent) { - console.log(newContent); content = newContent; + evOnChange.fire(); refreshEndDateBanner(); var answers, temp; if (!APP.isEditor) { answers = getFormResults(); } diff --git a/www/form/main.js b/www/form/main.js index 27e6552de..f51aae4cf 100644 --- a/www/form/main.js +++ b/www/form/main.js @@ -63,6 +63,7 @@ define([ }); sframeChan.on('Q_FORM_FETCH_ANSWERS', function (data, cb) { var myKeys = {}; + var myFormKeys; var CPNetflux; var network; nThen(function (w) { @@ -81,12 +82,19 @@ define([ } }); })); + Cryptpad.getFormKeys(w(function (keys) { + myFormKeys = keys; + })); Cryptpad.makeNetwork(w(function (err, nw) { network = nw; })); }).nThen(function () { if (!network) { return void cb({error: "E_CONNECT"}); } + if (myFormKeys.formSeed) { + myFormKeys = getAnonymousKeys(myFormKeys.formSeed, data.channel); + } + var keys = Utils.secret && Utils.secret.keys; var crypto = Utils.Crypto.Mailbox.createEncryptor({ @@ -105,7 +113,15 @@ define([ }; var results = {}; config.onReady = function () { - cb(results); + var myKey; + // If we have submitted an anonymous answer, retrieve it + if (myFormKeys.curvePublic && results[myFormKeys.curvePublic]) { + myKey = myFormKeys.curvePublic; + } + cb({ + myKey: myKey, + results: results + }); network.disconnect(); }; config.onMessage = function (msg, peer, vKey, isCp, hash, senderCurve, cfg) { @@ -135,6 +151,10 @@ define([ answer = obj; })); }).nThen(function () { + if (answer.anonymous) { + if (!myKeys.formSeed) { return void cb({ error: "ANONYMOUS_ERROR" }); } + myKeys = getAnonymousKeys(myKeys.formSeed, data.channel); + } Cryptpad.getHistoryRange({ channel: data.channel, lastKnownHash: answer.hash, @@ -154,6 +174,16 @@ define([ }); }); + var getAnonymousKeys = function (formSeed, channel) { + var array = Nacl.util.decodeBase64(formSeed + channel); + var hash = Nacl.hash(array); + var secretKey = Nacl.util.encodeBase64(hash.subarray(32)); + var publicKey = Utils.Hash.getCurvePublicFromPrivate(secretKey); + return { + curvePrivate: secretKey, + curvePublic: publicKey, + }; + }; sframeChan.on("Q_FORM_SUBMIT", function (data, cb) { var box = data.mailbox; var myKeys; @@ -162,7 +192,15 @@ define([ myKeys = keys; })); }).nThen(function () { - + // XXX if we are a registered user (myKeys.curvePrivate exists), we may + // have already answered anonymously. We should send a "proof" to show + // that the existing anonymous answer are ours (using myKeys.formSeed). + // Even if we never answered anonymously, the keyPair would be unique to + // the current channel so it wouldn't leak anything. + if (data.anonymous) { + if (!myKeys.formSeed) { return void cb({ error: "ANONYMOUS_ERROR" }); } + myKeys = getAnonymousKeys(myKeys.formSeed, box.channel); + } var keys = Utils.secret && Utils.secret.keys; myKeys.signingKey = keys.secondarySignKey; @@ -182,7 +220,8 @@ define([ Cryptpad.storeFormAnswer({ channel: box.channel, hash: hash, - curvePrivate: ephemeral_private + curvePrivate: ephemeral_private, + anonymous: Boolean(data.anonymous) }); cb({error: err, response: response, hash: hash}); });