diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..55fdd2f --- /dev/null +++ b/.editorconfig @@ -0,0 +1,8 @@ +root=true + +[*] +insert_final_newline=true +trim_trailing_whitespace=true +charset=utf-8 +indent_style=space +indent_size=2 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..f206d5b --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +/add-on/node_modules +/app/node_modules +/app/config diff --git a/.gitmodules b/.gitmodules new file mode 100644 index 0000000..5b984f8 --- /dev/null +++ b/.gitmodules @@ -0,0 +1,3 @@ +[submodule "lib/webExtNativeMsg"] + path = app/lib/webExtNativeMsg + url = https://github.com/asamuzaK/webExtNativeMsg.git diff --git a/README.org b/README.org new file mode 100644 index 0000000..a195600 --- /dev/null +++ b/README.org @@ -0,0 +1,43 @@ +* Install + +You have to install both the Firefox add-on and native application. + + +This add-on needs a password store (i.e., a ~~/.password-store~ +directory with ~*.gpg~ files in it) as defined by +[[https://www.passwordstore.org/][passwordstore.org]] but the ~pass~ shell script is *not* +needed. + +[[file:media/screenshot.png]] + +Fetch this repository and its submodules: + +#+BEGIN_SRC text +$ git submodule update --init +#+END_SRC + +** Install the Firefox add-on + +Install the add-on through [[https://addons.mozilla.org/en-US/firefox/addon/passwe/][Firefox add-ons website]]. + +** Install the native application + +The native application is implemented in NodeJS, so, [[https://nodejs.org][install NodeJS +(>= 8)]] first. Then, type: + +#+BEGIN_SRC text +$ cd app +$ npm install +$ node install.js +#+END_SRC + +* Use the Firefox add-on + +*NOTE:* Make sure that gpg-agent has your passphrase in its cache +because Firefox won't let gpg-agent ask for the passphrase. This is a +known bug and browserpass has [[https://github.com/dannyvankooten/browserpass/issues/23][the same problem]]. I'm working on fixing +it with the help of both the mozilla's [[https://mail.mozilla.org/pipermail/dev-addons/2017-July/002966.html][dev-addons mailing list]] and +[[https://lists.gnupg.org/pipermail/gnupg-users/2017-July/058660.html][gnupg-users mailing list]]. + +In Firefox, go to your favorite website and click the cat footprint +icon in the toolbar. diff --git a/add-on/.tidyrc b/add-on/.tidyrc new file mode 100644 index 0000000..e69de29 diff --git a/add-on/content_scripts/fill-form.js b/add-on/content_scripts/fill-form.js new file mode 100644 index 0000000..0bf0e14 --- /dev/null +++ b/add-on/content_scripts/fill-form.js @@ -0,0 +1,95 @@ +/* eslint-env browser */ +/* global browser */ + +/** + * + * Author: dannyvankooten + * + * MIT License + * + * Copyright (c) 2017 passwe + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +function queryAllVisible (parent, selector) { + var result = [] + var elems = parent.querySelectorAll(selector) + for (var i = 0; i < elems.length; i++) { + // Elem or its parent has a style 'display: none', + // or it is just too narrow to be a real field (a trap for spammers?). + if (elems[i].offsetWidth < 50 || elems[i].offsetHeight < 10) { continue } + + var style = window.getComputedStyle(elems[i]) + // Elem takes space on the screen, but it or its parent is hidden with a visibility style. + if (style.visibility === 'hidden') { continue } + + // This element is visible, will use it. + result.push(elems[i]) + } + return result +} + +function queryFirstVisible (parent, selector) { + var elems = queryAllVisible(parent, selector) + return (elems.length > 0) ? elems[0] : undefined +} + +function form () { + var passwordField = queryFirstVisible(document, 'input[type=password]') + return (passwordField && passwordField.form) ? passwordField.form : undefined +} + +function field (selector) { + return queryFirstVisible(form(), selector) || document.createElement('input') +} + +function update (el, value) { + if (!value.length) { + return false + } + + el.setAttribute('value', value) + el.value = value + + var eventNames = [ 'click', 'focus', 'keypress', 'keydown', 'keyup', 'input', 'blur', 'change' ] + eventNames.forEach(function (eventName) { + el.dispatchEvent(new Event(eventName, {'bubbles': true})) + }) + return true +} + +function fillForm (request) { + let username = request.username + let password = request.password + + update(field('input[type=password]'), password) + update(field('input[type=email], input[type=text], input:first-of-type'), username) + + let passwordInputs = queryAllVisible(form(), 'input[type=password]') + if (passwordInputs.length > 1) { + passwordInputs[1].select() + } else { + // window.requestAnimationFrame(() => { + // field('[type=submit]').click() + // }) + } +} + +browser.runtime.onMessage.addListener(fillForm) diff --git a/add-on/icons/border-32.png b/add-on/icons/border-32.png new file mode 100644 index 0000000..bcf6379 Binary files /dev/null and b/add-on/icons/border-32.png differ diff --git a/add-on/icons/border-48.png b/add-on/icons/border-48.png new file mode 100644 index 0000000..90687de Binary files /dev/null and b/add-on/icons/border-48.png differ diff --git a/add-on/manifest.json b/add-on/manifest.json new file mode 100644 index 0000000..b52b59f --- /dev/null +++ b/add-on/manifest.json @@ -0,0 +1,29 @@ +{ + "manifest_version": 2, + "name": "passwe", + "version": "0.1.0", + "description": "Access and present the content of your password store (pass) as a web-browser addon.", + "homepage_url": "https://gitlab.petton.fr/passwe/passwe", + "icons": { + "48": "icons/border-48.png" + }, + + "applications": { + "gecko": { + "id": "passwe@cassou.me" + } + }, + + "permissions": [ + "activeTab", + "nativeMessaging" + ], + + "browser_action": { + "browser_style": true, + "default_icon": "icons/border-32.png", + "default_title": "passwe", + "default_popup": "popup/content.html" + } + } +} diff --git a/add-on/package.json b/add-on/package.json new file mode 100644 index 0000000..f7c6094 --- /dev/null +++ b/add-on/package.json @@ -0,0 +1,13 @@ +{ + "name": "passwe", + "devDependencies": { + "standard": "*" + }, + "scripts": { + "test": "standard", + "fix": "standard --fix" + }, + "dependencies": { + "webextension-polyfill": "^0.1.1" + } +} diff --git a/add-on/popup/content.css b/add-on/popup/content.css new file mode 100644 index 0000000..86270f2 --- /dev/null +++ b/add-on/popup/content.css @@ -0,0 +1,30 @@ +body { + width: 300px; +} + +input { + width: 100%; +} + +ul { + list-style: none; + margin-bottom: 0; + margin-top: 0; + padding-left: 0; + width: 100%; +} + +li { + border-bottom: 1px solid; +} + +li a { + display: block; + padding-bottom: 10px; + padding-left: 6px; + padding-top: 10px; +} + +li:hover { + background-color: grey; +} diff --git a/add-on/popup/content.html b/add-on/popup/content.html new file mode 100644 index 0000000..eb47277 --- /dev/null +++ b/add-on/popup/content.html @@ -0,0 +1,15 @@ + + + + + + + + + +
+ + + + + diff --git a/add-on/popup/content.js b/add-on/popup/content.js new file mode 100644 index 0000000..943978b --- /dev/null +++ b/add-on/popup/content.js @@ -0,0 +1,161 @@ +/* global browser */ + +const nativeApplicationName = 'passwe' +const sendNativeMessage = browser.runtime.sendNativeMessage.bind(null, nativeApplicationName) + +/** + * Add initial content to the popup. This function is called when the + * add-on button is clicked. + */ +function initializePopup (tab, allPassEntries) { + let div = document.createElement('div') + + let searchFieldNode = createSearchFieldNode() + let resultNode = createResultNode() + + div.appendChild(searchFieldNode) + div.appendChild(resultNode) + + searchFieldNode.value = urlToHostname(tab.url) + + updateResults(searchFieldNode.value, resultNode, allPassEntries) + searchFieldNode.addEventListener( + 'input', + () => updateResults(searchFieldNode.value, resultNode, allPassEntries) + ) + + replaceNodeContent(getContainer(), div) +} + +async function fillForm (passEntry) { + const queryTabs = browser.tabs.query({active: true, currentWindow: true}) + const activeTab = (await queryTabs)[0] + + await browser.tabs.executeScript(activeTab.id, { + file: '/node_modules/webextension-polyfill/dist/browser-polyfill.js' + }) + + await browser.tabs.executeScript(activeTab.id, { + file: '/content_scripts/fill-form.js' + }) + + const {username, password} = await sendNativeMessage({ 'action': 'read', passEntry }) + + await browser.tabs.sendMessage(activeTab.id, {username, password}) + + window.close() +} + +function updateResults (filter, resultNode, allPassEntries) { + if (filter.length <= 2) { + let text = document.createTextNode('Type some more') + replaceNodeContent(resultNode, text) + return + } + + let matchedEntries = allPassEntries.filter((passEntry) => entryIsMatching(passEntry, filter)) + replaceEntries(resultNode, matchedEntries) +} + +function replaceEntries (resultNode, passEntries) { + let entryList = document.createElement('ul') + entryList.setAttribute('class', 'results') + + passEntries.forEach((passEntry) => { + entryList.appendChild(entryToListItem(passEntry)) + }) + + replaceNodeContent(resultNode, entryList) +} + +function entryToListItem (passEntry) { + let anchor = document.createElement('a') + anchor.addEventListener('click', + () => fillForm(passEntry) + ) + anchor.textContent = passEntry + + let listItem = document.createElement('li') + listItem.appendChild(anchor) + + return listItem +} + +function entryIsMatching (entry, filter) { + return entry.indexOf(filter) !== -1 +} + +function urlToHostname (url) { + var anchor = document.createElement('a') + anchor.href = url + let hostname = anchor.hostname + if (hostname.startsWith('www.')) { + hostname = hostname.substring('www.'.length) + } + return hostname +} + +/** + * Ask the external application for a list of all pass entries. + */ +async function fetchAllPassEntries (callback) { + const sendMessage = sendNativeMessage({ 'action': 'list' }) + const results = await sendMessage + + if (!Array.isArray(results)) { + throw new Error(`Received data is not an array: ${results}`) + } + + return results +} + +/** +* Create and return a node representing the search field. +*/ +function createSearchFieldNode () { + let input = document.createElement('input') + input.setAttribute('type', 'text') + return input +} + +/** + * Create and return a div node to put search results into. + */ +function createResultNode () { + let div = document.createElement('div') + div.setAttribute('class', 'results') + return div +} + +/** + * Replace content of `parent` by `newContent`. + */ +function replaceNodeContent (parent, newContent) { + removeAll(parent) + parent.appendChild(newContent) +} + +/** + * Remove every node under `node`. + */ +function removeAll (node) { + while (node.firstChild) { + node.firstChild.remove() + } +} + +/** + * Return the popup's container. + */ +function getContainer () { + return document.getElementById('container') +} + +async function init () { + const fetchEntries = fetchAllPassEntries() + const queryTabs = browser.tabs.query({ currentWindow: true, active: true }) + + initializePopup((await queryTabs)[0], await fetchEntries) +} + +init().then() diff --git a/app/gpg-trace.sh b/app/gpg-trace.sh new file mode 100755 index 0000000..5d03f99 --- /dev/null +++ b/app/gpg-trace.sh @@ -0,0 +1,4 @@ +#!/usr/bin/env bash + +export PINENTRY_BINARY=/home/cassou/Documents/projects/firefox/passwe/app/pinentry.sh +exec /usr/bin/strace -f -s 65535 -o /tmp/stracelog /usr/bin/gpg2 "${@}" diff --git a/app/index.js b/app/index.js new file mode 100755 index 0000000..9c8d26f --- /dev/null +++ b/app/index.js @@ -0,0 +1,125 @@ +const {env, stdout, stdin} = require('process') +const {join} = require('path') +const {readdirSync, statSync} = require('fs') + +const {Input, Output} = require('./lib/webExtNativeMsg/index') + +const gpg = require('./node_modules/gpg') + +const input = new Input() + +/** + * read stdin + * @param {string|Buffer} chunk - chunk + * @returns {?Promise.>} - composite a Promise chain + */ +function readStdin (chunk) { + const arr = input.decode(chunk) + Array.isArray(arr) && arr.length && arr.forEach(msg => { + msg && handleMsg(msg) + }) +} + +/** + * handle message + * @param {*} msg - message + * @returns {Promise.} - results of each handler + */ +function handleMsg (msg) { + switch (msg.action) { + case 'list': + handleList() + break + case 'read': + handleRead(msg.passEntry) + break + } +} + +function handleRead (passEntry) { + let entryPath = getEntryPath(getStorePath(), passEntry) + let entryStat = statSync(entryPath) + + // Security: This check is important to avoid `passEntry` to contain + // a shell command by itself + if (!entryStat.isFile()) { + console.error('Provided pathEntry is not a valid file in the store') + return + } + + gpg.decryptFile(entryPath, (err, contents) => { + if (err) { + console.error(`There was an error decrypting ${entryPath}: ${err}`) + return + } + + // contents is a Buffer: https://nodejs.org/api/buffer.html + handleEntryContent(contents.toString()) + }) +} + +function handleEntryContent (contents) { + let lines = contents.split('\n') + let result = {} + result.password = lines[0] + lines.shift() + + lines.forEach((line) => { + let match = /^(.*?):(.*)$/.exec(line) + if (match) { + let key = match[1].trim() + let value = match[2].trim() + result[key] = value + } + }) + + writeStdout(result) +} + +function handleList () { + let store = getStorePath() + let entries = [] + findEntries(store, store, entries) + writeStdout(entries) +} + +function findEntries (store, directory, entries) { + const passEntryExtension = '.gpg' + const ignoredDirectories = ['.git'] + + let possibleEntries = readdirSync(directory) + + possibleEntries.forEach((filename) => { + let fullPath = join(directory, filename) + let entryStat = statSync(fullPath) + if (entryStat.isDirectory() && !ignoredDirectories.includes(filename)) { + findEntries(store, fullPath, entries) + } else if (entryStat.isFile() && filename.endsWith(passEntryExtension)) { + let passEntry = fullPath.substring(store.length, fullPath.length - passEntryExtension.length) + entries.push(passEntry) + } + }) +}; + +function getStorePath () { + return join(env.HOME, '.password-store/') +} + +function getEntryPath (storePath, passEntry) { + return join(storePath, passEntry) + '.gpg' +} + +/** + * write stdout + * @param {*} msg - message + * @returns {?Function} - write message to the Writable stream + */ +function writeStdout (msg) { + msg = (new Output()).encode(msg) + if (!msg) { + return null + } + stdout.write(msg) +} + +stdin.on('data', readStdin) diff --git a/app/install.js b/app/install.js new file mode 100755 index 0000000..e4e2c8d --- /dev/null +++ b/app/install.js @@ -0,0 +1,16 @@ +#!/usr/bin/env node + +const {Setup} = require('./lib/webExtNativeMsg/index') + +const setup = new Setup({ + hostDescription: 'Handles communication with pass, the standard unix password manager', + hostName: 'passwe', + mainScriptFile: 'index.js', + chromeExtensionIds: [ + 'chrome-extension://adekiifmddccinajhalcnpafkhpliaof/', + 'chrome-extension://lojbocmimicmblaolbjaijdfdmfmfjkg/' + ], + webExtensionIds: ['passwe@cassou.me'] +}) + +setup.run() diff --git a/app/lib/webExtNativeMsg b/app/lib/webExtNativeMsg new file mode 160000 index 0000000..a0eb55c --- /dev/null +++ b/app/lib/webExtNativeMsg @@ -0,0 +1 @@ +Subproject commit a0eb55cb162e4d56b9dd3141b5b23d5f98f7c10d diff --git a/app/package.json b/app/package.json new file mode 100644 index 0000000..3556eed --- /dev/null +++ b/app/package.json @@ -0,0 +1,33 @@ +{ + "name": "passwe", + "version": "0.1.0", + "description": "Serve a password store through JSON on stdin/stdout", + "main": "index.js", + "scripts": { + "test": "standard && jasmine" + }, + "repository": { + "type": "git", + "url": "https://gitlab.petton.fr/passwe/passwe" + }, + "keywords": [ + "password store", + "web extension", + "firefox addon", + "chrome addon" + ], + "author": "Damien Cassou ", + "license": "MIT", + "standard": { + "ignore": [ + "/lib/" + ] + }, + "devDependencies": { + "jasmine": "^2.6.0", + "standard": "*" + }, + "dependencies": { + "gpg": "DamienCassou/node-gpg#gpg2" + } +} diff --git a/app/spec/main-spec.js b/app/spec/main-spec.js new file mode 100644 index 0000000..399a906 --- /dev/null +++ b/app/spec/main-spec.js @@ -0,0 +1,7 @@ +/* global describe, it, expect */ + +describe('passwe', () => { + it('foo bar', () => { + expect(true).toBeTruthy() + }) +}) diff --git a/app/spec/support/jasmine.json b/app/spec/support/jasmine.json new file mode 100644 index 0000000..3ea3166 --- /dev/null +++ b/app/spec/support/jasmine.json @@ -0,0 +1,11 @@ +{ + "spec_dir": "spec", + "spec_files": [ + "**/*[sS]pec.js" + ], + "helpers": [ + "helpers/**/*.js" + ], + "stopSpecOnExpectationFailure": false, + "random": false +} diff --git a/media/screenshot.png b/media/screenshot.png new file mode 100644 index 0000000..027a898 Binary files /dev/null and b/media/screenshot.png differ