├── .gitignore ├── filestore ├── testdata │ ├── empty │ ├── invalid │ └── multipleUsers_singleUrl ├── common_test.go ├── crypto_test.go ├── crypto.go ├── filestore.go └── filestore_test.go ├── go.mod ├── dist.sh ├── cmd └── passmgr │ ├── print.go │ ├── util.go │ ├── main.go │ ├── doc.go │ └── terminal.go ├── .github └── workflows │ ├── ci.yml │ └── release.yml ├── go.sum ├── Makefile ├── passmgr.go ├── LICENSE └── README.md /.gitignore: -------------------------------------------------------------------------------- 1 | build/** 2 | dist/** 3 | -------------------------------------------------------------------------------- /filestore/testdata/empty: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/urld/passmgr/HEAD/filestore/testdata/empty -------------------------------------------------------------------------------- /filestore/testdata/invalid: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/urld/passmgr/HEAD/filestore/testdata/invalid -------------------------------------------------------------------------------- /filestore/testdata/multipleUsers_singleUrl: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/urld/passmgr/HEAD/filestore/testdata/multipleUsers_singleUrl -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/urld/passmgr 2 | 3 | go 1.19 4 | 5 | require ( 6 | github.com/atotto/clipboard v0.1.4 7 | github.com/bgentry/speakeasy v0.1.0 8 | golang.org/x/crypto v0.1.0 9 | ) 10 | -------------------------------------------------------------------------------- /dist.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | set -e 4 | MAKE="make -e" 5 | export VERSION=$(git describe --exact-match --tags 2>/dev/null || git log -n1 --pretty='%h') 6 | 7 | 8 | $MAKE test 9 | 10 | GOOS=linux GOARCH=amd64 $MAKE shrink dist 11 | GOOS=windows GOARCH=amd64 $MAKE dist 12 | -------------------------------------------------------------------------------- /cmd/passmgr/print.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2017, David Url 2 | // Use of this source code is governed by a BSD-style 3 | // license that can be found in the LICENSE file. 4 | 5 | package main 6 | 7 | import ( 8 | "fmt" 9 | "io" 10 | ) 11 | 12 | func println(a ...interface{}) { 13 | fmt.Println(a...) 14 | } 15 | 16 | func fprintln(w io.Writer, format string, a ...interface{}) { 17 | s := fmt.Sprintf(format, a...) 18 | fmt.Fprintln(w, s) 19 | } 20 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | push: 5 | branches: [ main, master ] 6 | pull_request: 7 | branches: [ main, master ] 8 | 9 | jobs: 10 | 11 | build: 12 | runs-on: ubuntu-latest 13 | steps: 14 | - uses: actions/checkout@v2 15 | 16 | - name: Set up Go 17 | uses: actions/setup-go@v2 18 | with: 19 | go-version: 1.19 20 | 21 | - name: Build 22 | run: go build -v ./... 23 | 24 | - name: Test 25 | run: go test -v ./... 26 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4= 2 | github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI= 3 | github.com/bgentry/speakeasy v0.1.0 h1:ByYyxL9InA1OWqxJqqp2A5pYHUrCiAL6K3J+LKSsQkY= 4 | github.com/bgentry/speakeasy v0.1.0/go.mod h1:+zsyZBPWlz7T6j88CTgSN5bM796AkVf0kBD4zp0CCIs= 5 | golang.org/x/crypto v0.1.0 h1:MDRAIl0xIo9Io2xV565hzXHw3zVseKrJKodhohM5CjU= 6 | golang.org/x/crypto v0.1.0/go.mod h1:RecgLatLF4+eUMCP1PoPZQb+cVrJcOPbHkTkbkB9sbw= 7 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Release 2 | 3 | on: 4 | push: 5 | tags: 6 | - "v*.*.*" 7 | 8 | jobs: 9 | 10 | build: 11 | runs-on: ubuntu-latest 12 | steps: 13 | - name: Install tools 14 | run: sudo apt-get install -y make upx 15 | 16 | - uses: actions/checkout@v2 17 | 18 | - name: Set up Go 19 | uses: actions/setup-go@v2 20 | with: 21 | go-version: 1.19 22 | 23 | - name: Install Go Utils 24 | run: go install github.com/google/go-licenses@latest 25 | 26 | - name: Build 27 | run: sh dist.sh 28 | 29 | - name: Release 30 | uses: softprops/action-gh-release@v1 31 | with: 32 | files: dist/*.tar.gz 33 | env: 34 | GITHUB_TOKEN: ${{ github.token }} 35 | -------------------------------------------------------------------------------- /cmd/passmgr/util.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2017, David Url 2 | // Use of this source code is governed by a BSD-style 3 | // license that can be found in the LICENSE file. 4 | 5 | package main 6 | 7 | import ( 8 | "fmt" 9 | "os" 10 | "path/filepath" 11 | 12 | "github.com/atotto/clipboard" 13 | ) 14 | 15 | func quitErr(err error) { 16 | fmt.Fprintln(os.Stderr, err.Error()) 17 | os.Exit(1) 18 | } 19 | 20 | func calcFilename(filename string) string { 21 | if isDir(filename) { 22 | return filepath.Join(filename, ".passmgr_store") 23 | } 24 | return filename 25 | } 26 | 27 | func isDir(filename string) bool { 28 | info, err := os.Stat(filename) 29 | if err != nil { 30 | return false 31 | } 32 | return info.IsDir() 33 | } 34 | 35 | func isFile(filename string) bool { 36 | info, err := os.Stat(filename) 37 | if err != nil { 38 | return false 39 | } 40 | return !info.IsDir() 41 | } 42 | 43 | func setClipboard(str string) { 44 | err := clipboard.WriteAll(str) 45 | if err != nil { 46 | quitErr(err) 47 | } 48 | } 49 | 50 | func resetClipboard() { 51 | err := clipboard.WriteAll("") 52 | if err != nil { 53 | quitErr(err) 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | PACKAGE = passmgr 2 | VERSION = $(shell git log -n1 --pretty='%h') 3 | BUILD_DIR = build 4 | RELEASE_DIR = dist 5 | RELEASE_FILE = $(PACKAGE)_$(VERSION)_$(shell go env GOOS)-$(shell go env GOARCH) 6 | 7 | .PHONY: all clean clean_build clean_dist dist build install test 8 | 9 | 10 | all: test install dist 11 | 12 | 13 | 14 | dist: build 15 | mkdir -p $(RELEASE_DIR) 16 | go-licenses save "github.com/urld/passmgr/cmd/passmgr" --save_path="$(BUILD_DIR)/licenses" 17 | cp LICENSE $(BUILD_DIR)/licenses/passmgr.LICENSE 18 | tar -cvzf $(RELEASE_DIR)/$(RELEASE_FILE).tar.gz $(BUILD_DIR) --transform='s/$(BUILD_DIR)/$(RELEASE_FILE)/g' 19 | 20 | shrink: build 21 | strip $(BUILD_DIR)/passmgr* 22 | upx $(BUILD_DIR)/passmgr* 23 | 24 | build: clean_build 25 | mkdir -p $(BUILD_DIR) 26 | cd $(BUILD_DIR) && \ 27 | go build github.com/urld/passmgr/cmd/passmgr 28 | 29 | test: 30 | go test github.com/urld/passmgr/... 31 | 32 | 33 | install: 34 | go install github.com/urld/passmgr/cmd/passmgr 35 | 36 | 37 | clean: clean_build clean_dist 38 | 39 | 40 | clean_build: 41 | rm -rf $(BUILD_DIR) 42 | 43 | 44 | clean_dist: 45 | rm -rf $(RELEASE_DIR) 46 | -------------------------------------------------------------------------------- /filestore/common_test.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2017, David Url 2 | // Use of this source code is governed by a BSD-style 3 | // license that can be found in the LICENSE file. 4 | package filestore 5 | 6 | import ( 7 | "bytes" 8 | "fmt" 9 | "io" 10 | "os" 11 | "testing" 12 | ) 13 | 14 | func assertEqual(t *testing.T, a, b interface{}, message string) { 15 | switch a.(type) { 16 | case []byte: 17 | if bytes.Equal(a.([]byte), b.([]byte)) { 18 | return 19 | } 20 | default: 21 | if a == b { 22 | return 23 | } 24 | } 25 | msg := fmt.Sprintf("%s: %v != %v", message, a, b) 26 | t.Error(msg) 27 | } 28 | 29 | func assertNotEqual(t *testing.T, a, b interface{}, message string) { 30 | switch a.(type) { 31 | case []byte: 32 | if !bytes.Equal(a.([]byte), b.([]byte)) { 33 | return 34 | } 35 | default: 36 | if a != b { 37 | return 38 | } 39 | } 40 | msg := fmt.Sprintf("%s: %v == %v", message, a, b) 41 | t.Error(msg) 42 | } 43 | 44 | func remove(t *testing.T, filename string) { 45 | err := os.Remove(filename) 46 | if err != nil { 47 | t.Fatal(err) 48 | } 49 | } 50 | 51 | func close(t *testing.T, c io.Closer) { 52 | err := c.Close() 53 | if err != nil { 54 | t.Fatal(err) 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /passmgr.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2017, David Url 2 | // Use of this source code is governed by a BSD-style 3 | // license that can be found in the LICENSE file. 4 | 5 | // Package passmgr defines a store for credentials. 6 | package passmgr 7 | 8 | // Store provides access to stored credentials. 9 | type Store interface { 10 | 11 | // List retrieves a list of all Subjects known to the store. 12 | // The Secrets map of the returned Subjects is empty. To retrieve the 13 | // complete Subject including its secrets, the Load method needs to be 14 | // used. 15 | List() []Subject 16 | 17 | // Load looks up a Subject, identified by its User and URL fields. 18 | // It returns the complete Subject including its secrets and a flag 19 | // indicating whether the lookup was successful or not. 20 | Load(Subject) (s Subject, ok bool) 21 | 22 | // Store adds a new Subject to the store, or updates an existing one. 23 | Store(Subject) 24 | 25 | // Delete removes a subject from the store. It returns false if the 26 | // Subject to delete could not be found. 27 | Delete(Subject) bool 28 | } 29 | 30 | // Subject contains various secrets for a given user name. 31 | // Usually the URL and User fields are used as unique identifiers. 32 | type Subject struct { 33 | User string 34 | URL string 35 | Secrets map[string]string 36 | } 37 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2017, David Url 2 | All rights reserved. 3 | 4 | Redistribution and use in source and binary forms, with or without 5 | modification, are permitted provided that the following conditions are met: 6 | 7 | * Redistributions of source code must retain the above copyright notice, this 8 | list of conditions and the following disclaimer. 9 | 10 | * Redistributions in binary form must reproduce the above copyright notice, 11 | this list of conditions and the following disclaimer in the documentation 12 | and/or other materials provided with the distribution. 13 | 14 | * Neither the name of the copyright holder nor the names of its 15 | contributors may be used to endorse or promote products derived from 16 | this software without specific prior written permission. 17 | 18 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" 19 | AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE 20 | IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 21 | DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE 22 | FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL 23 | DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR 24 | SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER 25 | CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, 26 | OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE 27 | OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 28 | 29 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # passmgr 2 | 3 | [![Build Status](https://github.com/urld/passmgr/workflows/CI/badge.svg?event=push&branch=master)](https://github.com/urld/passmgr/actions?query=workflow%3ACI+event%3Apush+branch%3Amaster) 4 | [![Go Report Card](https://goreportcard.com/badge/github.com/urld/passmgr)](https://goreportcard.com/report/github.com/urld/passmgr) 5 | [![GoDoc](https://godoc.org/github.com/urld/passmgr/cmd/passmgr?status.svg)](https://godoc.org/github.com/urld/passmgr/cmd/passmgr) 6 | 7 | `passmgr` is a simple portable password manager. 8 | 9 | ## Usage 10 | 11 | Just call ```passmgr``` from your command line. 12 | The application will tell you how to proceed. 13 | 14 | Read the [command documentation](https://godoc.org/github.com/urld/passmgr/cmd/passmgr) 15 | for more detailed instructions and examples. 16 | 17 | 18 | ## Install 19 | 20 | Download the [latest release](https://github.com/urld/passmgr/releases/latest) for your platform, 21 | or build from source: 22 | ```go get github.com/urld/passmgr/cmd/passmgr``` 23 | 24 | 25 | ### Dependencies 26 | 27 | * [github.com/atotto/clipboard](https://github.com/atotto/clipboard) 28 | * [github.com/bgentry/speakeasy](https://github.com/bgentry/speakeasy) 29 | * [golang.org/x/crypto/scrypt](https://godoc.org/golang.org/x/crypto/scrypt) 30 | 31 | 32 | ### Platforms 33 | 34 | * OSX 35 | * Windows 36 | * Linux (requires `xclip` or `xsel` command to be installed, probably works on other *nix platforms too) 37 | 38 | 39 | ## Limitations 40 | 41 | * no protection against keyloggers 42 | * no protection against cliboard spies 43 | 44 | 45 | ## TODO 46 | 47 | * look into improved clipboard handling [hn comment](https://news.ycombinator.com/item?id=14581411) 48 | -------------------------------------------------------------------------------- /cmd/passmgr/main.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2017, David Url 2 | // Use of this source code is governed by a BSD-style 3 | // license that can be found in the LICENSE file. 4 | 5 | package main 6 | 7 | import ( 8 | "flag" 9 | "fmt" 10 | "os" 11 | "os/user" 12 | "path/filepath" 13 | "time" 14 | ) 15 | 16 | type command int 17 | 18 | const ( 19 | addCmd command = iota 20 | getCmd 21 | delCmd 22 | filterCmd 23 | changeKeyCmd 24 | quitCmd 25 | noCmd 26 | ) 27 | 28 | func main() { 29 | cmd, app := parseCmd() 30 | 31 | // setup: 32 | if isFile(app.filename) { 33 | app.Init() 34 | } else { 35 | app.InitEmpty() 36 | } 37 | app.Import() 38 | loop(app, cmd) 39 | } 40 | 41 | func parseCmd() (command, termApp) { 42 | user, err := user.Current() 43 | if err != nil { 44 | quitErr(err) 45 | } 46 | defaultFilename := filepath.Join(user.HomeDir, ".passmgr_store") 47 | 48 | // cmd parsing: 49 | filename := flag.String("file", defaultFilename, "specify the passmgr store") 50 | appTTL := flag.Int("appTTL", 120, "time in seconds after which the application quits if there is no user interaction") 51 | clipboardTTL := flag.Int("clipboardTTL", 15, "time in seconds after which the clipboard is reset") 52 | importFilename := flag.String("import", "", "file to import credentials from") 53 | changeKey := flag.Bool("change-key", false, "change the master passphrase") 54 | flag.Parse() 55 | 56 | cmd := noCmd 57 | if *changeKey { 58 | cmd = changeKeyCmd 59 | } 60 | return cmd, termApp{filename: calcFilename(*filename), clipboardTTL: *clipboardTTL, appTTL: *appTTL, importFilename: *importFilename} 61 | } 62 | 63 | func loop(app termApp, cmd command) { 64 | app.PrintTable() 65 | success := false 66 | for { 67 | timer := time.AfterFunc(time.Duration(app.appTTL)*time.Second, func() { 68 | fmt.Println("\n[passmgr] Exited due to inactivity.") 69 | os.Exit(1) 70 | }) 71 | switch cmd { 72 | case getCmd: 73 | success = app.Get() 74 | case addCmd: 75 | success = app.Add() 76 | case delCmd: 77 | success = app.Delete() 78 | case filterCmd: 79 | success = app.Filter() 80 | case changeKeyCmd: 81 | success = app.ChangeKey() 82 | case quitCmd: 83 | return 84 | default: 85 | cmd = askCommand() 86 | } 87 | if success { 88 | app.PrintTable() 89 | cmd = askCommand() 90 | } 91 | timer.Stop() 92 | } 93 | } 94 | -------------------------------------------------------------------------------- /filestore/crypto_test.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2017, David Url 2 | // Use of this source code is governed by a BSD-style 3 | // license that can be found in the LICENSE file. 4 | package filestore 5 | 6 | import ( 7 | "testing" 8 | ) 9 | 10 | func TestNonceRotates(t *testing.T) { 11 | gcm := aesGcm{nonce: []byte{0xAB, 0x00, 0x00, 0x00, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF}} 12 | gcm.incrementNonce() 13 | assertEqual(t, 14 | []byte{0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00}, 15 | gcm.nonce[4:], 16 | "") 17 | } 18 | 19 | func TestNonceIncrements(t *testing.T) { 20 | gcm := aesGcm{ 21 | nonce: []byte{0xAB, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00}, 22 | AEAD: &mockAead{}, 23 | } 24 | _, err := gcm.Encrypt([]byte{}) 25 | if err != nil { 26 | t.Fatal(err) 27 | } 28 | assertEqual(t, 29 | []byte{0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x01}, 30 | gcm.nonce[4:], 31 | "") 32 | } 33 | 34 | func TestNonceRandomizedPart(t *testing.T) { 35 | gcm := aesGcm{ 36 | nonce: []byte{0xAB, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00}, 37 | AEAD: &mockAead{}, 38 | } 39 | _, err := gcm.Encrypt([]byte{}) 40 | if err != nil { 41 | t.Fatal(err) 42 | } 43 | assertNotEqual(t, 44 | []byte{0xAB, 0x00, 0x00, 0x00}, 45 | gcm.nonce[:4], 46 | "") 47 | } 48 | 49 | func TestNonceDoesNotChange(t *testing.T) { 50 | gcm := aesGcm{ 51 | nonce: []byte{0xAB, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xFF}, 52 | AEAD: &mockAead{}, 53 | } 54 | _, err := gcm.Decrypt([]byte{0xAB, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xFF, 0xAB}) 55 | if err != nil { 56 | t.Fatal(err) 57 | } 58 | assertEqual(t, 59 | []byte{0xAB, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xFF}, 60 | gcm.nonce, 61 | "") 62 | } 63 | 64 | func TestCiphertextTooShort(t *testing.T) { 65 | gcm := aesGcm{ 66 | nonce: []byte{0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00}, 67 | AEAD: &mockAead{}, 68 | } 69 | _, err := gcm.Decrypt([]byte{0xAB, 0xAB}) 70 | if err == nil { 71 | t.Error("len(ciphertext) len(app.subjects) { 228 | fprintln(os.Stderr, "Please select a number within this range: %d..%d", 1, len(app.subjects)) 229 | return 0, false 230 | } 231 | return idx, true 232 | } 233 | 234 | func askConfirm(prompt string, a ...interface{}) bool { 235 | switch strings.ToLower(ask(prompt+" [Y/n] ", a...)) { 236 | case "y", "": 237 | return true 238 | case "n": 239 | return false 240 | default: 241 | return askConfirm(prompt) 242 | } 243 | } 244 | 245 | func askCommand() command { 246 | switch strings.ToLower(ask("Command: (S)elect, (f)ilter, (a)dd, (d)elete or (q)uit? ")) { 247 | case "a", "add": 248 | return addCmd 249 | case "d", "delete": 250 | return delCmd 251 | case "f", "filter": 252 | return filterCmd 253 | case "q", "quit": 254 | return quitCmd 255 | case "s", "select", "": 256 | return getCmd 257 | default: 258 | return askCommand() 259 | } 260 | } 261 | 262 | func ask(prompt string, a ...interface{}) string { 263 | reader := bufio.NewReader(os.Stdin) 264 | fmt.Printf(prompt, a...) 265 | text, err := reader.ReadString('\n') 266 | if err != nil { 267 | quitErr(err) 268 | } 269 | return strings.TrimRight(text, "\r\n") 270 | } 271 | 272 | func askSecret(prompt string, a ...interface{}) string { 273 | prompt = fmt.Sprintf(prompt, a...) 274 | secret, err := speakeasy.Ask(prompt) 275 | if err != nil { 276 | quitErr(err) 277 | } 278 | return secret 279 | } 280 | --------------------------------------------------------------------------------