├── .github ├── dependabot.yml ├── go │ └── Dockerfile ├── golangci.yml └── workflows │ ├── build.yml │ ├── lint.yml │ ├── release.yml │ ├── securityscan.yml │ ├── test.yml │ └── todo.yml ├── .gitignore ├── LICENSE ├── Makefile ├── README.md ├── cmd └── enpasscli │ └── main.go ├── go.mod ├── go.sum ├── goreleaser.yml ├── pkg ├── clipboard │ ├── clipboard.go │ ├── clipboard_darwin.go │ ├── clipboard_linux.go │ └── clipboard_openbsd.go ├── enpass │ ├── card.go │ ├── key.go │ ├── keyfile.go │ ├── vault.go │ ├── vault_test.go │ └── vaultinfo.go └── unlock │ ├── aes256gcm.go │ └── securestore.go └── test ├── vault.enpassdb └── vault.json /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: docker 4 | directory: "/.github/go" 5 | schedule: 6 | interval: daily 7 | time: '04:00' 8 | open-pull-requests-limit: 10 9 | target-branch: dev 10 | - package-ecosystem: github-actions 11 | directory: "/" 12 | schedule: 13 | interval: daily 14 | time: '04:00' 15 | open-pull-requests-limit: 10 16 | target-branch: dev 17 | - package-ecosystem: gomod 18 | directory: "/" 19 | schedule: 20 | interval: daily 21 | time: '04:00' 22 | open-pull-requests-limit: 10 23 | target-branch: dev 24 | -------------------------------------------------------------------------------- /.github/go/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM golang:1.23 2 | -------------------------------------------------------------------------------- /.github/golangci.yml: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hazcod/enpass-cli/a022b26714be0d350227d3db02656c313853aff4/.github/golangci.yml -------------------------------------------------------------------------------- /.github/workflows/build.yml: -------------------------------------------------------------------------------- 1 | name: build 2 | on: pull_request 3 | 4 | jobs: 5 | build: 6 | name: Build 7 | runs-on: ubuntu-latest 8 | steps: 9 | - 10 | uses: actions/checkout@v2 11 | - 12 | id: vars 13 | run: | 14 | goVersion=$(grep '^FROM go' .github/go/Dockerfile | cut -d ' ' -f 2 | cut -d ':' -f 2) 15 | echo "go_version=${goVersion}" >> $GITHUB_OUTPUT 16 | echo "Using Go version ${goVersion}" 17 | - 18 | name: Set up Go 19 | uses: actions/setup-go@v5 20 | with: 21 | go-version: ${{ steps.vars.outputs.go_version }} 22 | - 23 | name: Download Go modules 24 | run: go mod download 25 | - 26 | name: Go build 27 | run: go build -o /dev/null ./cmd/enpasscli/main.go -------------------------------------------------------------------------------- /.github/workflows/lint.yml: -------------------------------------------------------------------------------- 1 | name: lint 2 | 3 | on: pull_request 4 | 5 | jobs: 6 | golangci: 7 | name: Lint - Go 8 | runs-on: ubuntu-latest 9 | steps: 10 | - 11 | uses: actions/checkout@v2 12 | - 13 | name: golangci-lint 14 | uses: reviewdog/action-golangci-lint@master 15 | with: 16 | github_token: ${{ secrets.github_token }} 17 | golangci_lint_flags: "--config=.github/golangci.yml" 18 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: release 2 | 3 | on: 4 | push: 5 | branches: [master,main] 6 | 7 | jobs: 8 | 9 | goreleaser: 10 | name: Release 11 | runs-on: ubuntu-latest 12 | steps: 13 | - 14 | name: Checkout 15 | uses: actions/checkout@v2 16 | with: 17 | fetch-depth: 0 18 | - 19 | id: vars 20 | run: | 21 | goVersion=$(grep '^FROM go' .github/go/Dockerfile | cut -d ' ' -f 2 | cut -d ':' -f 2) 22 | echo "go_version=${goVersion}" >> $GITHUB_OUTPUT 23 | echo "Using Go version ${goVersion}" 24 | - 25 | name: Set up Go 26 | uses: actions/setup-go@v5 27 | with: 28 | go-version: ${{ steps.vars.outputs.go_version }} 29 | - 30 | name: release dry run 31 | run: make release-dry-run 32 | - 33 | uses: go-semantic-release/action@v1 34 | id: semantic 35 | with: 36 | github-token: ${{ secrets.GITHUB_TOKEN }} 37 | - 38 | name: Set up Git config for tagging 39 | if: steps.semantic.outputs.version != '' 40 | env: 41 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 42 | run: | 43 | git config user.name "${{ github.actor }}" 44 | git config user.email "${{ github.actor }}@users.noreply.github.com" 45 | git remote set-url origin https://x-access-token:${{ secrets.GITHUB_TOKEN }}@github.com/${{ github.repository }}.git 46 | - 47 | name: Update tags 48 | if: steps.semantic.outputs.version != '' 49 | run: | 50 | TAG=v${{ steps.semantic.outputs.version }} 51 | git push origin :refs/tags/${TAG} 52 | git tag -fa ${TAG} -m "release v${{ steps.semantic.outputs.version }}" 53 | git push origin ${TAG} 54 | - 55 | name: setup release environment 56 | run: |- 57 | echo 'GITHUB_TOKEN=${{secrets.GITHUB_TOKEN}}' > .release-env 58 | - 59 | name: release publish 60 | run: make release 61 | -------------------------------------------------------------------------------- /.github/workflows/securityscan.yml: -------------------------------------------------------------------------------- 1 | name: "security scan" 2 | 3 | on: 4 | push: 5 | branches: [master, ] 6 | pull_request: 7 | # The branches below must be a subset of the branches above 8 | branches: [master] 9 | schedule: 10 | - cron: '0 5 * * 6' 11 | 12 | jobs: 13 | codescan: 14 | 15 | runs-on: ubuntu-latest 16 | 17 | steps: 18 | - name: Checkout repository 19 | uses: actions/checkout@v2 20 | with: 21 | # We must fetch at least the immediate parents so that if this is 22 | # a pull request then we can checkout the head. 23 | fetch-depth: 2 24 | 25 | # Initializes the CodeQL tools for scanning. 26 | - name: Initialize CodeQL 27 | uses: github/codeql-action/init@v3 28 | with: 29 | languages: go 30 | 31 | - name: Perform CodeQL Analysis 32 | uses: github/codeql-action/analyze@v3 33 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: test 2 | on: pull_request 3 | 4 | jobs: 5 | test: 6 | name: Tests 7 | runs-on: ubuntu-latest 8 | steps: 9 | - 10 | uses: actions/checkout@v2 11 | - 12 | id: vars 13 | run: | 14 | goVersion=$(grep '^FROM go' .github/go/Dockerfile | cut -d ' ' -f 2 | cut -d ':' -f 2) 15 | echo "go_version=${goVersion}" >> $GITHUB_OUTPUT 16 | echo "Using Go version ${goVersion}" 17 | - 18 | name: Set up Go 19 | uses: actions/setup-go@v5 20 | with: 21 | go-version: ${{ steps.vars.outputs.go_version }} 22 | - 23 | name: Download Go modules 24 | run: go mod download 25 | - 26 | name: Run Go Tests 27 | run: go test -v $(go list ./... | grep -v /vendor/) 28 | -------------------------------------------------------------------------------- /.github/workflows/todo.yml: -------------------------------------------------------------------------------- 1 | name: ToDo 2 | on: 3 | push: 4 | branches: 5 | - master 6 | jobs: 7 | todo: 8 | runs-on: ubuntu-latest 9 | steps: 10 | - 11 | uses: actions/checkout@v2 12 | - 13 | name: Check Todos 14 | uses: ribtoks/tdg-github-action@v0.4.5-beta 15 | with: 16 | TOKEN: ${{ secrets.GITHUB_TOKEN }} 17 | REPO: ${{ github.repository }} 18 | SHA: ${{ github.sha }} 19 | REF: ${{ github.ref }} 20 | EXCLUDE_PATTERN: 'vendor/' 21 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .idea/ 2 | *.tar.xz 3 | dist 4 | vendor 5 | .release-env 6 | .semrel/ 7 | .generated-go-semantic-release-changelog.md 8 | .vscode/ 9 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 Niels Hofmans 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | PACKAGE_NAME := github.com/hazcod/enpass-cli 2 | GOLANG_CROSS_VERSION ?= v1.21.5 3 | 4 | SYSROOT_DIR ?= sysroots 5 | SYSROOT_ARCHIVE ?= sysroots.tar.bz2 6 | 7 | build: 8 | go build -o enpass-cli ./cmd/... 9 | 10 | .PHONY: sysroot-pack 11 | sysroot-pack: 12 | @tar cf - $(SYSROOT_DIR) -P | pv -s $[$(du -sk $(SYSROOT_DIR) | awk '{print $1}') * 1024] | pbzip2 > $(SYSROOT_ARCHIVE) 13 | 14 | .PHONY: sysroot-unpack 15 | sysroot-unpack: 16 | @pv $(SYSROOT_ARCHIVE) | pbzip2 -cd | tar -xf - 17 | 18 | .PHONY: release-dry-run 19 | release-dry-run: 20 | @docker run \ 21 | --rm \ 22 | --privileged \ 23 | -e CGO_ENABLED=1 \ 24 | -v /var/run/docker.sock:/var/run/docker.sock \ 25 | -v `pwd`:/go/src/$(PACKAGE_NAME) \ 26 | -v `pwd`/sysroot:/sysroot \ 27 | -w /go/src/$(PACKAGE_NAME) \ 28 | ghcr.io/goreleaser/goreleaser-cross:${GOLANG_CROSS_VERSION} \ 29 | --clean --skip=publish,validate 30 | 31 | .PHONY: release 32 | release: 33 | @if [ ! -f ".release-env" ]; then \ 34 | echo "\033[91m.release-env is required for release\033[0m";\ 35 | exit 1;\ 36 | fi 37 | docker run \ 38 | --rm \ 39 | --privileged \ 40 | -e CGO_ENABLED=1 \ 41 | --env-file .release-env \ 42 | -v /var/run/docker.sock:/var/run/docker.sock \ 43 | -v `pwd`:/go/src/$(PACKAGE_NAME) \ 44 | -v `pwd`/sysroot:/sysroot \ 45 | -w /go/src/$(PACKAGE_NAME) \ 46 | ghcr.io/goreleaser/goreleaser-cross:${GOLANG_CROSS_VERSION} \ 47 | release --clean 48 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | enpass-cli 2 | ========== 3 | 4 | A commandline utility for the Enpass password manager. 5 | 6 | Installation 7 | ----- 8 | Go get yourself a compiled binary from [the releases page](https://github.com/hazcod/enpass-cli/releases). 9 | 10 | CLI Usage 11 | ----- 12 | ```shell 13 | $ # set an alias to easily reuse 14 | $ alias enp="enpasscli -vault=/my-vault-dir/ -sort" 15 | 16 | $ # list anything containing 'twitter' (without password) 17 | $ enp list twitter 18 | 19 | $ # show passwords of 'enpass.com' 20 | $ enp show enpass.com 21 | 22 | $ # copy password of 'reddit.com' entry to clipboard 23 | $ enp copy reddit.com 24 | 25 | $ # print password of 'github.com' to stdout, useful for scripting 26 | $ password=$(enp pass github.com) 27 | ``` 28 | 29 | Commands 30 | ----- 31 | | Name | Description | 32 | | :---: | --- | 33 | | `list FILTER` | List vault entries matching FILTER without password | 34 | | `show FILTER` | List vault entries matching FILTER with password | 35 | | `copy FILTER` | Copy the password of a vault entry matching FILTER to the clipboard | 36 | | `pass FILTER` | Print the password of a vaulty entry matching FILTER to stdout | 37 | | `dryrun` | Opens the vault without reading anything from it | 38 | | `version` | Print the version | 39 | | `help` | Print the help text | 40 | 41 | Flags 42 | ----- 43 | | Name | Description | 44 | | :---: | --- | 45 | | `-vault=PATH` | Path to your Enpass vault | 46 | | `-keyfile=PATH` | Path to your Enpass vault keyfile | 47 | | `-type=TYPE` | The type of your card (password, ...) | 48 | | `-log=LEVEL` | The log level from debug (5) to error (1) | 49 | | `-nonInteractive` | Disable prompts and fail instead | 50 | | `-json` | Output as JSON to stdout | 51 | | `-pin` | Enable Quick Unlock using a PIN | 52 | | `-and` | Combines filters with AND instead of default OR | 53 | | `-sort` | Sort the output by title and username of the `list` and `show` command | 54 | | `-trashed` | Show trashed items in the `list` and `show` command | 55 | | `-clipboardPrimary` | Use primary X selection instead of clipboard for the `copy` command | 56 | 57 | Development 58 | ----- 59 | ```shell 60 | # to run it from code 61 | % go run ./cmd/... -vault=foo list 62 | 63 | # to build it yourself 64 | % make build 65 | % ./enpass-cli -vault=foo list 66 | ``` 67 | 68 | Testing Code 69 | ------- 70 | ```shell 71 | $ go test -v $(go list ./... | grep -v /vendor/) 72 | ``` 73 | 74 | Using the library 75 | ----------------- 76 | See the documentation on [pkg.go.dev](https://pkg.go.dev/github.com/hazcod/enpass-cli/pkg/enpass). 77 | -------------------------------------------------------------------------------- /cmd/enpasscli/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "encoding/json" 5 | "flag" 6 | "fmt" 7 | "os" 8 | "path/filepath" 9 | "runtime" 10 | "sort" 11 | "strconv" 12 | "strings" 13 | 14 | "github.com/gdamore/tcell/v2" 15 | "github.com/hazcod/enpass-cli/pkg/clipboard" 16 | "github.com/hazcod/enpass-cli/pkg/enpass" 17 | "github.com/hazcod/enpass-cli/pkg/unlock" 18 | "github.com/miquella/ask" 19 | "github.com/rivo/tview" 20 | "github.com/sirupsen/logrus" 21 | ) 22 | 23 | const ( 24 | // commands 25 | cmdVersion = "version" 26 | cmdHelp = "help" 27 | cmdDryRun = "dryrun" 28 | cmdList = "list" 29 | cmdShow = "show" 30 | cmdCopy = "copy" 31 | cmdPass = "pass" 32 | cmdUi = "ui" 33 | 34 | // defaults 35 | defaultLogLevel = logrus.InfoLevel 36 | pinMinLength = 8 37 | pinDefaultKdfIterCount = 100000 38 | ) 39 | 40 | var ( 41 | // overwritten by go build 42 | version = "dev" 43 | // set of all commands 44 | commands = map[string]struct{}{ 45 | cmdVersion: {}, cmdHelp: {}, cmdDryRun: {}, cmdList: {}, 46 | cmdShow: {}, cmdCopy: {}, cmdPass: {}, cmdUi: {}, 47 | } 48 | ) 49 | 50 | type Args struct { 51 | command string 52 | // params 53 | filters []string 54 | // flags 55 | vaultPath *string 56 | cardType *string 57 | keyFilePath *string 58 | logLevelStr *string 59 | jsonOutput *bool 60 | nonInteractive *bool 61 | pinEnable *bool 62 | sort *bool 63 | trashed *bool 64 | and *bool 65 | clipboardPrimary *bool 66 | } 67 | 68 | func (args *Args) parse() { 69 | args.vaultPath = flag.String("vault", "", "Path to your Enpass vault.") 70 | args.cardType = flag.String("type", "password", "The type of your card. (password, ...)") 71 | args.keyFilePath = flag.String("keyfile", "", "Path to your Enpass vault keyfile.") 72 | args.logLevelStr = flag.String("log", defaultLogLevel.String(), "The log level from debug (5) to error (1).") 73 | args.jsonOutput = flag.Bool("json", false, "Output data in JSON format.") 74 | args.nonInteractive = flag.Bool("nonInteractive", false, "Disable prompts and fail instead.") 75 | args.pinEnable = flag.Bool("pin", false, "Enable PIN.") 76 | args.and = flag.Bool("and", false, "Combines filters with AND instead of default OR.") 77 | args.sort = flag.Bool("sort", false, "Sort the output by title and username of the 'list' and 'show' command.") 78 | args.trashed = flag.Bool("trashed", false, "Show trashed items in the 'list' and 'show' command.") 79 | args.clipboardPrimary = flag.Bool("clipboardPrimary", false, "Use primary X selection instead of clipboard for the 'copy' command.") 80 | flag.Parse() 81 | args.command = strings.ToLower(flag.Arg(0)) 82 | if len(flag.Args()) > 1 { 83 | args.filters = flag.Args()[1:] 84 | } else { 85 | args.filters = []string{} 86 | } 87 | } 88 | 89 | func prompt(logger *logrus.Logger, args *Args, msg string) string { 90 | if !*args.nonInteractive { 91 | if response, err := ask.HiddenAsk("Enter " + msg + ": "); err != nil { 92 | logger.WithError(err).Fatal("could not prompt for " + msg) 93 | } else { 94 | return response 95 | } 96 | } 97 | return "" 98 | } 99 | 100 | func printHelp() { 101 | fmt.Print("Valid commands: ") 102 | for cmd := range commands { 103 | fmt.Printf("%s, ", cmd) 104 | } 105 | fmt.Println() 106 | flag.Usage() 107 | os.Exit(1) 108 | } 109 | 110 | func sortEntries(cards []enpass.Card) { 111 | // Sort by username preserving original order 112 | sort.SliceStable(cards, func(i, j int) bool { 113 | return strings.ToLower(cards[i].Subtitle) < strings.ToLower(cards[j].Subtitle) 114 | }) 115 | // Sort by title, preserving username order 116 | sort.SliceStable(cards, func(i, j int) bool { 117 | return strings.ToLower(cards[i].Title) < strings.ToLower(cards[j].Title) 118 | }) 119 | } 120 | 121 | func listEntries(logger *logrus.Logger, vault *enpass.Vault, args *Args) { 122 | cards, err := vault.GetEntries(*args.cardType, args.filters) 123 | if err != nil { 124 | logger.WithError(err).Fatal("could not retrieve cards") 125 | } 126 | if *args.sort { 127 | sortEntries(cards) 128 | } 129 | 130 | data, err := prepareCardData(cards, false, args) 131 | if err != nil { 132 | logger.WithError(err).Fatal(err.Error()) 133 | } 134 | 135 | outputDataOrLog(logger, data, args) 136 | } 137 | 138 | func showEntries(logger *logrus.Logger, vault *enpass.Vault, args *Args) { 139 | cards, err := vault.GetEntries(*args.cardType, args.filters) 140 | if err != nil { 141 | logger.WithError(err).Fatal("could not retrieve cards") 142 | } 143 | if *args.sort { 144 | sortEntries(cards) 145 | } 146 | 147 | data, err := prepareCardData(cards, true, args) 148 | if err != nil { 149 | logger.WithError(err).Fatal(err.Error()) 150 | } 151 | 152 | outputDataOrLog(logger, data, args) 153 | } 154 | 155 | func prepareCardData(cards []enpass.Card, includeDecrypted bool, args *Args) ([]map[string]string, error) { 156 | data := make([]map[string]string, 0) 157 | for _, card := range cards { 158 | if card.IsTrashed() && !*args.trashed { 159 | continue 160 | } 161 | 162 | cardMap := map[string]string{ 163 | "title": card.Title, 164 | "login": card.Subtitle, 165 | "category": card.Category, 166 | "label": card.Label, 167 | "type": card.Type, 168 | } 169 | 170 | if includeDecrypted { 171 | decrypted, err := card.Decrypt() 172 | if err != nil { 173 | return nil, fmt.Errorf("could not decrypt %s: %w", card.Title, err) 174 | } 175 | cardMap["password"] = decrypted 176 | } 177 | 178 | data = append(data, cardMap) 179 | } 180 | return data, nil 181 | } 182 | 183 | func outputDataOrLog(logger *logrus.Logger, data []map[string]string, args *Args) { 184 | if *args.jsonOutput { 185 | jsonData, jsonErr := json.Marshal(data) 186 | if jsonErr != nil { 187 | logger.WithError(jsonErr).Fatal("could not marshal JSON data") 188 | } 189 | fmt.Println(string(jsonData)) 190 | } else { 191 | for _, card := range data { 192 | logger.Printf( 193 | "> title: %s login: %s cat.: %s label: %s", 194 | card["title"], 195 | card["login"], 196 | card["category"], 197 | card["label"], 198 | ) 199 | } 200 | } 201 | } 202 | 203 | func copyEntry(logger *logrus.Logger, vault *enpass.Vault, args *Args) { 204 | card, err := vault.GetEntry(*args.cardType, args.filters, true) 205 | if err != nil { 206 | logger.WithError(err).Fatal("could not retrieve unique card") 207 | } 208 | 209 | decrypted, err := card.Decrypt() 210 | if err != nil { 211 | logger.WithError(err).Fatal("could not decrypt card") 212 | } 213 | 214 | if *args.clipboardPrimary { 215 | clipboard.Primary = true 216 | logger.Debug("primary X selection enabled") 217 | } 218 | 219 | if err := clipboard.WriteAll(decrypted); err != nil { 220 | logger.WithError(err).Fatal("could not copy password to clipboard") 221 | } 222 | } 223 | 224 | func entryPassword(logger *logrus.Logger, vault *enpass.Vault, args *Args) { 225 | card, err := vault.GetEntry(*args.cardType, args.filters, true) 226 | if err != nil { 227 | logger.WithError(err).Fatal("could not retrieve unique card") 228 | } 229 | 230 | if decrypted, err := card.Decrypt(); err != nil { 231 | logger.WithError(err).Fatal("could not decrypt card") 232 | } else { 233 | fmt.Println(decrypted) 234 | } 235 | } 236 | 237 | func ui(logger *logrus.Logger, vault *enpass.Vault, args *Args) { 238 | cards, err := vault.GetEntries(*args.cardType, args.filters) 239 | if err != nil { 240 | logger.WithError(err).Fatal("could not retrieve cards") 241 | } 242 | if *args.sort { 243 | sortEntries(cards) 244 | } 245 | 246 | app := tview.NewApplication() 247 | flex := tview.NewFlex().SetDirection(tview.FlexRow) 248 | table := tview.NewTable().SetBorders(false) 249 | flex.AddItem(table, 0, 1, true) 250 | 251 | var visibleCards []enpass.Card 252 | render := func(filter string) { 253 | filter = strings.ToLower(filter) 254 | visibleCards = []enpass.Card{} 255 | 256 | table.Clear() 257 | table.SetCell(0, 0, tview.NewTableCell("Title").SetBackgroundColor(tcell.ColorGray)) 258 | table.SetCell(0, 1, tview.NewTableCell("Subtitle").SetBackgroundColor(tcell.ColorGray)) 259 | table.SetCell(0, 2, tview.NewTableCell("Category").SetBackgroundColor(tcell.ColorGray)) 260 | 261 | i := 0 262 | for _, card := range cards { 263 | if card.IsTrashed() && !*args.trashed { 264 | continue 265 | } 266 | if !strings.Contains(strings.ToLower(card.Title+" "+card.Subtitle), filter) { 267 | continue 268 | } 269 | 270 | table.SetCell(i+1, 0, tview.NewTableCell(card.Title)) 271 | table.SetCell(i+1, 1, tview.NewTableCell(card.Subtitle)) 272 | table.SetCell(i+1, 2, tview.NewTableCell(card.Category)) 273 | i += 1 274 | visibleCards = append(visibleCards, card) 275 | } 276 | } 277 | render("") // render ininital table without filter 278 | 279 | statusText := tview.NewTextView().SetChangedFunc(func() { 280 | app.Draw() 281 | }) 282 | 283 | inputField := tview.NewInputField() 284 | inputField.SetLabel("Search: "). 285 | SetFieldWidth(30). 286 | SetDoneFunc(func(key tcell.Key) { 287 | render(inputField.GetText()) 288 | app.SetFocus(table) 289 | statusText.SetText(fmt.Sprintf("found %d", len(visibleCards))) 290 | }) 291 | 292 | status := tview.NewFlex() 293 | status.AddItem(inputField, 0, 1, false) 294 | status.AddItem(statusText, 0, 1, false) 295 | flex.AddItem(status, 1, 1, false) 296 | 297 | table.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey { 298 | if event.Rune() == '/' { 299 | app.SetFocus(inputField) 300 | } 301 | return event 302 | }) 303 | 304 | table.Select(0, 0).SetFixed(1, 1) 305 | table.SetSelectable(true, false) 306 | table.SetSelectedFunc(func(row int, column int) { 307 | card := visibleCards[row-1] 308 | if decrypted, err := card.Decrypt(); err != nil { 309 | logger.WithError(err).Fatal("could not decrypt card") 310 | } else { 311 | if err := clipboard.WriteAll(decrypted); err != nil { 312 | logger.WithError(err).Fatal("could not copy password to clipboard") 313 | } else { 314 | statusText.SetText("copied password for " + card.Title) 315 | } 316 | } 317 | }) 318 | 319 | if err := app.SetRoot(flex, true).SetFocus(inputField).Run(); err != nil { 320 | panic(err) 321 | } 322 | } 323 | 324 | func assembleVaultCredentials(logger *logrus.Logger, args *Args, store *unlock.SecureStore) *enpass.VaultCredentials { 325 | credentials := &enpass.VaultCredentials{ 326 | Password: os.Getenv("MASTERPW"), 327 | KeyfilePath: *args.keyFilePath, 328 | } 329 | 330 | if !credentials.IsComplete() && store != nil { 331 | var err error 332 | if credentials.DBKey, err = store.Read(); err != nil { 333 | logger.WithError(err).Fatal("could not read credentials from store") 334 | } 335 | logger.Debug("read credentials from store") 336 | } 337 | 338 | if !credentials.IsComplete() { 339 | credentials.Password = prompt(logger, args, "vault password") 340 | } 341 | 342 | return credentials 343 | } 344 | 345 | func initializeStore(logger *logrus.Logger, args *Args) *unlock.SecureStore { 346 | vaultPath, _ := filepath.EvalSymlinks(*args.vaultPath) 347 | store, err := unlock.NewSecureStore(filepath.Base(vaultPath), logger.Level) 348 | if err != nil { 349 | logger.WithError(err).Fatal("could not create store") 350 | } 351 | 352 | pin := os.Getenv("ENP_PIN") 353 | if pin == "" { 354 | pin = prompt(logger, args, "PIN") 355 | } 356 | if len(pin) < pinMinLength { 357 | logger.Fatal("PIN too short") 358 | } 359 | 360 | pepper := os.Getenv("ENP_PIN_PEPPER") 361 | 362 | pinKdfIterCount, err := strconv.ParseInt(os.Getenv("ENP_PIN_ITER_COUNT"), 10, 32) 363 | if err != nil { 364 | pinKdfIterCount = pinDefaultKdfIterCount 365 | } 366 | 367 | if err := store.GeneratePassphrase(pin, pepper, int(pinKdfIterCount)); err != nil { 368 | logger.WithError(err).Fatal("could not initialize store") 369 | } 370 | 371 | return store 372 | } 373 | 374 | func main() { 375 | args := &Args{} 376 | args.parse() 377 | 378 | logLevel, err := logrus.ParseLevel(*args.logLevelStr) 379 | if err != nil { 380 | logrus.WithError(err).Fatal("invalid log level specified") 381 | } 382 | logger := logrus.New() 383 | logger.SetLevel(logLevel) 384 | 385 | if _, contains := commands[args.command]; !contains { 386 | printHelp() 387 | logger.Exit(1) 388 | } 389 | 390 | switch args.command { 391 | case cmdHelp: 392 | printHelp() 393 | return 394 | case cmdVersion: 395 | logger.Printf( 396 | "%s arch=%s os=%s version=%s", 397 | filepath.Base(os.Args[0]), runtime.GOARCH, runtime.GOOS, version, 398 | ) 399 | return 400 | } 401 | 402 | vault, err := enpass.NewVault(*args.vaultPath, logger.Level) 403 | if err != nil { 404 | logger.WithError(err).Fatal("could not create vault") 405 | } 406 | vault.FilterAnd = *args.and 407 | 408 | var store *unlock.SecureStore 409 | if !*args.pinEnable { 410 | logger.Debug("PIN disabled") 411 | } else { 412 | logger.Debug("PIN enabled, using store") 413 | store = initializeStore(logger, args) 414 | logger.Debug("initialized store") 415 | } 416 | 417 | credentials := assembleVaultCredentials(logger, args, store) 418 | 419 | defer func() { 420 | vault.Close() 421 | }() 422 | if err := vault.Open(credentials); err != nil { 423 | logger.WithError(err).Error("could not open vault") 424 | logger.Exit(2) 425 | } 426 | logger.Debug("opened vault") 427 | 428 | switch args.command { 429 | case cmdDryRun: 430 | logger.Debug("dry run complete") // just init vault and store without doing anything 431 | case cmdList: 432 | listEntries(logger, vault, args) 433 | case cmdShow: 434 | showEntries(logger, vault, args) 435 | case cmdCopy: 436 | copyEntry(logger, vault, args) 437 | case cmdPass: 438 | entryPassword(logger, vault, args) 439 | case cmdUi: 440 | ui(logger, vault, args) 441 | default: 442 | logger.WithField("command", args.command).Fatal("unknown command") 443 | } 444 | 445 | if store != nil { 446 | if err := store.Write(credentials.DBKey); err != nil { 447 | logger.WithError(err).Fatal("failed to write credentials to store") 448 | } 449 | } 450 | } 451 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/hazcod/enpass-cli 2 | 3 | go 1.22.0 4 | 5 | require ( 6 | github.com/atotto/clipboard v0.1.4 7 | github.com/gdamore/tcell/v2 v2.7.4 8 | github.com/miquella/ask v1.0.0 9 | github.com/mutecomm/go-sqlcipher v0.0.0-20190227152316-55dbde17881f 10 | github.com/pkg/errors v0.9.1 11 | github.com/rivo/tview v0.0.0-20241103174730-c76f7879f592 12 | github.com/sirupsen/logrus v1.9.3 13 | golang.org/x/crypto v0.31.0 14 | ) 15 | 16 | require ( 17 | github.com/gdamore/encoding v1.0.1 // indirect 18 | github.com/lucasb-eyer/go-colorful v1.2.0 // indirect 19 | github.com/mattn/go-runewidth v0.0.16 // indirect 20 | github.com/rivo/uniseg v0.4.7 // indirect 21 | golang.org/x/net v0.33.0 // indirect 22 | golang.org/x/sys v0.28.0 // indirect 23 | golang.org/x/term v0.27.0 // indirect 24 | golang.org/x/text v0.21.0 // indirect 25 | ) 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/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 4 | github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= 5 | github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 6 | github.com/gdamore/encoding v1.0.0/go.mod h1:alR0ol34c49FCSBLjhosxzcPHQbf2trDkoo5dl+VrEg= 7 | github.com/gdamore/encoding v1.0.1 h1:YzKZckdBL6jVt2Gc+5p82qhrGiqMdG/eNs6Wy0u3Uhw= 8 | github.com/gdamore/encoding v1.0.1/go.mod h1:0Z0cMFinngz9kS1QfMjCP8TY7em3bZYeeklsSDPivEo= 9 | github.com/gdamore/tcell/v2 v2.7.4 h1:sg6/UnTM9jGpZU+oFYAsDahfchWAFW8Xx2yFinNSAYU= 10 | github.com/gdamore/tcell/v2 v2.7.4/go.mod h1:dSXtXTSK0VsW1biw65DZLZ2NKr7j0qP/0J7ONmsraWg= 11 | github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY= 12 | github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= 13 | github.com/mattn/go-runewidth v0.0.15/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= 14 | github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc= 15 | github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= 16 | github.com/miquella/ask v1.0.0 h1:QrFtpgA7tbDSlPUUwCMaAzZLnWseFZtryAn/pnvd3d8= 17 | github.com/miquella/ask v1.0.0/go.mod h1:5hBixDZi2issKiqBf4oQ5c8BauqAYOOrkFOjG4eiUWk= 18 | github.com/mutecomm/go-sqlcipher v0.0.0-20190227152316-55dbde17881f h1:hd3r+uv9DNLScbOrnlj82rBldHQf3XWmCeXAWbw8euQ= 19 | github.com/mutecomm/go-sqlcipher v0.0.0-20190227152316-55dbde17881f/go.mod h1:MyUWrZlB1aI5bs7j9/pJ8ckLLZ4QcCYcNiSbsAW32D4= 20 | github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= 21 | github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= 22 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= 23 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 24 | github.com/rivo/tview v0.0.0-20241103174730-c76f7879f592 h1:YIJ+B1hePP6AgynC5TcqpO0H9k3SSoZa2BGyL6vDUzM= 25 | github.com/rivo/tview v0.0.0-20241103174730-c76f7879f592/go.mod h1:02iFIz7K/A9jGCvrizLPvoqr4cEIx7q54RH5Qudkrss= 26 | github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= 27 | github.com/rivo/uniseg v0.4.3/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= 28 | github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= 29 | github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= 30 | github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ= 31 | github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ= 32 | github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= 33 | github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= 34 | github.com/stretchr/testify v1.7.0 h1:nwc3DEeHmmLAfoZucVR881uASk0Mfjw8xYJ99tb5CcY= 35 | github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= 36 | github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= 37 | golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= 38 | golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= 39 | golang.org/x/crypto v0.31.0 h1:ihbySMvVjLAeSH1IbfcRTkD/iNscyz8rGzjF/E5hV6U= 40 | golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk= 41 | golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= 42 | golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= 43 | golang.org/x/net v0.0.0-20190225153610-fe579d43d832/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= 44 | golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= 45 | golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= 46 | golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= 47 | golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= 48 | golang.org/x/net v0.33.0 h1:74SYHlV8BIgHIFC/LrYkOGIwL19eTYXQ5wc6TBuO36I= 49 | golang.org/x/net v0.33.0/go.mod h1:HXLR5J+9DxmrqMwG9qjGCxZ+zKXxBru04zlTvWlWuN4= 50 | golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 51 | golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 52 | golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 53 | golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= 54 | golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 55 | golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 56 | golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 57 | golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 58 | golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 59 | golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 60 | golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= 61 | golang.org/x/sys v0.28.0 h1:Fksou7UEQUWlKvIdsqzJmUmCX3cZuD2+P3XyyzwMhlA= 62 | golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= 63 | golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= 64 | golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= 65 | golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= 66 | golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk= 67 | golang.org/x/term v0.27.0 h1:WP60Sv1nlK1T6SupCHbXzSaN0b9wUmsPoRS9b61A23Q= 68 | golang.org/x/term v0.27.0/go.mod h1:iMsnZpn0cago0GOrHO2+Y7u7JPn5AylBrcoWkElMTSM= 69 | golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= 70 | golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= 71 | golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= 72 | golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= 73 | golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= 74 | golang.org/x/text v0.21.0 h1:zyQAAkrwaneQ066sspRyJaG9VNi/YJ1NfzcGB3hZ/qo= 75 | golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ= 76 | golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= 77 | golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= 78 | golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= 79 | golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= 80 | golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 81 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 82 | gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c h1:dUUwHk2QECo/6vqA44rthZ8ie2QXMNeKRTHCNY2nXvo= 83 | gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 84 | -------------------------------------------------------------------------------- /goreleaser.yml: -------------------------------------------------------------------------------- 1 | before: 2 | hooks: 3 | - go mod download 4 | 5 | builds: 6 | - id: linux64 7 | main: ./cmd/enpasscli/main.go 8 | binary: enpasscli 9 | goos: 10 | - linux 11 | goarch: 12 | - amd64 13 | env: 14 | - CC=gcc 15 | - CXX=g++ 16 | ldflags: 17 | - -s -w -X main.version={{.Version}} 18 | - id: darwin64 19 | main: ./cmd/enpasscli/main.go 20 | binary: enpasscli 21 | goos: 22 | - darwin 23 | goarch: 24 | - amd64 25 | - arm64 26 | env: 27 | - CC=o64-clang 28 | - CXX=o64-clang++ 29 | ldflags: 30 | - -s -w -X main.version={{.Version}} 31 | - id: linux-armhf 32 | main: ./cmd/enpasscli/main.go 33 | binary: enpasscli 34 | goos: 35 | - linux 36 | goarch: 37 | - arm 38 | goarm: 39 | - 7 40 | env: 41 | - CC=arm-linux-gnueabihf-gcc 42 | - CXX=arm-linux-gnueabihf-g++ 43 | ldflags: 44 | - -s -w -X main.version={{.Version}} 45 | archives: 46 | - id: enpass-cli 47 | builds: 48 | - darwin64 49 | - linux64 50 | - linux-armhf 51 | name_template: "{{ .ProjectName }}_{{ .Version }}_{{ .Os }}_{{ .Arch }}" 52 | format: zip 53 | wrap_in_directory: true 54 | checksum: 55 | name_template: 'checksums.txt' 56 | snapshot: 57 | name_template: "{{ .Tag }}" 58 | changelog: 59 | sort: asc 60 | filters: 61 | exclude: 62 | - '^docs:' 63 | - '^test:' 64 | 65 | release: 66 | github: 67 | owner: hazcod 68 | name: enpass-cli 69 | prerelease: auto 70 | draft: false 71 | -------------------------------------------------------------------------------- /pkg/clipboard/clipboard.go: -------------------------------------------------------------------------------- 1 | package clipboard 2 | 3 | var ( 4 | // using X selection primary if set to true and os allows for it 5 | Primary bool 6 | ) 7 | 8 | // WriteAll : writes to the clipboard 9 | func WriteAll(text string) error { 10 | return writeAll(text) 11 | } 12 | -------------------------------------------------------------------------------- /pkg/clipboard/clipboard_darwin.go: -------------------------------------------------------------------------------- 1 | package clipboard 2 | 3 | import ( 4 | "github.com/atotto/clipboard" 5 | ) 6 | 7 | func writeAll(text string) error { 8 | return clipboard.WriteAll(text) 9 | } 10 | -------------------------------------------------------------------------------- /pkg/clipboard/clipboard_linux.go: -------------------------------------------------------------------------------- 1 | package clipboard 2 | 3 | import ( 4 | "github.com/atotto/clipboard" 5 | ) 6 | 7 | func writeAll(text string) error { 8 | clipboard.Primary = Primary 9 | return clipboard.WriteAll(text) 10 | } 11 | -------------------------------------------------------------------------------- /pkg/clipboard/clipboard_openbsd.go: -------------------------------------------------------------------------------- 1 | package clipboard 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | "os/exec" 7 | ) 8 | 9 | // writes usinng the xclip command, might also 10 | // work on freebsd and netbsd 11 | func writeAll(text string) error { 12 | path, err := exec.LookPath("xclip") 13 | if err != nil { 14 | return fmt.Errorf("failed to find xclip: %w", err) 15 | } 16 | 17 | r, w, err := os.Pipe() 18 | if err != nil { 19 | return fmt.Errorf("failed to create xclip pipe: %w", err) 20 | } 21 | var perr error 22 | go func() { 23 | _, err := w.WriteString(text) 24 | if err != nil { 25 | perr = fmt.Errorf("failed to write to xclip: %w", err) 26 | } 27 | w.Close() // ignore err 28 | }() 29 | 30 | c := exec.Cmd{ 31 | Path: path, 32 | Args: []string{ 33 | "-i", 34 | "-selection", 35 | "clipboard", 36 | }, 37 | Stdin: r, 38 | Stdout: nil, 39 | Stderr: nil, 40 | } 41 | err = c.Run() 42 | if err != nil { 43 | return fmt.Errorf("failed to run xclip: %w", err) 44 | } 45 | if perr != nil { 46 | return fmt.Errorf("failed to write to xclip: %w", err) 47 | } 48 | 49 | return nil 50 | } 51 | -------------------------------------------------------------------------------- /pkg/enpass/card.go: -------------------------------------------------------------------------------- 1 | package enpass 2 | 3 | import ( 4 | "crypto/aes" 5 | "crypto/cipher" 6 | "encoding/hex" 7 | "strings" 8 | 9 | "github.com/pkg/errors" 10 | ) 11 | 12 | /* 13 | 2020/12/08 08:59:36 > ID 14 | 2020/12/08 08:59:36 > uuid 15 | 2020/12/08 08:59:36 > created_at 16 | 2020/12/08 08:59:36 > meta_updated_at 17 | 2020/12/08 08:59:36 > field_updated_at 18 | 2020/12/08 08:59:36 > title 19 | 2020/12/08 08:59:36 > subtitle 20 | 2020/12/08 08:59:36 > note 21 | 2020/12/08 08:59:36 > icon 22 | 2020/12/08 08:59:36 > favorite 23 | 2020/12/08 08:59:36 > trashed 24 | 2020/12/08 08:59:36 > archived 25 | 2020/12/08 08:59:36 > deleted 26 | 2020/12/08 08:59:36 > auto_submit 27 | 2020/12/08 08:59:36 > form_data 28 | 2020/12/08 08:59:36 > category 29 | 2020/12/08 08:59:36 > template 30 | 2020/12/08 08:59:36 > wearable 31 | 2020/12/08 08:59:36 > usage_count 32 | 2020/12/08 08:59:36 > last_used 33 | 2020/12/08 08:59:36 > key 34 | 2020/12/08 08:59:36 > extra 35 | 2020/12/08 08:59:36 > updated_at 36 | 2020/12/08 08:59:36 > ID 37 | 2020/12/08 08:59:36 > item_uuid 38 | 2020/12/08 08:59:36 > item_field_uid 39 | 2020/12/08 08:59:36 > label 40 | 2020/12/08 08:59:36 > value 41 | 2020/12/08 08:59:36 > deleted 42 | 2020/12/08 08:59:36 > sensitive 43 | 2020/12/08 08:59:36 > historical 44 | 2020/12/08 08:59:36 > type 45 | 2020/12/08 08:59:36 > form_id 46 | 2020/12/08 08:59:36 > updated_at 47 | 2020/12/08 08:59:36 > value_updated_at 48 | 2020/12/08 08:59:36 > orde 49 | 2020/12/08 08:59:36 > wearable 50 | 2020/12/08 08:59:36 > history 51 | 2020/12/08 08:59:36 > initial 52 | 2020/12/08 08:59:36 > hash 53 | 2020/12/08 08:59:36 > strength 54 | 2020/12/08 08:59:36 > algo_version 55 | 2020/12/08 08:59:36 > expiry 56 | 2020/12/08 08:59:36 > excluded 57 | 2020/12/08 08:59:36 > pwned_check_time 58 | 2020/12/08 08:59:36 > extra 59 | */ 60 | 61 | type Card struct { 62 | // plaintext 63 | UUID string 64 | CreatedAt int64 65 | Type string 66 | UpdatedAt int64 67 | Title string 68 | Subtitle string 69 | Note string 70 | Trashed int64 71 | Deleted int64 72 | Category string 73 | Label string 74 | LastUsed int64 75 | Sensitive bool 76 | Icon string 77 | RawValue string 78 | 79 | // encrypted 80 | value string 81 | itemKey []byte 82 | } 83 | 84 | func (c *Card) IsTrashed() bool { 85 | return c.Trashed != 0 86 | } 87 | 88 | func (c *Card) IsDeleted() bool { 89 | return c.Deleted != 0 90 | } 91 | 92 | func (c *Card) Decrypt() (string, error) { 93 | // Intercept item fields without value 94 | if len(c.value) == 0 { 95 | return "", nil 96 | } 97 | 98 | // Intercept non-password item fields, their value isn't encrypted 99 | if c.Type != "password" { 100 | return c.value, nil 101 | } 102 | 103 | // The key object is saved in binary from and actually consists of the 104 | // AES key (32 bytes) and a nonce (12 bytes) for GCM 105 | key := c.itemKey[:32] 106 | nonce := c.itemKey[32:] 107 | 108 | // If you deleted an item from Enpass, it stays in the database, but the 109 | // entries are cleared 110 | if len(nonce) == 0 { 111 | return "", errors.New("this item has been deleted") 112 | } 113 | 114 | // The value object holds the ciphertext (same length as plaintext) + 115 | // (authentication) tag (16 bytes) and is stored in hex 116 | ciphertextAndTag, err := hex.DecodeString(c.value) 117 | if err != nil { 118 | return "", errors.Wrap(err, "could not decode card hex cipherstring") 119 | } 120 | 121 | // As additional authenticated data (AAD) they use the UUID but without 122 | // the dashes: e.g. a2ec30c0aeed41f7aed7cc50e69ff506 123 | header, err := hex.DecodeString(strings.ReplaceAll(c.UUID, "-", "")) 124 | if err != nil { 125 | return "", errors.Wrap(err, "could not decode card hex AAD") 126 | } 127 | 128 | // Now we can initialize, decrypt the ciphertext and verify the AAD. 129 | // You can compare the SHA-1 output with the value stored in the db 130 | block, err := aes.NewCipher(key) 131 | if err != nil { 132 | return "", errors.Wrap(err, "could not initialize card cipher") 133 | } 134 | 135 | aesgcm, err := cipher.NewGCM(block) 136 | if err != nil { 137 | return "", errors.Wrap(err, "could not initialize GCM block") 138 | } 139 | 140 | plaintext, err := aesgcm.Open(nil, nonce, ciphertextAndTag, header) 141 | if err != nil { 142 | return "", errors.Wrap(err, "could not decrypt data") 143 | } 144 | 145 | return string(plaintext), nil 146 | } 147 | -------------------------------------------------------------------------------- /pkg/enpass/key.go: -------------------------------------------------------------------------------- 1 | package enpass 2 | 3 | import ( 4 | "bufio" 5 | "crypto/sha512" 6 | "os" 7 | 8 | "github.com/pkg/errors" 9 | "golang.org/x/crypto/pbkdf2" 10 | ) 11 | 12 | const ( 13 | // current key derivation algo 14 | keyDerivationAlgo = "pbkdf2" 15 | // current database encryption algo 16 | dbEncryptionAlgo = "aes-256-cbc" 17 | // database key salt length 18 | saltLength = 16 19 | // length of the database master key (capped) 20 | masterKeyLength = 64 21 | ) 22 | 23 | // generateMasterPassword : generates the master password to decrypt the vault database 24 | func (v *Vault) generateMasterPassword(password []byte, keyfilePath string) ([]byte, error) { 25 | if keyfilePath == "" { 26 | v.logger.Debug("not using keyfile") 27 | 28 | if password == nil { 29 | return nil, errors.New("empty master password provided") 30 | } 31 | 32 | return password, nil 33 | } 34 | 35 | v.logger.Debug("using keyfile") 36 | 37 | keyfileBytes, err := loadKeyFilePassword(keyfilePath) 38 | if err != nil { 39 | return nil, err 40 | } 41 | 42 | return append(password, keyfileBytes...), nil 43 | } 44 | 45 | // extractSalt : extract the encryption salt stored in the database 46 | func (v *Vault) extractSalt() ([]byte, error) { 47 | f, err := os.OpenFile(v.databaseFilename, os.O_RDONLY, 0) 48 | if err != nil { 49 | return []byte{}, errors.Wrap(err, "could not open database") 50 | } 51 | defer func() { _ = f.Close() }() 52 | 53 | bytesSalt, err := bufio.NewReader(f).Peek(saltLength) 54 | if err != nil { 55 | return []byte{}, errors.Wrap(err, "could not read database salt") 56 | } 57 | 58 | return bytesSalt, nil 59 | } 60 | 61 | // deriveKey : generate the SQLCipher crypto key, possibly with the 64-bit Keyfile 62 | func (v *Vault) deriveKey(masterPassword []byte, salt []byte) ([]byte, error) { 63 | if v.vaultInfo.KDFAlgo != keyDerivationAlgo { 64 | return nil, errors.New("key derivation algo has changed, open up a github issue") 65 | } 66 | 67 | if v.vaultInfo.EncryptionAlgo != dbEncryptionAlgo { 68 | return nil, errors.New("database encryption algo has changed, open up a github issue") 69 | } 70 | 71 | // The database key is derived from the master password 72 | // and the database salt with 100k iterations of PBKDF2-HMAC-SHA512 73 | return pbkdf2.Key(masterPassword, salt, v.vaultInfo.KDFIterations, sha512.Size, sha512.New), nil 74 | } 75 | -------------------------------------------------------------------------------- /pkg/enpass/keyfile.go: -------------------------------------------------------------------------------- 1 | package enpass 2 | 3 | import ( 4 | "encoding/hex" 5 | "encoding/xml" 6 | "github.com/pkg/errors" 7 | "io/ioutil" 8 | ) 9 | 10 | type Keyfile struct { 11 | Key string `xml:",innerxml"` 12 | } 13 | 14 | func loadKeyFilePassword(path string) ([]byte, error) { 15 | bytes, err := ioutil.ReadFile(path) 16 | if err != nil { 17 | return nil, errors.Wrap(err, "could not load keyfile") 18 | } 19 | 20 | var kf Keyfile 21 | if err := xml.Unmarshal(bytes, &kf); err != nil { 22 | return nil, errors.Wrap(err, "could not decode keyfile") 23 | } 24 | 25 | keyBytes, err := hex.DecodeString(kf.Key) 26 | if err != nil { 27 | return nil, errors.Wrap(err, "could not decode keyfile hex byte") 28 | } 29 | 30 | return keyBytes, nil 31 | } 32 | -------------------------------------------------------------------------------- /pkg/enpass/vault.go: -------------------------------------------------------------------------------- 1 | package enpass 2 | 3 | import ( 4 | "database/sql" 5 | "encoding/hex" 6 | "fmt" 7 | "os" 8 | "path/filepath" 9 | "strings" 10 | 11 | // sqlcipher is necessary for sqlite crypto support 12 | _ "github.com/mutecomm/go-sqlcipher" 13 | "github.com/pkg/errors" 14 | "github.com/sirupsen/logrus" 15 | ) 16 | 17 | const ( 18 | // filename of the sqlite vault file 19 | vaultFileName = "vault.enpassdb" 20 | // contains info about your vault 21 | vaultInfoFileName = "vault.json" 22 | ) 23 | 24 | // Vault : vault is the container object for vault-related operations 25 | type Vault struct { 26 | // Logger : the logger instance 27 | logger logrus.Logger 28 | 29 | // settings for filtering entries 30 | FilterFields []string 31 | FilterAnd bool 32 | 33 | // vault.enpassdb : SQLCipher database 34 | databaseFilename string 35 | 36 | // vault.json 37 | vaultInfoFilename string 38 | 39 | // .enpassattach : SQLCipher database files for attachments >1KB 40 | //attachments []string 41 | 42 | // pointer to our opened database 43 | db *sql.DB 44 | 45 | // vault.json : contains info about your vault for synchronizing 46 | vaultInfo VaultInfo 47 | } 48 | 49 | type VaultCredentials struct { 50 | KeyfilePath string 51 | Password string 52 | DBKey []byte 53 | } 54 | 55 | func (credentials *VaultCredentials) IsComplete() bool { 56 | return credentials.Password != "" || credentials.DBKey != nil 57 | } 58 | 59 | // NewVault : Create new instance of vault and load vault info 60 | func NewVault(vaultPath string, logLevel logrus.Level) (*Vault, error) { 61 | v := Vault{ 62 | logger: *logrus.New(), 63 | FilterFields: []string{"title", "subtitle"}, 64 | } 65 | v.logger.SetLevel(logLevel) 66 | 67 | if vaultPath == "" { 68 | return nil, errors.New("empty vault path provided") 69 | } 70 | 71 | vaultPath, _ = filepath.EvalSymlinks(vaultPath) 72 | v.databaseFilename = filepath.Join(vaultPath, vaultFileName) 73 | v.vaultInfoFilename = filepath.Join(vaultPath, vaultInfoFileName) 74 | v.logger.Debug("checking provided vault paths") 75 | if err := v.checkPaths(); err != nil { 76 | return nil, err 77 | } 78 | 79 | v.logger.Debug("loading vault info") 80 | var err error 81 | v.vaultInfo, err = v.loadVaultInfo() 82 | if err != nil { 83 | return nil, errors.Wrap(err, "could not load vault info") 84 | } 85 | 86 | v.logger. 87 | WithField("db_path", vaultFileName). 88 | WithField("info_path", vaultInfoFileName). 89 | Debug("initialized paths") 90 | 91 | return &v, nil 92 | } 93 | 94 | func (v *Vault) openEncryptedDatabase(path string, dbKey []byte) (err error) { 95 | // The raw key for the sqlcipher database is given 96 | // by the first 64 characters of the hex-encoded key 97 | dbName := fmt.Sprintf( 98 | "%s?_pragma_key=x'%s'&_pragma_cipher_compatibility=3", 99 | path, 100 | hex.EncodeToString(dbKey)[:masterKeyLength], 101 | ) 102 | 103 | v.db, err = sql.Open("sqlite3", dbName) 104 | if err != nil { 105 | return errors.Wrap(err, "could not open database") 106 | } 107 | 108 | return nil 109 | } 110 | 111 | func (v *Vault) checkPaths() error { 112 | if _, err := os.Stat(v.databaseFilename); os.IsNotExist(err) { 113 | return errors.New("vault does not exist: " + v.databaseFilename) 114 | } 115 | 116 | if _, err := os.Stat(v.vaultInfoFilename); os.IsNotExist(err) { 117 | return errors.New("vault info file does not exist: " + v.vaultInfoFilename) 118 | } 119 | 120 | return nil 121 | } 122 | 123 | func (v *Vault) generateAndSetDBKey(credentials *VaultCredentials) error { 124 | if credentials.DBKey != nil { 125 | v.logger.Debug("skipping database key generation, already set") 126 | return nil 127 | } 128 | 129 | if credentials.Password == "" { 130 | return errors.New("empty vault password provided") 131 | } 132 | 133 | if credentials.KeyfilePath == "" && v.vaultInfo.HasKeyfile == 1 { 134 | return errors.New("you should specify a keyfile") 135 | } else if credentials.KeyfilePath != "" && v.vaultInfo.HasKeyfile == 0 { 136 | return errors.New("you are specifying an unnecessary keyfile") 137 | } 138 | 139 | v.logger.Debug("generating master password") 140 | masterPassword, err := v.generateMasterPassword([]byte(credentials.Password), credentials.KeyfilePath) 141 | if err != nil { 142 | return errors.Wrap(err, "could not generate vault unlock key") 143 | } 144 | 145 | v.logger.Debug("extracting salt from database") 146 | keySalt, err := v.extractSalt() 147 | if err != nil { 148 | return errors.Wrap(err, "could not get master password salt") 149 | } 150 | 151 | v.logger.Debug("deriving decryption key") 152 | credentials.DBKey, err = v.deriveKey(masterPassword, keySalt) 153 | if err != nil { 154 | return errors.Wrap(err, "could not derive database key from master password") 155 | } 156 | 157 | return nil 158 | } 159 | 160 | // Open : setup a connection to the Enpass database. Call this before doing anything. 161 | func (v *Vault) Open(credentials *VaultCredentials) error { 162 | v.logger.Debug("generating database key") 163 | if err := v.generateAndSetDBKey(credentials); err != nil { 164 | return errors.Wrap(err, "could not generate database key") 165 | } 166 | 167 | v.logger.Debug("opening encrypted database") 168 | if err := v.openEncryptedDatabase(v.databaseFilename, credentials.DBKey); err != nil { 169 | return errors.Wrap(err, "could not open encrypted database") 170 | } 171 | 172 | var tableName string 173 | err := v.db.QueryRow(` 174 | SELECT name 175 | FROM sqlite_master 176 | WHERE type='table' AND name='item' 177 | `).Scan(&tableName) 178 | if err != nil { 179 | return errors.Wrap(err, "could not connect to database") 180 | } else if tableName != "item" { 181 | return errors.New("could not connect to database") 182 | } 183 | 184 | return nil 185 | } 186 | 187 | // Close : close the connection to the underlying database. Always call this in the end. 188 | func (v *Vault) Close() { 189 | if v.db != nil { 190 | err := v.db.Close() 191 | v.logger.WithError(err).Debug("closed vault") 192 | } 193 | } 194 | 195 | // GetEntries : return the cardType entries in the Enpass database filtered by filters. 196 | func (v *Vault) GetEntries(cardType string, filters []string) ([]Card, error) { 197 | if v.db == nil || v.vaultInfo.VaultName == "" { 198 | return nil, errors.New("vault is not initialized") 199 | } 200 | 201 | rows, err := v.executeEntryQuery(cardType, filters) 202 | if err != nil { 203 | return nil, errors.Wrap(err, "could not retrieve cards from database") 204 | } 205 | 206 | var cards []Card 207 | 208 | for rows.Next() { 209 | var card Card 210 | 211 | // read the database columns into Card object 212 | if err := rows.Scan( 213 | &card.UUID, &card.Type, &card.CreatedAt, &card.UpdatedAt, &card.Title, 214 | &card.Subtitle, &card.Note, &card.Trashed, &card.Deleted, &card.Category, 215 | &card.Label, &card.value, &card.itemKey, &card.LastUsed, &card.Sensitive, &card.Icon, 216 | ); err != nil { 217 | return nil, errors.Wrap(err, "could not read card from database") 218 | } 219 | 220 | card.RawValue = card.value 221 | 222 | cards = append(cards, card) 223 | } 224 | 225 | return cards, nil 226 | } 227 | 228 | func (v *Vault) GetEntry(cardType string, filters []string, unique bool) (*Card, error) { 229 | cards, err := v.GetEntries(cardType, filters) 230 | if err != nil { 231 | return nil, errors.Wrap(err, "could not retrieve cards") 232 | } 233 | 234 | var ret *Card 235 | for _, card := range cards { 236 | if card.IsTrashed() || card.IsDeleted() { 237 | continue 238 | } else if ret == nil { 239 | ret = &card 240 | } else if unique { 241 | return nil, errors.New("multiple cards match that title") 242 | } else { 243 | break 244 | } 245 | } 246 | 247 | if ret == nil { 248 | return nil, errors.New("card not found") 249 | } 250 | 251 | return ret, nil 252 | } 253 | 254 | func (v *Vault) executeEntryQuery(cardType string, filters []string) (*sql.Rows, error) { 255 | query := ` 256 | SELECT uuid, type, created_at, field_updated_at, title, 257 | subtitle, note, trashed, item.deleted, category, 258 | label, value, key, last_used, sensitive, item.icon 259 | FROM item 260 | INNER JOIN itemfield ON uuid = item_uuid 261 | ` 262 | 263 | where := []string{"item.deleted = ?"} 264 | values := []interface{}{0} 265 | 266 | if cardType != "" { 267 | where = append(where, "type = ?") 268 | values = append(values, cardType) 269 | } 270 | 271 | filterWhere := []string{} 272 | for _, filter := range filters { 273 | fq := "(0" 274 | for _, field := range v.FilterFields { 275 | fq += " + instr(lower(" + field + "), ?)" 276 | values = append(values, strings.ToLower(filter)) 277 | } 278 | fq += " > 0)" 279 | filterWhere = append(filterWhere, fq) 280 | } 281 | 282 | if v.FilterAnd { 283 | where = append(where, filterWhere...) 284 | } else if len(filterWhere) > 0 { 285 | where = append(where, "("+strings.Join(filterWhere, " OR ")+")") 286 | } 287 | 288 | query += " WHERE " + strings.Join(where, " AND ") 289 | v.logger.Trace("query: ", query) 290 | return v.db.Query(query, values...) 291 | } 292 | -------------------------------------------------------------------------------- /pkg/enpass/vault_test.go: -------------------------------------------------------------------------------- 1 | package enpass 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/sirupsen/logrus" 7 | ) 8 | 9 | const ( 10 | testPassword = "mymasterpassword" 11 | vaultPath = "../../test/" 12 | ) 13 | 14 | func TestVault_Initialize(t *testing.T) { 15 | vault, err := NewVault(vaultPath, logrus.ErrorLevel) 16 | if err != nil { 17 | t.Errorf("vault initialization failed: %+v", err) 18 | } 19 | defer vault.Close() 20 | credentials := &VaultCredentials{Password: testPassword} 21 | if err := vault.Open(credentials); err != nil { 22 | t.Errorf("opening vault failed: %+v", err) 23 | } 24 | } 25 | 26 | func TestVault_GetEntries(t *testing.T) { 27 | vault, err := NewVault(vaultPath, logrus.ErrorLevel) 28 | if err != nil { 29 | t.Errorf("vault initialization failed: %+v", err) 30 | } 31 | defer vault.Close() 32 | credentials := &VaultCredentials{Password: testPassword} 33 | if err := vault.Open(credentials); err != nil { 34 | t.Errorf("opening vault failed: %+v", err) 35 | } 36 | 37 | Assert_GetEntries(t, vault, nil, 1) 38 | } 39 | 40 | func TestVault_GetEntries_Filter_OR(t *testing.T) { 41 | vault, err := NewVault(vaultPath, logrus.ErrorLevel) 42 | if err != nil { 43 | t.Errorf("vault initialization failed: %+v", err) 44 | } 45 | defer vault.Close() 46 | credentials := &VaultCredentials{Password: testPassword} 47 | if err := vault.Open(credentials); err != nil { 48 | t.Errorf("opening vault failed: %+v", err) 49 | } 50 | 51 | vault.FilterAnd = false 52 | 53 | Assert_GetEntries(t, vault, []string{"mylogin"}, 1) // matches title 54 | Assert_GetEntries(t, vault, []string{"myusername"}, 1) // matches subtitle 55 | Assert_GetEntries(t, vault, []string{"inexistent"}, 0) // matches nothing 56 | Assert_GetEntries(t, vault, []string{"mylogin", "myusername"}, 1) 57 | Assert_GetEntries(t, vault, []string{"mylogin", "inexistent"}, 1) 58 | Assert_GetEntries(t, vault, []string{"inexistent", "alsoinexistent"}, 0) 59 | } 60 | 61 | func TestVault_GetEntries_Filter_AND(t *testing.T) { 62 | vault, err := NewVault(vaultPath, logrus.ErrorLevel) 63 | if err != nil { 64 | t.Errorf("vault initialization failed: %+v", err) 65 | } 66 | defer vault.Close() 67 | credentials := &VaultCredentials{Password: testPassword} 68 | if err := vault.Open(credentials); err != nil { 69 | t.Errorf("opening vault failed: %+v", err) 70 | } 71 | 72 | vault.FilterAnd = true 73 | 74 | Assert_GetEntries(t, vault, []string{"mylogin"}, 1) // matches title 75 | Assert_GetEntries(t, vault, []string{"myusername"}, 1) // matches subtitle 76 | Assert_GetEntries(t, vault, []string{"inexistent"}, 0) // matches nothing 77 | Assert_GetEntries(t, vault, []string{"mylogin", "myusername"}, 1) 78 | Assert_GetEntries(t, vault, []string{"mylogin", "inexistent"}, 0) 79 | Assert_GetEntries(t, vault, []string{"inexistent", "alsoinexistent"}, 0) 80 | } 81 | 82 | func TestVault_GetEntries_Filter_Fields(t *testing.T) { 83 | vault, err := NewVault(vaultPath, logrus.ErrorLevel) 84 | if err != nil { 85 | t.Errorf("vault initialization failed: %+v", err) 86 | } 87 | defer vault.Close() 88 | credentials := &VaultCredentials{Password: testPassword} 89 | if err := vault.Open(credentials); err != nil { 90 | t.Errorf("opening vault failed: %+v", err) 91 | } 92 | 93 | vault.FilterAnd = false 94 | 95 | vault.FilterFields = []string{"title"} 96 | Assert_GetEntries(t, vault, []string{"mylogin"}, 1) // matches title 97 | Assert_GetEntries(t, vault, []string{"myusername"}, 0) // matches subtitle 98 | Assert_GetEntries(t, vault, []string{"mylogin", "myusername"}, 1) 99 | 100 | vault.FilterFields = []string{"subtitle"} 101 | Assert_GetEntries(t, vault, []string{"mylogin"}, 0) // matches title 102 | Assert_GetEntries(t, vault, []string{"myusername"}, 1) // matches subtitle 103 | Assert_GetEntries(t, vault, []string{"mylogin", "myusername"}, 1) 104 | 105 | vault.FilterFields = []string{"title", "subtitle"} 106 | Assert_GetEntries(t, vault, []string{"mylogin"}, 1) // matches title 107 | Assert_GetEntries(t, vault, []string{"myusername"}, 1) // matches subtitle 108 | Assert_GetEntries(t, vault, []string{"mylogin", "myusername"}, 1) 109 | } 110 | 111 | func Assert_GetEntries(t *testing.T, vault *Vault, filters []string, expectedCount int) { 112 | entries, err := vault.GetEntries("password", filters) 113 | if err != nil { 114 | t.Errorf("vault get entries failed: %+v", err) 115 | } else if len(entries) != expectedCount { 116 | t.Error("wrong number of entries returned") 117 | } 118 | } 119 | -------------------------------------------------------------------------------- /pkg/enpass/vaultinfo.go: -------------------------------------------------------------------------------- 1 | package enpass 2 | 3 | import ( 4 | "encoding/json" 5 | "io/ioutil" 6 | 7 | "github.com/pkg/errors" 8 | ) 9 | 10 | type VaultInfo struct { 11 | EncryptionAlgo string `json:"encryption_algo"` 12 | HasKeyfile int `json:"have_keyfile"` 13 | KDFAlgo string `json:"kdf_algo"` 14 | KDFIterations int `json:"kdf_iter"` 15 | VaultNumItems int `json:"vault_items_count"` 16 | VaultName string `json:"vault_name"` 17 | VaultVersion int `json:"version"` 18 | } 19 | 20 | // loadVaultInfo : the vault info file dictates how we should decrypt the vault database 21 | func (v *Vault) loadVaultInfo() (VaultInfo, error) { 22 | vaultInfoBytes, err := ioutil.ReadFile(v.vaultInfoFilename) 23 | if err != nil { 24 | return VaultInfo{}, errors.Wrap(err, "could not read vault info") 25 | } 26 | 27 | var vaultInfo VaultInfo 28 | if err := json.Unmarshal(vaultInfoBytes, &vaultInfo); err != nil { 29 | return VaultInfo{}, errors.Wrap(err, "could not parse vault info") 30 | } 31 | 32 | v.logger. 33 | WithField("vault_name", vaultInfo.VaultName). 34 | WithField("vault_version", vaultInfo.VaultVersion). 35 | Debug("vault info loaded") 36 | 37 | return vaultInfo, nil 38 | } 39 | -------------------------------------------------------------------------------- /pkg/unlock/aes256gcm.go: -------------------------------------------------------------------------------- 1 | package unlock 2 | 3 | // based on https://gist.github.com/tscholl2/dc7dc15dc132ea70a98e8542fefffa28#file-aes-go 4 | 5 | import ( 6 | "crypto/aes" 7 | "crypto/cipher" 8 | "crypto/rand" 9 | "crypto/sha256" 10 | 11 | "golang.org/x/crypto/pbkdf2" 12 | ) 13 | 14 | const ( 15 | bytesIV = 12 16 | bytesSalt = 16 17 | minKdfIterCount = 10000 18 | ) 19 | 20 | func generateRandom(bytes int) ([]byte, error) { 21 | generated := make([]byte, bytes) 22 | _, err := rand.Read(generated) 23 | return generated, err 24 | } 25 | 26 | func sha256sum(data []byte) []byte { 27 | sum := sha256.Sum256(data) 28 | return sum[:] 29 | } 30 | 31 | func deriveKey(passphrase []byte, salt []byte, kdfIterCount int) []byte { 32 | if kdfIterCount < minKdfIterCount { 33 | kdfIterCount = minKdfIterCount 34 | } 35 | return pbkdf2.Key(passphrase, salt, kdfIterCount, sha256.Size, sha256.New) 36 | } 37 | 38 | func createCipherGCM(key []byte) (cipher.AEAD, error) { 39 | cipherBlock, err := aes.NewCipher(key) 40 | if err != nil { 41 | return nil, err 42 | } 43 | return cipher.NewGCM(cipherBlock) 44 | } 45 | 46 | func encrypt(passphrase []byte, plaintext []byte, kdfIterCount int) ([]byte, error) { 47 | salt, err := generateRandom(bytesSalt) 48 | if err != nil { 49 | return nil, err 50 | } 51 | key := deriveKey(passphrase, salt, kdfIterCount) 52 | aesgcm, err := createCipherGCM(key) 53 | if err != nil { 54 | return nil, err 55 | } 56 | iv, err := generateRandom(bytesIV) 57 | if err != nil { 58 | return nil, err 59 | } 60 | ciphertext := aesgcm.Seal(nil, iv, plaintext, nil) 61 | data := append(ciphertext, salt...) 62 | data = append(iv, data...) 63 | return data, nil 64 | } 65 | 66 | func decrypt(passphrase []byte, data []byte, kdfIterCount int) ([]byte, error) { 67 | saltIdx := len(data) - bytesSalt 68 | iv := data[:bytesIV] 69 | ciphertext := data[bytesIV:saltIdx] 70 | salt := data[saltIdx:] 71 | key := deriveKey(passphrase, salt, kdfIterCount) 72 | aesgcm, err := createCipherGCM(key) 73 | if err != nil { 74 | return nil, err 75 | } 76 | return aesgcm.Open(nil, iv, ciphertext, nil) 77 | } 78 | -------------------------------------------------------------------------------- /pkg/unlock/securestore.go: -------------------------------------------------------------------------------- 1 | package unlock 2 | 3 | import ( 4 | "os" 5 | "path/filepath" 6 | "time" 7 | 8 | "github.com/pkg/errors" 9 | "github.com/sirupsen/logrus" 10 | ) 11 | 12 | const ( 13 | fileNamePref = "enpasscli-" 14 | fileMode = 0600 15 | ) 16 | 17 | type SecureStore struct { 18 | logger logrus.Logger 19 | file *os.File 20 | passphrase []byte 21 | kdfIterCount int 22 | wasReadSuccessfully bool 23 | } 24 | 25 | func NewSecureStore(name string, logLevel logrus.Level) (*SecureStore, error) { 26 | store := SecureStore{logger: *logrus.New()} 27 | store.logger.SetLevel(logLevel) 28 | store.logger.Debug("loading store file") 29 | var err error 30 | store.file, err = store.getStoreFile(name) 31 | return &store, errors.Wrap(err, "could not load store file") 32 | } 33 | 34 | func (store *SecureStore) getStoreFile(name string) (*os.File, error) { 35 | var storeFile *os.File 36 | var err error 37 | storeFileName := fileNamePref + name 38 | for _, tempDir := range [...]string{ 39 | os.Getenv("TMPDIR"), 40 | os.Getenv("XDG_RUNTIME_DIR"), 41 | "/dev/shm", 42 | os.TempDir(), 43 | } { 44 | if tempDir == "" { 45 | continue 46 | } 47 | store.logger.WithField("tempDir", tempDir).Debug("trying store directory") 48 | storeFilePath := filepath.Join(tempDir, storeFileName) 49 | storeFile, err = os.OpenFile(storeFilePath, os.O_CREATE, fileMode) 50 | if err == nil { 51 | break 52 | } 53 | store.logger.WithError(err).Debug("skipping store directory") 54 | } 55 | return storeFile, err 56 | } 57 | 58 | func (store *SecureStore) GeneratePassphrase(pin string, pepper string, kdfIterCount int) error { 59 | store.logger.WithField("kdfIterCount", kdfIterCount).Debug("generating store passphrase from pin") 60 | store.kdfIterCount = kdfIterCount 61 | data := append([]byte(pin), []byte(pepper)...) 62 | store.passphrase = sha256sum(data) 63 | return nil 64 | } 65 | 66 | func (store *SecureStore) Read() ([]byte, error) { 67 | if store.passphrase == nil { 68 | return nil, errors.New("empty store passphrase") 69 | } 70 | store.logger.Debug("reading store data") 71 | data, _ := os.ReadFile(store.file.Name()) 72 | if len(data) == 0 { 73 | return nil, nil // nothing to read 74 | } 75 | store.logger.Debug("decrypting store data") 76 | ts := time.Now().UnixNano() 77 | dbKey, err := decrypt(store.passphrase, data, store.kdfIterCount) 78 | ts = time.Now().UnixNano() - ts 79 | store.logger.Trace("decrypted in ", ts/int64(time.Millisecond), "ms") 80 | if err != nil { 81 | return nil, err 82 | } 83 | store.wasReadSuccessfully = (len(dbKey) > 0) 84 | return dbKey, nil 85 | } 86 | 87 | func (store *SecureStore) Write(dbKey []byte) error { 88 | if store.wasReadSuccessfully { 89 | return nil // no need to overwrite the file if read was already successful 90 | } 91 | if store.passphrase == nil { 92 | return errors.New("empty store passphrase") 93 | } 94 | store.logger.Debug("encrypting store data") 95 | data, err := encrypt(store.passphrase, dbKey, store.kdfIterCount) 96 | if err != nil { 97 | return err 98 | } 99 | store.logger.Debug("writing store data") 100 | return os.WriteFile(store.file.Name(), data, fileMode) 101 | } 102 | 103 | func (store *SecureStore) Clean() error { 104 | store.wasReadSuccessfully = false 105 | return os.Remove(store.file.Name()) 106 | } 107 | -------------------------------------------------------------------------------- /test/vault.enpassdb: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hazcod/enpass-cli/a022b26714be0d350227d3db02656c313853aff4/test/vault.enpassdb -------------------------------------------------------------------------------- /test/vault.json: -------------------------------------------------------------------------------- 1 | { 2 | "creating_device": "django", 3 | "encryption_algo": "aes-256-cbc", 4 | "have_keyfile": 0, 5 | "kdf_algo": "pbkdf2", 6 | "kdf_iter": 100000, 7 | "last_modified_device": "django", 8 | "last_modified_time": 1607451151, 9 | "last_password_changed_time": 1607085524, 10 | "last_password_changing_device": "django", 11 | "vault_att_count": 0, 12 | "vault_icon": "vault/v2", 13 | "vault_items_count": 2, 14 | "vault_name": "Primary", 15 | "vault_uuid": "primary", 16 | "version": 6 17 | } 18 | --------------------------------------------------------------------------------