/* global pako */ "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"), crypto = new WebCrypto(), atob = require("atob"), btoa = require("btoa"), pako = require('../external/pako-1.0.5/pako_inflate.min.js'), { TextDecoder } = require('text-encoding'), chrome = require("sinon-chrome"), window = { atob, btoa, chrome, crypto, pako, TextDecoder }; combined_update_channels = update_channels; } (function(exports) { const util = require('./util'); let store, background_callback; // how often we should check for new rulesets const periodicity = 86400; 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.'); } } } // Determine the time until we should check for new rulesets async function timeToNextCheck() { const last_checked = await store.local.get_promise('last-checked', false); if(last_checked) { const current_timestamp = Date.now() / 1000; const secs_since_last_checked = current_timestamp - last_checked; return Math.max(0, periodicity - secs_since_last_checked); } else { return 0; } } // Check for new rulesets. If found, return the timestamp. If not, return false async function checkForNewRulesets(update_channel) { let timestamp_result = await fetch(update_channel.update_path_prefix + "/latest-rulesets-timestamp"); if(timestamp_result.status == 200) { let rulesets_timestamp = Number(await timestamp_result.text()); if((await store.local.get_promise('rulesets-timestamp: ' + update_channel.name, 0)) < rulesets_timestamp){ return rulesets_timestamp; } } return false; } // Retrieve the timestamp for when a stored ruleset bundle was published async function getRulesetTimestamps(){ let timestamp_promises = []; 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]); })); } let timestamps = await Promise.all(timestamp_promises); return timestamps; } // Download and return new rulesets async function getNewRulesets(rulesets_timestamp, update_channel) { store.local.set_promise('rulesets-timestamp: ' + update_channel.name, rulesets_timestamp); let signature_promise = fetch(update_channel.update_path_prefix + "/rulesets-signature." + rulesets_timestamp + ".sha256"); let rulesets_promise = fetch(update_channel.update_path_prefix + "/default.rulesets." + rulesets_timestamp + ".gz"); let responses = await Promise.all([ signature_promise, rulesets_promise ]); let resolutions = await Promise.all([ responses[0].arrayBuffer(), responses[1].arrayBuffer() ]); return { signature_array_buffer: resolutions[0], rulesets_array_buffer: resolutions[1] }; } // Returns a promise which verifies that the rulesets have a valid EFF // signature, and if so, stores them and returns true. // Otherwise, it throws an exception. function verifyAndStoreNewRulesets(new_rulesets, rulesets_timestamp, update_channel){ return new Promise((resolve, reject) => { 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 { await store.local.set_promise('rulesets: ' + update_channel.name, window.btoa(rulesets_gz)); resolve(true); } } else { reject(update_channel.name + ': Downloaded ruleset signature is invalid. Aborting.'); } }).catch(() => { reject(update_channel.name + ': Downloaded ruleset signature could not be verified. Aborting.'); }); }); } // Unzip and apply the rulesets we have stored. async function applyStoredRulesets(rulesets_obj){ let rulesets_promises = []; for(let update_channel of combined_update_channels){ rulesets_promises.push(new Promise(resolve => { 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.'); const rulesets_gz = window.atob(root[key]); const rulesets_byte_array = pako.inflate(rulesets_gz); const rulesets = new TextDecoder("utf-8").decode(rulesets_byte_array); const rulesets_json = JSON.parse(rulesets); resolve(rulesets_json); } else { resolve(); } }); })); } const rulesets_jsons = await Promise.all(rulesets_promises); if(rulesets_jsons.join("").length > 0){ for(let rulesets_json of rulesets_jsons){ if(typeof(rulesets_json) != 'undefined'){ rulesets_obj.addFromJson(rulesets_json.rulesets); } } } else { rulesets_obj.addFromJson(util.loadExtensionFile('rules/default.rulesets', 'json')); } } // basic workflow for periodic checks async function performCheck() { util.log(util.NOTE, 'Checking for new rulesets.'); const current_timestamp = Date.now() / 1000; store.local.set_promise('last-checked', current_timestamp); let num_updates = 0; 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.'); let new_rulesets = await getNewRulesets(new_rulesets_timestamp, update_channel); try{ await verifyAndStoreNewRulesets(new_rulesets, new_rulesets_timestamp, update_channel); store.local.set_promise('rulesets-stored-timestamp: ' + update_channel.name, new_rulesets_timestamp); num_updates++; } catch(err) { util.log(util.WARN, update_channel.name + ': ' + err); } } } if(num_updates > 0){ background_callback(); } }; chrome.storage.onChanged.addListener(async function(changes, areaName) { if (areaName === 'sync' || areaName === 'local') { if ('autoUpdateRulesets' in changes) { if (changes.autoUpdateRulesets.newValue) { await createTimer(); } else { destroyTimer(); } } } if ('update_channels' in changes) { await loadUpdateChannelsKeys(); } }); let initialCheck, subsequentChecks; async function createTimer(){ const time_to_next_check = await timeToNextCheck(); initialCheck = setTimeout(() => { performCheck(); subsequentChecks = setInterval(performCheck, periodicity * 1000); }, time_to_next_check * 1000); } function destroyTimer(){ if (initialCheck) { clearTimeout(initialCheck); } if (subsequentChecks) { clearInterval(subsequentChecks); } } async function initialize(store_param, cb){ store = store_param; background_callback = cb; await loadUpdateChannelsKeys(); if (await store.get_promise('autoUpdateRulesets', false)) { await createTimer(); } } Object.assign(exports, { applyStoredRulesets, initialize, getRulesetTimestamps }); })(typeof exports == 'undefined' ? require.scopes.update = {} : exports);