Browse Source

Initial version

asyncify-app
Damien Cassou 4 years ago
parent
commit
de05d31066
No known key found for this signature in database GPG Key ID: A7123815F5DCE914
21 changed files with 597 additions and 0 deletions
  1. +8
    -0
      .editorconfig
  2. +3
    -0
      .gitignore
  3. +3
    -0
      .gitmodules
  4. +43
    -0
      README.org
  5. +0
    -0
      add-on/.tidyrc
  6. +95
    -0
      add-on/content_scripts/fill-form.js
  7. BIN
      add-on/icons/border-32.png
  8. BIN
      add-on/icons/border-48.png
  9. +29
    -0
      add-on/manifest.json
  10. +13
    -0
      add-on/package.json
  11. +30
    -0
      add-on/popup/content.css
  12. +15
    -0
      add-on/popup/content.html
  13. +161
    -0
      add-on/popup/content.js
  14. +4
    -0
      app/gpg-trace.sh
  15. +125
    -0
      app/index.js
  16. +16
    -0
      app/install.js
  17. +1
    -0
      app/lib/webExtNativeMsg
  18. +33
    -0
      app/package.json
  19. +7
    -0
      app/spec/main-spec.js
  20. +11
    -0
      app/spec/support/jasmine.json
  21. BIN
      media/screenshot.png

+ 8
- 0
.editorconfig View File

@ -0,0 +1,8 @@
root=true
[*]
insert_final_newline=true
trim_trailing_whitespace=true
charset=utf-8
indent_style=space
indent_size=2

+ 3
- 0
.gitignore View File

@ -0,0 +1,3 @@
/add-on/node_modules
/app/node_modules
/app/config

+ 3
- 0
.gitmodules View File

@ -0,0 +1,3 @@
[submodule "lib/webExtNativeMsg"]
path = app/lib/webExtNativeMsg
url = https://github.com/asamuzaK/webExtNativeMsg.git

+ 43
- 0
README.org View File

@ -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.

+ 0
- 0
add-on/.tidyrc View File


+ 95
- 0
add-on/content_scripts/fill-form.js View File

@ -0,0 +1,95 @@
/* eslint-env browser */
/* global browser */
/**
*
* Author: dannyvankooten <https://github.com/dannyvankooten/browserpass>
*
* 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)

BIN
add-on/icons/border-32.png View File

Before After
Width: 30  |  Height: 30  |  Size: 550 B

BIN
add-on/icons/border-48.png View File

Before After
Width: 48  |  Height: 48  |  Size: 225 B

+ 29
- 0
add-on/manifest.json View File

@ -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"
}
}
}

+ 13
- 0
add-on/package.json View File

@ -0,0 +1,13 @@
{
"name": "passwe",
"devDependencies": {
"standard": "*"
},
"scripts": {
"test": "standard",
"fix": "standard --fix"
},
"dependencies": {
"webextension-polyfill": "^0.1.1"
}
}

+ 30
- 0
add-on/popup/content.css View File

@ -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;
}

+ 15
- 0
add-on/popup/content.html View File

@ -0,0 +1,15 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<link rel="stylesheet" href="content.css"/>
</head>
<body>
<div class="container" id="container"></div>
<script type="application/javascript" src="/node_modules/webextension-polyfill/dist/browser-polyfill.js"></script>
<script type="application/javascript" src="content.js"></script>
</body>
</html>

+ 161
- 0
add-on/popup/content.js View File

@ -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()

+ 4
- 0
app/gpg-trace.sh View File

@ -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 "${@}"

+ 125
- 0
app/index.js View File

@ -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.<Array.<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.<Array>} - 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)

+ 16
- 0
app/install.js View File

@ -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()

+ 1
- 0
app/lib/webExtNativeMsg

@ -0,0 +1 @@
Subproject commit a0eb55cb162e4d56b9dd3141b5b23d5f98f7c10d

+ 33
- 0
app/package.json View File

@ -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 <damien@cassou.me>",
"license": "MIT",
"standard": {
"ignore": [
"/lib/"
]
},
"devDependencies": {
"jasmine": "^2.6.0",
"standard": "*"
},
"dependencies": {
"gpg": "DamienCassou/node-gpg#gpg2"
}
}

+ 7
- 0
app/spec/main-spec.js View File

@ -0,0 +1,7 @@
/* global describe, it, expect */
describe('passwe', () => {
it('foo bar', () => {
expect(true).toBeTruthy()
})
})

+ 11
- 0
app/spec/support/jasmine.json View File

@ -0,0 +1,11 @@
{
"spec_dir": "spec",
"spec_files": [
"**/*[sS]pec.js"
],
"helpers": [
"helpers/**/*.js"
],
"stopSpecOnExpectationFailure": false,
"random": false
}

BIN
media/screenshot.png View File

Before After
Width: 1031  |  Height: 659  |  Size: 74 KiB

Loading…
Cancel
Save