Files
browserpass-extension/src/background.js
2020-06-27 13:16:45 +02:00

1167 lines
36 KiB
JavaScript

//-------------------------------- Background initialisation --------------------------------//
"use strict";
require("chrome-extension-async");
const sha1 = require("sha1");
const idb = require("idb");
const BrowserpassURL = require("@browserpass/url");
const helpers = require("./helpers");
// native application id
var appID = "com.github.browserpass.native";
// OTP extension id
var otpID = [
"afjjoildnccgmjbblnklbohcbjehjaph", // webstore releases
"jbnpmhhgnchcoljeobafpinmchnpdpin", // github releases
"fcmmcnalhjjejhpnlfnddimcdlmpkbdf", // local unpacked
"browserpass-otp@maximbaz.com" // firefox
];
// default settings
var defaultSettings = {
autoSubmit: false,
gpgPath: null,
stores: {},
foreignFills: {},
username: null,
theme: "dark"
};
var authListeners = {};
var badgeCache = {
files: null,
settings: null,
expires: Date.now(),
isRefreshing: false
};
// the last text copied to the clipboard is stored here in order to be cleared after 60 seconds
let lastCopiedText = null;
chrome.browserAction.setBadgeBackgroundColor({
color: "#666"
});
// watch for tab updates
chrome.tabs.onUpdated.addListener((tabId, info) => {
// unregister any auth listeners for this tab
if (info.status === "complete") {
if (authListeners[tabId]) {
chrome.webRequest.onAuthRequired.removeListener(authListeners[tabId]);
delete authListeners[tabId];
}
}
// redraw badge counter
updateMatchingPasswordsCount(tabId);
});
// handle incoming messages
chrome.runtime.onMessage.addListener(function(message, sender, sendResponse) {
receiveMessage(message, sender, sendResponse);
// allow async responses after this function returns
return true;
});
// handle keyboard shortcuts
chrome.commands.onCommand.addListener(async command => {
switch (command) {
case "fillBest":
try {
const settings = await getFullSettings();
if (settings.tab.url.match(/^(chrome|about):/)) {
// only fill on real domains
return;
}
handleMessage(settings, { action: "listFiles" }, listResults => {
const logins = helpers.prepareLogins(listResults.files, settings);
const bestLogin = helpers.filterSortLogins(logins, "", true)[0];
if (bestLogin) {
handleMessage(settings, { action: "fill", login: bestLogin }, () => {});
}
});
} catch (e) {
console.log(e);
}
break;
}
});
// handle fired alarms
chrome.alarms.onAlarm.addListener(alarm => {
if (alarm.name === "clearClipboard") {
if (readFromClipboard() === lastCopiedText) {
copyToClipboard("", false);
}
lastCopiedText = null;
}
});
chrome.runtime.onInstalled.addListener(onExtensionInstalled);
//----------------------------------- Function definitions ----------------------------------//
/**
* Set badge text with the number of matching password entries
*
* @since 3.0.0
*
* @param int tabId Tab id
* @param bool forceRefresh force invalidate cache
* @return void
*/
async function updateMatchingPasswordsCount(tabId, forceRefresh = false) {
if (badgeCache.isRefreshing) {
return;
}
try {
if (forceRefresh || Date.now() > badgeCache.expires) {
badgeCache.isRefreshing = true;
let settings = await getFullSettings();
let response = await hostAction(settings, "list");
if (response.status != "ok") {
throw new Error(JSON.stringify(response));
}
const CACHE_TTL_MS = 60 * 1000;
badgeCache = {
files: response.data.files,
settings: settings,
expires: Date.now() + CACHE_TTL_MS,
isRefreshing: false
};
}
try {
const tab = await chrome.tabs.get(tabId);
badgeCache.settings.origin = new BrowserpassURL(tab.url).origin;
} catch (e) {
throw new Error(`Unable to determine domain of the tab with id ${tabId}`);
}
// Compule badge counter
const files = helpers.ignoreFiles(badgeCache.files, badgeCache.settings);
const logins = helpers.prepareLogins(files, badgeCache.settings);
const matchedPasswordsCount = logins.reduce(
(acc, login) => acc + (login.recent.count || login.inCurrentHost ? 1 : 0),
0
);
// Set badge for the current tab
chrome.browserAction.setBadgeText({
text: "" + (matchedPasswordsCount || ""),
tabId: tabId
});
} catch (e) {
console.log(e);
}
}
/**
* Copy text to clipboard and optionally clear it from the clipboard after one minute
*
* @since 3.2.0
*
* @param string text Text to copy
* @param boolean clear Whether to clear the clipboard after one minute
* @return void
*/
function copyToClipboard(text, clear = true) {
document.addEventListener(
"copy",
function(e) {
e.clipboardData.setData("text/plain", text);
e.preventDefault();
},
{ once: true }
);
document.execCommand("copy");
if (clear) {
lastCopiedText = text;
chrome.alarms.create("clearClipboard", { delayInMinutes: 1 });
}
}
/**
* Read plain text from clipboard
*
* @since 3.2.0
*
* @return string The current plaintext content of the clipboard
*/
function readFromClipboard() {
const ta = document.createElement("textarea");
// these lines are carefully crafted to make paste work in both Chrome and Firefox
ta.contentEditable = true;
ta.textContent = "";
document.body.appendChild(ta);
ta.select();
document.execCommand("paste");
const content = ta.value;
document.body.removeChild(ta);
return content;
}
/**
* Save login to recent list for current domain
*
* @since 3.0.0
*
* @param object settings Settings object
* @param string host Hostname
* @param object login Login object
* @param bool remove Remove this item from recent history
* @return void
*/
async function saveRecent(settings, login, remove = false) {
var ignoreInterval = 60000; // 60 seconds - don't increment counter twice within this window
// save store timestamp
localStorage.setItem("recent:" + login.store.id, JSON.stringify(Date.now()));
// update login usage count & timestamp
if (Date.now() > login.recent.when + ignoreInterval) {
login.recent.count++;
}
login.recent.when = Date.now();
settings.recent[sha1(settings.origin + sha1(login.store.id + sha1(login.login)))] =
login.recent;
// save to local storage
localStorage.setItem("recent", JSON.stringify(settings.recent));
// a new entry was added to the popup matching list, need to refresh the count
if (!login.inCurrentHost && login.recent.count === 1) {
updateMatchingPasswordsCount(settings.tab.id, true);
}
// save to usage log
try {
const DB_VERSION = 1;
const db = await idb.openDB("browserpass", DB_VERSION, {
upgrade(db) {
db.createObjectStore("log", { keyPath: "time" });
}
});
await db.add("log", { time: Date.now(), host: settings.origin, login: login.login });
} catch {
// ignore any errors and proceed without saving a log entry to Indexed DB
}
}
/**
* Call injected code to fill the form
*
* @param object settings Settings object
* @param object request Request details
* @param boolean allFrames Dispatch to all frames
* @param boolean allowForeign Allow foreign-origin iframes
* @param boolean allowNoSecret Allow forms that don't contain a password field
* @return array list of filled fields
*/
async function dispatchFill(settings, request, allFrames, allowForeign, allowNoSecret) {
request = Object.assign(deepCopy(request), {
allowForeign: allowForeign,
allowNoSecret: allowNoSecret,
foreignFills: settings.foreignFills[settings.origin] || {}
});
let perFrameResults = await chrome.tabs.executeScript(settings.tab.id, {
allFrames: allFrames,
code: `window.browserpass.fillLogin(${JSON.stringify(request)});`
});
// merge filled fields into a single array
let filledFields = perFrameResults
.reduce((merged, frameResult) => merged.concat(frameResult.filledFields), [])
.filter((val, i, merged) => merged.indexOf(val) === i);
// if user answered a foreign-origin confirmation,
// store the answers in the settings
let foreignFillsChanged = false;
for (let frame of perFrameResults) {
if (typeof frame.foreignFill !== "undefined") {
if (typeof settings.foreignFills[settings.origin] === "undefined") {
settings.foreignFills[settings.origin] = {};
}
settings.foreignFills[settings.origin][frame.foreignOrigin] = frame.foreignFill;
foreignFillsChanged = true;
}
}
if (foreignFillsChanged) {
await saveSettings(settings);
}
return filledFields;
}
/**
* Call injected code to focus or submit the form
*
* @param object settings Settings object
* @param object request Request details
* @param boolean allFrames Dispatch to all frames
* @param boolean allowForeign Allow foreign-origin iframes
* @return void
*/
async function dispatchFocusOrSubmit(settings, request, allFrames, allowForeign) {
request = Object.assign(deepCopy(request), {
allowForeign: allowForeign,
foreignFills: settings.foreignFills[settings.origin] || {}
});
let perFrameResults = await chrome.tabs.executeScript(settings.tab.id, {
allFrames: allFrames,
code: `window.browserpass.focusOrSubmit(${JSON.stringify(request)});`
});
// if necessary, dispatch Enter keypress to autosubmit the form
// currently only works on Chromium and requires debugger permission
try {
for (let frame of perFrameResults) {
if (frame.needPressEnter) {
chrome.debugger.attach({ tabId: settings.tab.id }, "1.2");
for (let type of ["keyDown", "char", "keyUp"]) {
chrome.debugger.sendCommand(
{ tabId: settings.tab.id },
"Input.dispatchKeyEvent",
{
type: type,
key: "Enter",
windowsVirtualKeyCode: 13,
nativeVirtualKeyCode: 13,
unmodifiedText: "\r",
text: "\r"
}
);
}
chrome.debugger.detach({ tabId: settings.tab.id });
break;
}
}
} catch (e) {}
}
/**
* Inject script
*
* @param object settings Settings object
* @param boolean allFrames Inject in all frames
* @return object Cancellable promise
*/
async function injectScript(settings, allFrames) {
const MAX_WAIT = 1000;
return new Promise(async (resolve, reject) => {
const waitTimeout = setTimeout(reject, MAX_WAIT);
await chrome.tabs.executeScript(settings.tab.id, {
allFrames: allFrames,
file: "js/inject.dist.js"
});
clearTimeout(waitTimeout);
resolve(true);
});
}
/**
* Fill form fields
*
* @param object settings Settings object
* @param object login Login object
* @param array fields List of fields to fill
* @return array List of filled fields
*/
async function fillFields(settings, login, fields) {
// inject script
try {
await injectScript(settings, false);
} catch {
throw new Error("Unable to inject script in the top frame");
}
let injectedAllFrames = false;
try {
await injectScript(settings, true);
injectedAllFrames = true;
} catch {
// we'll proceed with trying to fill only the top frame
}
// build fill request
var fillRequest = {
origin: new BrowserpassURL(settings.tab.url).origin,
login: login,
fields: fields
};
let allFrames = false;
let allowForeign = false;
let allowNoSecret = !fields.includes("secret");
let filledFields = [];
let importantFieldToFill = fields.includes("openid") ? "openid" : "secret";
// fill form via injected script
filledFields = filledFields.concat(
await dispatchFill(settings, fillRequest, allFrames, allowForeign, allowNoSecret)
);
if (injectedAllFrames) {
// try again using same-origin frames if we couldn't fill an "important" field
if (!filledFields.includes(importantFieldToFill)) {
allFrames = true;
filledFields = filledFields.concat(
await dispatchFill(settings, fillRequest, allFrames, allowForeign, allowNoSecret)
);
}
// try again using all available frames if we couldn't fill an "important" field
if (
!filledFields.includes(importantFieldToFill) &&
settings.foreignFills[settings.origin] !== false
) {
allowForeign = true;
filledFields = filledFields.concat(
await dispatchFill(settings, fillRequest, allFrames, allowForeign, allowNoSecret)
);
}
}
// try again, but don't require a password field (if it was required until now)
if (!allowNoSecret) {
allowNoSecret = true;
// try again using only the top frame
if (!filledFields.length) {
allFrames = false;
allowForeign = false;
filledFields = filledFields.concat(
await dispatchFill(settings, fillRequest, allFrames, allowForeign, allowNoSecret)
);
}
if (injectedAllFrames) {
// try again using same-origin frames
if (!filledFields.length) {
allFrames = true;
filledFields = filledFields.concat(
await dispatchFill(
settings,
fillRequest,
allFrames,
allowForeign,
allowNoSecret
)
);
}
// try again using all available frames
if (!filledFields.length && settings.foreignFills[settings.origin] !== false) {
allowForeign = true;
filledFields = filledFields.concat(
await dispatchFill(
settings,
fillRequest,
allFrames,
allowForeign,
allowNoSecret
)
);
}
}
}
if (!filledFields.length) {
throw new Error(`No fillable forms available for fields: ${fields.join(", ")}`);
}
// build focus or submit request
let focusOrSubmitRequest = {
origin: new BrowserpassURL(settings.tab.url).origin,
autoSubmit: getSetting("autoSubmit", login, settings),
filledFields: filledFields
};
// try to focus or submit form with the settings that were used to fill it
await dispatchFocusOrSubmit(settings, focusOrSubmitRequest, allFrames, allowForeign);
return filledFields;
}
/**
* Get Local settings from the extension
*
* @since 3.0.0
*
* @return object Local settings from the extension
*/
function getLocalSettings() {
var settings = deepCopy(defaultSettings);
for (var key in settings) {
var value = localStorage.getItem(key);
if (value !== null) {
settings[key] = JSON.parse(value);
}
}
return settings;
}
/**
* Get full settings from the extension and host application
*
* @since 3.0.0
*
* @return object Full settings object
*/
async function getFullSettings() {
var settings = getLocalSettings();
var configureSettings = Object.assign(deepCopy(settings), {
defaultStore: {}
});
var response = await hostAction(configureSettings, "configure");
if (response.status != "ok") {
settings.hostError = response;
}
settings.version = response.version;
// Fill store settings, only makes sense if 'configure' succeeded
if (response.status === "ok") {
if (Object.keys(settings.stores).length > 0) {
// there are user-configured stores present
for (var storeId in settings.stores) {
if (response.data.storeSettings.hasOwnProperty(storeId)) {
var fileSettings = JSON.parse(response.data.storeSettings[storeId]);
if (typeof settings.stores[storeId].settings !== "object") {
settings.stores[storeId].settings = {};
}
var storeSettings = settings.stores[storeId].settings;
for (var settingKey in fileSettings) {
if (!storeSettings.hasOwnProperty(settingKey)) {
storeSettings[settingKey] = fileSettings[settingKey];
}
}
}
}
} else {
// no user-configured stores, so use the default store
settings.stores.default = {
id: "default",
name: "pass",
path: response.data.defaultStore.path
};
var fileSettings = JSON.parse(response.data.defaultStore.settings);
if (typeof settings.stores.default.settings !== "object") {
settings.stores.default.settings = {};
}
var storeSettings = settings.stores.default.settings;
for (var settingKey in fileSettings) {
if (!storeSettings.hasOwnProperty(settingKey)) {
storeSettings[settingKey] = fileSettings[settingKey];
}
}
}
}
// Fill recent data
for (var storeId in settings.stores) {
var when = localStorage.getItem("recent:" + storeId);
if (when) {
settings.stores[storeId].when = JSON.parse(when);
} else {
settings.stores[storeId].when = 0;
}
}
settings.recent = localStorage.getItem("recent");
if (settings.recent) {
settings.recent = JSON.parse(settings.recent);
} else {
settings.recent = {};
}
// Fill current tab info
try {
settings.tab = (await chrome.tabs.query({ active: true, currentWindow: true }))[0];
let originInfo = new BrowserpassURL(settings.tab.url);
settings.host = originInfo.host; // TODO remove this after OTP extension is migrated
settings.origin = originInfo.origin;
} catch (e) {}
return settings;
}
/**
* Get most relevant setting value
*
* @param string key Setting key
* @param object login Login object
* @param object settings Settings object
* @return object Setting value
*/
function getSetting(key, login, settings) {
if (typeof login.settings[key] !== "undefined") {
return login.settings[key];
}
if (typeof settings.stores[login.store.id].settings[key] !== "undefined") {
return settings.stores[login.store.id].settings[key];
}
return settings[key];
}
/**
* Deep copy an object
*
* @since 3.0.0
*
* @param object obj an object to copy
* @return object a new deep copy
*/
function deepCopy(obj) {
return JSON.parse(JSON.stringify(obj));
}
/**
* Handle modal authentication requests (e.g. HTTP basic)
*
* @since 3.0.0
*
* @param object requestDetails Auth request details
* @return object Authentication credentials or {}
*/
function handleModalAuth(requestDetails) {
var launchHost = requestDetails.url.match(/:\/\/([^\/]+)/)[1];
// don't attempt authentication against the same login more than once
if (!this.login.allowFill) {
return {};
}
this.login.allowFill = false;
// don't attempt authentication outside the main frame
if (requestDetails.type !== "main_frame") {
return {};
}
// ensure the auth domain is the same, or ask the user for permissions to continue
if (launchHost !== requestDetails.challenger.host) {
var message =
"You are about to send login credentials to a domain that is different than " +
"the one you launched from the browserpass extension. Do you wish to proceed?\n\n" +
"Realm: " +
requestDetails.realm +
"\n" +
"Launched URL: " +
this.url +
"\n" +
"Authentication URL: " +
requestDetails.url;
if (!confirm(message)) {
return {};
}
}
// ask the user before sending credentials over an insecure connection
if (!requestDetails.url.match(/^https:/i)) {
var message =
"You are about to send login credentials via an insecure connection!\n\n" +
"Are you sure you want to do this? If there is an attacker watching your " +
"network traffic, they may be able to see your username and password.\n\n" +
"URL: " +
requestDetails.url;
if (!confirm(message)) {
return {};
}
}
// supply credentials
return {
authCredentials: {
username: this.login.fields.login,
password: this.login.fields.secret
}
};
}
/**
* Handle a message from elsewhere within the extension
*
* @since 3.0.0
*
* @param object settings Settings object
* @param mixed message Incoming message
* @param function(mixed) sendResponse Callback to send response
* @return void
*/
async function handleMessage(settings, message, sendResponse) {
// check that action is present
if (typeof message !== "object" || !message.hasOwnProperty("action")) {
sendResponse({ status: "error", message: "Action is missing" });
return;
}
// fetch file & parse fields if a login entry is present
try {
if (typeof message.login !== "undefined") {
await parseFields(settings, message.login);
}
} catch (e) {
sendResponse({
status: "error",
message: "Unable to fetch and parse login fields: " + e.toString()
});
return;
}
// route action
switch (message.action) {
case "getSettings":
sendResponse({
status: "ok",
settings: settings
});
break;
case "saveSettings":
try {
await saveSettings(message.settings);
sendResponse({ status: "ok" });
} catch (e) {
sendResponse({
status: "error",
message: e.message
});
}
break;
case "listFiles":
try {
var response = await hostAction(settings, "list");
if (response.status != "ok") {
throw new Error(JSON.stringify(response)); // TODO handle host error
}
let files = helpers.ignoreFiles(response.data.files, settings);
sendResponse({ status: "ok", files });
} catch (e) {
sendResponse({
status: "error",
message: "Unable to enumerate password files" + e.toString()
});
}
break;
case "copyPassword":
try {
copyToClipboard(message.login.fields.secret);
await saveRecent(settings, message.login);
sendResponse({ status: "ok" });
} catch (e) {
sendResponse({
status: "error",
message: "Unable to copy password"
});
}
break;
case "copyUsername":
try {
copyToClipboard(message.login.fields.login);
await saveRecent(settings, message.login);
sendResponse({ status: "ok" });
} catch (e) {
sendResponse({
status: "error",
message: "Unable to copy username"
});
}
break;
case "launch":
case "launchInNewTab":
try {
var url = message.login.fields.url || message.login.host;
if (!url) {
throw new Error("No URL is defined for this entry");
}
if (!url.match(/:\/\//)) {
url = "http://" + url;
}
const tab =
message.action === "launch"
? await chrome.tabs.update(settings.tab.id, { url: url })
: await chrome.tabs.create({ url: url });
if (authListeners[tab.id]) {
chrome.tabs.onUpdated.removeListener(authListeners[tab.id]);
delete authListeners[tab.id];
}
authListeners[tab.id] = handleModalAuth.bind({
url: url,
login: message.login
});
chrome.webRequest.onAuthRequired.addListener(
authListeners[tab.id],
{ urls: ["*://*/*"], tabId: tab.id },
["blocking"]
);
sendResponse({ status: "ok" });
} catch (e) {
sendResponse({
status: "error",
message: "Unable to launch URL: " + e.toString()
});
}
break;
case "fill":
try {
let fields = message.login.fields.openid ? ["openid"] : ["login", "secret"];
// dispatch initial fill request
var filledFields = await fillFields(settings, message.login, fields);
await saveRecent(settings, message.login);
// no need to check filledFields, because fillFields() already throws an error if empty
sendResponse({ status: "ok", filledFields: filledFields });
} catch (e) {
try {
sendResponse({
status: "error",
message: e.toString()
});
} catch (e) {
// TODO An error here is typically a closed message port, due to a popup taking focus
// away from the extension menu and the menu closing as a result. Need to investigate
// whether triggering the extension menu from the background script is possible.
console.log(e);
}
}
break;
case "clearUsageData":
try {
await clearUsageData();
sendResponse({ status: "ok" });
} catch (e) {
sendResponse({
status: "error",
message: e.message
});
}
break;
default:
sendResponse({
status: "error",
message: "Unknown action: " + message.action
});
break;
}
// trigger browserpass-otp
if (typeof message.login !== "undefined" && message.login.fields.hasOwnProperty("otp")) {
triggerOTPExtension(settings, message.action, message.login.fields.otp);
}
}
/**
* Send a request to the host app
*
* @since 3.0.0
*
* @param object settings Live settings object
* @param string action Action to run
* @param params object Additional params to pass to the host app
* @return Promise
*/
function hostAction(settings, action, params = {}) {
var request = {
settings: settings,
action: action
};
for (var key in params) {
request[key] = params[key];
}
return chrome.runtime.sendNativeMessage(appID, request);
}
/**
* Fetch file & parse fields
*
* @since 3.0.0
*
* @param object settings Settings object
* @param object login Login object
* @return void
*/
async function parseFields(settings, login) {
var response = await hostAction(settings, "fetch", {
storeId: login.store.id,
file: login.login + ".gpg"
});
if (response.status != "ok") {
throw new Error(JSON.stringify(response)); // TODO handle host error
}
// save raw data inside login
login.raw = response.data.contents;
// parse lines
login.fields = {
secret: ["secret", "password", "pass"],
login: ["login", "username", "user"],
openid: ["openid"],
otp: ["otp", "totp", "hotp"],
url: ["url", "uri", "website", "site", "link", "launch"]
};
login.settings = {
autoSubmit: { name: "autosubmit", type: "bool" }
};
var lines = login.raw.split(/[\r\n]+/).filter(line => line.trim().length > 0);
lines.forEach(function(line) {
// check for uri-encoded otp
if (line.match(/^otpauth:\/\/.+/)) {
login.fields.otp = { key: null, data: line };
return;
}
// split key / value & ignore non-k/v lines
var parts = line.match(/^(.+?):(.+)$/);
if (parts === null) {
return;
}
parts = parts
.slice(1)
.map(value => value.trim())
.filter(value => value.length);
if (parts.length != 2) {
return;
}
// assign to fields
for (var key in login.fields) {
if (
Array.isArray(login.fields[key]) &&
login.fields[key].includes(parts[0].toLowerCase())
) {
if (key === "otp") {
login.fields[key] = { key: parts[0].toLowerCase(), data: parts[1] };
} else {
login.fields[key] = parts[1];
}
break;
}
}
// assign to settings
for (var key in login.settings) {
if (
typeof login.settings[key].type !== "undefined" &&
login.settings[key].name === parts[0].toLowerCase()
) {
if (login.settings[key].type === "bool") {
login.settings[key] = ["true", "yes"].includes(parts[1].toLowerCase());
} else {
login.settings[key] = parts[1];
}
break;
}
}
});
// clean up unassigned fields
for (var key in login.fields) {
if (Array.isArray(login.fields[key])) {
if (key === "secret" && lines.length) {
login.fields.secret = lines[0];
} else if (key === "login") {
const defaultUsername = getSetting("username", login, settings);
login.fields[key] = defaultUsername || login.login.match(/([^\/]+)$/)[1];
} else {
delete login.fields[key];
}
}
}
for (var key in login.settings) {
if (typeof login.settings[key].type !== "undefined") {
delete login.settings[key];
}
}
}
/**
* Wrap inbound messages to fetch native configuration
*
* @since 3.0.0
*
* @param mixed message Incoming message
* @param MessageSender sender Message sender
* @param function(mixed) sendResponse Callback to send response
* @return void
*/
async function receiveMessage(message, sender, sendResponse) {
// restrict messages to this extension only
if (sender.id !== chrome.runtime.id) {
// silently exit without responding when the source is foreign
return;
}
try {
const settings = await getFullSettings();
handleMessage(settings, message, sendResponse);
} catch (e) {
// handle error
console.log(e);
sendResponse({ status: "error", message: e.toString() });
}
}
/**
* Clear usage data
*
* @since 3.0.10
*
* @return void
*/
async function clearUsageData() {
// clear local storage
localStorage.removeItem("foreignFills");
localStorage.removeItem("recent");
Object.keys(localStorage).forEach(key => {
if (key.startsWith("recent:")) {
localStorage.removeItem(key);
}
});
// clear Indexed DB
await idb.deleteDB("browserpass");
}
/**
* Save settings if they are valid
*
* @since 3.0.0
*
* @param object Final settings object
* @return void
*/
async function saveSettings(settings) {
let settingsToSave = deepCopy(settings);
// 'default' is our reserved name for the default store
delete settingsToSave.stores.default;
// verify that the native host is happy with the provided settings
var response = await hostAction(settingsToSave, "configure");
if (response.status != "ok") {
throw new Error(`${response.params.message}: ${response.params.error}`);
}
// before save, make sure to remove store settings that we receive from the host app
if (typeof settingsToSave.stores === "object") {
for (var store in settingsToSave.stores) {
delete settingsToSave.stores[store].settings;
}
}
for (var key in defaultSettings) {
if (settingsToSave.hasOwnProperty(key)) {
localStorage.setItem(key, JSON.stringify(settingsToSave[key]));
}
}
}
/**
* Trigger OTP extension (browserpass-otp)
*
* @since 3.0.13
*
* @param object settings Settings object
* @param string action Browserpass action
* @param object otp OTP field data
* @return void
*/
function triggerOTPExtension(settings, action, otp) {
// trigger otp extension
for (let targetID of otpID) {
chrome.runtime
.sendMessage(targetID, {
version: chrome.runtime.getManifest().version,
action: action,
otp: otp,
settings: {
host: settings.host,
origin: settings.origin,
tab: settings.tab
}
})
// Both response & error are noop functions, because we don't care about
// the response, and if there's an error it just means the otp extension
// is probably not installed. We can't detect that without requesting the
// management permission, so this is an acceptable workaround.
.then(
noop => null,
noop => null
);
}
}
/**
* Handle browser extension installation and updates
*
* @since 3.0.0
*
* @param object Event details
* @return void
*/
function onExtensionInstalled(details) {
// No permissions
if (!chrome.notifications) {
return;
}
var show = (id, title, message) => {
chrome.notifications.create(id, {
title: title,
message: message,
iconUrl: "icon.png",
type: "basic"
});
};
if (details.reason === "install") {
if (localStorage.getItem("installed") === null) {
localStorage.setItem("installed", Date.now());
show(
"installed",
"browserpass: Install native host app",
"Remember to install the complementary native host app to use this extension.\n" +
"Instructions here: https://github.com/browserpass/browserpass-native"
);
}
} else if (details.reason === "update") {
var changelog = {
3002000: "New permissions added to clear copied credentials after 60 seconds.",
3000000:
"New major update is out, please update the native host app to v3.\n" +
"Instructions here: https://github.com/browserpass/browserpass-native"
};
var parseVersion = version => {
var [major, minor, patch] = version.split(".");
return parseInt(major) * 1000000 + parseInt(minor) * 1000 + parseInt(patch);
};
var newVersion = parseVersion(chrome.runtime.getManifest().version);
var prevVersion = parseVersion(details.previousVersion);
Object.keys(changelog)
.sort()
.forEach(function(version) {
if (prevVersion < version && newVersion >= version) {
show(version.toString(), "browserpass: Important changes", changelog[version]);
}
});
}
}