├── .github └── workflows │ ├── ci.yml │ └── release.yml ├── .gitignore ├── LICENSE ├── Makefile ├── README.md ├── cmd └── passmgr │ ├── doc.go │ ├── main.go │ ├── print.go │ ├── terminal.go │ └── util.go ├── dist.sh ├── filestore ├── common_test.go ├── crypto.go ├── crypto_test.go ├── filestore.go ├── filestore_test.go └── testdata │ ├── empty │ ├── invalid │ └── multipleUsers_singleUrl ├── go.mod ├── go.sum └── passmgr.go /.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 | -------------------------------------------------------------------------------- /.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 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | build/** 2 | dist/** 3 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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/doc.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 | /* 6 | Passmgr is a simple password manager which allows to securely store 7 | passphrases and retrieve them via commandline. 8 | 9 | Usage of passmgr: 10 | -appTTL int 11 | time in seconds after which the application quits if there is no user interaction (default 120) 12 | -change-key 13 | change the master passphrase 14 | -clipboardTTL int 15 | time in seconds after which the clipboard is reset (default 15) 16 | -file string 17 | specify the passmgr store (default "/home/david/.passmgr_store") 18 | -import string 19 | file to import credentials from 20 | 21 | 22 | In its default mode (no arguments), passmgr allows to select stored passphrases 23 | which are then copied to the clipboard for a limited amount of time in order 24 | to be pasted into a passphrase field. After this time, the clipboard is erased. 25 | 26 | All credentials are stored AES256-GCM encrypted in a single file which by default 27 | is located in the users home directory. 28 | The encryption key for this file is derived from a master passphrase using scrypt. 29 | 30 | Select Example: 31 | $ passmgr 32 | [passmgr] master passphrase for /home/david/.passmgr_store: 33 | 34 | n) User URL 35 | 1) urld github.com 36 | 2) david twitter.com 37 | 3) david example.com 38 | 4) other@example.com twitter.com 39 | 40 | Command: (S)elect, (f)ilter, (a)dd, (d)elete or (q)uit? 41 | Select: 1 42 | 43 | Passphrase copied to clipboard! 44 | Clipboard will be erased in 15 seconds. 45 | 46 | ............... 47 | 48 | Passphrase erased from clipboard. 49 | 50 | Filter Example: 51 | # ... 52 | 53 | Command: (S)elect, (f)ilter, (a)dd, (d)elete or (q)uit? f 54 | Filter: twitterdavid 55 | 56 | n) User URL 57 | 2) david twitter.com 58 | 3) david example.com 59 | 60 | The filter can be reset by leaving it empty. 61 | 62 | Import Example: 63 | $ passmgr -import dump.json 64 | [passmgr] master passphrase for /home/david/.passmgr_store: 65 | 66 | n) User URL 67 | 1) urld github.com 68 | 2) david twitter.com 69 | 3) david example.com 70 | 4) other@example.com twitter.com 71 | 5) import1 github.com 72 | 6) import2 google.com 73 | 7) import3 facebook.com 74 | 75 | Do you wish to save the imported changes? [Y/n] 76 | 77 | The dump.json has to look like this: 78 | [ 79 | {"User":"import1", "URL":"github.com", "Secrets":{"passphrase":"secret2"}}, 80 | {"User":"import2", "URL":"google.com", "Secrets":{"passphrase":"secret3"}}, 81 | {"User":"import3", "URL":"facebook.com", "Secrets":{"passphrase":"secret4"}}, 82 | ] 83 | 84 | Please make sure you delete the json file after it is imported, since it contains 85 | all your secrets in plaintext. 86 | 87 | 88 | */ 89 | package main 90 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /cmd/passmgr/terminal.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 | "bufio" 9 | "encoding/json" 10 | "fmt" 11 | "io/ioutil" 12 | "os" 13 | "strconv" 14 | "strings" 15 | "text/tabwriter" 16 | "time" 17 | 18 | "github.com/bgentry/speakeasy" 19 | "github.com/urld/passmgr" 20 | "github.com/urld/passmgr/filestore" 21 | ) 22 | 23 | // termApp provides means to interact with a passmgr store via terminal. 24 | type termApp struct { 25 | filename string 26 | store passmgr.Store 27 | subjects []passmgr.Subject 28 | clipboardTTL int // seconds 29 | appTTL int // seconds 30 | importFilename string 31 | filter string 32 | } 33 | 34 | func (app *termApp) Init() { 35 | if !isFile(app.filename) { 36 | fprintln(os.Stderr, "The passmgr store does not exist yet. Add some passphrases first.") 37 | fprintln(os.Stderr, "See passmgr -h for help.") 38 | os.Exit(1) 39 | } 40 | masterPassphrase := askSecret("[passmgr] master passphrase for %s: ", app.filename) 41 | 42 | store, err := filestore.Read(app.filename, masterPassphrase) 43 | if err != nil { 44 | quitErr(err) 45 | } 46 | app.store = store 47 | app.subjects = store.List() 48 | } 49 | 50 | func (app *termApp) ChangeKey() bool { 51 | masterPassphrase := askSecret("[passmgr] new master passphrase for %s: ", app.filename) 52 | if masterPassphrase != askSecret("[passmgr] retype master passphrase for %s: ", app.filename) { 53 | quitErr(fmt.Errorf("error: passphrases did not match")) 54 | } 55 | 56 | err := filestore.ChangeKey(app.store, masterPassphrase) 57 | if err != nil { 58 | quitErr(err) 59 | } 60 | 61 | err = filestore.Write(app.store) 62 | if err != nil { 63 | quitErr(err) 64 | } 65 | return true 66 | } 67 | 68 | func (app *termApp) InitEmpty() { 69 | masterPassphrase := askSecret("[passmgr] new master passphrase for %s: ", app.filename) 70 | if masterPassphrase != askSecret("[passmgr] retype master passphrase for %s: ", app.filename) { 71 | quitErr(fmt.Errorf("error: passphrases did not match")) 72 | } 73 | store, err := filestore.Read(app.filename, masterPassphrase) 74 | if err != nil { 75 | quitErr(err) 76 | } 77 | app.store = store 78 | app.subjects = store.List() 79 | 80 | err = filestore.Write(store) 81 | if err != nil { 82 | quitErr(err) 83 | } 84 | } 85 | 86 | func (app *termApp) Import() { 87 | if app.importFilename == "" { 88 | return 89 | } 90 | 91 | content, err := ioutil.ReadFile(app.importFilename) 92 | if err != nil { 93 | println("could not import:", err) 94 | return 95 | } 96 | var subjects []passmgr.Subject 97 | err = json.Unmarshal(content, &subjects) 98 | if err != nil { 99 | println("could not import:", err) 100 | return 101 | } 102 | 103 | for _, subj := range subjects { 104 | app.store.Store(subj) 105 | } 106 | 107 | app.PrintTable() 108 | if !askConfirm("Do you wish to save the imported changes?") { 109 | quitErr(fmt.Errorf("import aborted.")) 110 | } 111 | err = filestore.Write(app.store) 112 | if err != nil { 113 | quitErr(err) 114 | } 115 | } 116 | 117 | func (app *termApp) PrintTable() { 118 | app.subjects = app.store.List() 119 | if len(app.subjects) == 0 { 120 | println("") 121 | println("-- store is empty --") 122 | println("") 123 | return 124 | } 125 | 126 | println("") 127 | w := tabwriter.NewWriter(os.Stdout, 0, 0, 3, ' ', tabwriter.StripEscape) 128 | fprintln(w, "n)\t%s\t%s", "User", "URL") 129 | for i, c := range app.subjects { 130 | if strings.Contains(c.User, app.filter) || strings.Contains(c.URL, app.filter) { 131 | fprintln(w, "%d)\t%s\t%s", i+1, c.User, c.URL) 132 | } 133 | } 134 | _ = w.Flush() 135 | println("") 136 | } 137 | 138 | const passphraseKey = "passphrase" 139 | 140 | func (app *termApp) Add() bool { 141 | var subject passmgr.Subject 142 | println("Enter the values for the new entry") 143 | subject.User = ask("\tUser: ") 144 | subject.URL = ask("\tURL: ") 145 | subject.Secrets = make(map[string]string) 146 | subject.Secrets[passphraseKey] = askSecret("\tPassphrase: ") 147 | 148 | app.store.Store(subject) 149 | err := filestore.Write(app.store) 150 | if err != nil { 151 | quitErr(err) 152 | } 153 | return true 154 | } 155 | 156 | func (app *termApp) Get() bool { 157 | if len(app.subjects) == 0 { 158 | return true 159 | } 160 | 161 | idx, ok := app.readSelection("Select: ") 162 | if !ok { 163 | return false 164 | } 165 | 166 | subject, _ := app.store.Load(app.subjects[idx-1]) 167 | passphrase, ok := subject.Secrets[passphraseKey] 168 | if !ok { 169 | // ignore for now. may become relevant if support for 170 | // multiple secrets gets added. 171 | return false 172 | } 173 | 174 | println("") 175 | println("Passphrase copied to clipboard!") 176 | println("Clipboard will be erased in", app.clipboardTTL, "seconds.") 177 | println("") 178 | setClipboard(passphrase) 179 | defer resetClipboard() 180 | for i := 1; i <= app.clipboardTTL; i++ { 181 | time.Sleep(1 * time.Second) 182 | fmt.Print(".") 183 | } 184 | println("") 185 | println("") 186 | println("Passphrase erased from clipboard.") 187 | return true 188 | } 189 | 190 | func (app *termApp) Delete() bool { 191 | if len(app.subjects) == 0 { 192 | return true 193 | } 194 | 195 | idx, ok := app.readSelection("Delete: ") 196 | if !ok { 197 | return false 198 | } 199 | subject := app.subjects[idx-1] 200 | if !askConfirm("Delete all secrets for '%s | %s?", subject.User, subject.URL) { 201 | return true 202 | } 203 | 204 | app.store.Delete(app.subjects[idx-1]) 205 | err := filestore.Write(app.store) 206 | if err != nil { 207 | quitErr(err) 208 | } 209 | return true 210 | } 211 | 212 | func (app *termApp) Filter() bool { 213 | if len(app.subjects) == 0 { 214 | return true 215 | } 216 | 217 | app.filter = ask("Filter: ") 218 | return true 219 | } 220 | 221 | func (app *termApp) readSelection(prompt string) (int, bool) { 222 | idx, err := strconv.Atoi(ask(prompt)) 223 | if err != nil { 224 | fprintln(os.Stderr, "Please type a valid number.") 225 | return 0, false 226 | } 227 | if idx < 1 || idx > 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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /filestore/crypto.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 filestore 6 | 7 | import ( 8 | "crypto/aes" 9 | "crypto/cipher" 10 | "crypto/rand" 11 | "encoding/binary" 12 | "errors" 13 | "io" 14 | 15 | "golang.org/x/crypto/scrypt" 16 | ) 17 | 18 | // aesCipher provides methods to encrypt and decrypt arbitrary content. 19 | // The returned byte slice of each operation is guaranteed to be a valid 20 | // input for the opposite operation. 21 | type aesCipher interface { 22 | Encrypt(plaintext []byte) ([]byte, error) 23 | Decrypt(ciphertext []byte) ([]byte, error) 24 | } 25 | 26 | type aesGcm struct { 27 | cipher.AEAD 28 | nonce []byte 29 | } 30 | 31 | func newGCM(key []byte) (aesCipher, error) { 32 | block, err := aes.NewCipher(key) 33 | if err != nil { 34 | return nil, err 35 | } 36 | 37 | aead, err := cipher.NewGCMWithNonceSize(block, nonceLenV1) 38 | if err != nil { 39 | return nil, err 40 | } 41 | 42 | return &aesGcm{AEAD: aead, nonce: nil}, nil 43 | } 44 | 45 | func (c *aesGcm) Encrypt(plaintext []byte) ([]byte, error) { 46 | // setup nonce: 47 | nonceSize := c.AEAD.NonceSize() 48 | if c.nonce == nil { 49 | // initial random nonce generation 50 | c.nonce = make([]byte, nonceSize) 51 | if _, err := io.ReadFull(rand.Reader, c.nonce); err != nil { 52 | return nil, err 53 | } 54 | } else { 55 | if err := c.incrementNonce(); err != nil { 56 | return nil, err 57 | } 58 | } 59 | 60 | ciphertext := make([]byte, nonceSize+len(plaintext)+c.AEAD.Overhead()) 61 | copy(ciphertext[:nonceSize], c.nonce) 62 | 63 | // encrypt: 64 | c.AEAD.Seal(ciphertext[:nonceSize], c.nonce, plaintext, nil) 65 | return ciphertext, nil 66 | } 67 | 68 | func (c *aesGcm) Decrypt(ciphertext []byte) ([]byte, error) { 69 | // extract nonce: 70 | nonceSize := c.AEAD.NonceSize() 71 | if len(ciphertext) < nonceSize { 72 | return nil, errors.New("ciphertext too short") 73 | } 74 | if c.nonce == nil { 75 | c.nonce = make([]byte, nonceSize) 76 | } 77 | copy(c.nonce, ciphertext[:nonceSize]) 78 | 79 | // decrypt: 80 | return c.AEAD.Open(nil, c.nonce, ciphertext[nonceSize:], nil) 81 | } 82 | 83 | func (c *aesGcm) incrementNonce() error { 84 | // increment the counter part of the nonce: 85 | counter := binary.BigEndian.Uint64(c.nonce[4:]) 86 | counter++ 87 | binary.BigEndian.PutUint64(c.nonce[4:], counter) 88 | // Change the random part of the nonce too, to avoid nonce reuse. 89 | // Nonce reuse could occur if a previous version of the store file 90 | // is checked out or restored using version control software like git 91 | // or other backup mechanisms. 92 | _, err := io.ReadFull(rand.Reader, c.nonce[:4]) 93 | return err 94 | 95 | } 96 | 97 | // genSalt generates a salt, which can be used for the deriveKey fuction. 98 | func genSalt() ([]byte, error) { 99 | 100 | salt := make([]byte, saltLenV1) 101 | if _, err := io.ReadFull(rand.Reader, salt); err != nil { 102 | return nil, err 103 | } 104 | return salt, nil 105 | 106 | } 107 | 108 | // deriveKey derives a key for AES-256, using scrypt. 109 | func deriveKey(passwd []byte, salt []byte) ([]byte, error) { 110 | return scrypt.Key(passwd, salt, 32768, 8, 4, 32) 111 | } 112 | -------------------------------------------------------------------------------- /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)