/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 |
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 |
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 |
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 |
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 |
--------------------------------------------------------------------------------