diff --git a/src/bg/LifeCycle.js b/src/bg/LifeCycle.js new file mode 100644 index 0000000..126fa4d --- /dev/null +++ b/src/bg/LifeCycle.js @@ -0,0 +1,182 @@ +"use strict"; + +var LifeCycle = (() => { + + const AES = "AES-GCM", + keyUsages = ["encrypt", "decrypt"]; + + function toBase64(bytes) { + return btoa(Array.from(bytes).map(b => String.fromCharCode(b)).join('')); + } + function fromBase64(string) { + return Uint8Array.from((Array.from(atob(string)).map(c => c.charCodeAt(0)))); + } + async function encrypt(clearText) { + let key = await crypto.subtle.generateKey({ + name: AES, + length: 256, + }, + true, + keyUsages, + ); + let iv = crypto.getRandomValues(new Uint8Array(12)); + let encoded = new TextEncoder().encode(clearText); + let cypherText = await crypto.subtle.encrypt({ + name: AES, + iv + }, key, encoded); + return {cypherText, key: await crypto.subtle.exportKey("jwk", key), iv}; + } + + var SurvivalTab = { + url: "about:blank", + async createAndStore() { + let allSeen = {}; + await Promise.all((await browser.tabs.query({})).map( + async t => { + let seen = await ns.collectSeen(t.id); + if (seen) allSeen[t.id] = seen; + } + )); + + let {url} = SurvivalTab; + let tabInfo = { + url, + active: false, + }; + if (browser.windows) { // it may be missing on mobile + // check if an incognito windows exist and open our "survival" tab there + for (let w of await browser.windows.getAll()) { + if (w.incognito) { + tabInfo.windowId = w.id; + break; + } + } + } + let tab; + for (;!tab;) { + try { + tab = await browser.tabs.create(tabInfo); + } catch (e) { + error(e); + if (tabInfo.windowId) { + // we might not have incognito permissions, let's try using any window + delete tabInfo.windowId; + } else { + return; // bailout + } + } + } + let tabId = tab.id; + + let {cypherText, key, iv} = await encrypt(JSON.stringify({ + policy: ns.policy.dry(true), + allSeen, + unrestrictedTabs: [...ns.unrestrictedTabs] + })); + + await new Promise((resolve, reject) => { + let l = async (tabId, changeInfo) => { + debug("Survival tab updating", changeInfo); + if (changeInfo.status !== "complete") return; + try { + await Messages.send("store", {url, data: toBase64(new Uint8Array(cypherText))}, {tabId, frameId: 0}); + resolve(); + debug("Survival tab updated"); + browser.tabs.onUpdated.removeListener(l); + } catch (e) { + if (!Messages.isMissingEndpoint(e)) { + error(e, "Survival tab failed"); + reject(e); + } // otherwise we keep waiting for further updates from the tab until content script is ready to answer + }; + } + browser.tabs.onUpdated.addListener(l, {tabId}); + }); + await Storage.set("local", { "updateInfo": {key, iv: toBase64(iv), tabId}}); + debug("Ready to reload...", await Storage.get("local", "updateInfo")); + }, + + async retrieveAndDestroy() { + let {updateInfo} = await Storage.get("local", "updateInfo"); + if (!updateInfo) return; + await Storage.remove("local", "updateInfo"); + let {key, iv, tabId} = updateInfo; + key = await crypto.subtle.importKey("jwk", key, AES, true, keyUsages); + iv = fromBase64(iv); + let cypherText = fromBase64(await Messages.send("retrieve", + {url: SurvivalTab.url}, + {tabId, frameId: 0})); + let encoded = await crypto.subtle.decrypt({ + name: AES, + iv + }, key, cypherText + ); + let {policy, allSeen, unrestrictedTabs} = JSON.parse(new TextDecoder().decode(encoded)); + if (!policy) { + error("Ephemeral policy not found!"); + return; + } + ns.unrestrictedTabs = new Set(unrestrictedTabs); + browser.tabs.remove(tabId); + await ns.initializing; + ns.policy = new Policy(policy); + await Promise.all( + Object.entries(allSeen).map( + async ([tabId, seen]) => { + try { + debug("Restoring seen %o to tab %s", seen, tabId); + await Messages.send("allSeen", {seen}, {tabId, frameId: 0}); + } catch (e) { + error(e, "Cannot send previously seen data to tab", tabId); + } + } + ) + ) + } + }; + + return { + async onInstalled(details) { + browser.runtime.onInstalled.removeListener(this.onInstalled); + let {reason, previousVersion} = details; + if (reason !== "update") return; + + try { + await SurvivalTab.retrieveAndDestroy(); + } catch (e) { + error(e); + } + + await include("/lib/Ver.js"); + previousVersion = new Ver(previousVersion); + let currentVersion = new Ver(browser.runtime.getManifest().version); + let upgrading = Ver.is(previousVersion, "<=", currentVersion); + if (!upgrading) return; + + // put here any version specific upgrade adjustment in stored data + + if (Ver.is(previousVersion, "<=", "11.0.10")) { + log(`Upgrading from 11.0.10 or below (${previousVersion}): configure the "ping" capability.`); + await ns.initializing; + ns.policy.TRUSTED.capabilities.add("ping") + await ns.savePolicy(); + } + }, + + async onUpdateAvailable(details) { + await include("/lib/Ver.js"); + if (Ver.is(details.version, "<", browser.runtime.getManifest().version)) { + // downgrade: temporary survival might not be supported, and we don't care + return; + } + try { + await SurvivalTab.createAndStore(); + } catch (e) { + console.error(e); + } finally { + browser.runtime.reload(); // apply update + } + } + }; +})(); \ No newline at end of file diff --git a/src/bg/main.js b/src/bg/main.js index 77893b3..285ef7a 100644 --- a/src/bg/main.js +++ b/src/bg/main.js @@ -1,18 +1,12 @@ { 'use strict'; { - let onInstalled = async details => { - browser.runtime.onInstalled.removeListener(onInstalled); - let {reason, previousVersion} = details; - if (reason !== "update") return; - let v = previousVersion.split(".").map(n => parseInt(n)); - if (v[0] > 11 || v[1] > 0 || v[2] > 10) return; - log(`Upgrading from 11.0.10 or below (${previousVersion}): configure the "ping" capability.`); - await ns.initializing; - ns.policy.TRUSTED.capabilities.add("ping") - await ns.savePolicy(); - }; - browser.runtime.onInstalled.addListener(onInstalled); + for (let event of ["onInstalled", "onUpdateAvailable"]) { + browser.runtime[event].addListener(async details => { + await include("/bg/LifeCycle.js"); + LifeCycle[event](details); + }); + } } let popupURL = browser.extension.getURL("/ui/popup.html"); let popupFor = tabId => `${popupURL}#tab${tabId}`; diff --git a/src/content/content.js b/src/content/content.js index 3bb8359..8a0199b 100644 --- a/src/content/content.js +++ b/src/content/content.js @@ -25,6 +25,9 @@ var seen = { this._map.set(key, event); this._list = null; }, + recordAll(events) { + for (let e of events) this.record(e); + }, get list() { return this._list || (this._list = [...this._map.values()]); } @@ -55,10 +58,21 @@ Messages.addHandler({ } } }, + allSeen(event) { + seen.recordAll(event.seen); + }, collect(event) { let list = seen.list; debug("COLLECT", list); return list; + }, + store(event) { + if (document.URL !== event.url) return; + document.documentElement.appendChild(document.createComment(event.data)); + }, + retrieve(event) { + if (document.URL !== event.url) return; + return document.documentElement.lastChild.textContent; } }); diff --git a/src/lib/Ver.js b/src/lib/Ver.js new file mode 100644 index 0000000..3d86140 --- /dev/null +++ b/src/lib/Ver.js @@ -0,0 +1,45 @@ +"use strict"; +class Ver { + constructor(version) { + if (version instanceof Ver) { + this.versionString = version.versionString; + this.parts = version.parts; + } else { + this.versionString = version.toString(); + this.parts = this.versionString.split("."); + } + } + toString() { + return this.versionString; + } + compare(other) { + if (!(other instanceof Ver)) other = new Ver(other); + let p1 = this.parts, p2 = other.parts; + let maxParts = Math.max(p1.length, p2.length); + for (let j = 0; j < maxParts; j++) { + let s1 = p1[j] || "0"; + let s2 = p2[j] || "0"; + if (s1 === s2) continue; + let n1 = parseInt(s1); + let n2 = parseInt(s2); + if (n1 > n2) return 1; + if (n1 < n2) return -1; + // if numeric part is the same, an alphabetic suffix decreases value + // so a "pure number" wins + if (!/\D/.test(s1)) return 1; + if (!/\D/.test(s2)) return -1; + // both have an alhpabetic suffix, let's compare lexicographycally + if (s1 > s2) return 1; + if (s1 < s2) return -1; + } + return 0; + } + static is(ver1, op, ver2) { + let res = new Ver(ver1).compare(ver2); + + return op.includes("!=") && res !== 0 || + op.includes("=") && res === 0 || + op.includes("<") && res === -1 || + op.includes(">") && res === 1; + } +} \ No newline at end of file