diff --git a/README.md b/README.md
index ad52d7db..05a847cf 100644
--- a/README.md
+++ b/README.md
@@ -25,6 +25,7 @@ In order to use Browserpass you must also install a [companion native messaging
- [Password store locations](#password-store-locations)
- [Options](#options)
- - [A note about autosubmit](#a-note-about-autosubmit)
+- - [A note about OTP](#a-note-about-otp)
- [Usage data](#usage-data)
- [Security](#security)
- [Privacy](#privacy)
@@ -33,7 +34,6 @@ In order to use Browserpass you must also install a [companion native messaging
- [Error: Unable to fetch and parse login fields](#error-unable-to-fetch-and-parse-login-fields)
- [How to use the same username and password pair on multiple domains](#how-to-use-the-same-username-and-password-pair-on-multiple-domains)
- [Why Browserpass on Firefox does not work on Mozilla domains?](#why-browserpass-on-firefox-does-not-work-on-mozilla-domains)
- - [Why is OTP not supported?](#why-is-otp-not-supported)
- [Building the extension](#building-the-extension)
- [Build locally](#build-locally)
- [Load an unpacked extension](#load-an-unpacked-extension)
@@ -217,15 +217,16 @@ Using the `Custom store locations` setting in the browser extension options, you
The list of available options:
-| Name | Description |
-| --------------------------------------------------------------- | ------------------------------------------------------------ |
-| Automatically submit forms after filling (aka `autoSubmit`) | Make Browserpass automatically submit the login form for you |
-| Default username (aka `username`) | Username to use when it's not defined in the password file |
-| Custom gpg binary (aka `gpgPath`) | Path to a custom `gpg` binary to use |
-| Custom store locations | List of password stores to use |
-| Custom store locations - badge background color (aka `bgColor`) | Badge background color for a given password store in popup |
-| Custom store locations - badge text color (aka `color`) | Badge text color for a given password store in popup |
-| Ignore items (aka `ignore`) | Ignore all matching logins |
+| Name | Description |
+| --------------------------------------------------------------- | ------------------------------------------------------------- |
+| Automatically submit forms after filling (aka `autoSubmit`) | Make Browserpass automatically submit the login form for you |
+| Enable support for OTP tokens (aka `enableOTP`) | Generate TOTP codes if a TOTP seed is found in the pass entry |
+| Default username (aka `username`) | Username to use when it's not defined in the password file |
+| Custom gpg binary (aka `gpgPath`) | Path to a custom `gpg` binary to use |
+| Custom store locations | List of password stores to use |
+| Custom store locations - badge background color (aka `bgColor`) | Badge background color for a given password store in popup |
+| Custom store locations - badge text color (aka `color`) | Badge text color for a given password store in popup |
+| Ignore items (aka `ignore`) | Ignore all matching logins |
Browserpass allows configuring certain settings in different places places using the following priority, highest first:
@@ -233,6 +234,7 @@ Browserpass allows configuring certain settings in different places places using
- `autoSubmit`
1. Options defined in `.browserpass.json` file located in the root of a password store:
- `autoSubmit`
+ - `enableOTP`
- `gpgPath`
- `username`
- `bgColor`
@@ -240,6 +242,7 @@ Browserpass allows configuring certain settings in different places places using
- `ignore`
1. Options defined in browser extension options:
- Automatically submit forms after filling (aka `autoSubmit`)
+ - Enable support for OTP tokens (aka `enableOTP`)
- Default username (aka `username`)
- Custom gpg binary (aka `gpgPath`)
- Custom store locations
@@ -252,6 +255,16 @@ While we provide autosubmit as an option for users, we do not recommend it. This
As the demand for autosubmit is extremely high, we have decided to provide it anyway - however it is disabled by default, and we recommend that users do not enable it.
+### A note about OTP
+
+Tools like `pass-otp` make it possible to use `pass` for generating OTP codes, however keeping both passwords and OTP URI in the same location diminishes the major benefit that OTP is supposed to provide: two factor authentication. The purpose of multi-factor authentication is to protect your account even when attackers gain access to your password store, but if your OTP seed is stored in the same place, all auth factors will be compromised at once. In particular, Browserpass has access to the entire contents of your password entries, so if it is ever compromised, all your accounts will be at risk, even though you signed up for 2FA.
+
+Browserpass is opinionated, it does not promote `pass-otp` and by default does not generate OTP codes from OTP seeds in password entries, even though there are other password managers that provide such functionality out of the box.
+
+There are valid scenarios for using `pass-otp` (e.g. it gives protection against intercepting your password during transmission), but users are strongly advised to very carefully consider whether `pass-otp` is really an appropriate solution - and if so, come up with their own ways of accessing OTP codes that conforms to their security requirements. For the majority of people `pass-otp` is not recommended; using any phone app like Authy will be a much better and more secure alternative, because this way attackers would have to not only break into your password store, but they would _also_ have to break into your phone.
+
+If you still want the OTP support regardless, you may enable it in the Browserpass settings.
+
## Usage data
Browserpass keeps metadata of recently used credentials in local storage and Indexed DB of the background page. This is first and foremost internal data to make Browserpass function properly, used for example to implement the [Password matching and sorting](#password-matching-and-sorting) algorithm, but nevertheless you might find it useful to explore using your browser's devtools. For example, if you are considering to rotate all passwords that you used in the past month (e.g. if you just found out that you had a malicious app installed for several weeks), you can retrieve such list from Indexed DB quite easily (open an issue if you need help).
@@ -360,16 +373,6 @@ The full list of blocked domains at the time of writing is:
- sync.services.mozilla.com
- testpilot.firefox.com
-### Why is OTP not supported?
-
-Tools like `pass-otp` make it possible to use `pass` for generating OTP codes, however keeping both passwords and OTP URI in the same location diminishes the major benefit that OTP is supposed to provide: two factor authentication. The purpose of multi-factor authentication is to protect your account even when attackers gain access to your password store, but if your OTP seed is stored in the same place, all auth factors will be compromised at once. In particular, Browserpass has access to the entire contents of your password entries, so if it is ever compromised, all your accounts will be at risk, even though you signed up for 2FA.
-
-Browserpass is opinionated, it does not promote `pass-otp` and intentionally does not support generating OTP codes from OTP URIs in password entiries, even though there are other password managers that provide such functionality.
-
-There are valid scenarios for using `pass-otp` (e.g. it gives protection against intercepting your password during transmission), but users are strongly advised to very carefully consider whether `pass-otp` is really an appropriate solution - and if so, come up with their own ways of accessing OTP codes that conforms to their security requirements (for example by using dmenu/rofi scripts). For the majority of people `pass-otp` is not recommended; using any phone app like Authy will be a much better and more secure alternative, because this way attackers would have to not only break into your password store, but they would _also_ have to break into your phone.
-
-If you still want the OTP support, it is provided via a separate extension [browserpass-otp](https://github.com/browserpass/browserpass-otp). That extension integrates with Browserpass to ensure a streamlined workflow, for example if the OTP extension is installed, it will be automatically triggered when Browserpass fills an entry and an OTP token is present.
-
## Building the extension
### Build locally
diff --git a/src/background.js b/src/background.js
index d451eeca..a5be17d2 100644
--- a/src/background.js
+++ b/src/background.js
@@ -10,14 +10,6 @@ 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,
@@ -26,6 +18,7 @@ var defaultSettings = {
foreignFills: {},
username: null,
theme: "dark",
+ enableOTP: false,
};
var authListeners = {};
@@ -562,7 +555,6 @@ async function getFullSettings() {
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) {}
@@ -750,6 +742,28 @@ async function handleMessage(settings, message, sendResponse) {
});
}
break;
+ case "copyOTP":
+ if (settings.enableOTP) {
+ try {
+ if (!message.login.fields.otp) {
+ throw new Exception("No OTP seed available");
+ }
+ copyToClipboard(helpers.makeTOTP(message.login.fields.otp.params));
+ sendResponse({ status: "ok" });
+ } catch (e) {
+ sendResponse({
+ status: "error",
+ message: "Unable to copy OTP token",
+ });
+ }
+ } else {
+ sendResponse({ status: "error", message: "OTP support is disabled" });
+ }
+ break;
+
+ case "getDetails":
+ sendResponse({ status: "ok", login: message.login });
+ break;
case "launch":
case "launchInNewTab":
@@ -831,9 +845,13 @@ async function handleMessage(settings, message, sendResponse) {
break;
}
- // trigger browserpass-otp
- if (typeof message.login !== "undefined" && message.login.fields.hasOwnProperty("otp")) {
- triggerOTPExtension(settings, message.action, message.login.fields.otp);
+ // copy OTP token after fill
+ if (
+ settings.enableOTP &&
+ typeof message.login !== "undefined" &&
+ message.login.fields.hasOwnProperty("otp")
+ ) {
+ copyToClipboard(helpers.makeTOTP(message.login.fields.otp.params));
}
}
@@ -885,7 +903,7 @@ async function parseFields(settings, login) {
secret: ["secret", "password", "pass"],
login: ["login", "username", "user"],
openid: ["openid"],
- otp: ["otp", "totp", "hotp"],
+ otp: ["otp", "totp"],
url: ["url", "uri", "website", "site", "link", "launch"],
};
login.settings = {
@@ -893,10 +911,9 @@ async function parseFields(settings, login) {
};
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;
+ // check for uri-encoded otp without line prefix
+ if (line.match(/^otpauth:\/\/.+/i)) {
+ line = `otp: ${line}`;
}
// split key / value & ignore non-k/v lines
@@ -918,11 +935,7 @@ async function parseFields(settings, login) {
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];
- }
+ login.fields[key] = parts[1];
break;
}
}
@@ -962,6 +975,41 @@ async function parseFields(settings, login) {
delete login.settings[key];
}
}
+
+ // preprocess otp
+ if (settings.enableOTP && login.fields.hasOwnProperty("otp")) {
+ if (login.fields.otp.match(/^otpauth:\/\/.+/i)) {
+ // attempt to parse otp data as URI
+ try {
+ let url = new URL(login.fields.otp.toLowerCase());
+ let otpParts = url.pathname.split("/").filter((s) => s.trim());
+ login.fields.otp = {
+ raw: login.fields.otp,
+ params: {
+ type: otpParts[0] === "otp" ? "totp" : otpParts[0],
+ secret: url.searchParams.get("secret").toUpperCase(),
+ algorithm: url.searchParams.get("algorithm") || "sha1",
+ digits: parseInt(url.searchParams.get("digits") || "6"),
+ period: parseInt(url.searchParams.get("period") || "30"),
+ },
+ };
+ } catch (e) {
+ throw new Exception(`Unable to parse URI: ${otp.data}`, e);
+ }
+ } else {
+ // use default params for secret-only otp data
+ login.fields.otp = {
+ raw: login.fields.otp,
+ params: {
+ type: "totp",
+ secret: login.fields.otp.toUpperCase(),
+ algorithm: "sha1",
+ digits: 6,
+ period: 30,
+ },
+ };
+ }
+ }
}
/**
@@ -1046,41 +1094,6 @@ async function saveSettings(settings) {
}
}
-/**
- * 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
*
diff --git a/src/fonts/SourceCodePro-LICENSE.txt b/src/fonts/SourceCodePro-LICENSE.txt
new file mode 100644
index 00000000..6f4c937e
--- /dev/null
+++ b/src/fonts/SourceCodePro-LICENSE.txt
@@ -0,0 +1,93 @@
+Copyright 2010, 2012 Adobe Systems Incorporated (http://www.adobe.com/), with Reserved Font Name 'Source'. All Rights Reserved. Source is a trademark of Adobe Systems Incorporated in the United States and/or other countries.
+
+This Font Software is licensed under the SIL Open Font License, Version 1.1.
+This license is copied below, and is also available with a FAQ at:
+http://scripts.sil.org/OFL
+
+
+-----------------------------------------------------------
+SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
+-----------------------------------------------------------
+
+PREAMBLE
+The goals of the Open Font License (OFL) are to stimulate worldwide
+development of collaborative font projects, to support the font creation
+efforts of academic and linguistic communities, and to provide a free and
+open framework in which fonts may be shared and improved in partnership
+with others.
+
+The OFL allows the licensed fonts to be used, studied, modified and
+redistributed freely as long as they are not sold by themselves. The
+fonts, including any derivative works, can be bundled, embedded,
+redistributed and/or sold with any software provided that any reserved
+names are not used by derivative works. The fonts and derivatives,
+however, cannot be released under any other type of license. The
+requirement for fonts to remain under this license does not apply
+to any document created using the fonts or their derivatives.
+
+DEFINITIONS
+"Font Software" refers to the set of files released by the Copyright
+Holder(s) under this license and clearly marked as such. This may
+include source files, build scripts and documentation.
+
+"Reserved Font Name" refers to any names specified as such after the
+copyright statement(s).
+
+"Original Version" refers to the collection of Font Software components as
+distributed by the Copyright Holder(s).
+
+"Modified Version" refers to any derivative made by adding to, deleting,
+or substituting -- in part or in whole -- any of the components of the
+Original Version, by changing formats or by porting the Font Software to a
+new environment.
+
+"Author" refers to any designer, engineer, programmer, technical
+writer or other person who contributed to the Font Software.
+
+PERMISSION & CONDITIONS
+Permission is hereby granted, free of charge, to any person obtaining
+a copy of the Font Software, to use, study, copy, merge, embed, modify,
+redistribute, and sell modified and unmodified copies of the Font
+Software, subject to the following conditions:
+
+1) Neither the Font Software nor any of its individual components,
+in Original or Modified Versions, may be sold by itself.
+
+2) Original or Modified Versions of the Font Software may be bundled,
+redistributed and/or sold with any software, provided that each copy
+contains the above copyright notice and this license. These can be
+included either as stand-alone text files, human-readable headers or
+in the appropriate machine-readable metadata fields within text or
+binary files as long as those fields can be easily viewed by the user.
+
+3) No Modified Version of the Font Software may use the Reserved Font
+Name(s) unless explicit written permission is granted by the corresponding
+Copyright Holder. This restriction only applies to the primary font name as
+presented to the users.
+
+4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
+Software shall not be used to promote, endorse or advertise any
+Modified Version, except to acknowledge the contribution(s) of the
+Copyright Holder(s) and the Author(s) or with their explicit written
+permission.
+
+5) The Font Software, modified or unmodified, in part or in whole,
+must be distributed entirely under this license, and must not be
+distributed under any other license. The requirement for fonts to
+remain under this license does not apply to any document created
+using the Font Software.
+
+TERMINATION
+This license becomes null and void if any of the above conditions are
+not met.
+
+DISCLAIMER
+THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
+EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
+MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
+OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
+COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
+INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
+DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
+FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
+OTHER DEALINGS IN THE FONT SOFTWARE.
diff --git a/src/fonts/SourceCodePro-Regular.ttf b/src/fonts/SourceCodePro-Regular.ttf
new file mode 100644
index 00000000..3563e734
Binary files /dev/null and b/src/fonts/SourceCodePro-Regular.ttf differ
diff --git a/src/helpers.js b/src/helpers.js
index 84ff4ce0..e486abf1 100644
--- a/src/helpers.js
+++ b/src/helpers.js
@@ -4,12 +4,15 @@
const FuzzySort = require("fuzzysort");
const sha1 = require("sha1");
const ignore = require("ignore");
+const hash = require("hash.js");
+const Authenticator = require("otplib").authenticator.Authenticator;
const BrowserpassURL = require("@browserpass/url");
module.exports = {
prepareLogins,
filterSortLogins,
ignoreFiles,
+ makeTOTP,
};
//----------------------------------- Function definitions ----------------------------------//
@@ -220,6 +223,37 @@ function filterSortLogins(logins, searchQuery, currentDomainOnly) {
return candidates;
}
+/**
+ * Generate TOTP token
+ *
+ * @since 3.6.0
+ *
+ * @param object params OTP generation params
+ * @return string Generated OTP code
+ */
+function makeTOTP(params) {
+ switch (params.algorithm) {
+ case "sha1":
+ case "sha256":
+ case "sha512":
+ break;
+ default:
+ throw new Error(`Unsupported TOTP algorithm: ${params.algorithm}`);
+ }
+
+ var generator = new Authenticator();
+ generator.options = {
+ crypto: {
+ createHmac: (a, k) => hash.hmac(hash[a], k),
+ },
+ algorithm: params.algorithm,
+ digits: params.digits,
+ step: params.period,
+ };
+
+ return generator.generate(params.secret);
+}
+
//----------------------------------- Private functions ----------------------------------//
/**
diff --git a/src/options/interface.js b/src/options/interface.js
index 732dfcc2..24451c66 100644
--- a/src/options/interface.js
+++ b/src/options/interface.js
@@ -55,6 +55,9 @@ function view(ctl, params) {
"Automatically submit forms after filling (not recommended)"
)
);
+ nodes.push(
+ createCheckbox.call(this, "enableOTP", "Enable support for OTP tokens (not recommended)")
+ );
nodes.push(createInput.call(this, "username", "Default username", "john.smith"));
nodes.push(createInput.call(this, "gpgPath", "Custom gpg binary", "/path/to/gpg"));
diff --git a/src/package.json b/src/package.json
index 178d98be..4d68a04b 100644
--- a/src/package.json
+++ b/src/package.json
@@ -18,10 +18,12 @@
"@browserpass/url": "^1.1.6",
"chrome-extension-async": "^3.4.1",
"fuzzysort": "^1.1.4",
+ "hash.js": "^1.1.7",
"idb": "^4.0.5",
"ignore": "^5.1.8",
"mithril": "^1.1.7",
"moment": "^2.27.0",
+ "otplib": "^11.0.0",
"sha1": "^1.1.1"
},
"devDependencies": {
diff --git a/src/popup/colors-dark.less b/src/popup/colors-dark.less
index 7e42edeb..768da75c 100644
--- a/src/popup/colors-dark.less
+++ b/src/popup/colors-dark.less
@@ -15,6 +15,11 @@
@hint-bg-color: #d79921,
@hint-color: #363636,
@match-text-bg-color: transparent,
- @match-text-color: #d79921
+ @match-text-color: #d79921,
+ @invert-text-color: #414141,
+ @snack-color: #525252,
+ @snack-label-color: #afafaf,
+ @progress-color: #bd861a,
+ @edit-bg-color: #4a4a4a
);
}
diff --git a/src/popup/colors-light.less b/src/popup/colors-light.less
index 273f62d9..ab7df917 100644
--- a/src/popup/colors-light.less
+++ b/src/popup/colors-light.less
@@ -15,12 +15,19 @@
@hint-bg-color: #1c7ed6,
@hint-color: #e7f5ff,
@match-text-bg-color: #cfecff,
- @match-text-color: #1873ea
+ @match-text-color: #1873ea,
+ @invert-text-color: #f1f3f5,
+ @snack-color: #7a7a7a,
+ @snack-label-color: #7a7a7a,
+ @progress-color: #c7d5ff,
+ @edit-bg-color: #ffffff
);
.part.login .name .line1 .recent,
.part.login .action.copy-password,
- .part.login .action.copy-user {
+ .part.login .action.copy-user,
+ .part.login .action.details,
+ .part.details .action.copy {
filter: invert(85%);
}
@@ -29,8 +36,22 @@
.part.login .action.copy-password:focus,
.part.login .action.copy-password:hover,
.part.login .action.copy-user:focus,
- .part.login .action.copy-user:hover {
+ .part.login .action.copy-user:hover,
+ .part.login .action.details:focus,
+ .part.login .action.details:hover {
// colour such that invert(85%) ~= @hover-bg-color
background-color: #0c0804;
}
+
+ .part.details .part.snack {
+ &.line-otp {
+ background: transparent;
+ }
+ .progress-container {
+ background-color: #ffffff;
+ z-index: -1;
+ margin-top: -4px;
+ height: 34px;
+ }
+ }
}
diff --git a/src/popup/colors.less b/src/popup/colors.less
index e28c8394..34e17246 100644
--- a/src/popup/colors.less
+++ b/src/popup/colors.less
@@ -12,7 +12,12 @@
@hint-bg-color,
@hint-color,
@match-text-bg-color,
- @match-text-color) {
+ @match-text-color,
+ @invert-text-color,
+ @snack-color,
+ @snack-label-color,
+ @progress-color,
+ @edit-bg-color) {
html,
body {
background-color: @bg-color;
@@ -27,6 +32,34 @@
color: @error-text-color;
}
+ .part.details {
+ .part {
+ &.snack {
+ background-color: @edit-bg-color;
+ border-color: @snack-color;
+ .label {
+ background-color: @snack-label-color;
+ color: @invert-text-color;
+ }
+ .char.num,
+ .char.punct {
+ color: @match-text-color;
+ }
+ .progress-container {
+ background: transparent;
+ .progress {
+ background-color: @progress-color;
+ }
+ }
+ }
+ &.raw textarea {
+ background-color: @edit-bg-color;
+ border-color: @snack-color;
+ color: @text-color;
+ }
+ }
+ }
+
.part.search {
background-color: @input-bg-color;
}
@@ -53,11 +86,11 @@
background-color: @default-bg-color;
}
- .part.login > .name:hover,
- .part.login > .name:focus,
- .part.login > .action:hover,
- .part.login > .action:focus,
- .part.login:focus > .name {
+ .part.login:not(.details-header) > .name:hover,
+ .part.login:not(.details-header) > .name:focus,
+ .part.login:not(.details-header) > .action:hover,
+ .part.login:not(.details-header) > .action:focus,
+ .part.login:not(.details-header):focus > .name {
background-color: @hover-bg-color;
}
diff --git a/src/popup/detailsInterface.js b/src/popup/detailsInterface.js
new file mode 100644
index 00000000..7c0a4507
--- /dev/null
+++ b/src/popup/detailsInterface.js
@@ -0,0 +1,144 @@
+module.exports = DetailsInterface;
+
+const m = require("mithril");
+const Moment = require("moment");
+const helpers = require("../helpers");
+
+/**
+ * Login details interface
+ *
+ * @since 3.6.0
+ *
+ * @param object settings Settings object
+ * @param array login Target login object
+ * @return void
+ */
+function DetailsInterface(settings, login) {
+ // public methods
+ this.attach = attach;
+ this.view = view;
+
+ //fields
+ this.settings = settings;
+ this.login = login;
+
+ // get basename & dirname of entry
+ this.login.basename = this.login.login.substr(this.login.login.lastIndexOf("/") + 1);
+ this.login.dirname = this.login.login.substr(0, this.login.login.lastIndexOf("/")) + "/";
+}
+
+/**
+ * Attach the interface on the given element
+ *
+ * @since 3.6.0
+ *
+ * @param DOMElement element Target element
+ * @return void
+ */
+function attach(element) {
+ m.mount(element, this);
+}
+
+/**
+ * Generates vnodes for render
+ *
+ * @since 3.6.0
+ *
+ * @param function ctl Controller
+ * @param object params Runtime params
+ * @return []Vnode
+ */
+function view(ctl, params) {
+ const login = this.login;
+ const storeBgColor = login.store.bgColor || login.store.settings.bgColor;
+ const storeColor = login.store.color || login.store.settings.color;
+ const passChars = login.fields.secret.split("").map((c) => {
+ if (c.match(/[0-9]/)) {
+ return m("span.char.num", c);
+ } else if (c.match(/[^\w\s]/)) {
+ return m("span.char.punct", c);
+ }
+ return m("span.char", c);
+ });
+
+ var nodes = [];
+ nodes.push(
+ m("div.part.login.details-header", [
+ m("div.name", [
+ m("div.line1", [
+ m(
+ "div.store.badge",
+ {
+ style: `background-color: ${storeBgColor};
+ color: ${storeColor}`,
+ },
+ login.store.name
+ ),
+ m("div.path", [m.trust(login.dirname)]),
+ login.recent.when > 0
+ ? m("div.recent", {
+ title:
+ "Used here " +
+ login.recent.count +
+ " time" +
+ (login.recent.count > 1 ? "s" : "") +
+ ", last " +
+ Moment(new Date(login.recent.when)).fromNow(),
+ })
+ : null,
+ ]),
+ m("div.line2", [m.trust(login.basename)]),
+ ]),
+ ]),
+ m("div.part.details", [
+ m("div.part.snack.line-secret", [
+ m("div.label", "Secret"),
+ m("div.chars", passChars),
+ m("div.action.copy", { onclick: () => login.doAction("copyPassword") }),
+ ]),
+ m("div.part.snack.line-login", [
+ m("div.label", "Login"),
+ m("div", login.fields.login),
+ m("div.action.copy", { onclick: () => login.doAction("copyUsername") }),
+ ]),
+ (() => {
+ if (
+ this.settings.enableOTP &&
+ login.fields.otp &&
+ login.fields.otp.params.type === "totp"
+ ) {
+ // update progress
+ let progress = this.progress;
+ let updateProgress = (vnode) => {
+ let period = login.fields.otp.params.period;
+ let remaining = period - ((Date.now() / 1000) % period);
+ vnode.dom.style.transition = "none";
+ vnode.dom.style.width = `${(remaining / period) * 100}%`;
+ setTimeout(function () {
+ vnode.dom.style.transition = `width linear ${remaining}s`;
+ vnode.dom.style.width = "0%";
+ }, 100);
+ setTimeout(function () {
+ m.redraw();
+ }, remaining);
+ };
+ let progressNode = m("div.progress", {
+ oncreate: updateProgress,
+ onupdate: updateProgress,
+ });
+
+ // display otp snack
+ return m("div.part.snack.line-otp", [
+ m("div.label", "Token"),
+ m("div.progress-container", progressNode),
+ m("div", helpers.makeTOTP(login.fields.otp.params)),
+ m("div.action.copy", { onclick: () => login.doAction("copyOTP") }),
+ ]);
+ }
+ })(),
+ m("div.part.raw", m("textarea", login.raw.trim())),
+ ])
+ );
+
+ return nodes;
+}
diff --git a/src/popup/icon-copy.svg b/src/popup/icon-copy.svg
new file mode 100644
index 00000000..2d505b3d
--- /dev/null
+++ b/src/popup/icon-copy.svg
@@ -0,0 +1,14 @@
+
+
+
diff --git a/src/popup/icon-details.svg b/src/popup/icon-details.svg
new file mode 100644
index 00000000..d040a712
--- /dev/null
+++ b/src/popup/icon-details.svg
@@ -0,0 +1,8 @@
+
+
diff --git a/src/popup/interface.js b/src/popup/interface.js
index 785fc4aa..d96c7528 100644
--- a/src/popup/interface.js
+++ b/src/popup/interface.js
@@ -106,15 +106,20 @@ function view(ctl, params) {
]),
m("div.line2", [m.trust(result.display)]),
]),
+ m("div.action.copy-user", {
+ tabindex: 0,
+ title: "Copy username | ",
+ action: "copyUsername",
+ }),
m("div.action.copy-password", {
tabindex: 0,
title: "Copy password | ",
action: "copyPassword",
}),
- m("div.action.copy-user", {
+ m("div.action.details", {
tabindex: 0,
- title: "Copy username | ",
- action: "copyUsername",
+ title: "Open Details | ",
+ action: "getDetails",
}),
]
);
@@ -188,6 +193,8 @@ function keyHandler(e) {
e.target.querySelector(".action").focus();
} else if (e.target.nextElementSibling) {
e.target.nextElementSibling.focus();
+ } else {
+ this.doAction("getDetails");
}
break;
case "ArrowLeft":
@@ -218,6 +225,11 @@ function keyHandler(e) {
this.doAction(e.shiftKey ? "launchInNewTab" : "launch");
}
break;
+ case "KeyO":
+ if (e.ctrlKey) {
+ this.doAction("getDetails");
+ }
+ break;
case "Home": {
document.querySelector(".part.search input[type=text]").focus();
document.querySelector(".logins").scrollTo(0, 0);
diff --git a/src/popup/popup.js b/src/popup/popup.js
index 6b7da5fd..aad6ccec 100644
--- a/src/popup/popup.js
+++ b/src/popup/popup.js
@@ -3,7 +3,9 @@
require("chrome-extension-async");
const Interface = require("./interface");
+const DetailsInterface = require("./detailsInterface");
const helpers = require("../helpers");
+const m = require("mithril");
run();
@@ -21,11 +23,8 @@ function handleError(error, type = "error") {
if (type == "error") {
console.log(error);
}
- var errorNode = document.createElement("div");
- errorNode.setAttribute("class", "part " + type);
- errorNode.textContent = error.toString();
- document.body.innerHTML = "";
- document.body.appendChild(errorNode);
+ var node = { view: () => m(`div.part.${type}`, error.toString()) };
+ m.mount(document.body, node);
}
/**
@@ -100,6 +99,12 @@ async function withLogin(action) {
case "copyUsername":
handleError("Copying username to clipboard...", "notice");
break;
+ case "copyOTP":
+ handleError("Copying OTP token to clipboard...", "notice");
+ break;
+ case "getDetails":
+ handleError("Loading entry details...", "notice");
+ break;
default:
handleError("Please wait...", "notice");
break;
@@ -117,7 +122,18 @@ async function withLogin(action) {
if (response.status != "ok") {
throw new Error(response.message);
} else {
- window.close();
+ if (response.login && typeof response.login === "object") {
+ response.login.doAction = withLogin.bind({
+ settings: this.settings,
+ login: response.login,
+ });
+ }
+ if (action === "getDetails") {
+ var details = new DetailsInterface(this.settings, response.login);
+ details.attach(document.body);
+ } else {
+ window.close();
+ }
}
} catch (e) {
handleError(e);
diff --git a/src/popup/popup.less b/src/popup/popup.less
index 9ca9a3eb..984023ae 100644
--- a/src/popup/popup.less
+++ b/src/popup/popup.less
@@ -20,6 +20,13 @@
src: local("Open Sans Light"), url("/fonts/OpenSans-Light.ttf") format("truetype");
}
+@font-face {
+ font-family: "Source Code Pro";
+ font-style: normal;
+ font-weight: 400;
+ src: local("Source Code Pro"), url("/fonts/SourceCodePro-Regular.ttf") format("truetype");
+}
+
html,
body {
font-family: "Open Sans";
@@ -66,6 +73,11 @@ body {
padding: 1px 4px;
}
+.details .header {
+ display: flex;
+ margin-bottom: 4px;
+}
+
.part {
box-sizing: border-box;
display: flex;
@@ -91,6 +103,80 @@ body {
padding: 7px;
}
+.part.details {
+ flex-direction: column;
+ padding: 5px 10px 10px;
+ & > .part {
+ display: flex;
+ margin-bottom: 11px;
+ &:last-child {
+ margin-bottom: 0;
+ }
+ &.snack {
+ border: 1px solid;
+ border-radius: 2px;
+ height: 36px;
+ padding: 4px;
+ .char {
+ white-space: pre;
+ }
+ & > .label {
+ border-radius: 2px 0 0 2px;
+ cursor: default;
+ display: flex;
+ flex-grow: 0;
+ font-weight: bold;
+ justify-content: flex-end;
+ margin: -5px 8px -5px -5px;
+ padding: 4px 8px 4px 4px;
+ width: 3.25em;
+ }
+ & > :not(.label) {
+ display: flex;
+ align-items: center;
+ font-family: Source Code Pro, monospace;
+ }
+ & > .copy {
+ cursor: pointer;
+ flex-grow: 0;
+ padding: 0 24px 0 0;
+ background-image: url("/popup/icon-copy.svg");
+ background-position: top 4px right 4px;
+ background-repeat: no-repeat;
+ background-size: 16px;
+ margin: 2px;
+ }
+
+ & > .progress-container {
+ z-index: 2;
+ position: absolute;
+ margin: 30px 0 -4px calc(3.25em + 7px);
+ height: 1px;
+ width: calc(100% - 6.5em + 12px);
+ & > .progress {
+ height: 100%;
+ margin: 0;
+ }
+ }
+ }
+ &.raw textarea {
+ border: 1px solid;
+ border-radius: 2px;
+ flex-grow: 1;
+ font-family: Source Code Pro, monospace;
+ min-height: 110px;
+ min-width: 340px;
+ outline: none;
+ padding: 10px;
+ white-space: pre;
+ }
+ & > * {
+ flex-grow: 1;
+ align-items: center;
+ }
+ }
+}
+
.part.search {
padding: 6px 28px 6px 6px;
background-image: url("/popup/icon-search.svg");
@@ -134,10 +220,19 @@ body {
.part.login {
display: flex;
- cursor: pointer;
align-items: center;
height: @login-height;
+ &.details-header {
+ height: calc(@login-height + 6px);
+ padding: 0 4px;
+ outline: none;
+ }
+
+ &:not(.details-header) {
+ cursor: pointer;
+ }
+
&:hover,
&:focus {
outline: none;
@@ -186,6 +281,10 @@ body {
&.copy-user {
background-image: url("/popup/icon-user.svg");
}
+
+ &.details {
+ background-image: url("/popup/icon-details.svg");
+ }
}
}
diff --git a/src/popup/searchinterface.js b/src/popup/searchinterface.js
index 73e1126e..8d80ca8e 100644
--- a/src/popup/searchinterface.js
+++ b/src/popup/searchinterface.js
@@ -116,6 +116,12 @@ function view(ctl, params) {
);
}
break;
+ case "KeyO":
+ if (e.ctrlKey && e.target.selectionStart == e.target.selectionEnd) {
+ e.preventDefault();
+ self.popup.results[0].doAction("getDetails");
+ }
+ break;
case "End": {
if (e.target.selectionStart === e.target.value.length) {
let logins = document.querySelectorAll(".login");
diff --git a/src/yarn.lock b/src/yarn.lock
index 9291e981..a93c0c9f 100644
--- a/src/yarn.lock
+++ b/src/yarn.lock
@@ -514,7 +514,7 @@ hash-base@^3.0.0:
readable-stream "^3.6.0"
safe-buffer "^5.2.0"
-hash.js@^1.0.0, hash.js@^1.0.3:
+hash.js@^1.0.0, hash.js@^1.0.3, hash.js@^1.1.7:
version "1.1.7"
resolved "https://registry.yarnpkg.com/hash.js/-/hash.js-1.1.7.tgz#0babca538e8d4ee4a0f8988d68866537a003cf42"
integrity sha512-taOaskGt4z4SOANNseOviYDvjEJinIkRgmp7LbKP2YTTmVxWBl87s/uzK9r+44BclBSp2X7K1hqeNfz9JbBeXA==
@@ -772,6 +772,13 @@ os-browserify@~0.3.0:
resolved "https://registry.yarnpkg.com/os-browserify/-/os-browserify-0.3.0.tgz#854373c7f5c2315914fc9bfc6bd8238fdda1ec27"
integrity sha1-hUNzx/XCMVkU/Jv8a9gjj92h7Cc=
+otplib@^11.0.0:
+ version "11.0.1"
+ resolved "https://registry.yarnpkg.com/otplib/-/otplib-11.0.1.tgz#7d64aa87029f07c99c7f96819fb10cdb67dea886"
+ integrity sha512-oi57teljNyWTC/JqJztHOtSGeFNDiDh5C1myd+faocUtFAX27Sm1mbx69kpEJ8/JqrblI3kAm4Pqd6tZJoOIBQ==
+ dependencies:
+ thirty-two "1.0.2"
+
pako@~1.0.5:
version "1.0.11"
resolved "https://registry.yarnpkg.com/pako/-/pako-1.0.11.tgz#6c9599d340d54dfd3946380252a35705a6b992bf"
@@ -1080,6 +1087,11 @@ syntax-error@^1.1.1:
dependencies:
acorn-node "^1.2.0"
+thirty-two@1.0.2:
+ version "1.0.2"
+ resolved "https://registry.yarnpkg.com/thirty-two/-/thirty-two-1.0.2.tgz#4ca2fffc02a51290d2744b9e3f557693ca6b627a"
+ integrity sha1-TKL//AKlEpDSdEueP1V2k8prYno=
+
through2@^2.0.0:
version "2.0.5"
resolved "https://registry.yarnpkg.com/through2/-/through2-2.0.5.tgz#01c1e39eb31d07cb7d03a96a70823260b23132cd"