Fill best matching password entry with Ctrl+Shift+F (#131)

This commit is contained in:
Maxim Baz
2019-04-21 10:10:55 +02:00
committed by GitHub
parent 66f43b557f
commit 0b9e1435ad
7 changed files with 301 additions and 236 deletions

View File

@@ -121,7 +121,7 @@ Browserpass was designed with an assumption that certain conventions are being f
### First steps in browser extension
Click on the icon or use <kbd>Ctrl+Shift+L</kbd> to open Browserpass with the entries that match current domain.
Click on the icon or use <kbd>Ctrl+Shift+L</kbd> to open the Browserpass popup with the entries that match the current domain. You can also use <kbd>Ctrl+Shift+F</kbd> to fill the form with the best matching credentials without even opening the popup (the best matching credentials are the first ones on the list if you open the popup).
How to change the shortcut:
@@ -139,19 +139,20 @@ If you want to intentionally disable phishing attack protection and search the e
Note: If the cursor is located in the search input field, every shortcut that works on the selected entry will be applied on the first entry in the popup list.
| Shortcut | Action |
| ---------------------------------------------------- | ----------------------------------------------- |
| <kbd>Ctrl+Shift+L</kbd> | Open Browserpass popup |
| <kbd>Enter</kbd> | Submit form with currently selected credentials |
| Arrow keys and <kbd>Tab</kbd> / <kbd>Shift+Tab</kbd> | Navigate popup list |
| <kbd>Ctrl+C</kbd> | Copy password to clipboard |
| <kbd>Ctrl+Shift+C</kbd> | Copy username to clipboard |
| <kbd>Ctrl+G</kbd> | Open URL in the current tab |
| <kbd>Ctrl+Shift+G</kbd> | Open URL in the new tab |
| <kbd>Backspace</kbd> (with no search text entered) | Search passwords in the entire password store |
| ---------------------------------------------------- | ------------------------------------------------ |
| <kbd>Ctrl+Shift+L</kbd> | Open Browserpass popup |
| <kbd>Ctrl+Shift+F</kbd> | Fill the form with the best matching credentials |
| <kbd>Enter</kbd> | Submit form with currently selected credentials |
| Arrow keys and <kbd>Tab</kbd> / <kbd>Shift+Tab</kbd> | Navigate popup list |
| <kbd>Ctrl+C</kbd> | Copy password to clipboard |
| <kbd>Ctrl+Shift+C</kbd> | Copy username to clipboard |
| <kbd>Ctrl+G</kbd> | Open URL in the current tab |
| <kbd>Ctrl+Shift+G</kbd> | Open URL in the new tab |
| <kbd>Backspace</kbd> (with no search text entered) | Search passwords in the entire password store |
### Password matching and sorting
When you first open Browserpass popup, you will see a badge with the current domain name in the search input field:
When you first open the Browserpass popup, you will see a badge with the current domain name in the search input field:
![image](https://user-images.githubusercontent.com/1177900/54785353-52046a00-4c26-11e9-8497-8dc50701ddc4.png)

View File

@@ -57,6 +57,30 @@ chrome.runtime.onMessage.addListener(function(message, sender, sendResponse) {
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;
}
});
chrome.runtime.onInstalled.addListener(onExtensionInstalled);
//----------------------------------- Function definitions ----------------------------------//
@@ -78,27 +102,18 @@ async function updateMatchingPasswordsCount(tabId) {
}
// Get tab info
let currentDomain = undefined;
try {
const tab = await chrome.tabs.get(tabId);
currentDomain = new URL(tab.url).hostname;
settings.host = new URL(tab.url).hostname;
} catch (e) {
throw new Error(`Unable to determine domain of the tab with id ${tabId}`);
}
let matchedPasswordsCount = 0;
for (var storeId in response.data.files) {
for (var key in response.data.files[storeId]) {
const login = response.data.files[storeId][key].replace(/\.gpg$/i, "");
const domain = helpers.pathToDomain(storeId + "/" + login, currentDomain);
const inCurrentDomain =
currentDomain === domain || currentDomain.endsWith("." + domain);
const recent = settings.recent[sha1(currentDomain + sha1(storeId + sha1(login)))];
if (recent || inCurrentDomain) {
matchedPasswordsCount++;
}
}
}
const logins = helpers.prepareLogins(response.data.files, settings);
const matchedPasswordsCount = logins.reduce(
(acc, login) => acc + (login.recent.count || login.inCurrentDomain ? 1 : 0),
0
);
if (matchedPasswordsCount) {
// Set badge for the current tab

View File

@@ -1,10 +1,14 @@
//------------------------------------- Initialisation --------------------------------------//
"use strict";
const FuzzySort = require("fuzzysort");
const TldJS = require("tldjs");
const sha1 = require("sha1");
module.exports = {
pathToDomain
pathToDomain,
prepareLogins,
filterSortLogins
};
//----------------------------------- Function definitions ----------------------------------//
@@ -37,3 +41,237 @@ function pathToDomain(path, currentHost) {
return null;
}
/**
* Prepare list of logins based on provided files
*
* @since 3.1.0
*
* @param string array List of password files
* @param string object Settings object
* @return array List of logins
*/
function prepareLogins(files, settings) {
const logins = [];
let index = 0;
for (let storeId in files) {
for (let key in files[storeId]) {
// set login fields
const login = {
index: index++,
store: settings.stores[storeId],
login: files[storeId][key].replace(/\.gpg$/i, ""),
allowFill: true
};
login.domain = pathToDomain(storeId + "/" + login.login, settings.host);
login.inCurrentDomain =
settings.host == login.domain || settings.host.endsWith("." + login.domain);
login.recent =
settings.recent[sha1(settings.host + sha1(login.store.id + sha1(login.login)))];
if (!login.recent) {
login.recent = {
when: 0,
count: 0
};
}
logins.push(login);
}
}
return logins;
}
/**
* Filter and sort logins
*
* @since 3.1.0
*
* @param string array List of logins
* @param string object Settings object
* @return array Filtered and sorted list of logins
*/
function filterSortLogins(logins, searchQuery, currentDomainOnly) {
var fuzzyFirstWord = searchQuery.substr(0, 1) !== " ";
searchQuery = searchQuery.trim();
// get candidate list
var candidates = logins.map(candidate => {
let lastSlashIndex = candidate.login.lastIndexOf("/") + 1;
return Object.assign(candidate, {
path: candidate.login.substr(0, lastSlashIndex),
display: candidate.login.substr(lastSlashIndex)
});
});
var mostRecent = null;
if (currentDomainOnly) {
var recent = candidates.filter(function(login) {
if (login.recent.count > 0) {
// find most recently used login
if (!mostRecent || login.recent.when > mostRecent.recent.when) {
mostRecent = login;
}
return true;
}
return false;
});
var remainingInCurrentDomain = candidates.filter(
login => login.inCurrentDomain && !login.recent.count
);
candidates = recent.concat(remainingInCurrentDomain);
}
candidates.sort((a, b) => {
// show most recent first
if (a === mostRecent) {
return -1;
}
if (b === mostRecent) {
return 1;
}
// sort by frequency
var countDiff = b.recent.count - a.recent.count;
if (countDiff) {
return countDiff;
}
// sort by specificity, only if filtering for one domain
if (currentDomainOnly) {
var domainLevelsDiff =
(b.login.match(/\./g) || []).length - (a.login.match(/\./g) || []).length;
if (domainLevelsDiff) {
return domainLevelsDiff;
}
}
// sort alphabetically
return a.login.localeCompare(b.login);
});
if (searchQuery.length) {
let filter = searchQuery.split(/\s+/);
let fuzzyFilter = fuzzyFirstWord ? filter[0] : "";
let substringFilters = filter.slice(fuzzyFirstWord ? 1 : 0).map(w => w.toLowerCase());
// First reduce the list by running the substring search
substringFilters.forEach(function(word) {
candidates = candidates.filter(c => c.login.toLowerCase().indexOf(word) >= 0);
});
// Then run the fuzzy filter
let fuzzyResults = {};
if (fuzzyFilter) {
candidates = FuzzySort.go(fuzzyFilter, candidates, {
keys: ["login", "store.name"],
allowTypo: false
}).map(result => {
fuzzyResults[result.obj.login] = result;
return result.obj;
});
}
// Finally highlight all matches
candidates = candidates.map(c => highlightMatches(c, fuzzyResults, substringFilters));
}
// Prefix root entries with slash to let them have some visible path
candidates.forEach(c => {
c.path = c.path || "/";
});
return candidates;
}
//----------------------------------- Private functions ----------------------------------//
/**
* Highlight filter matches
*
* @since 3.0.0
*
* @param object entry password entry
* @param object fuzzyResults positions of fuzzy filter matches
* @param array substringFilters list of substring filters applied
* @return object entry with highlighted matches
*/
function highlightMatches(entry, fuzzyResults, substringFilters) {
// Add all positions of the fuzzy search to the array
let matches = (fuzzyResults[entry.login] && fuzzyResults[entry.login][0]
? fuzzyResults[entry.login][0].indexes
: []
).slice();
// Add all positions of substring searches to the array
let login = entry.login.toLowerCase();
for (let word of substringFilters) {
let startIndex = login.indexOf(word);
for (let i = 0; i < word.length; i++) {
matches.push(startIndex + i);
}
}
// Prepare the final array of matches before
matches = sortUnique(matches, (a, b) => a - b);
const OPEN = "<em>";
const CLOSE = "</em>";
let highlighted = "";
var matchesIndex = 0;
var opened = false;
for (var i = 0; i < entry.login.length; ++i) {
var char = entry.login[i];
if (i == entry.path.length) {
if (opened) {
highlighted += CLOSE;
}
var path = highlighted;
highlighted = "";
if (opened) {
highlighted += OPEN;
}
}
if (matches[matchesIndex] === i) {
matchesIndex++;
if (!opened) {
opened = true;
highlighted += OPEN;
}
} else {
if (opened) {
opened = false;
highlighted += CLOSE;
}
}
highlighted += char;
}
if (opened) {
opened = false;
highlighted += CLOSE;
}
let display = highlighted;
return Object.assign(entry, {
path: path,
display: display
});
}
/**
* Sort and remove duplicates
*
* @since 3.0.0
*
* @param array array items to sort
* @param function comparator sort comparator
* @return array sorted items without duplicates
*/
function sortUnique(array, comparator) {
return array
.sort(comparator)
.filter((elem, index, arr) => index == !arr.length || arr[index - 1] != elem);
}

View File

@@ -39,6 +39,12 @@
"suggested_key": {
"default": "Ctrl+Shift+L"
}
},
"fillBest": {
"suggested_key": {
"default": "Ctrl+Shift+F"
},
"description": "Fill form with the best matching credentials"
}
}
}

View File

@@ -42,6 +42,12 @@
"suggested_key": {
"default": "Ctrl+Shift+L"
}
},
"fillBest": {
"suggested_key": {
"default": "Ctrl+Shift+F"
},
"description": "Fill form with the best matching credentials"
}
}
}

View File

@@ -1,9 +1,9 @@
module.exports = Interface;
const m = require("mithril");
const FuzzySort = require("fuzzysort");
const Moment = require("moment");
const SearchInterface = require("./searchinterface");
const helpers = require("../helpers");
const LATEST_NATIVE_APP_VERSION = 3000003;
@@ -144,188 +144,11 @@ function view(ctl, params) {
/**
* Run a search
*
* @param string s Search string
* @param string searchQuery Search query
* @return void
*/
function search(s) {
var self = this;
var fuzzyFirstWord = s.substr(0, 1) !== " ";
s = s.trim();
// get candidate list
var candidates = this.logins.map(candidate => {
let lastSlashIndex = candidate.login.lastIndexOf("/") + 1;
return Object.assign(candidate, {
path: candidate.login.substr(0, lastSlashIndex),
display: candidate.login.substr(lastSlashIndex)
});
});
var mostRecent = null;
if (this.currentDomainOnly) {
var recent = candidates.filter(function(login) {
if (login.recent.count > 0) {
// find most recently used login
if (!mostRecent || login.recent.when > mostRecent.recent.when) {
mostRecent = login;
}
return true;
}
return false;
});
var remainingInCurrentDomain = candidates.filter(
login => login.inCurrentDomain && !login.recent.count
);
candidates = recent.concat(remainingInCurrentDomain);
}
candidates.sort((a, b) => {
// show most recent first
if (a === mostRecent) {
return -1;
}
if (b === mostRecent) {
return 1;
}
// sort by frequency
var countDiff = b.recent.count - a.recent.count;
if (countDiff) {
return countDiff;
}
// sort by specificity, only if filtering for one domain
if (this.currentDomainOnly) {
var domainLevelsDiff =
(b.login.match(/\./g) || []).length - (a.login.match(/\./g) || []).length;
if (domainLevelsDiff) {
return domainLevelsDiff;
}
}
// sort alphabetically
return a.login.localeCompare(b.login);
});
if (s.length) {
let filter = s.split(/\s+/);
let fuzzyFilter = fuzzyFirstWord ? filter[0] : "";
let substringFilters = filter.slice(fuzzyFirstWord ? 1 : 0).map(w => w.toLowerCase());
// First reduce the list by running the substring search
substringFilters.forEach(function(word) {
candidates = candidates.filter(c => c.login.toLowerCase().indexOf(word) >= 0);
});
// Then run the fuzzy filter
let fuzzyResults = {};
if (fuzzyFilter) {
candidates = FuzzySort.go(fuzzyFilter, candidates, {
keys: ["login", "store.name"],
allowTypo: false
}).map(result => {
fuzzyResults[result.obj.login] = result;
return result.obj;
});
}
// Finally highlight all matches
candidates = candidates.map(c => highlightMatches(c, fuzzyResults, substringFilters));
}
// Prefix root entries with slash to let them have some visible path
candidates.forEach(c => {
c.path = c.path || "/";
});
this.results = candidates;
}
/**
* Highlight filter matches
*
* @since 3.0.0
*
* @param object entry password entry
* @param object fuzzyResults positions of fuzzy filter matches
* @param array substringFilters list of substring filters applied
* @return object entry with highlighted matches
*/
function highlightMatches(entry, fuzzyResults, substringFilters) {
// Add all positions of the fuzzy search to the array
let matches = (fuzzyResults[entry.login] && fuzzyResults[entry.login][0]
? fuzzyResults[entry.login][0].indexes
: []
).slice();
// Add all positions of substring searches to the array
let login = entry.login.toLowerCase();
for (let word of substringFilters) {
let startIndex = login.indexOf(word);
for (let i = 0; i < word.length; i++) {
matches.push(startIndex + i);
}
}
// Prepare the final array of matches before
matches = sortUnique(matches, (a, b) => a - b);
const OPEN = "<em>";
const CLOSE = "</em>";
let highlighted = "";
var matchesIndex = 0;
var opened = false;
for (var i = 0; i < entry.login.length; ++i) {
var char = entry.login[i];
if (i == entry.path.length) {
if (opened) {
highlighted += CLOSE;
}
var path = highlighted;
highlighted = "";
if (opened) {
highlighted += OPEN;
}
}
if (matches[matchesIndex] === i) {
matchesIndex++;
if (!opened) {
opened = true;
highlighted += OPEN;
}
} else {
if (opened) {
opened = false;
highlighted += CLOSE;
}
}
highlighted += char;
}
if (opened) {
opened = false;
highlighted += CLOSE;
}
let display = highlighted;
return Object.assign(entry, {
path: path,
display: display
});
}
/**
* Sort and remove duplicates
*
* @since 3.0.0
*
* @param array array items to sort
* @param function comparator sort comparator
* @return array sorted items without duplicates
*/
function sortUnique(array, comparator) {
return array
.sort(comparator)
.filter((elem, index, arr) => index == !arr.length || arr[index - 1] != elem);
function search(searchQuery) {
this.results = helpers.filterSortLogins(this.logins, searchQuery, this.currentDomainOnly);
}
/**

View File

@@ -2,7 +2,6 @@
"use strict";
require("chrome-extension-async");
const sha1 = require("sha1");
const Interface = require("./interface");
const helpers = require("../helpers");
@@ -58,34 +57,11 @@ async function run() {
throw new Error(response.message);
}
var logins = [];
var index = 0;
for (var storeId in response.files) {
for (var key in response.files[storeId]) {
// set login fields
var login = {
index: index++,
store: settings.stores[storeId],
login: response.files[storeId][key].replace(/\.gpg$/i, ""),
allowFill: true
};
login.domain = helpers.pathToDomain(storeId + "/" + login.login, settings.host);
login.inCurrentDomain =
settings.host == login.domain || settings.host.endsWith("." + login.domain);
login.recent =
settings.recent[sha1(settings.host + sha1(login.store.id + sha1(login.login)))];
if (!login.recent) {
login.recent = {
when: 0,
count: 0
};
}
// bind handlers
login.doAction = withLogin.bind({ settings: settings, login: login });
logins.push(login);
}
const logins = helpers.prepareLogins(response.files, settings);
for (let login of logins) {
login.doAction = withLogin.bind({ settings: settings, login: login });
}
var popup = new Interface(settings, logins);
popup.attach(document.body);
} catch (e) {