From 53c18b6774ecef6648fbeaffb662030c87b211c3 Mon Sep 17 00:00:00 2001 From: William Budington Date: Thu, 16 Aug 2018 13:19:18 -0700 Subject: [PATCH] Update channels ux (#16306) Adding a UX with the ability to add, delete, and edit update channels. --- chromium/background-scripts/background.js | 96 +++++++++- chromium/background-scripts/store.js | 2 + chromium/background-scripts/update.js | 121 ++++++++----- chromium/pages/options/index.html | 51 ++++-- chromium/pages/options/style.css | 127 +++++++++++++- chromium/pages/options/ux.js | 202 ++++++++++++++++++++++ src/chrome/locale/en/https-everywhere.dtd | 9 + 7 files changed, 537 insertions(+), 71 deletions(-) diff --git a/chromium/background-scripts/background.js b/chromium/background-scripts/background.js index ad9a628f988f..71f38f6c318f 100644 --- a/chromium/background-scripts/background.js +++ b/chromium/background-scripts/background.js @@ -6,7 +6,8 @@ const rules = require('./rules'), store = require('./store'), incognito = require('./incognito'), util = require('./util'), - update = require('./update'); + update = require('./update'), + { update_channels } = require('./update_channels'); let all_rules = new rules.RuleSets(); @@ -721,6 +722,25 @@ chrome.runtime.onConnect.addListener(function (port) { // This is necessary for communication with the popup in Firefox Private // Browsing Mode, see https://bugzilla.mozilla.org/show_bug.cgi?id=1329304 chrome.runtime.onMessage.addListener(function(message, sender, sendResponse){ + + function get_update_channels_generic(update_channels){ + let last_updated_promises = []; + for(let update_channel of update_channels) { + last_updated_promises.push(new Promise(resolve => { + store.local.get({['rulesets-timestamp: ' + update_channel.name]: 0}, item => { + resolve([update_channel.name, item['rulesets-timestamp: ' + update_channel.name]]); + }); + })); + } + Promise.all(last_updated_promises).then(results => { + const last_updated = results.reduce((obj, item) => { + obj[item[0]] = item[1]; + return obj; + }, {}); + sendResponse({update_channels, last_updated}); + }); + } + const responses = { get_option: () => { store.get(message.object, sendResponse); @@ -782,6 +802,80 @@ chrome.runtime.onMessage.addListener(function(message, sender, sendResponse){ get_ruleset_timestamps: () => { update.getRulesetTimestamps().then(timestamps => sendResponse(timestamps)); return true; + }, + get_pinned_update_channels: () => { + get_update_channels_generic(update_channels); + return true; + }, + get_stored_update_channels: () => { + store.get({update_channels: []}, item => { + get_update_channels_generic(item.update_channels); + }); + return true; + }, + create_update_channel: () => { + + store.get({update_channels: []}, item => { + + const update_channel_names = update_channels.concat(item.update_channels).reduce((obj, item) => { + obj.add(item.name); + return obj; + }, new Set()); + + if(update_channel_names.has(message.object)){ + return sendResponse(false); + } + + item.update_channels.push({ + name: message.object, + jwk: {}, + update_path_prefix: '' + }); + + store.set({update_channels: item.update_channels}, () => { + sendResponse(true); + }); + + }); + return true; + }, + delete_update_channel: () => { + store.get({update_channels: []}, item => { + store.set({update_channels: item.update_channels.filter(update_channel => { + return (update_channel.name != message.object); + })}, () => { + store.local.remove([ + 'rulesets-timestamp: ' + message.object, + 'rulesets-stored-timestamp: ' + message.object, + 'rulesets: ' + message.object + ], () => { + initializeAllRules(); + sendResponse(true); + }); + }); + }); + return true; + }, + update_update_channel: () => { + store.get({update_channels: []}, item => { + for(let i = 0; i < item.update_channels.length; i++){ + if(item.update_channels[i].name == message.object.name){ + item.update_channels[i] = message.object; + } + } + + store.set({update_channels: item.update_channels}, () => { + sendResponse(true); + }); + + }); + return true; + }, + get_last_checked: () => { + store.local.get({'last-checked': false}, item => { + sendResponse(item['last-checked']); + }); + return true; } }; if (message.type in responses) { diff --git a/chromium/background-scripts/store.js b/chromium/background-scripts/store.js index ce3448136a4a..3b555c7311d5 100644 --- a/chromium/background-scripts/store.js +++ b/chromium/background-scripts/store.js @@ -79,6 +79,7 @@ async function performMigrations() { const local = { get: chrome.storage.local.get, set: chrome.storage.local.set, + remove: chrome.storage.local.remove, get_promise: local_get_promise, set_promise: local_set_promise }; @@ -87,6 +88,7 @@ function setStorage(store) { Object.assign(exports, { get: store.get.bind(store), set: store.set.bind(store), + remove: store.remove.bind(store), get_promise, set_promise, local diff --git a/chromium/background-scripts/update.js b/chromium/background-scripts/update.js index c2b48b46d293..3d5b7a916edd 100644 --- a/chromium/background-scripts/update.js +++ b/chromium/background-scripts/update.js @@ -2,6 +2,9 @@ "use strict"; +let combined_update_channels; +const { update_channels } = require('./update_channels'); + // Determine if we're in the tests. If so, define some necessary components. if (typeof window === "undefined") { var WebCrypto = require("node-webcrypto-ossl"), @@ -12,11 +15,12 @@ if (typeof window === "undefined") { { TextDecoder } = require('text-encoding'), chrome = require("sinon-chrome"), window = { atob, btoa, chrome, crypto, pako, TextDecoder }; + + combined_update_channels = update_channels; } (function(exports) { -const { update_channels } = require('./update_channels'); const util = require('./util'); let store, @@ -25,19 +29,36 @@ let store, // how often we should check for new rulesets const periodicity = 86400; -// jwk key loaded from keys.js -let imported_keys = {}; -for(let update_channel of update_channels){ - imported_keys[update_channel.name] = window.crypto.subtle.importKey( - "jwk", - update_channel.jwk, - { - name: "RSA-PSS", - hash: {name: "SHA-256"}, - }, - false, - ["verify"] - ); +let imported_keys; + +// update channels are loaded from `background-scripts/update_channels.js` as well as the storage api +async function loadUpdateChannelsKeys() { + util.log(util.NOTE, 'Loading update channels and importing associated public keys.'); + + const stored_update_channels = await store.get_promise('update_channels', []); + const combined_update_channels_preflight = update_channels.concat(stored_update_channels); + + imported_keys = {}; + combined_update_channels = []; + + for(let update_channel of combined_update_channels_preflight){ + + try{ + imported_keys[update_channel.name] = await window.crypto.subtle.importKey( + "jwk", + update_channel.jwk, + { + name: "RSA-PSS", + hash: {name: "SHA-256"}, + }, + false, + ["verify"] + ); + combined_update_channels.push(update_channel); + } catch(err) { + util.log(util.WARN, update_channel.name + ': Could not import key. Aborting.'); + } + } } @@ -69,7 +90,7 @@ async function checkForNewRulesets(update_channel) { // Retrieve the timestamp for when a stored ruleset bundle was published async function getRulesetTimestamps(){ let timestamp_promises = []; - for(let update_channel of update_channels){ + for(let update_channel of combined_update_channels){ timestamp_promises.push(new Promise(async resolve => { let timestamp = await store.local.get_promise('rulesets-stored-timestamp: ' + update_channel.name, 0); resolve([update_channel.name, timestamp]); @@ -108,38 +129,34 @@ async function getNewRulesets(rulesets_timestamp, update_channel) { // Otherwise, it throws an exception. function verifyAndStoreNewRulesets(new_rulesets, rulesets_timestamp, update_channel){ return new Promise((resolve, reject) => { - imported_keys[update_channel.name].then(publicKey => { - window.crypto.subtle.verify( - { - name: "RSA-PSS", - saltLength: 32 - }, - publicKey, - new_rulesets.signature_array_buffer, - new_rulesets.rulesets_array_buffer - ).then(async isvalid => { - if(isvalid) { - util.log(util.NOTE, update_channel.name + ': Downloaded ruleset signature checks out. Storing rulesets.'); - - const rulesets_gz = util.ArrayBufferToString(new_rulesets.rulesets_array_buffer); - const rulesets_byte_array = pako.inflate(rulesets_gz); - const rulesets = new TextDecoder("utf-8").decode(rulesets_byte_array); - const rulesets_json = JSON.parse(rulesets); - - if(rulesets_json.timestamp != rulesets_timestamp){ - reject(update_channel.name + ': Downloaded ruleset had an incorrect timestamp. This may be an attempted downgrade attack. Aborting.'); - } else { - await store.local.set_promise('rulesets: ' + update_channel.name, window.btoa(rulesets_gz)); - resolve(true); - } + window.crypto.subtle.verify( + { + name: "RSA-PSS", + saltLength: 32 + }, + imported_keys[update_channel.name], + new_rulesets.signature_array_buffer, + new_rulesets.rulesets_array_buffer + ).then(async isvalid => { + if(isvalid) { + util.log(util.NOTE, update_channel.name + ': Downloaded ruleset signature checks out. Storing rulesets.'); + + const rulesets_gz = util.ArrayBufferToString(new_rulesets.rulesets_array_buffer); + const rulesets_byte_array = pako.inflate(rulesets_gz); + const rulesets = new TextDecoder("utf-8").decode(rulesets_byte_array); + const rulesets_json = JSON.parse(rulesets); + + if(rulesets_json.timestamp != rulesets_timestamp){ + reject(update_channel.name + ': Downloaded ruleset had an incorrect timestamp. This may be an attempted downgrade attack. Aborting.'); } else { - reject(update_channel.name + ': Downloaded ruleset signature is invalid. Aborting.'); + await store.local.set_promise('rulesets: ' + update_channel.name, window.btoa(rulesets_gz)); + resolve(true); } - }).catch(() => { - reject(update_channel.name + ': Downloaded ruleset signature could not be verified. Aborting.'); - }); + } else { + reject(update_channel.name + ': Downloaded ruleset signature is invalid. Aborting.'); + } }).catch(() => { - reject(update_channel.name + ': Could not import key. Aborting.'); + reject(update_channel.name + ': Downloaded ruleset signature could not be verified. Aborting.'); }); }); } @@ -147,9 +164,9 @@ function verifyAndStoreNewRulesets(new_rulesets, rulesets_timestamp, update_chan // Unzip and apply the rulesets we have stored. async function applyStoredRulesets(rulesets_obj){ let rulesets_promises = []; - for(let update_channel of update_channels){ + for(let update_channel of combined_update_channels){ rulesets_promises.push(new Promise(resolve => { - const key = 'rulesets: ' + update_channel.name + const key = 'rulesets: ' + update_channel.name; chrome.storage.local.get(key, root => { if(root[key]){ util.log(util.NOTE, update_channel.name + ': Applying stored rulesets.'); @@ -170,7 +187,9 @@ async function applyStoredRulesets(rulesets_obj){ const rulesets_jsons = await Promise.all(rulesets_promises); if(rulesets_jsons.join("").length > 0){ for(let rulesets_json of rulesets_jsons){ - rulesets_obj.addFromJson(rulesets_json.rulesets); + if(typeof(rulesets_json) != 'undefined'){ + rulesets_obj.addFromJson(rulesets_json.rulesets); + } } } else { rulesets_obj.addFromJson(util.loadExtensionFile('rules/default.rulesets', 'json')); @@ -185,7 +204,7 @@ async function performCheck() { store.local.set_promise('last-checked', current_timestamp); let num_updates = 0; - for(let update_channel of update_channels){ + for(let update_channel of combined_update_channels){ let new_rulesets_timestamp = await checkForNewRulesets(update_channel); if(new_rulesets_timestamp){ util.log(util.NOTE, update_channel.name + ': A new ruleset bundle has been released. Downloading now.'); @@ -214,6 +233,10 @@ chrome.storage.onChanged.addListener(async function(changes, areaName) { } } } + + if ('update_channels' in changes) { + await loadUpdateChannelsKeys(); + } }); let initialCheck, @@ -241,6 +264,8 @@ async function initialize(store_param, cb){ store = store_param; background_callback = cb; + await loadUpdateChannelsKeys(); + if (await store.get_promise('autoUpdateRulesets', true)) { await createTimer(); } diff --git a/chromium/pages/options/index.html b/chromium/pages/options/index.html index 3cf275abcce8..d72d2793d395 100644 --- a/chromium/pages/options/index.html +++ b/chromium/pages/options/index.html @@ -6,24 +6,45 @@ -
-
- - -
-
- - + + + + +
+
+ + +
+
+ + +
-
-
- - +
+
+ + +
+
+ + +
-
- - + +
+
+ + +
+
+
+
+
+
+ + +
diff --git a/chromium/pages/options/style.css b/chromium/pages/options/style.css index f30769daed81..50d45b678523 100644 --- a/chromium/pages/options/style.css +++ b/chromium/pages/options/style.css @@ -1,12 +1,17 @@ -.general-settings-wrapper{ +body{ + min-width: 500px; + display: block; +} + +.settings-wrapper{ margin: 10px 0 0 0; } -.general-settings-wrapper#update-wrapper{ +.settings-wrapper#update-wrapper{ margin-bottom: 20px; } -.general-settings-wrapper#show-devtools-tab-wrapper{ +.settings-wrapper#show-devtools-tab-wrapper{ margin-bottom: 20px; } @@ -14,16 +19,124 @@ margin-bottom: 10px; } +#import{ + margin-bottom: 10px; + float: right; +} + +#import-confirmed{ + display: none; +} + .section-header-span{ - border-bottom: 1px solid #ccc; font-size: 15px; + border: 0px; + border-radius: 13px; + padding: 8px; + margin-right: 10px; + display: inline-block; } -#import{ - margin-bottom: 10px; +.section-header-span.active{ + background-color: #66ccff; +} + +.section-header-span.inactive{ + background-color: #ddd; +} + +.update-channel{ + margin-top: 30px; + margin-bottom: 30px; + border: 1px solid grey; + border-radius: 20px; + width: 498px; +} + +.update-channel-name{ + font-weight: bold; + font-size: 14px; + margin: 10px; +} + +.update-channel-column-left { + width: 89px; + float: left; + font-size: 13px; + text-align: right; + margin-right: 10px; + min-height: 1px; +} + +.update-channel-column-right { + float: left; + width: 380px; +} + +textarea.update-channel-jwk { + width: 380px; + height: 250px; +} + +input.update-channel-path-prefix { + width: 367px; +} + +.update-channel-column-right button { float: right; + margin: 10px; + border-radius: 7px; } -#import-confirmed{ +button#add-update-channel{ + float: right; + margin: 0px 10px 10px 10px; + border-radius: 7px; +} + +input#update-channel-name{ + float: right; +} + +.clearer{ + clear: both; +} + +.update-channel-last-updated { + float: right; + font-weight: lighter; + font-size: 10px; +} + +div#update-channels-error, div#update-channels-warning { + margin-top: 20px; + font-weight: bold; + padding: 10px; + border-radius: 10px; +} + +div#update-channels-error { + background-color: #CC3333; display: none; + color: white; +} + +div#update-channels-warning { + background-color: #FFCC00; + font-color: black; +} + +img#update-channels-error-hide { + float: right; +} + +div#update-channels-last-checked { + margin-top: 10px; + float: right; + font-weight: bold; + font-size: 10px; +} + +#update-channels-wrapper{ + width: 500px; } diff --git a/chromium/pages/options/ux.js b/chromium/pages/options/ux.js index faab4a96154c..fd503630f0a7 100644 --- a/chromium/pages/options/ux.js +++ b/chromium/pages/options/ux.js @@ -39,6 +39,208 @@ document.addEventListener("DOMContentLoaded", () => { }); }); + function onlyShowSection(sectionId){ + document.querySelectorAll('.section-wrapper').forEach(sw => { + sw.style.display = "none"; + }); + document.getElementById(sectionId).style.display = "block"; + } + onlyShowSection('general-settings-wrapper'); + + document.querySelectorAll('.section-header-span').forEach(shs => { + shs.addEventListener("click", () => { + document.querySelectorAll('.section-header-span').forEach(shs => { + shs.classList.remove("active"); + shs.classList.add("inactive"); + }); + shs.classList.remove("inactive"); + shs.classList.add("active"); + onlyShowSection(shs.dataset.show); + }); + }); + + function create_update_channel_element(update_channel, last_updated, pinned){ + let ruleset_version_string; + + if(last_updated){ + const ruleset_date = new Date(last_updated * 1000); + ruleset_version_string = ruleset_date.getUTCFullYear() + "." + (ruleset_date.getUTCMonth() + 1) + "." + ruleset_date.getUTCDate(); + } else { + ruleset_version_string = "n/a"; + } + + const update_channel_div = document.createElement('div'); + update_channel_div.className = "update-channel"; + + const update_channel_name = document.createElement('div'); + update_channel_name.className = "update-channel-name"; + update_channel_name.innerText = update_channel.name; + update_channel_div.appendChild(update_channel_name); + const update_channel_last_updated = document.createElement('div'); + update_channel_last_updated.className = "update-channel-last-updated"; + update_channel_last_updated.innerText = chrome.i18n.getMessage("options_storedRulesetsVersion") + ruleset_version_string; + update_channel_name.appendChild(update_channel_last_updated); + + const update_channel_row_jwk = document.createElement('div'); + update_channel_row_jwk.className = "update-channel-row-jwk"; + update_channel_div.appendChild(update_channel_row_jwk); + const update_channel_jwk_column_left = document.createElement('div'); + update_channel_jwk_column_left.className = "update-channel-column-left"; + update_channel_jwk_column_left.innerText = "JWK:"; + update_channel_row_jwk.appendChild(update_channel_jwk_column_left); + const update_channel_jwk_column_right = document.createElement('div'); + update_channel_jwk_column_right.className = "update-channel-column-right"; + update_channel_row_jwk.appendChild(update_channel_jwk_column_right); + const update_channel_jwk = document.createElement('textarea'); + update_channel_jwk.className = "update-channel-jwk"; + update_channel_jwk.setAttribute("data-name", update_channel.name); + update_channel_jwk.disabled = pinned; + update_channel_jwk.innerText = JSON.stringify(update_channel.jwk); + update_channel_jwk_column_right.appendChild(update_channel_jwk); + + const update_channel_row_path_prefix = document.createElement('div'); + update_channel_row_path_prefix.className = "update-channel-row-path-prefix"; + update_channel_div.appendChild(update_channel_row_path_prefix); + const update_channel_path_prefix_column_left = document.createElement('div'); + update_channel_path_prefix_column_left.className = "update-channel-column-left"; + update_channel_path_prefix_column_left.innerText = "Path Prefix:"; + update_channel_row_path_prefix.appendChild(update_channel_path_prefix_column_left); + const update_channel_path_prefix_column_right = document.createElement('div'); + update_channel_path_prefix_column_right.className = "update-channel-column-right"; + update_channel_row_path_prefix.appendChild(update_channel_path_prefix_column_right); + const update_channel_path_prefix = document.createElement('input'); + update_channel_path_prefix.setAttribute("type", "text"); + update_channel_path_prefix.className = "update-channel-path-prefix"; + update_channel_path_prefix.setAttribute("data-name", update_channel.name); + update_channel_path_prefix.disabled = pinned; + update_channel_path_prefix.value = update_channel.update_path_prefix; + update_channel_path_prefix_column_right.appendChild(update_channel_path_prefix); + + const update_channel_row_controls = document.createElement('div'); + update_channel_row_controls.className = "update-channel-row-controls"; + update_channel_div.appendChild(update_channel_row_controls); + const update_channel_controls_column_left = document.createElement('div'); + update_channel_controls_column_left.className = "update-channel-column-left"; + update_channel_controls_column_left.innerText = " "; + update_channel_row_controls.appendChild(update_channel_controls_column_left); + const update_channel_controls_column_right = document.createElement('div'); + update_channel_controls_column_right.className = "update-channel-column-right"; + update_channel_row_controls.appendChild(update_channel_controls_column_right); + const update_channel_update = document.createElement('button'); + update_channel_update.className = "update-channel-update"; + update_channel_update.setAttribute("data-name", update_channel.name); + update_channel_update.disabled = pinned; + update_channel_update.innerText = chrome.i18n.getMessage("options_update"); + update_channel_controls_column_right.appendChild(update_channel_update); + const update_channel_delete = document.createElement('button'); + update_channel_delete.className = "update-channel-update"; + update_channel_delete.setAttribute("data-name", update_channel.name); + update_channel_delete.disabled = pinned; + update_channel_delete.innerText = chrome.i18n.getMessage("options_delete"); + update_channel_controls_column_right.appendChild(update_channel_delete); + + const clearer = document.createElement('div'); + clearer.className = "clearer"; + update_channel_div.appendChild(clearer); + + update_channel_delete.addEventListener("click", () => { + sendMessage("delete_update_channel", update_channel.name, () => { + render_update_channels(); + }); + }); + + update_channel_update.addEventListener("click", () => { + sendMessage("update_update_channel", { + name: update_channel.name, + jwk: JSON.parse(update_channel_jwk.value), + update_path_prefix: update_channel_path_prefix.value + }, () => { + render_update_channels(); + }); + }); + + return update_channel_div; + } + + function render_update_channels(){ + const update_channels_list = document.getElementById("update-channels-list"); + while(update_channels_list.firstChild){ + update_channels_list.removeChild(update_channels_list.firstChild); + } + + sendMessage("get_pinned_update_channels", null, item => { + for(const update_channel of item.update_channels){ + update_channels_list.appendChild( + create_update_channel_element( + update_channel, + item.last_updated[update_channel.name], + true + ) + ); + + } + }); + + sendMessage("get_stored_update_channels", null, item => { + for(const update_channel of item.update_channels){ + update_channels_list.appendChild( + create_update_channel_element( + update_channel, + item.last_updated[update_channel.name], + false + ) + ); + } + }); + } + render_update_channels(); + + const add_update_channel = document.getElementById("add-update-channel"); + const update_channel_name_div = document.getElementById("update-channel-name"); + const update_channels_error_text = document.getElementById("update-channels-error-text"); + const update_channels_error = document.getElementById("update-channels-error"); + update_channel_name_div.setAttribute("placeholder", chrome.i18n.getMessage("options_enterUpdateChannelName")); + + add_update_channel.addEventListener("click", () => { + const update_channel_name = update_channel_name_div.value; + update_channel_name_div.value = ""; + sendMessage("create_update_channel", update_channel_name, result => { + if(result == true){ + render_update_channels(); + } else { + update_channels_error_text.innerText = "Error: There already exists an update channel with this name."; + update_channels_error.style.display = "block"; + window.scrollTo(0,0); + } + }); + }); + + const update_channels_error_hide = document.getElementById("update-channels-error-hide"); + update_channels_error_hide.addEventListener("click", () => { + update_channels_error.style.display = "none"; + }); + + const update_channels_last_checked = document.getElementById("update-channels-last-checked"); + sendMessage("get_last_checked", null, last_checked => { + let last_checked_string; + if(last_checked){ + const last_checked_date = new Date(last_checked * 1000); + const options = { + year: '2-digit', + month: '2-digit', + day: '2-digit', + hour: '2-digit', + minute: '2-digit', + timeZoneName: 'short' + }; + const customDateTime = new Intl.DateTimeFormat('default', options).format; + last_checked_string = customDateTime(last_checked_date); + } else { + last_checked_string = chrome.i18n.getMessage("options_updatesLastCheckedNever"); + } + update_channels_last_checked.innerText = chrome.i18n.getMessage("options_updatesLastChecked") + last_checked_string; + }); + document.onkeydown = function(evt) { evt = evt || window.event; if (evt.ctrlKey && evt.keyCode == 90) { diff --git a/src/chrome/locale/en/https-everywhere.dtd b/src/chrome/locale/en/https-everywhere.dtd index 73bdde5d51f9..949efdef28ee 100644 --- a/src/chrome/locale/en/https-everywhere.dtd +++ b/src/chrome/locale/en/https-everywhere.dtd @@ -14,9 +14,18 @@ + + + + + + + + +