├── .babelrc.js ├── .editorconfig ├── .eslintignore ├── .eslintrc.js ├── .gitignore ├── .npmignore ├── .prettierignore ├── .prettierrc.js ├── .travis.yml ├── LICENSE ├── README.md ├── assets ├── lock.svg ├── rasterize-favicons.bash └── unlock.svg ├── jest.config.js ├── package.json ├── package └── Arch Linux │ └── PKGBUILD ├── public ├── .eslintrc.yml ├── actions.js ├── components │ ├── App.js │ ├── Auth.js │ ├── CopyIcon.js │ ├── Directory.js │ ├── File.js │ ├── Icon.js │ ├── Line.js │ ├── List.js │ └── Search.js ├── css.js ├── domUtil.js ├── favicon-unlocked.png ├── favicon.js ├── favicon.png ├── icons.svg ├── index.ejs ├── main.js ├── promiseUtil.js ├── selection.js └── store.js ├── server ├── .eslintrc.yml ├── Keys.js ├── app.js ├── index.js ├── log.js └── promiseUtil.js ├── tests ├── api.js └── util │ └── generateKey.js ├── webpack.config.js └── yarn.lock /.babelrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | presets: [ 3 | ["@babel/preset-env", { loose: true }], 4 | ["@babel/preset-react", { pragma: "h" }], 5 | ], 6 | plugins: [["@babel/plugin-proposal-function-bind"]], 7 | } 8 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | charset = "utf-8" 5 | end_of_line = lf 6 | indent_size = 2 7 | indent_style = space 8 | insert_final_newline = true 9 | -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | dist/ 2 | package/ 3 | -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | parser: "babel-eslint", 3 | 4 | extends: [ 5 | "eslint:recommended", 6 | "plugin:react/recommended", 7 | "benoitz-prettier", 8 | ], 9 | 10 | rules: { 11 | "filenames/match-exported": "error", 12 | "react/no-unknown-property": "off", 13 | "react/prop-types": "off", 14 | "react/display-name": "off", 15 | }, 16 | 17 | env: { 18 | node: true, 19 | }, 20 | 21 | plugins: ["filenames", "react"], 22 | 23 | settings: { 24 | react: { 25 | pragma: "h", 26 | version: "16", 27 | }, 28 | }, 29 | } 30 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /node_modules/ 2 | /dist/ 3 | -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | *.key 2 | /* 3 | !/dist/ 4 | !/server/ 5 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | *.bash 2 | *.ejs 3 | *.key 4 | *.png 5 | *.svg 6 | /.editorconfig 7 | /.eslintignore 8 | /.gitignore 9 | /.npmignore 10 | /.prettierignore 11 | /LICENSE 12 | /dist 13 | /package/* 14 | /test-store/* 15 | /yarn.lock 16 | -------------------------------------------------------------------------------- /.prettierrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | semi: false, 3 | trailingComma: "all", 4 | } 5 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | node_js: 3 | - "8" 4 | - "10" 5 | - "12" 6 | script: 7 | - yarn run lint 8 | - yarn test 9 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2015 Benoît Zugmeyer 2 | 3 | 4 | Permission is hereby granted, free of charge, to any person obtaining a copy 5 | of this software and associated documentation files (the "Software"), to deal 6 | in the Software without restriction, including without limitation the rights 7 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 8 | copies of the Software, and to permit persons to whom the Software is 9 | furnished to do so, subject to the following conditions: 10 | 11 | 12 | The above copyright notice and this permission notice shall be included in 13 | all copies or substantial portions of the Software. 14 | 15 | 16 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 17 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 18 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 19 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 20 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 21 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 22 | THE SOFTWARE. 23 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # pass-web 2 | 3 | [![NPM version](https://img.shields.io/npm/v/pass-web.svg)](https://www.npmjs.com/package/pass-web) 4 | [![Build Status](https://travis-ci.org/BenoitZugmeyer/pass-web.svg?branch=master)](https://travis-ci.org/BenoitZugmeyer/pass-web) 5 | 6 | A web interface for [pass](http://www.passwordstore.org/) (password-store). 7 | 8 | ## Install 9 | 10 | For those lucky enough to run Arch Linux, you should install it [from 11 | AUR](https://aur.archlinux.org/packages/pass-web) 12 | 13 | For other, you can install it via npm: 14 | 15 | ``` 16 | $ npm install -g pass-web 17 | ``` 18 | 19 | You will need nodejs 5+ to run it. 20 | 21 | ## Usage 22 | 23 | The executable is called `pass-web`. Use `pass-web --help` to get help. 24 | 25 | ## HTTPS concerns 26 | 27 | You should always use HTTPS to serve this application. Please use the `--cert` and `--key` options 28 | to provide an SSL certificate and key, or use another HTTP server (like nginx) configured to serve 29 | this through an HTTPS-enabled reverse proxy. 30 | 31 | ## Demo 32 | 33 | For a preview of the interface you'll get when using this project, go to 34 | [https://benoitzugmeyer.github.io/pass-web/](https://benoitzugmeyer.github.io/pass-web/) 35 | 36 | ## Notes 37 | 38 | This is currently a read-only interface, and there is no plan to support all the features 39 | of pass. The goal was to have a nice and simple access to the password store from anywhere. But if 40 | you need other features, feel free to ask or contribute. 41 | 42 | This project may have some security flaws. Please open an issue if something's fucky. 43 | -------------------------------------------------------------------------------- /assets/lock.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 6 | 9 | 13 | 14 | -------------------------------------------------------------------------------- /assets/rasterize-favicons.bash: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | # Didn't find a proper rasterize loader for webpack (that doesn't use phantomjs), so I'll just put 3 | # this here. 4 | 5 | set -euo pipefail 6 | 7 | cd $(dirname "$0")/.. 8 | 9 | rasterize () { 10 | local input="assets/$1" 11 | local output="public/$2" 12 | inkscape -w 32 -h 32 -e "$output" "$input" 13 | pngquant -f -o "$output" "$output" 14 | } 15 | 16 | rasterize lock.svg favicon.png 17 | rasterize unlock.svg favicon-unlocked.png 18 | -------------------------------------------------------------------------------- /assets/unlock.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 6 | 11 | 12 | 16 | 17 | 18 | 23 | 24 | 25 | -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | clearMocks: true, 3 | testEnvironment: "node", 4 | testMatch: ["/tests/api.js"], 5 | transform: {}, 6 | } 7 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "pass-web", 3 | "version": "1.0.0-beta.18", 4 | "description": "A web interface for pass (password-store)", 5 | "main": "server/index.js", 6 | "scripts": { 7 | "test": "jest", 8 | "lint": "eslint .", 9 | "prepublish": "rm -rf dist && webpack --mode production", 10 | "update-demo": "NODE_ENV=demo webpack --mode production && git checkout gh-pages && mv dist/* . && rm -r dist && git add -u && git commit -m 'Update demo'", 11 | "update-arch-package": "cd 'package/Arch Linux'; perl -ni -e \"s/(?<=^_npmpkgver=).*/$npm_package_version/; s/(?<=^pkgver=).*/${npm_package_version//-/.}/; print unless /^\\w+sums=\\(/\" PKGBUILD; makepkg -g >> PKGBUILD" 12 | }, 13 | "bin": { 14 | "pass-web": "server/index.js" 15 | }, 16 | "author": "Benoît Zugmeyer", 17 | "license": "MIT", 18 | "engines": { 19 | "node": ">=5.0.0" 20 | }, 21 | "dependencies": { 22 | "body-parser": "^1.15.0", 23 | "express": "^4.13.3", 24 | "http-auth": "^3.1.1", 25 | "kbpgp": "^2.0.52", 26 | "minimist": "^1.2.0" 27 | }, 28 | "devDependencies": { 29 | "@babel/core": "^7", 30 | "@babel/plugin-proposal-function-bind": "^7.2.0", 31 | "@babel/preset-env": "^7", 32 | "@babel/preset-react": "^7", 33 | "babel-eslint": "^10", 34 | "babel-loader": "^8", 35 | "eslint": "^6", 36 | "eslint-config-benoitz-prettier": "^1.1.0", 37 | "eslint-plugin-filenames": "^1.0.0", 38 | "eslint-plugin-react": "^7.8.2", 39 | "file-loader": "^4", 40 | "html-webpack-plugin": "^3.2.0", 41 | "jest": "^24.8.0", 42 | "node-fetch": "^2.6.0", 43 | "preact": "^8.1.0", 44 | "prettier": "^1.18.2", 45 | "sans-sel": "1.0.0-beta.2", 46 | "svgo": "^1.0.5", 47 | "svgo-loader": "^2.1.0", 48 | "tmp-promise": "^2.0.2", 49 | "webpack": "^4.8.3", 50 | "webpack-cli": "^3", 51 | "webpack-dev-server": "^3.1.4" 52 | }, 53 | "keywords": [ 54 | "pass", 55 | "passwordstore", 56 | "password-store" 57 | ] 58 | } 59 | -------------------------------------------------------------------------------- /package/Arch Linux/PKGBUILD: -------------------------------------------------------------------------------- 1 | pkgname=pass-web 2 | _npmpkgver=1.0.0-beta.17 3 | pkgver=1.0.0.beta.17 4 | pkgrel=1 5 | pkgdesc="A web interface for pass (password-store)" 6 | arch=('any') 7 | url="https://github.com/BenoitZugmeyer/pass-web" 8 | license=('MIT') 9 | depends=('nodejs') 10 | makedepends=('npm') 11 | source=("https://registry.npmjs.org/$pkgname/-/$pkgname-$_npmpkgver.tgz") 12 | noextract=($pkgname-$_npmpkgver.tgz) 13 | 14 | package() { 15 | npm install -g --user root --prefix "$pkgdir"/usr "$srcdir"/$pkgname-$_npmpkgver.tgz 16 | } 17 | md5sums=('91e537867b3a2c8546211ae7344b0d3a') 18 | -------------------------------------------------------------------------------- /public/.eslintrc.yml: -------------------------------------------------------------------------------- 1 | env: 2 | node: false 3 | browser: true 4 | 5 | globals: 6 | process: true 7 | -------------------------------------------------------------------------------- /public/actions.js: -------------------------------------------------------------------------------- 1 | import store from "./store" 2 | import { emptyClipboard } from "./selection" 3 | 4 | let request 5 | 6 | if (process.env.NODE_ENV === "demo") { 7 | const dd = (chunks, ...variables) => { 8 | let formated = "" 9 | 10 | for (let i = 0; i < chunks.length; i += 1) { 11 | formated += chunks[i] 12 | if (i < chunks.length - 1) formated += variables[i] 13 | } 14 | 15 | if (formated[0] === "\n") formated = formated.slice(1) 16 | 17 | const indent = formated.match(/([\t ]*)\S/) 18 | if (indent) { 19 | formated = formated.replace(new RegExp(`^${indent[1]}`, "mg"), "") 20 | } 21 | 22 | return formated 23 | } 24 | 25 | const passphrase = "demo" 26 | const files = { 27 | Business: { 28 | "some-silly-business-site.com.gpg": dd` 29 | mypassword 30 | User name: mail@example.org`, 31 | "another-business-site.net.gpg": dd` 32 | somepassword`, 33 | }, 34 | Email: { 35 | "donenfeld.com.gpg": dd` 36 | emailpassword 37 | Address: me@donenfeld.com`, 38 | "zx2c4.com.gpg": dd` 39 | emailpassword 40 | Address: jean-michel@zx2c4.com`, 41 | }, 42 | France: { 43 | "bank.gpg": dd` 44 | bankpassword 45 | User name: me 46 | URL: https://mybank.org`, 47 | "freebox.gpg": "pwet", 48 | "mobilephone.gpg": dd`\n 49 | +33654235423`, 50 | }, 51 | } 52 | 53 | const getFiles = (root = files) => { 54 | return Object.keys(root).map(name => ({ 55 | name, 56 | children: typeof root[name] === "object" && getFiles(root[name]), 57 | })) 58 | } 59 | 60 | const getFileContent = (path, root = files) => { 61 | const file = root[path[0]] 62 | if (path.length > 1) return getFileContent(path.slice(1), file) 63 | return file 64 | } 65 | 66 | const error = message => ({ error: { message } }) 67 | 68 | const getResponse = (route, data) => { 69 | if (data.passphrase !== passphrase) return error("Bad passphrase") 70 | if (route === "list") return getFiles() 71 | if (route === "get") return getFileContent(data.path) 72 | return error("Invalid route") 73 | } 74 | 75 | request = (route, data) => Promise.resolve(getResponse(route, data)) 76 | } else { 77 | request = (route, data) => { 78 | return new Promise((resolve, reject) => { 79 | const xhr = new XMLHttpRequest() 80 | xhr.addEventListener("timeout", () => reject(new Error("http timeout"))) 81 | xhr.addEventListener("error", () => reject(new Error("http error"))) 82 | xhr.addEventListener("load", () => { 83 | if (xhr.status !== 200) 84 | reject(new Error(`Unexpected HTTP status code ${xhr.status}`)) 85 | else resolve(xhr.response) 86 | }) 87 | xhr.open("POST", `api/${route}`) 88 | xhr.responseType = "json" 89 | xhr.setRequestHeader("Content-Type", "application/json") 90 | xhr.send(JSON.stringify(data)) 91 | }) 92 | } 93 | } 94 | 95 | function call(route, data) { 96 | return request(route, data).then(response => { 97 | if (response.error) { 98 | throw Object.assign(new Error(response.error.message), response.error) 99 | } 100 | return response 101 | }) 102 | } 103 | 104 | let fullList 105 | 106 | export function signin(passphrase) { 107 | return call("list", { passphrase }).then(list => { 108 | fullList = list 109 | store.setPassphrase(passphrase) 110 | store.setList(list) 111 | }) 112 | } 113 | 114 | export function get(path) { 115 | return call("get", { passphrase: store.passphrase, path }) 116 | } 117 | 118 | export function logout() { 119 | emptyClipboard() 120 | store.setList() 121 | } 122 | 123 | function escapeRegExp(str) { 124 | return str.replace(/[-[\]\/{}()*+?.\\^$|]/g, "\\$&") // eslint-disable-line no-useless-escape 125 | } 126 | 127 | function filterList(list, filter) { 128 | const result = [] 129 | for (const file of list) { 130 | if (filter.test(file.name)) result.push(file) 131 | else if (file.children) { 132 | const children = filterList(file.children, filter) 133 | if (children.length) result.push({ ...file, children }) 134 | } 135 | } 136 | return result 137 | } 138 | 139 | export function search(rawQuery) { 140 | const query = rawQuery.trim() 141 | if (query) { 142 | const filter = new RegExp(escapeRegExp(query).replace(/\s+/g, ".*"), "i") 143 | const list = filterList(fullList, filter) 144 | store.setList(list) 145 | } else { 146 | store.setList(fullList) 147 | } 148 | } 149 | -------------------------------------------------------------------------------- /public/components/App.js: -------------------------------------------------------------------------------- 1 | import { Component, h } from "preact" 2 | import Auth from "./Auth" 3 | import store from "../store" 4 | import List from "./List" 5 | import Search from "./Search" 6 | import { logout, search } from "../actions" 7 | import { stop } from "../domUtil" 8 | import { base, marginSize } from "../css" 9 | 10 | const ss = base.namespace("App").addRules({ 11 | root: { 12 | display: "flex", 13 | flexDirection: "column", 14 | alignItems: "stretch", 15 | 16 | position: "relative", 17 | boxSizing: "border-box", 18 | 19 | height: "100vh", 20 | maxWidth: "40em", 21 | margin: "auto", 22 | padding: "12px", 23 | }, 24 | 25 | header: { 26 | display: "flex", 27 | flexShrink: 0, 28 | justifyContent: "space-between", 29 | marginBottom: marginSize, 30 | }, 31 | }) 32 | 33 | export default class App extends Component { 34 | constructor() { 35 | super() 36 | this.state = { store } 37 | this.updateStore = () => this.setState({ store }) 38 | } 39 | 40 | focus() { 41 | if (this.auth) this.auth.focus() 42 | else this.search.focus() 43 | } 44 | 45 | componentDidMount() { 46 | store.register(this.updateStore) 47 | this.focus() 48 | } 49 | 50 | componentDidUpdate() { 51 | this.focus() 52 | } 53 | 54 | componentWillUnmount() { 55 | store.unregister(this.updateStore) 56 | } 57 | 58 | render(_, { store }) { 59 | return ( 60 |
61 | {store.loggedIn && ( 62 |
63 | { 66 | this.search = search 67 | }} 68 | /> 69 | 72 |
73 | )} 74 | {store.loggedIn && } 75 | {!store.loggedIn && ( 76 | { 78 | this.auth = auth 79 | }} 80 | /> 81 | )} 82 |
83 | ) 84 | } 85 | } 86 | -------------------------------------------------------------------------------- /public/components/Auth.js: -------------------------------------------------------------------------------- 1 | import { Component, h } from "preact" 2 | import { signin } from "../actions" 3 | import { stop } from "../domUtil" 4 | import { catch_, finally_ } from "../promiseUtil" 5 | import { base, marginSize } from "../css" 6 | 7 | const ss = base.namespace("Auth").addRules({ 8 | root: { 9 | textAlign: "center", 10 | }, 11 | 12 | textField: { 13 | inherit: "textField", 14 | marginRight: marginSize, 15 | marginBottom: marginSize, 16 | }, 17 | 18 | error: { 19 | inherit: "error", 20 | marginBottom: marginSize, 21 | }, 22 | }) 23 | 24 | export default class Auth extends Component { 25 | constructor() { 26 | super() 27 | this.state = { 28 | passphrase: "", 29 | error: null, 30 | loading: false, 31 | } 32 | 33 | this.submit = () => { 34 | this.setState({ loading: true }) 35 | signin(this.state.passphrase) 36 | ::finally_(() => this.setState({ loading: false })) 37 | ::catch_(error => this.setState({ error, passphrase: "" })) 38 | } 39 | } 40 | 41 | render(props, { error, passphrase, loading }) { 42 | return ( 43 |
44 | {error &&
Error: {error.message}
} 45 | { 49 | this.input = el 50 | }} 51 | onInput={event => 52 | this.setState({ passphrase: event.target.value, error: null }) 53 | } 54 | value={passphrase} 55 | /> 56 | 59 | {process.env.NODE_ENV === "demo" && ( 60 |
Hint: the demo passphrase is 'demo'.
61 | )} 62 |
63 | ) 64 | } 65 | 66 | focus() { 67 | this.input.focus() 68 | } 69 | } 70 | -------------------------------------------------------------------------------- /public/components/CopyIcon.js: -------------------------------------------------------------------------------- 1 | import { h } from "preact" 2 | import { copy } from "../selection" 3 | import { base, marginSize } from "../css" 4 | 5 | const ss = base.namespace("CopyIcon").addRules({ 6 | root: { 7 | verticalAlign: "middle", 8 | display: "inline-block", 9 | cursor: "pointer", 10 | position: "relative", 11 | top: "-4px", 12 | margin: `0 ${marginSize}`, 13 | }, 14 | }) 15 | 16 | export default ({ content, style, ...attrs }) => ( 17 | copy(content)} 25 | > 26 | Copy 27 | 36 | 37 | ) 38 | -------------------------------------------------------------------------------- /public/components/Directory.js: -------------------------------------------------------------------------------- 1 | import { h } from "preact" 2 | import Line from "./Line" 3 | 4 | export default ({ children, activeChild, onActiveChildChanged = () => {} }) => { 5 | children.sort((a, b) => { 6 | // Sort directories first 7 | if (Boolean(a.children) ^ Boolean(b.children)) return a.children ? -1 : 1 8 | // Then sort by name 9 | return a.name < b.name ? -1 : 1 10 | }) 11 | 12 | return ( 13 |
14 | {children.map(child => { 15 | const isDirectory = child.children 16 | return ( 17 | 23 | onActiveChildChanged(child === activeChild ? null : child) 24 | } 25 | /> 26 | ) 27 | })} 28 |
29 | ) 30 | } 31 | -------------------------------------------------------------------------------- /public/components/File.js: -------------------------------------------------------------------------------- 1 | import { h, Component } from "preact" 2 | import { get } from "../actions" 3 | import CopyIcon from "./CopyIcon" 4 | import { select, unselect } from "../selection" 5 | import { finally_ } from "../promiseUtil" 6 | import { base, marginSize } from "../css" 7 | 8 | const ss = base.namespace("File").addRules({ 9 | root: { 10 | overflow: "hidden", 11 | }, 12 | 13 | passwordSelector: { 14 | display: "inline-block", 15 | position: "relative", 16 | lineHeight: "24px", 17 | hover: { 18 | backgroundColor: "#3498DB", 19 | }, 20 | }, 21 | 22 | passwordLine: { 23 | display: "block", 24 | position: "absolute", 25 | top: "0", 26 | left: "0", 27 | right: "0", 28 | bottom: "0", 29 | overflow: "hidden", 30 | color: "transparent", 31 | opacity: "0", 32 | fontSize: "1000px", 33 | }, 34 | 35 | rest: { 36 | whiteSpace: "pre", 37 | lineHeight: "24px", 38 | }, 39 | 40 | button: { 41 | inherit: "button", 42 | 43 | marginLeft: marginSize, 44 | }, 45 | 46 | link: { 47 | color: "#2C3E50", 48 | textDecoration: "none", 49 | hover: { 50 | textDecoration: "underline", 51 | }, 52 | }, 53 | }) 54 | 55 | class Renderer { 56 | constructor() { 57 | this._renderers = [] 58 | this._re = null 59 | } 60 | 61 | add(re, fn) { 62 | this._renderers.push({ re, fn }) 63 | this._re = null 64 | } 65 | 66 | render(text) { 67 | let re = this._re 68 | if (!re) { 69 | this._re = re = new RegExp( 70 | this._renderers.map(({ re }) => `(${re.source})`).join("|"), 71 | "gm", 72 | ) 73 | } 74 | 75 | const result = [] 76 | 77 | while (true) { 78 | const lastIndex = re.lastIndex 79 | const match = re.exec(text) 80 | 81 | if (!match) { 82 | result.push(text.slice(lastIndex)) 83 | break 84 | } 85 | 86 | let rendererIndex = 0 87 | for (; match[rendererIndex + 1] === undefined; rendererIndex += 1); 88 | 89 | result.push( 90 | text.slice(lastIndex, match.index), 91 | this._renderers[rendererIndex].fn(match), 92 | ) 93 | } 94 | 95 | return result 96 | } 97 | } 98 | 99 | const renderer = new Renderer() 100 | 101 | renderer.add(/\bhttps?:\/\/\S+/, match => ( 102 | 108 | {match[0]} 109 | 110 | )) 111 | 112 | renderer.add(/\S+@\S+/, match => ( 113 | 119 | {match[0]} 120 | 121 | )) 122 | 123 | renderer.add(/^[A-Z].*?:/, match => {match[0]}) 124 | 125 | export default class File extends Component { 126 | constructor({ path }) { 127 | super() 128 | this.state = { 129 | content: "", 130 | error: false, 131 | loading: true, 132 | } 133 | 134 | get(path) 135 | ::finally_(() => this.setState({ loading: false })) 136 | .then( 137 | content => this.setState({ content }), 138 | error => this.setState({ error }), 139 | ) 140 | } 141 | 142 | renderLoaded(content) { 143 | const lines = content.split("\n") 144 | const passwordLine = lines[0] 145 | const rest = lines.slice(1).join("\n") 146 | let passwordLineElement 147 | const selectPassword = () => select(passwordLineElement) 148 | 149 | /*eslint-disable react/jsx-key*/ 150 | return [ 151 |
152 | {" "} 153 | {passwordLine && ( 154 | 160 | {"\u2022".repeat(10)} 161 | (passwordLineElement = el)} 164 | > 165 | {passwordLine} 166 | 167 | 168 | )} 169 | 170 |
, 171 |
{renderer.render(rest.trimRight())}
, 172 | ] 173 | /*eslint-enable react/jsx-key*/ 174 | } 175 | 176 | render(_, { loading, error, content }) { 177 | return ( 178 |
179 | {loading ? ( 180 |
Loading...
181 | ) : error ? ( 182 |
Error: {error.message}
183 | ) : ( 184 | this.renderLoaded(content) 185 | )} 186 |
187 | ) 188 | } 189 | } 190 | -------------------------------------------------------------------------------- /public/components/Icon.js: -------------------------------------------------------------------------------- 1 | import { h } from "preact" 2 | import { base } from "../css" 3 | import icons from "../icons.svg" 4 | 5 | const ss = base.namespace("Icon").addRules({ 6 | root: { 7 | verticalAlign: "middle", 8 | display: "inline-block", 9 | }, 10 | }) 11 | 12 | export default ({ name, style, ...props }) => ( 13 | 14 | ) 15 | -------------------------------------------------------------------------------- /public/components/Line.js: -------------------------------------------------------------------------------- 1 | import { h } from "preact" 2 | import Icon from "./Icon" 3 | import { stop } from "../domUtil" 4 | import { base, marginSize, borderRadius } from "../css" 5 | 6 | const background = "236, 240, 241" 7 | const activeBackground = "189, 195, 199" 8 | const ss = base.namespace("Line").addRules({ 9 | root: { 10 | position: "relative", 11 | cursor: "pointer", 12 | whiteSpace: "nowrap", 13 | overflow: "hidden", 14 | borderRadius, 15 | padding: `2px ${marginSize}`, 16 | }, 17 | 18 | active: { 19 | backgroundColor: `rgb(${activeBackground})`, 20 | }, 21 | 22 | icon: { 23 | marginRight: marginSize, 24 | }, 25 | 26 | shadow: { 27 | position: "absolute", 28 | top: borderRadius, 29 | bottom: borderRadius, 30 | right: 0, 31 | width: marginSize, 32 | 33 | background: `linear-gradient( 34 | to right, 35 | rgba(${background}, 0) 0%, 36 | rgba(${background}, 1) 50%, 37 | rgba(${background}, 1) 100% 38 | )`, 39 | }, 40 | 41 | activeShadow: { 42 | inherit: "shadow", 43 | 44 | background: `linear-gradient( 45 | to right, 46 | rgba(${activeBackground}, 0) 0%, 47 | rgba(${activeBackground}, 1) 50%, 48 | rgba(${activeBackground}, 1) 100% 49 | )`, 50 | }, 51 | }) 52 | 53 | export default ({ title, icon, active = false, onClick = () => {} }) => ( 54 |
59 | {typeof icon === "string" ? : icon} 60 | {title} 61 |
62 |
63 | ) 64 | -------------------------------------------------------------------------------- /public/components/List.js: -------------------------------------------------------------------------------- 1 | import { h, Component } from "preact" 2 | import Directory from "./Directory" 3 | import File from "./File" 4 | import { base, marginSize, borderRadius, boxShadow } from "../css" 5 | 6 | const transitionDelay = 0.5 // second 7 | const empty = {} 8 | const ss = base.namespace("List").addRules({ 9 | root: { 10 | display: "flex", 11 | borderRadius, 12 | backgroundColor: "#ECF0F1", 13 | boxShadow, 14 | overflow: "hidden", 15 | }, 16 | 17 | container: { 18 | flex: "1", 19 | display: "flex", 20 | marginLeft: "-2px", // Hide left separator 21 | }, 22 | 23 | column: { 24 | position: "relative", 25 | display: "flex", 26 | transition: `width ${transitionDelay}s`, 27 | verticalAlign: "top", 28 | overflow: "hidden", 29 | }, 30 | 31 | columnContent: { 32 | flex: "1", 33 | padding: `${marginSize}`, 34 | paddingLeft: `calc(${marginSize} + 2px)`, 35 | overflowY: "auto", 36 | overflowX: "hidden", 37 | boxSizing: "border-box", 38 | }, 39 | 40 | separator: { 41 | position: "absolute", 42 | left: "0", 43 | top: marginSize, 44 | bottom: marginSize, 45 | 46 | border: "1px solid #BDC3C7", 47 | }, 48 | 49 | noResult: { 50 | inherit: "error", 51 | margin: marginSize, 52 | }, 53 | }) 54 | 55 | export default class List extends Component { 56 | constructor() { 57 | super() 58 | this.state = { 59 | path: [], 60 | previousPath: null, 61 | } 62 | 63 | this.setPath = newPath => { 64 | this.setState(({ path }) => ({ 65 | previousPath: path.slice(), 66 | path: newPath, 67 | })) 68 | } 69 | } 70 | 71 | updatePath(list) { 72 | const path = this.state.path 73 | const findFileByName = (list, name) => { 74 | for (const file of list) { 75 | if (file.name === name) return file 76 | } 77 | } 78 | 79 | for (let i = 0; i < path.length; i += 1) { 80 | const newChild = findFileByName(list, path[i].name) 81 | if (newChild) { 82 | path[i] = newChild 83 | list = newChild.children 84 | if (!list) break 85 | } else { 86 | path.length = i 87 | break 88 | } 89 | } 90 | 91 | while (list && list.length === 1) { 92 | path.push(list[0]) 93 | list = list[0].children 94 | } 95 | } 96 | 97 | adjustColumnWidths() { 98 | if (this.props.list.length === 0) return 99 | 100 | const columnWidth = 200 101 | const columnCount = this.state.path.length + 1 102 | const fullWidth = this.container.clientWidth 103 | const nodes = Array.from(this.container.childNodes) 104 | 105 | if (fullWidth > columnCount * columnWidth) { 106 | nodes.forEach((node, index) => { 107 | if (index >= columnCount) { 108 | // Shrink previous extra columns 109 | node.style.width = "0" 110 | } else if (index >= columnCount - 1) { 111 | // Last column fills the extra space 112 | const extraSpace = fullWidth - columnWidth * (columnCount - 1) 113 | node.style.width = `${extraSpace}px` 114 | } else { 115 | // Other columns have the default width 116 | node.style.width = `${columnWidth}px` 117 | } 118 | }) 119 | } else { 120 | const remainingWidth = Math.max( 121 | (fullWidth - 2 * columnWidth) / (columnCount - 2), 122 | 0, 123 | ) 124 | 125 | nodes.forEach((node, index) => { 126 | if (index >= columnCount) { 127 | // Shrink previous extra columns 128 | node.style.width = "0" 129 | } else if (index >= columnCount - 2) { 130 | // Last two columns have the default width 131 | node.style.width = `${columnWidth}px` 132 | } else { 133 | // Shrink other columns to fill the remaining space 134 | node.style.width = `${remainingWidth}px` 135 | } 136 | }) 137 | } 138 | } 139 | 140 | componentDidMount() { 141 | this.adjustColumnWidths() 142 | } 143 | 144 | componentDidUpdate() { 145 | this.adjustColumnWidths() 146 | } 147 | 148 | render({ list }, { path, previousPath }) { 149 | this.updatePath(list) 150 | 151 | const renderPath = path.slice() 152 | if (previousPath && previousPath.length > path.length) { 153 | const isSubpath = path[path.length - 1] === previousPath[path.length - 1] 154 | for (const child of previousPath.slice(path.length)) { 155 | renderPath.push(isSubpath ? child : empty) 156 | } 157 | clearTimeout(this.redrawTimer) 158 | this.redrawTimer = setTimeout(() => { 159 | this.setState({ previousPath: null }) 160 | }, transitionDelay * 1000) 161 | } 162 | 163 | const renderColumn = (file, index) => { 164 | let children, width 165 | const columnPath = renderPath.slice(0, index) 166 | 167 | if (file.children) { 168 | children = ( 169 | { 171 | if (!child) this.setPath(columnPath) 172 | else this.setPath([...columnPath, child]) 173 | }} 174 | activeChild={path[index]} 175 | > 176 | {file.children} 177 | 178 | ) 179 | } else if (file !== empty) { 180 | children = f.name)} /> 181 | } 182 | 183 | if (previousPath && index > previousPath.length) { 184 | // New column, starts with an empty width 185 | width = "0" 186 | } 187 | 188 | return ( 189 |
190 |
191 |
192 | {children} 193 |
194 |
195 | ) 196 | } 197 | 198 | return ( 199 |
200 | {list.length ? ( 201 |
(this.container = el)}> 202 | {renderColumn({ children: list }, 0)} 203 | {renderPath.map((file, i) => renderColumn(file, i + 1))} 204 |
205 | ) : ( 206 |
No result
207 | )} 208 |
209 | ) 210 | } 211 | } 212 | -------------------------------------------------------------------------------- /public/components/Search.js: -------------------------------------------------------------------------------- 1 | import { h, Component } from "preact" 2 | import Icon from "./Icon" 3 | import { base, marginSize } from "../css" 4 | 5 | const ss = base.namespace("Search").addRules({ 6 | root: { 7 | position: "relative", 8 | flex: "1", 9 | maxWidth: "300px", 10 | marginRight: marginSize, 11 | }, 12 | 13 | textField: { 14 | inherit: "textField", 15 | width: "100%", 16 | boxSizing: "border-box", 17 | paddingRight: `calc(20px + ${marginSize})`, 18 | }, 19 | 20 | searchIcon: {}, 21 | 22 | button: { 23 | pointerEvents: "none", 24 | position: "absolute", 25 | right: "0", 26 | top: "0", 27 | padding: `5px ${marginSize}`, 28 | cursor: "pointer", 29 | }, 30 | 31 | buttonActive: { 32 | pointerEvents: "initial", 33 | }, 34 | }) 35 | 36 | export default class Search extends Component { 37 | render({ onChange }) { 38 | const triggerChange = () => { 39 | if (!onChange || !this.input) return 40 | onChange(this.input.value) 41 | } 42 | 43 | const emptyInput = () => { 44 | this.input.value = "" 45 | triggerChange() 46 | } 47 | 48 | const hasValue = Boolean(this.input && this.input.value) 49 | 50 | return ( 51 |
52 | { 55 | this.input = el 56 | }} 57 | onKeyUp={triggerChange} 58 | /> 59 |
63 | 64 |
65 |
66 | ) 67 | } 68 | 69 | focus() { 70 | this.input.focus() 71 | } 72 | } 73 | -------------------------------------------------------------------------------- /public/css.js: -------------------------------------------------------------------------------- 1 | import sansSel from "sans-sel" 2 | 3 | export const marginSize = "6px" 4 | export const borderRadius = "2px" 5 | export const boxShadow = "0.5px 0.5px 3px rgba(0, 0, 0, 0.5)" 6 | export const base = sansSel().addRules({ 7 | input: { 8 | font: "inherit", 9 | padding: "6px 12px", 10 | borderRadius, 11 | border: "1px solid transparent", 12 | boxShadow, 13 | outline: 0, 14 | }, 15 | 16 | textField: { 17 | inherit: "input", 18 | 19 | backgroundColor: "#fff", 20 | }, 21 | 22 | button: { 23 | inherit: "input", 24 | 25 | color: "#fff", 26 | borderColor: "#27AE60", 27 | backgroundColor: "#27AE60", 28 | cursor: "pointer", 29 | 30 | hover: { 31 | backgroundColor: "#2ECC71", 32 | }, 33 | }, 34 | 35 | error: { 36 | color: "#C0392B", 37 | fontWeight: "bold", 38 | }, 39 | }) 40 | -------------------------------------------------------------------------------- /public/domUtil.js: -------------------------------------------------------------------------------- 1 | export function stop(fn) { 2 | return (...args) => { 3 | if (args[0] instanceof Event) args.shift().preventDefault() 4 | if (fn) fn(...args) 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /public/favicon-unlocked.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/BenoitZugmeyer/pass-web/22e632af01889c13f413acc3f62e681b3347bbde/public/favicon-unlocked.png -------------------------------------------------------------------------------- /public/favicon.js: -------------------------------------------------------------------------------- 1 | import store from "./store" 2 | import faviconLocked from "./favicon.png" 3 | import faviconUnlocked from "./favicon-unlocked.png" 4 | 5 | function render() { 6 | const favicon = store.loggedIn ? faviconUnlocked : faviconLocked 7 | document.querySelector("link[rel=icon]").setAttribute("href", favicon) 8 | } 9 | 10 | export function init() { 11 | store.register(render) 12 | render() 13 | } 14 | -------------------------------------------------------------------------------- /public/favicon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/BenoitZugmeyer/pass-web/22e632af01889c13f413acc3f62e681b3347bbde/public/favicon.png -------------------------------------------------------------------------------- /public/icons.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 18 | 19 | 27 | 28 | 30 | 52 | 60 | 61 | 63 | 64 | 66 | image/svg+xml 67 | 69 | 70 | 71 | 72 | 73 | 78 | 83 | 88 | 89 | 94 | 100 | 106 | 107 | 113 | 118 | 123 | 124 | 130 | 135 | 140 | 141 | 142 | -------------------------------------------------------------------------------- /public/index.ejs: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Pass 7 | 8 | 9 | 10 | 11 | 12 | -------------------------------------------------------------------------------- /public/main.js: -------------------------------------------------------------------------------- 1 | import { render, h } from "preact" 2 | import App from "./components/App" 3 | import { init as initFavicon } from "./favicon" 4 | 5 | document.body.style.margin = 0 6 | document.body.style.fontFamily = "Helvetica, Arial, sans-serif" 7 | document.body.style.fontSize = "14px" 8 | 9 | initFavicon() 10 | 11 | render(, document.body) 12 | -------------------------------------------------------------------------------- /public/promiseUtil.js: -------------------------------------------------------------------------------- 1 | export function finally_(fn) { 2 | return this.then( 3 | response => { 4 | fn() 5 | return response 6 | }, 7 | error => { 8 | fn() 9 | throw error 10 | }, 11 | ) 12 | } 13 | 14 | export function catch_(fn) { 15 | return this.then(null, fn) 16 | } 17 | -------------------------------------------------------------------------------- /public/selection.js: -------------------------------------------------------------------------------- 1 | let copyElement 2 | 3 | export function copy(content) { 4 | if (!copyElement) { 5 | copyElement = document.createElement("div") 6 | copyElement.style.opacity = "0" 7 | copyElement.style.position = "absolute" 8 | copyElement.style.top = "0" 9 | document.body.appendChild(copyElement) 10 | } 11 | 12 | copyElement.textContent = content 13 | select(copyElement) 14 | 15 | try { 16 | return document.execCommand("copy") 17 | } catch (e) { 18 | return false 19 | } finally { 20 | unselect() 21 | } 22 | } 23 | 24 | export function emptyClipboard() { 25 | copy(".") 26 | } 27 | 28 | export function select(element) { 29 | if (!element) return 30 | unselect() 31 | const range = document.createRange() 32 | range.selectNode(element) 33 | window.getSelection().addRange(range) 34 | } 35 | 36 | export function unselect() { 37 | window.getSelection().removeAllRanges() 38 | } 39 | -------------------------------------------------------------------------------- /public/store.js: -------------------------------------------------------------------------------- 1 | let callbacks = [] 2 | 3 | function emit() { 4 | callbacks.forEach(cb => cb()) 5 | } 6 | 7 | let list = false 8 | let passphrase = false 9 | 10 | export default { 11 | setList(list_) { 12 | list = list_ 13 | emit() 14 | }, 15 | 16 | setPassphrase(passphrase_) { 17 | passphrase = passphrase_ 18 | emit() 19 | }, 20 | 21 | logout() { 22 | list = false 23 | }, 24 | 25 | get loggedIn() { 26 | return Boolean(list) 27 | }, 28 | 29 | get passphrase() { 30 | return passphrase 31 | }, 32 | 33 | get list() { 34 | return list || [] 35 | }, 36 | 37 | register(cb) { 38 | callbacks.push(cb) 39 | }, 40 | 41 | unregister(cb) { 42 | callbacks = callbacks.filter(other => other !== cb) 43 | }, 44 | } 45 | -------------------------------------------------------------------------------- /server/.eslintrc.yml: -------------------------------------------------------------------------------- 1 | parserOptions: 2 | sourceType: script 3 | -------------------------------------------------------------------------------- /server/Keys.js: -------------------------------------------------------------------------------- 1 | "use strict" 2 | 3 | const fs = require("fs") 4 | const kbpgp = require("kbpgp") 5 | 6 | const promiseUtil = require("./promiseUtil") 7 | const log = require("./log") 8 | 9 | const importFromArmoredPGP = promiseUtil.wrapCPS( 10 | kbpgp.KeyManager.import_from_armored_pgp, 11 | ) 12 | const fileRead = promiseUtil.wrapCPS(fs.readFile) 13 | const unbox = promiseUtil.wrapCPS(kbpgp.unbox) 14 | 15 | class KeyError extends Error {} 16 | 17 | function keyId(key) { 18 | let fullKey 19 | if (typeof key === "string") fullKey = key 20 | else if (Buffer.isBuffer(key)) fullKey = key.toString("hex") 21 | else if (key.get_pgp_key_id) fullKey = key.get_pgp_key_id().toString("hex") 22 | else throw new KeyError("Invalid key id type") 23 | 24 | if (!/^[0-9a-f]*$/i.test(fullKey)) 25 | throw new KeyError(`Invalid key id value ${fullKey}`) 26 | 27 | return fullKey.slice(-8).toLowerCase() 28 | } 29 | 30 | module.exports = class Keys { 31 | constructor() { 32 | this._keys = new Map() 33 | } 34 | 35 | add(manager) { 36 | for (const id of manager.get_all_pgp_key_ids()) { 37 | const material = manager.find_pgp_key_material(id) 38 | const emails = material.get_signed_userids().map(u => u.get_email()) 39 | const printableEmails = emails.length ? ` (${emails.join(", ")})` : "" 40 | 41 | log.info(`Add key ${keyId(id)}${printableEmails}`) 42 | this._keys.set(keyId(id), { 43 | manager, 44 | material, 45 | }) 46 | } 47 | } 48 | 49 | verify(ids, passphrase) { 50 | const key = ids.reduce((key, id) => key || this._get(id), null) 51 | if (!key) throw new KeyError("No key found") 52 | return this.verifyKey(key, passphrase) 53 | } 54 | 55 | verifyKey(key, passphrase) { 56 | const material = key.material 57 | return promiseUtil 58 | .wrapCPS(material.unlock.bind(material))({ passphrase }) 59 | .then(() => true, () => false) 60 | } 61 | 62 | decrypt(content, passphrase) { 63 | if (!passphrase) throw new Error("passphrase is required") 64 | 65 | const fetch = async (ids, opts) => { 66 | for (let index = 0; index < ids.length; index++) { 67 | const requestId = keyId(ids[index]) 68 | const key = this._getById(requestId) 69 | if (!key) continue 70 | 71 | if ( 72 | (await this.verifyKey(key, passphrase)) && 73 | key.material.key.can_perform(opts) 74 | ) { 75 | return { manager: key.manager, index } 76 | } 77 | } 78 | 79 | throw new KeyError("No key found") 80 | } 81 | 82 | let contentOptions 83 | if (content.includes("-----BEGIN PGP MESSAGE-----")) { 84 | contentOptions = { armored: content } 85 | } else { 86 | contentOptions = { 87 | raw: content, 88 | msg_type: kbpgp.const.openpgp.message_types.generic, 89 | } 90 | } 91 | 92 | return unbox({ 93 | ...contentOptions, 94 | keyfetch: { 95 | fetch(ids, opts, cb) { 96 | fetch(ids, opts).then( 97 | result => cb(null, result.manager, result.index), 98 | error => cb(error), 99 | ) 100 | }, 101 | }, 102 | }) 103 | } 104 | 105 | isEmpty() { 106 | return this._keys.size === 0 107 | } 108 | 109 | _get(idOrEmail) { 110 | return idOrEmail.indexOf("@") > 0 111 | ? this._getByEmail(idOrEmail) 112 | : this._getById(idOrEmail) 113 | } 114 | 115 | _getById(id) { 116 | id = keyId(id) 117 | return this._keys.get(id) 118 | } 119 | 120 | _getByEmail(email) { 121 | for (const key of this._keys.values()) { 122 | for (const userid of key.material.get_signed_userids()) { 123 | if (userid.get_email() === email) return key 124 | } 125 | } 126 | } 127 | 128 | addFromFile(filePath) { 129 | return fileRead(filePath) 130 | .then(armored => importFromArmoredPGP({ armored })) 131 | .then(manager => this.add(manager)) 132 | } 133 | } 134 | -------------------------------------------------------------------------------- /server/app.js: -------------------------------------------------------------------------------- 1 | "use strict" 2 | const https = require("https") 3 | const http = require("http") 4 | const path = require("path") 5 | const fs = require("fs") 6 | const express = require("express") 7 | const httpAuth = require("http-auth") 8 | const bodyParser = require("body-parser") 9 | const log = require("./log") 10 | const promiseUtil = require("./promiseUtil") 11 | const fileStat = promiseUtil.wrapCPS(fs.stat) 12 | const fileRead = promiseUtil.wrapCPS(fs.readFile) 13 | const directoryRead = promiseUtil.wrapCPS(fs.readdir) 14 | const realpath = promiseUtil.wrapCPS(fs.realpath) 15 | 16 | class InvalidParameter extends Error {} 17 | class AuthError extends Error {} 18 | 19 | async function listDirectory(root, filter) { 20 | const files = await directoryRead(root) 21 | 22 | const result = await Promise.all( 23 | files.map(async name => { 24 | const filePath = path.join(root, name) 25 | const stat = await fileStat(filePath) 26 | if (!filter || filter(name, stat)) { 27 | return stat.isDirectory() 28 | ? { 29 | name, 30 | children: await listDirectory(filePath, filter), 31 | } 32 | : { 33 | name, 34 | } 35 | } 36 | }), 37 | ) 38 | 39 | return result.filter(file => file) 40 | } 41 | 42 | function validDirectoryName(name) { 43 | return !name.startsWith(".") 44 | } 45 | 46 | function validFileName(name) { 47 | return !name.startsWith(".") && name.endsWith(".gpg") 48 | } 49 | 50 | function validFilePath(filePath) { 51 | const splitted = filePath.split(path.sep) 52 | return Boolean( 53 | splitted.length && 54 | splitted.slice(0, -1).every(validDirectoryName) && 55 | validFileName(splitted[splitted.length - 1]), 56 | ) 57 | } 58 | 59 | function filterFiles(name, stat) { 60 | return stat.isDirectory() 61 | ? validDirectoryName(name) 62 | : stat.isFile() 63 | ? validFileName(name) 64 | : false 65 | } 66 | 67 | async function getGPGIds(rootPath) { 68 | const stat = await fileStat(rootPath) 69 | 70 | if (stat.isDirectory()) { 71 | const gpgIdPath = path.resolve(rootPath, ".gpg-id") 72 | let gpgStat 73 | try { 74 | gpgStat = await fileStat(gpgIdPath) 75 | } catch (e) { 76 | // Ignore ENOENT errors, just check for parent directory 77 | if (e.code !== "ENOENT") { 78 | log.error(e) 79 | } 80 | } 81 | if (gpgStat && gpgStat.isFile()) { 82 | return (await fileRead(gpgIdPath, { encoding: "utf-8" })) 83 | .split("\n") 84 | .map(id => id.trim()) 85 | .filter(Boolean) 86 | } 87 | } 88 | 89 | const parentPath = path.resolve(rootPath, "..") 90 | if (rootPath === parentPath) throw new Error("No .gpg-id found") 91 | 92 | return getGPGIds(parentPath) 93 | } 94 | 95 | async function auth(conf, requestPath, passphrase) { 96 | const gpgIds = await getGPGIds(requestPath || conf.passwordStorePath) 97 | 98 | if (!(await conf.keys.verify(gpgIds, passphrase))) { 99 | throw new AuthError("Bad passphrase") 100 | } 101 | 102 | return gpgIds 103 | } 104 | 105 | function apiRouter(conf) { 106 | const router = express.Router() 107 | 108 | router.use(bodyParser.json()) 109 | 110 | function sendError(res, error) { 111 | res.json({ 112 | error: { 113 | type: error.constructor.name, 114 | message: error.message, 115 | }, 116 | }) 117 | } 118 | 119 | function wrap(gen) { 120 | return async (req, res, next) => { 121 | try { 122 | await gen(req, res, next) 123 | } catch (error) { 124 | log.debug(error) 125 | sendError(res, error) 126 | } 127 | } 128 | } 129 | 130 | async function getSecurePath(requestPath) { 131 | try { 132 | if (!Array.isArray(requestPath)) return 133 | if (requestPath.some(p => typeof p !== "string")) return 134 | 135 | const filePath = await realpath( 136 | path.resolve(conf.passwordStorePath, path.join(...requestPath)), 137 | ) 138 | 139 | // Make sure the path is inside passwordStorePath and isn't in a dotted directory/file 140 | if (validFilePath(path.relative(conf.passwordStorePath, filePath))) 141 | return filePath 142 | } catch (e) { 143 | log.debug(e) 144 | } 145 | } 146 | 147 | router.use( 148 | wrap((req, res, next) => { 149 | if (!req.body) throw new InvalidParameter("No request body") 150 | if (!req.body.passphrase) throw new InvalidParameter("No passphrase") 151 | req.auth = requestPath => auth(conf, requestPath, req.body.passphrase) 152 | next() 153 | }), 154 | ) 155 | 156 | router.post( 157 | "/list", 158 | wrap(async (req, res) => { 159 | await req.auth() 160 | res.json(await listDirectory(conf.passwordStorePath, filterFiles)) 161 | }), 162 | ) 163 | 164 | router.post( 165 | "/get", 166 | wrap(async (req, res) => { 167 | const filePath = await getSecurePath(req.body.path) 168 | 169 | // Always authenticate. We shouldn't throw any exception related to the file path before 170 | // authentication, as it could be a privacy leak (= an attacker could craft queries to check if 171 | // a file exists) 172 | await req.auth(filePath) 173 | 174 | if (!filePath) throw new InvalidParameter("Invalid path parameter") 175 | 176 | const rawContent = await fileRead(filePath) 177 | const content = await conf.keys.decrypt(rawContent, req.body.passphrase) 178 | if (!content.length) throw new Error("The file seems empty") 179 | res.json(content[0].toString("utf-8")) 180 | }), 181 | ) 182 | 183 | return router 184 | } 185 | 186 | function launchApp(conf) { 187 | const app = createApp(conf) 188 | const secureServer = Boolean(conf.key && conf.cert) 189 | let server 190 | 191 | if (secureServer) { 192 | server = https.createServer( 193 | { 194 | key: fs.readFileSync(conf.key), 195 | cert: fs.readFileSync(conf.cert), 196 | }, 197 | app, 198 | ) 199 | } else { 200 | if (conf.address !== "localhost" && conf.address !== "127.0.0.1") { 201 | log.warning( 202 | "Serving on a non-local address in non-secure HTTP is highly discouraged.", 203 | ) 204 | } 205 | server = http.createServer(app) 206 | } 207 | 208 | server.listen(conf.port, conf.address, function() { 209 | const address = this.address() 210 | const scheme = secureServer ? "https" : "http" 211 | log.info( 212 | `Server listening on ${scheme}://${address.address}:${address.port}${conf.urlBaseDir}`, 213 | ) 214 | }) 215 | 216 | return new Promise((resolve, reject) => { 217 | server.on("listening", resolve) 218 | server.on("error", reject) 219 | }) 220 | } 221 | 222 | function createApp(conf) { 223 | const app = express() 224 | 225 | if (conf.htpasswd) { 226 | const basicAuth = httpAuth.basic({ 227 | realm: "Log in to pass-web interface", 228 | file: conf.htpasswd, 229 | }) 230 | 231 | app.use(httpAuth.connect(basicAuth)) 232 | } 233 | 234 | app.use(conf.urlBaseDir, express.static(path.join(__dirname, "..", "dist"))) 235 | app.use(`${conf.urlBaseDir}api`, apiRouter(conf)) 236 | 237 | return app 238 | } 239 | 240 | module.exports = { 241 | createApp, 242 | launchApp, 243 | } 244 | -------------------------------------------------------------------------------- /server/index.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | "use strict" 3 | 4 | const fs = require("fs") 5 | const path = require("path") 6 | const parseArgs = require("minimist") 7 | const { launchApp } = require("./app") 8 | const promiseUtil = require("./promiseUtil") 9 | const Keys = require("./Keys") 10 | const log = require("./log") 11 | const fileStat = promiseUtil.wrapCPS(fs.stat) 12 | const realpath = promiseUtil.wrapCPS(fs.realpath) 13 | 14 | function printHelp() { 15 | process.stderr.write( 16 | `\ 17 | pass-web [OPTION]... PGPKEY... 18 | 19 | Launch the HTTP server. The PGPKEY arguments are paths to the exported (armored, encrypted) pgp secret keys. 20 | 21 | The server will use HTTPS only if the options --key and --cert are provided. 22 | 23 | Options: 24 | 25 | -d, --debug 26 | log additional information, useful for debugging purposes 27 | 28 | -s STOREPATH, --store STOREPATH 29 | path of the password-store directory, defaults to ~/.password-store 30 | 31 | -p PORT, --port PORT 32 | port to use, defaults to 3000 33 | 34 | -a ADDRESS, --address ADDRESS 35 | address to use, defaults to 127.0.0.1 36 | 37 | -h, --help 38 | print this help and quit 39 | 40 | --version 41 | print the version and quit 42 | 43 | --url-base-dir URLBASEDIR 44 | url subdirectory being used to serve the app, defaults to /. For example, /pass-web for a server at https://example.com/pass-web 45 | 46 | --key KEY 47 | path to key file to use for SSL. If omitted, serves without SSL 48 | 49 | --cert CERT 50 | path to certificate file to use for SSL. If omitted, serves without SSL 51 | 52 | --htpasswd HTPASSWD 53 | htpasswd file to use for additional HTTP basic authentication. If omitted, no authentication will be used 54 | 55 | Usage example to makes bash-compatible shells temporarily export gpg keys: 56 | 57 | pass-web -p 9082 <(gpg --export-secret-keys -a) 58 | `, 59 | ) 60 | } 61 | 62 | function printVersion() { 63 | const pkg = require("../package.json") 64 | process.stdout.write(`${pkg.name} ${pkg.version}\n`) 65 | } 66 | 67 | ;(async () => { 68 | const args = parseArgs(process.argv, { 69 | alias: { 70 | debug: ["d"], 71 | store: ["s"], 72 | port: ["p"], 73 | address: ["a"], 74 | help: ["h"], 75 | }, 76 | boolean: ["debug"], 77 | }) 78 | 79 | if (args.help) { 80 | printHelp() 81 | return 82 | } 83 | 84 | if (args.version) { 85 | printVersion() 86 | return 87 | } 88 | 89 | const passwordStorePath = await realpath( 90 | args.store || path.join(process.env.HOME, ".password-store"), 91 | ) 92 | const passwordStoreStat = await fileStat(passwordStorePath) 93 | if (!passwordStoreStat.isDirectory()) { 94 | log.error(`${passwordStorePath} is not a directory`) 95 | process.exit(1) 96 | } 97 | 98 | const keys = new Keys() 99 | await Promise.all(args._.slice(2).map(key => keys.addFromFile(key))) 100 | 101 | log.setLevel(args.debug ? log.DEBUG : log.INFO) 102 | 103 | if (keys.isEmpty()) { 104 | log.error("No key added. Use pass-web --help for more information.") 105 | process.exit(1) 106 | } 107 | 108 | const urlBaseDirArg = (args["url-base-dir"] || "").replace(/^\/+|\/+$/g, "") 109 | const urlBaseDir = urlBaseDirArg ? `/${urlBaseDirArg}/` : "/" 110 | 111 | await launchApp({ 112 | passwordStorePath, 113 | keys, 114 | port: args.port || 3000, 115 | address: args.address || "127.0.0.1", 116 | key: args.key || false, 117 | cert: args.cert || false, 118 | htpasswd: args.htpasswd || false, 119 | urlBaseDir, 120 | }) 121 | })().catch(error => { 122 | log.error(error) 123 | process.exit(1) 124 | }) 125 | -------------------------------------------------------------------------------- /server/log.js: -------------------------------------------------------------------------------- 1 | "use strict" 2 | 3 | const DEBUG = 1 4 | const INFO = 2 5 | const WARNING = 3 6 | const ERROR = 4 7 | 8 | let level = DEBUG 9 | 10 | function setLevel(level_) { 11 | level = level_ 12 | } 13 | 14 | function formatMessage(msg) { 15 | if (msg instanceof Error) return level <= DEBUG ? msg.stack : msg.message 16 | return String(msg) 17 | } 18 | 19 | function makeLogger(levelLabel, level_) { 20 | return function(msg) { 21 | if (level_ >= level) { 22 | let result = "" 23 | if (Array.isArray(msg) && msg.raw) { 24 | // Template string 25 | for (let i = 0; i < msg.length; i++) { 26 | if (i) result += formatMessage(arguments[i]) 27 | result += msg[i] 28 | } 29 | } else { 30 | result = formatMessage(msg) 31 | } 32 | process.stderr.write(`${levelLabel}: ${result}\n`) 33 | } 34 | } 35 | } 36 | 37 | module.exports = { 38 | setLevel, 39 | debug: makeLogger("DEBUG", DEBUG), 40 | info: makeLogger("INFO", INFO), 41 | warning: makeLogger("WARNING", WARNING), 42 | error: makeLogger("ERROR", ERROR), 43 | DEBUG, 44 | INFO, 45 | WARNING, 46 | ERROR, 47 | } 48 | -------------------------------------------------------------------------------- /server/promiseUtil.js: -------------------------------------------------------------------------------- 1 | "use strict" 2 | 3 | const wrapCPS = (fn, options) => { 4 | if (typeof fn !== "function") throw new Error("fn is not a function") 5 | 6 | return function() { 7 | const args = Array.from(arguments) 8 | return new Promise((resolve, reject) => { 9 | args.push(function() { 10 | const resultArgs = Array.from(arguments) 11 | const error = options && options.noError ? null : resultArgs.shift() 12 | 13 | if (error) reject(error) 14 | else if (options && options.multi) { 15 | const result = {} 16 | for (let i = 0; i < options.multi.length; i++) { 17 | result[options.multi[i]] = resultArgs[i] 18 | } 19 | resolve(result) 20 | } else resolve(resultArgs[0]) 21 | }) 22 | fn(...args) 23 | }) 24 | } 25 | } 26 | 27 | module.exports = { 28 | wrapCPS, 29 | } 30 | -------------------------------------------------------------------------------- /tests/api.js: -------------------------------------------------------------------------------- 1 | /*eslint-env jest*/ 2 | const path = require("path") 3 | const fs = require("fs") 4 | const { spawn } = require("child_process") 5 | const tmp = require("tmp-promise") 6 | const fetch = require("node-fetch") 7 | const generateKey = require("./util/generateKey") 8 | 9 | function pipePrefixed(input, output, prefix) { 10 | const buffers = [] 11 | input.on("data", buffer => { 12 | let previousIndex = 0 13 | while (true) { 14 | const index = buffer.indexOf("\n", previousIndex) + 1 15 | if (index === 0) { 16 | buffers.push(buffer.slice(previousIndex)) 17 | break 18 | } 19 | buffers.push(buffer.slice(previousIndex, index)) 20 | process.stdout.write(prefix) 21 | process.stdout.write(Buffer.concat(buffers)) 22 | buffers.length = 0 23 | previousIndex = index 24 | } 25 | }) 26 | } 27 | 28 | function launchPassWeb(args) { 29 | return new Promise((resolve, reject) => { 30 | const p = spawn("node", [ 31 | path.join(__dirname, "..", "server", "index.js"), 32 | ...args, 33 | ]) 34 | let onClose 35 | let closePromise 36 | 37 | pipePrefixed(p.stdout, process.stdout, "[pass-web] ") 38 | pipePrefixed(p.stderr, process.stderr, "[pass-web] ") 39 | p.on("close", code => onClose(code)) 40 | 41 | function close() { 42 | if (closePromise) return closePromise 43 | closePromise = new Promise((resolve, reject) => { 44 | // Close 45 | onClose = code => { 46 | if (code !== 0 && code !== null) { 47 | reject(new Error("pass-web has crashed")) 48 | } else { 49 | resolve() 50 | } 51 | } 52 | p.kill("SIGTERM") 53 | }) 54 | } 55 | 56 | // Start 57 | onClose = code => { 58 | if (code !== 0 && code !== null) { 59 | reject(new Error("pass-web has crashed")) 60 | } 61 | } 62 | 63 | p.stderr.on("data", data => { 64 | const matched = /Server listening on (.*)/.exec(data) 65 | if (matched) { 66 | resolve({ 67 | baseURL: matched[1], 68 | close, 69 | }) 70 | } 71 | }) 72 | }) 73 | } 74 | 75 | function withPassWeb(args = []) { 76 | let tmpDir 77 | let passWeb 78 | beforeAll(async () => { 79 | tmpDir = await tmp.dir() 80 | const keyPath = path.join(tmpDir.path, "test.key") 81 | const storePath = path.join(tmpDir.path, "store") 82 | const { encrypt, privateKey } = await generateKey() 83 | fs.writeFileSync(keyPath, privateKey) 84 | fs.mkdirSync(storePath) 85 | fs.writeFileSync(path.join(storePath, ".gpg-id"), "user@example.com") 86 | fs.writeFileSync(path.join(storePath, "foo.gpg"), await encrypt("foo")) 87 | fs.writeFileSync( 88 | path.join(storePath, "armored.gpg"), 89 | await encrypt("foo", { armored: true }), 90 | ) 91 | passWeb = await launchPassWeb(["-s", storePath, keyPath, ...args]) 92 | }) 93 | 94 | afterAll(async () => { 95 | if (passWeb) await passWeb.close() 96 | if (tmpDir) await tmpDir.cleanup() 97 | }) 98 | 99 | return { 100 | fetch(path) { 101 | if (!passWeb) throw new Error("pass-web failed to initialize") 102 | return fetch(passWeb.baseURL + path) 103 | }, 104 | fetchAPI(route, params) { 105 | if (!passWeb) throw new Error("pass-web failed to initialize") 106 | return fetch(`${passWeb.baseURL}api/${route}`, { 107 | method: "POST", 108 | headers: { 109 | "Content-Type": "application/json; charset=utf-8", 110 | }, 111 | body: JSON.stringify(params), 112 | }).then(response => response.json()) 113 | }, 114 | } 115 | } 116 | 117 | describe("static resources", () => { 118 | const { fetch } = withPassWeb() 119 | test("HTML entrypoint", async () => { 120 | const response = await fetch("/") 121 | expect(response.status).toBe(200) 122 | const body = await response.text() 123 | expect(body.includes("main.js")).toBe(true) 124 | }) 125 | }) 126 | 127 | describe("/api/list", () => { 128 | const { fetchAPI } = withPassWeb() 129 | 130 | test("Wrong argument provided", async () => { 131 | expect(await fetchAPI("list")).toEqual({ 132 | error: { type: "InvalidParameter", message: "No passphrase" }, 133 | }) 134 | }) 135 | 136 | test("Invalid passphrase", async () => { 137 | expect(await fetchAPI("list", { passphrase: "ab" })).toEqual({ 138 | error: { message: "Bad passphrase", type: "AuthError" }, 139 | }) 140 | }) 141 | 142 | test("Returns the list of the files", async () => { 143 | expect(await fetchAPI("list", { passphrase: "abc" })).toEqual([ 144 | { name: "armored.gpg" }, 145 | { name: "foo.gpg" }, 146 | ]) 147 | }) 148 | }) 149 | 150 | describe("/api/get", () => { 151 | const { fetchAPI } = withPassWeb() 152 | 153 | test("Wrong argument provided", async () => { 154 | expect(await fetchAPI("get", { passphrase: "abc" })).toEqual({ 155 | error: { message: "Invalid path parameter", type: "InvalidParameter" }, 156 | }) 157 | }) 158 | 159 | test("Invalid passphrase", async () => { 160 | expect(await fetchAPI("get", { passphrase: "ab" })).toEqual({ 161 | error: { message: "Bad passphrase", type: "AuthError" }, 162 | }) 163 | }) 164 | 165 | test("Get a raw file", async () => { 166 | expect( 167 | await fetchAPI("get", { passphrase: "abc", path: ["foo.gpg"] }), 168 | ).toEqual("foo") 169 | }) 170 | 171 | test("Get an armored file", async () => { 172 | expect( 173 | await fetchAPI("get", { passphrase: "abc", path: ["armored.gpg"] }), 174 | ).toEqual("foo") 175 | }) 176 | }) 177 | -------------------------------------------------------------------------------- /tests/util/generateKey.js: -------------------------------------------------------------------------------- 1 | const { promisify } = require("util") 2 | const kbpgp = require("kbpgp") 3 | const F = kbpgp.const.openpgp 4 | 5 | function promisifyMethod(method) { 6 | return promisify(method.call.bind(method)) 7 | } 8 | const generateKeyManager = promisify(kbpgp.KeyManager.generate) 9 | const signKeyManager = promisifyMethod(kbpgp.KeyManager.prototype.sign) 10 | const exportGPGPrivate = promisifyMethod( 11 | kbpgp.KeyManager.prototype.export_pgp_private, 12 | ) 13 | 14 | async function generateKey() { 15 | const keyManager = await generateKeyManager({ 16 | userid: "User McTester (Born 1979) ", 17 | primary: { 18 | nbits: 1024, 19 | flags: F.certify_keys, 20 | }, 21 | subkeys: [ 22 | { 23 | nbits: 1024, 24 | flags: F.sign_data, 25 | }, 26 | { 27 | nbits: 1024, 28 | flags: F.encrypt_comm | F.encrypt_storage, 29 | }, 30 | ], 31 | }) 32 | 33 | await signKeyManager(keyManager, {}) 34 | return { 35 | privateKey: await exportGPGPrivate(keyManager, { 36 | passphrase: "abc", 37 | }), 38 | encrypt(content, { armored = false } = {}) { 39 | return new Promise((resolve, reject) => { 40 | kbpgp.box( 41 | { msg: content, encrypt_for: keyManager }, 42 | (error, armoredResult, rawResult) => { 43 | if (error) reject(error) 44 | else if (armored) resolve(armoredResult) 45 | else resolve(rawResult) 46 | }, 47 | ) 48 | }) 49 | }, 50 | } 51 | } 52 | 53 | let cachedKey 54 | 55 | module.exports = () => { 56 | if (!cachedKey) cachedKey = generateKey() 57 | return cachedKey 58 | } 59 | -------------------------------------------------------------------------------- /webpack.config.js: -------------------------------------------------------------------------------- 1 | /*eslint filenames/match-exported: off*/ 2 | 3 | const path = require("path") 4 | const webpack = require("webpack") 5 | const HtmlWebpackPlugin = require("html-webpack-plugin") 6 | 7 | const getPath = fullPath => { 8 | const args = fullPath.split("/") 9 | args.unshift(__dirname) 10 | return path.resolve(...args) 11 | } 12 | 13 | const svgoConfig = {} 14 | 15 | const config = { 16 | context: getPath("public"), 17 | entry: ["./main"], 18 | output: { 19 | path: getPath("dist"), 20 | filename: "main.js", 21 | }, 22 | plugins: [ 23 | new HtmlWebpackPlugin({ 24 | hash: true, 25 | template: getPath("public/index.ejs"), 26 | }), 27 | new webpack.DefinePlugin({ 28 | "process.env": { 29 | NODE_ENV: JSON.stringify(process.env.NODE_ENV || ""), 30 | }, 31 | __DEV__: process.env.NODE_ENV !== "production", 32 | }), 33 | ], 34 | module: { 35 | rules: [ 36 | { 37 | test: /\.js$/, 38 | exclude(path) { 39 | return path.includes("/node_modules/") || path.includes("/sans-sel/") 40 | }, 41 | loader: "babel-loader", 42 | }, 43 | { 44 | test: /\.svg$/, 45 | loaders: [ 46 | "file-loader?name=[path][name].[ext]?[hash]", 47 | `svgo-loader?${JSON.stringify(svgoConfig)}`, 48 | ], 49 | }, 50 | { 51 | test: /\.png$/, 52 | loader: "file-loader?name=[path][name].[ext]?[hash]", 53 | }, 54 | ], 55 | }, 56 | } 57 | 58 | module.exports = config 59 | --------------------------------------------------------------------------------