├── .gitignore ├── .github └── workflows │ ├── golangci-lint.yml │ ├── build-lint-test.yml │ └── release.yml ├── .golangci.yml ├── Makefile ├── go.mod ├── .goreleaser.yaml ├── fuzzy.go ├── install.sh ├── aegis.go ├── README.md ├── go.sum ├── main.go └── LICENSE /.gitignore: -------------------------------------------------------------------------------- 1 | # Binaries for programs and plugins 2 | termotp 3 | 4 | # Test binary, build with `go test -c` 5 | *.test 6 | 7 | # Vim stuff 8 | *.sw* 9 | 10 | # Other dirs 11 | arch/ 12 | -------------------------------------------------------------------------------- /.github/workflows/golangci-lint.yml: -------------------------------------------------------------------------------- 1 | name: golangci-lint 2 | on: [push, pull_request] 3 | jobs: 4 | golangci: 5 | name: lint 6 | runs-on: ubuntu-latest 7 | steps: 8 | - name: Checkout repo 9 | uses: actions/checkout@v4 10 | - name: Setup Go 11 | uses: actions/setup-go@v5 12 | with: 13 | go-version: stable 14 | - name: golangci-lint 15 | uses: golangci/golangci-lint-action@v8 16 | with: 17 | version: v2.1 18 | -------------------------------------------------------------------------------- /.golangci.yml: -------------------------------------------------------------------------------- 1 | version: "2" 2 | linters: 3 | disable: 4 | - errcheck 5 | exclusions: 6 | generated: lax 7 | presets: 8 | - comments 9 | - common-false-positives 10 | - legacy 11 | - std-error-handling 12 | paths: 13 | - third_party$ 14 | - builtin$ 15 | - examples$ 16 | formatters: 17 | enable: 18 | - gofmt 19 | exclusions: 20 | generated: lax 21 | paths: 22 | - third_party$ 23 | - builtin$ 24 | - examples$ 25 | -------------------------------------------------------------------------------- /.github/workflows/build-lint-test.yml: -------------------------------------------------------------------------------- 1 | name: Go build/test 2 | on: [push, pull_request] 3 | jobs: 4 | build_lint_test: 5 | runs-on: ubuntu-latest 6 | steps: 7 | - name: Checkout repo 8 | uses: actions/checkout@v4 9 | - name: Setup Go compiler 10 | uses: actions/setup-go@v5 11 | with: 12 | go-version: stable 13 | - name: Build binary 14 | run: | 15 | go build 16 | - name: Go test 17 | run: | 18 | go test -v ./... 19 | go test -cpu=2 -race -v ./... 20 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | # This file is part of termOTP, a TOTP program for your terminal. 2 | # https://github.com/marcopaganini/termotp. 3 | .PHONY: arch clean install 4 | 5 | bin := termotp 6 | bindir := /usr/local/bin 7 | archdir := arch 8 | src := $(wildcard *.go) 9 | git_tag := $(shell git describe --always --tags) 10 | 11 | # Default target 12 | ${bin}: Makefile ${src} 13 | CGO_ENABLED=0 go build -v -ldflags "-X main.Build=${git_tag}" -o "${bin}" 14 | 15 | clean: 16 | rm -f "${bin}" 17 | rm -f "docs/${bin}.1" 18 | 19 | install: ${bin} 20 | install -m 755 "${bin}" "${bindir}" 21 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/marcopaganini/termotp 2 | 3 | go 1.19 4 | 5 | require ( 6 | github.com/jedib0t/go-pretty/v6 v6.4.4 7 | github.com/ktr0731/go-fuzzyfinder v0.7.0 8 | github.com/romana/rlog v0.0.0-20220412051723-c08f605858a9 9 | github.com/xlzd/gotp v0.1.0 10 | github.com/zalando/go-keyring v0.2.3 11 | golang.org/x/crypto v0.5.0 12 | golang.org/x/term v0.4.0 13 | ) 14 | 15 | require ( 16 | github.com/alessio/shellescape v1.4.1 // indirect 17 | github.com/danieljoos/wincred v1.2.0 // indirect 18 | github.com/gdamore/encoding v1.0.0 // indirect 19 | github.com/gdamore/tcell/v2 v2.5.3 // indirect 20 | github.com/godbus/dbus/v5 v5.1.0 // indirect 21 | github.com/ktr0731/go-ansisgr v0.1.0 // indirect 22 | github.com/lucasb-eyer/go-colorful v1.2.0 // indirect 23 | github.com/mattn/go-runewidth v0.0.14 // indirect 24 | github.com/nsf/termbox-go v1.1.1 // indirect 25 | github.com/pkg/errors v0.9.1 // indirect 26 | github.com/rivo/uniseg v0.4.2 // indirect 27 | golang.org/x/sys v0.8.0 // indirect 28 | golang.org/x/text v0.6.0 // indirect 29 | ) 30 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | # .github/workflows/release.yml 2 | name: goreleaser 3 | 4 | on: 5 | push: 6 | # run only against tags 7 | tags: 8 | - "v*" 9 | 10 | permissions: 11 | contents: write 12 | # packages: write 13 | # issues: write 14 | # id-token: write 15 | 16 | jobs: 17 | goreleaser: 18 | runs-on: ubuntu-latest 19 | steps: 20 | - name: Checkout 21 | uses: actions/checkout@v4 22 | with: 23 | fetch-depth: 0 24 | - name: Set up Go 25 | uses: actions/setup-go@v5 26 | with: 27 | go-version: stable 28 | # More assembly might be required: Docker logins, GPG, etc. 29 | # It all depends on your needs. 30 | - name: Run GoReleaser 31 | uses: goreleaser/goreleaser-action@v6 32 | with: 33 | # either 'goreleaser' (default) or 'goreleaser-pro' 34 | distribution: goreleaser 35 | # 'latest', 'nightly', or a semver 36 | version: "~> v2" 37 | args: release --clean 38 | env: 39 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 40 | -------------------------------------------------------------------------------- /.goreleaser.yaml: -------------------------------------------------------------------------------- 1 | # yaml-language-server: $schema=https://goreleaser.com/static/schema.json 2 | # vim: set ts=2 sw=2 tw=0 fo=cnqoj 3 | 4 | version: 2 5 | 6 | before: 7 | hooks: 8 | # You may remove this if you don't use go modules. 9 | - go mod tidy 10 | # you may remove this if you don't need go generate 11 | - go generate ./... 12 | 13 | builds: 14 | - env: 15 | - CGO_ENABLED=0 16 | goos: 17 | - linux 18 | - windows 19 | - darwin 20 | ldflags: 21 | - -s -w -X main.Build={{.Version}} 22 | 23 | archives: 24 | - format: tar.gz 25 | files: 26 | - LICENSE 27 | - README.md 28 | # use zip for windows archives 29 | format_overrides: 30 | - goos: windows 31 | format: zip 32 | 33 | nfpms: 34 | - homepage: https://github.com/marcopaganini/termotp 35 | maintainer: Marco Paganini 36 | description: |- 37 | termotp - A terminal OTP codes generator. 38 | license: MIT 39 | formats: 40 | - apk 41 | - deb 42 | - rpm 43 | - archlinux 44 | provides: 45 | - termotp 46 | bindir: /usr/bin 47 | contents: 48 | - src: README.md 49 | dst: /usr/share/doc/termotp/README.md 50 | - src: LICENSE 51 | dst: /usr/share/doc/termotp/LICENSE.txt 52 | 53 | changelog: 54 | sort: asc 55 | filters: 56 | exclude: 57 | - "^docs:" 58 | - "^test:" 59 | -------------------------------------------------------------------------------- /fuzzy.go: -------------------------------------------------------------------------------- 1 | // This file is part of termOTP, a TOTP program for your terminal. 2 | // https://github.com/marcopaganini/termotp. 3 | package main 4 | 5 | import ( 6 | "fmt" 7 | 8 | "github.com/ktr0731/go-fuzzyfinder" 9 | ) 10 | 11 | const ( 12 | fuzzyPadding = 3 13 | defaultIssuerName = "(no issuer name)" 14 | ) 15 | 16 | // Return the maximum length of one of the otp entries (returned 17 | // by function 'f'. 18 | func maxlen(vault []otpEntry, f func(v otpEntry) string) int { 19 | max := 0 20 | for _, entry := range vault { 21 | length := len(f(entry)) 22 | if length == 0 { 23 | length = len(defaultIssuerName) 24 | } 25 | if length > max { 26 | max = length 27 | } 28 | } 29 | return max 30 | } 31 | 32 | // fuzzyFind opens a fuzzy finder window and allows the user to select the 33 | // desired issuer/account and returns the token. 34 | func fuzzyFind(vault []otpEntry) (string, error) { 35 | maxIssuerLen := maxlen(vault, func(otp otpEntry) string { return otp.Issuer }) 36 | maxAccountLen := maxlen(vault, func(otp otpEntry) string { return otp.Account }) 37 | maxTokenLen := maxlen(vault, func(otp otpEntry) string { return otp.Token }) 38 | 39 | idx, err := fuzzyfinder.Find( 40 | vault, 41 | func(i int) string { 42 | // Default issuer name 43 | issuer := defaultIssuerName 44 | if vault[i].Issuer != "" { 45 | issuer = vault[i].Issuer 46 | } 47 | 48 | issuer = fmt.Sprintf("%-[1]*s", maxIssuerLen+fuzzyPadding, issuer) 49 | account := fmt.Sprintf("%-[1]*s", maxAccountLen+fuzzyPadding, vault[i].Account) 50 | token := fmt.Sprintf("%-[1]*s", maxTokenLen+fuzzyPadding, vault[i].Token) 51 | 52 | return fmt.Sprintf("%s %s %s", issuer, account, token) 53 | }) 54 | if err != nil { 55 | return "", err 56 | } 57 | return vault[idx].Token, nil 58 | } 59 | -------------------------------------------------------------------------------- /install.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | # See http://github.com/marcopaganini/termotp 3 | # for details on how to use this script. 4 | 5 | set -eu 6 | 7 | readonly DEFAULT_INSTALL_DIR="/usr/local/bin" 8 | readonly PROGRAM="${0##*/}" 9 | readonly OK="✅" 10 | readonly ERR="❌" 11 | 12 | # go_arch retrieves the go equivalent architecture for this machine. 13 | go_arch() { 14 | arch="$(uname -m)" 15 | case "${arch}" in 16 | aarch64_be|aarch64|arm64|armv8b|armv8l) 17 | echo "arm64" ;; 18 | arm) 19 | echo "arm" ;; 20 | i386|i686) 21 | echo "386" ;; 22 | mips) 23 | echo "mips" ;; 24 | mips64) 25 | echo "mips64" ;; 26 | s390|s390x) 27 | echo "s390x" ;; 28 | x86_64) 29 | echo "amd64" ;; 30 | esac 31 | } 32 | 33 | # os_name returns the os_name. 34 | os_name() { 35 | os="$(uname -o)" 36 | # Windows not supported for now. 37 | case "${os}" in 38 | "GNU/Linux") 39 | echo "linux" ;; 40 | "Darwin") 41 | echo "darwin" ;; 42 | esac 43 | } 44 | 45 | # tempname returns a temporary directory name. This is a crude workaround for 46 | # systems that don't have mktemp and rudimentary date commands. 47 | tempname() { 48 | echo "/tmp/${PROGRAM}-$$" 49 | } 50 | 51 | die() { 52 | echo >&2 "${ERR} ${*}" 53 | exit 1 54 | } 55 | 56 | main() { 57 | # First argument = github username/repo. 58 | if [ $# -lt 1 ]; then 59 | die "Use: install.sh username/repo [destination_dir]" 60 | fi 61 | 62 | # Second argument (optional) installation directory. 63 | install_dir="${DEFAULT_INSTALL_DIR}" 64 | if [ $# -eq 2 ]; then 65 | install_dir="${2}" 66 | fi 67 | 68 | readonly repo="${1}" 69 | readonly release_name="${repo##*/}" 70 | readonly releases_url="https://api.github.com/repos/${repo}/releases" 71 | 72 | os="$(os_name)" 73 | arch="$(go_arch)" 74 | 75 | [ -z "${os}" ] && die "Unknown OS. Please send the result of 'uname -o' and 'uname -m' to the author." 76 | [ -z "${arch}" ] && die "Unknown processor architecture. Please send the result of 'uname -m' to the author." 77 | 78 | echo "${OK} Your OS is: ${os}" 79 | echo "${OK} Your architecture is: ${arch}" 80 | echo "${OK} Install directory: ${install_dir}" 81 | 82 | tgz="${release_name}_[0-9]\\+\\.[0-9]\\+\\.[0-9]\\+_${os}_${arch}\\.tar\\.gz" 83 | 84 | # Retrieve the list releases from github (this is hacky but we don't 85 | # want to force people to have a proper JSON parser installed.) 86 | latest="$(wget -q -O- "${releases_url}" | grep browser_download_url | grep -o "https://.*${tgz}" | sort -g | tail -1)" 87 | [ -z "${latest}" ] && die "Unable to find any releases." 88 | echo "${OK} Latest release: ${latest}" 89 | 90 | # Download and install 91 | tmp="$(tempname)" 92 | out="${tmp}/${release_name}.tar.gz" 93 | 94 | rm -rf "${tmp}" 95 | mkdir -p "${tmp}" 96 | echo "${OK} Downloading latest release." 97 | if ! wget -q "${latest}" -O "${out}"; then 98 | die "Error downloading: ${latest}" 99 | fi 100 | 101 | cwd="$(pwd)" 102 | cd "${tmp}" 103 | echo "${OK} Unpacking local installation file." 104 | if ! gzip -d -c "${out}" | tar x; then 105 | die "Error unpacking local release ($out)." 106 | fi 107 | 108 | mv "${tmp}/${release_name}" "${install_dir}" || die "Installation failed." 109 | chmod 755 "${install_dir}/${release_name}" 110 | 111 | cd "${cwd}" 112 | rm -rf "${tmp}" 113 | echo "${OK} Installation finished! Please make sure ${install_dir} is in your PATH." 114 | } 115 | 116 | main "${@}" 117 | -------------------------------------------------------------------------------- /aegis.go: -------------------------------------------------------------------------------- 1 | // This file is part of termOTP, a TOTP program for your terminal. 2 | // https://github.com/marcopaganini/termotp. 3 | package main 4 | 5 | import ( 6 | "crypto/aes" 7 | "crypto/cipher" 8 | "encoding/base64" 9 | "encoding/hex" 10 | "encoding/json" 11 | "errors" 12 | "fmt" 13 | "os" 14 | "regexp" 15 | "strings" 16 | 17 | "github.com/romana/rlog" 18 | "github.com/xlzd/gotp" 19 | "golang.org/x/crypto/scrypt" 20 | "golang.org/x/term" 21 | ) 22 | 23 | const ( 24 | aegisKeyLen = 32 25 | ) 26 | 27 | // aegisEncryptedJSON represents an encrypted Aegis JSON export file. 28 | type aegisEncryptedJSON struct { 29 | Version int `json:"version"` 30 | Header struct { 31 | Slots []struct { 32 | Type int `json:"type"` 33 | UUID string `json:"uuid"` 34 | Key string `json:"key"` 35 | KeyParams struct { 36 | Nonce string `json:"nonce"` 37 | Tag string `json:"tag"` 38 | } `json:"key_params"` 39 | N int `json:"n"` 40 | R int `json:"r"` 41 | P int `json:"p"` 42 | Salt string `json:"salt"` 43 | } `json:"slots"` 44 | Params struct { 45 | Nonce string `json:"nonce"` 46 | Tag string `json:"tag"` 47 | } `json:"params"` 48 | } `json:"header"` 49 | Db string `json:"db"` 50 | } 51 | 52 | // aegisJSON represents a plain Aegis JSON export file. 53 | type aegisJSON struct { 54 | Version int `json:"version"` 55 | Entries []struct { 56 | Type string `json:"type"` 57 | Name string `json:"name"` 58 | Issuer string `json:"issuer"` 59 | Icon string `json:"icon"` 60 | Info struct { 61 | Secret string `json:"secret"` 62 | Digits int `json:"digits"` 63 | Algo string `json:"algo"` 64 | Period int `json:"period"` 65 | } `json:"info"` 66 | } 67 | } 68 | 69 | // newAES creates a new AESGCM cipher. 70 | func newAES(key []byte) (cipher.AEAD, error) { 71 | // AES GCM decrypt. 72 | b, err := aes.NewCipher(key) 73 | if err != nil { 74 | return nil, err 75 | } 76 | aesgcm, err := cipher.NewGCM(b) 77 | if err != nil { 78 | return nil, err 79 | } 80 | return aesgcm, nil 81 | } 82 | 83 | // readPassword reads the user password from the terminal. If the input is a 84 | // terminal, it uses terminal specific codes to turn off typing echo. If the 85 | // input is not a terminal, it assumes we can read the password directly from 86 | // it (E.g, when redirecting from a process or a file.) 87 | func readPassword() ([]byte, error) { 88 | fi, err := os.Stdin.Stat() 89 | if err != nil { 90 | return nil, err 91 | } 92 | 93 | // Test if we're reading from pipe or terminal. 94 | 95 | var password string 96 | if (fi.Mode() & os.ModeCharDevice) != 0 { 97 | // Reading from terminal. 98 | savedState, err := term.MakeRaw(int(os.Stdin.Fd())) 99 | if err != nil { 100 | return nil, err 101 | } 102 | defer term.Restore(int(os.Stdin.Fd()), savedState) 103 | 104 | terminal := term.NewTerminal(os.Stdin, ">") 105 | password, err = terminal.ReadPassword("Enter password: ") 106 | if err != nil { 107 | return nil, err 108 | } 109 | } else { 110 | // 256-byte passwords ought to be enough for everybody :) 111 | buf := make([]byte, 256) 112 | if _, err := os.Stdin.Read(buf); err != nil { 113 | return nil, err 114 | } 115 | password = strings.TrimRight(string(buf), "\r\n\x00") 116 | } 117 | return []byte(password), nil 118 | } 119 | 120 | // filterAegisVault filters an Aegis plain JSON into our internal 121 | // representation of the vault, using "rematch" as a regular expression to 122 | // match the issuer or account. 123 | func filterAegisVault(plainJSON []byte, rematch *regexp.Regexp) ([]otpEntry, error) { 124 | vault := &aegisJSON{} 125 | if err := json.Unmarshal(plainJSON, &vault); err != nil { 126 | return nil, err 127 | } 128 | 129 | ret := []otpEntry{} 130 | 131 | for _, entry := range vault.Entries { 132 | token := "Unknown OTP type: " + entry.Type 133 | if entry.Type == "totp" { 134 | token = gotp.NewDefaultTOTP(entry.Info.Secret).Now() 135 | } 136 | if rematch.MatchString(entry.Issuer) || rematch.MatchString(entry.Name) { 137 | ret = append(ret, otpEntry{ 138 | Issuer: entry.Issuer, 139 | Account: entry.Name, 140 | Token: token, 141 | }) 142 | } 143 | } 144 | return ret, nil 145 | } 146 | 147 | // aegisDecrypt opens an encrypted Aegis JSON export file and 148 | // returns the plain json contents. 149 | func aegisDecrypt(fname string, password []byte) ([]byte, error) { 150 | buf, err := os.ReadFile(fname) 151 | if err != nil { 152 | return nil, err 153 | } 154 | 155 | encJSON := aegisEncryptedJSON{} 156 | if err := json.Unmarshal(buf, &encJSON); err != nil { 157 | return nil, err 158 | } 159 | 160 | // Extract all master key slots from header. 161 | // Exit when a valid masterkey has been found. 162 | var masterkey []byte 163 | for _, slot := range encJSON.Header.Slots { 164 | var ( 165 | nonce []byte 166 | keyslot []byte 167 | tag []byte 168 | salt []byte 169 | ) 170 | 171 | if slot.Type != 1 { 172 | continue 173 | } 174 | if salt, err = hex.DecodeString(slot.Salt); err != nil { 175 | return nil, fmt.Errorf("slot salt: %v", err) 176 | } 177 | 178 | key, err := scrypt.Key(password, salt, slot.N, slot.R, slot.P, aegisKeyLen) 179 | if err != nil { 180 | return nil, err 181 | } 182 | 183 | // AES GCM decrypt. 184 | aesgcm, err := newAES(key) 185 | if err != nil { 186 | return nil, err 187 | } 188 | 189 | if nonce, err = hex.DecodeString(slot.KeyParams.Nonce); err != nil { 190 | return nil, fmt.Errorf("slot nonce: %v", err) 191 | } 192 | if tag, err = hex.DecodeString(slot.KeyParams.Tag); err != nil { 193 | return nil, fmt.Errorf("slot tag: %v", err) 194 | } 195 | if keyslot, err = hex.DecodeString(slot.Key); err != nil { 196 | return nil, fmt.Errorf("slot key: %v", err) 197 | } 198 | 199 | // ciphertext := keyslot + tag 200 | ciphertext := keyslot 201 | ciphertext = append(ciphertext, tag...) 202 | 203 | // Decrypt and break out of the loop if found. If not, try the next slot. 204 | masterkey, err = aesgcm.Open(nil, nonce, ciphertext, nil) 205 | if err != nil { 206 | // Issue a warning only, but continue. 207 | rlog.Debug(err) 208 | continue 209 | } 210 | break 211 | } 212 | 213 | if len(masterkey) == 0 { 214 | return nil, errors.New("unable to decrypt the master key with the given password") 215 | } 216 | 217 | // Decode DB contents. 218 | content, err := base64.StdEncoding.DecodeString(encJSON.Db) 219 | if err != nil { 220 | return nil, err 221 | } 222 | 223 | // Decrypt the vault contents using the master key. 224 | cipher, err := newAES(masterkey) 225 | if err != nil { 226 | return nil, err 227 | } 228 | 229 | nonce, err := hex.DecodeString(encJSON.Header.Params.Nonce) 230 | if err != nil { 231 | return nil, fmt.Errorf("params nonce: %v", err) 232 | } 233 | tag, err := hex.DecodeString(encJSON.Header.Params.Tag) 234 | if err != nil { 235 | return nil, fmt.Errorf("params tag: %v", err) 236 | } 237 | 238 | data := append(content, tag...) 239 | 240 | // Decrypt and return. 241 | db, err := cipher.Open(nil, nonce, data, nil) 242 | if err != nil { 243 | return nil, err 244 | } 245 | 246 | return db, nil 247 | } 248 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | [![Go Report Card](https://goreportcard.com/badge/github.com/marcopaganini/termotp)](https://goreportcard.com/report/github.com/marcopaganini/termotp) 2 | 3 | # termotp - A terminal OTP codes generator. 4 | 5 | ## Description 6 | 7 | **termotp** reads an encrypted vault export from your TOTP Android App (currently, only Aegis Authenticator is supported) and displays issuers, providers and a TOTP for each of them. The program uses no database and reads directly from the App export. Since backups are encrypted, your credentials never stay on the disk unencrypted. It's basically a pure terminal based way to generate TOTP tokens while keeping your credentials encrypted. 8 | 9 | Future versions will read encrypted exports from other apps, such AndOTP and others. 10 | 11 | **termotp** can display a simple table with issuers, accounts, and otps, or engage in an interactive fuzzy finder with the user. 12 | 13 | A regular expression allows the selection of a group of entries. If called without a regexp, **termotp** will show all entries. 14 | 15 | ## Why another CLI/TUI based authenticator? 16 | 17 | Similar CLI/TUI applications exist, but most (if not all) of them focus on being a full HOTP/TOTP code generator. These applications keep a local database with the secrets and synchronization of tokens between the CLI application and your mobile app needs to be done manually (and in some cases, by adding the secrets directly). While some CLI applications offer *import* capabilities, not many can *export* into other formats. Even with import/export, both databases need to me synchronized carefully, or loss of data may occur. 18 | 19 | **termotp**'s main purpose is to use the export file from mobile apps *directly*. There are no other databases, no possibility of adding new codes via **termotp**, so no chance for data loss of synchronization issues. It also has both a CLI and a simple TUI mode, which not all alternatives offer. 20 | 21 | ## Installation 22 | 23 | There are a few ways to install **termotp**: 24 | 25 | ### Direct download from releases 26 | 27 | To download and install the latest release into `/usr/local/bin` (requires 28 | root), cut and paste the following shell command: 29 | 30 | ```bash 31 | curl -s \ 32 | 'https://raw.githubusercontent.com/marcopaganini/termotp/master/install.sh' | 33 | sudo sh -s -- marcopaganini/termotp 34 | ``` 35 | 36 | To download and install the latest release into another directory (say, `~/.local/bin`): 37 | 38 | ```bash 39 | curl -s \ 40 | 'https://raw.githubusercontent.com/marcopaganini/termotp/master/install.sh' | 41 | sh -s -- marcopaganini/termotp "${HOME}/.local/bin" 42 | ``` 43 | 44 | ### Compile and install yourself 45 | 46 | If you have the Go compiler installed, just clone the repository and type `make`, followed by `make install`: 47 | 48 | ```bash 49 | git clone https://github.com/marcopaganini/termotp 50 | cd termotp 51 | make 52 | sudo make install 53 | ``` 54 | 55 | ### Linux packages 56 | 57 | You'll also find packages for multiple distributions (DEB/RPM/APK files, etc) in the 58 | [Releases](https://github.com/marcopaganini/termotp/releases/) are of the repository. 59 | 60 | ## Usage 61 | 62 | The basic usage is: 63 | 64 | ``` 65 | termotp --input=file_glob [options] [entry_regexp} 66 | ``` 67 | 68 | ### Options ### 69 | 70 | **--input=file_glob** 71 | 72 | Specifies the file or a glob matching more than one file holding the encrypted vault exports. If the glob expands to more than one file, **termotp** will pick the newest one. This is useful if you sync your phone vault exports to a directory on your computer (using syncthing, for example.). Aegis by default uses a date on the filename, so in case of multiple files being present, the latest one is what you usually want. 73 | 74 | E.g.: Specifying `--input="/backups/aegis/*.json"` (note the quotes) will cause **termotop** to use the latest file named `*.json` in the `/backup/aegis` directory. 75 | 76 | **--fzf** 77 | 78 | Uses [fzf](https://github.com/junegunn/fzf) to select the desired OTP. The `fzf` binary must be installed on the system. 79 | 80 | **--fuzzy** 81 | 82 | Without any special options, **termotp** shows a formatted table of your TOTP providers and the calculated tokens. This option shows a simple TUI with a fuzzy selector. Hitting enter on an entry will print the otp to the standard output. 83 | 84 | **--json** 85 | 86 | Emits the output in JSON format. 87 | 88 | **--plain** 89 | 90 | Produces a plain listing of the vault. 91 | 92 | **--set-keyring** 93 | 94 | Read the password from the keyboard and write it to the keyring. This option causes all other options to be silently ignored. 95 | 96 | Under OS X, you'll need the `/usr/bin/security` binary to interface with the OS X keychain. This binary should be available by default. 97 | 98 | In Linux and BSD implementations, this depends on the [Secret Service](https://specifications.freedesktop.org/secret-service/latest/) dbus interface provided by [Gnome Keyring](https://wiki.gnome.org/Projects/GnomeKeyring). These implementations are installed and started by default on most modern distributions. 99 | 100 | Please note that this assumes that the `login` collection exists in the keyring (the default on most distros). If it doesn't, use [Seahorse](https://wiki.gnome.org/Apps/Seahorse) to create it: 101 | 102 | * Open `seahorse` 103 | * Go to `File` > `New` > `Password Keyring` 104 | * Click `Continue` 105 | * When asked for a name, use `login`. 106 | 107 | **--use-keyring** 108 | 109 | Read the password from the `login` keyring (which should be open by default after login) instead of the keyboard. This allows passwordless operation while maintaining your vault encrypted. 110 | 111 | Make sure to write your password to the keyring with `--set-keyring` before using this option. 112 | 113 | **---version** 114 | 115 | Just print the current program version (or git commit number) and exit. 116 | 117 | ## Future plans 118 | 119 | Add support for other OTP programs, like AndOTP, 2FA, etc. I'll proceed to do that once I have literature on the encrypted export formats for those programs. For now, only Aegis Authenticator is supported. 120 | 121 | ## Related and similar programs 122 | 123 | * [Aegis authenticator for Android](http://getaegis.app): It's my TOTP app of choice on Android (Lots of features and open source!) 124 | * [cotp](https://github.com/replydev/cotp): Capable TUI based OTP generator. Can import external files, but uses its own database on disk. 125 | * [OTPCLient](https://github.com/paolostivanin/OTPClient) allows you to import different vault formats into its own encrypted vault. Has a graphical UI and a less capable CLI client. 126 | * [oathtool](https://www.nongnu.org/oath-toolkit/): Bare bones CLI authenticator. 127 | * [2fa](https://github.com/rsc/2fa): Another bare bones OTP generator that uses its own database (manual import). 128 | * [Syncthing](http://syncthing.net): Allows you to sync files directly between multiple devices (including your phone.) 129 | 130 | ## Thanks 131 | 132 | * https://github.com/zalando/ for their Keyring manipulation library. 133 | * https://github.com/sam-artuso and http://github.com/timkgh for their great feature requests. 134 | 135 | ## Author 136 | 137 | Marco Paganini 138 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/alessio/shellescape v1.4.1 h1:V7yhSDDn8LP4lc4jS8pFkt0zCnzVJlG5JXy9BVKJUX0= 2 | github.com/alessio/shellescape v1.4.1/go.mod h1:PZAiSCk0LJaZkiCSkPv8qIobYglO3FPpyFjDCtHLS30= 3 | github.com/danieljoos/wincred v1.2.0 h1:ozqKHaLK0W/ii4KVbbvluM91W2H3Sh0BncbUNPS7jLE= 4 | github.com/danieljoos/wincred v1.2.0/go.mod h1:FzQLLMKBFdvu+osBrnFODiv32YGwCfx0SkRa/eYHgec= 5 | github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 6 | github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= 7 | github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 8 | github.com/gdamore/encoding v1.0.0 h1:+7OoQ1Bc6eTm5niUzBa0Ctsh6JbMW6Ra+YNuAtDBdko= 9 | github.com/gdamore/encoding v1.0.0/go.mod h1:alR0ol34c49FCSBLjhosxzcPHQbf2trDkoo5dl+VrEg= 10 | github.com/gdamore/tcell/v2 v2.5.3 h1:b9XQrT6QGbgI7JvZOJXFNczOQeIYbo8BfeSMzt2sAV0= 11 | github.com/gdamore/tcell/v2 v2.5.3/go.mod h1:wSkrPaXoiIWZqW/g7Px4xc79di6FTcpB8tvaKJ6uGBo= 12 | github.com/godbus/dbus/v5 v5.1.0 h1:4KLkAxT3aOY8Li4FRJe/KvhoNFFxo0m6fNuFUO8QJUk= 13 | github.com/godbus/dbus/v5 v5.1.0/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= 14 | github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38= 15 | github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= 16 | github.com/google/gofuzz v1.2.0 h1:xRy4A+RhZaiKjJ1bPfwQ8sedCA+YS2YcCHW6ec7JMi0= 17 | github.com/google/gofuzz v1.2.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= 18 | github.com/jedib0t/go-pretty/v6 v6.4.4 h1:N+gz6UngBPF4M288kiMURPHELDMIhF/Em35aYuKrsSc= 19 | github.com/jedib0t/go-pretty/v6 v6.4.4/go.mod h1:MgmISkTWDSFu0xOqiZ0mKNntMQ2mDgOcwOkwBEkMDJI= 20 | github.com/ktr0731/go-ansisgr v0.1.0 h1:fbuupput8739hQbEmZn1cEKjqQFwtCCZNznnF6ANo5w= 21 | github.com/ktr0731/go-ansisgr v0.1.0/go.mod h1:G9lxwgBwH0iey0Dw5YQd7n6PmQTwTuTM/X5Sgm/UrzE= 22 | github.com/ktr0731/go-fuzzyfinder v0.7.0 h1:EqkCoqQh9Xpqet0PMAGSwgEnqLPXOSiRwIUMzhWQw2I= 23 | github.com/ktr0731/go-fuzzyfinder v0.7.0/go.mod h1:/5RXp7U9PRhvIrM86u/9TK0FjPbZQVT/NaplQO7CZmU= 24 | github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY= 25 | github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= 26 | github.com/mattn/go-runewidth v0.0.9/go.mod h1:H031xJmbD/WCDINGzjvQ9THkh0rPKHF+m2gUSrubnMI= 27 | github.com/mattn/go-runewidth v0.0.13/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= 28 | github.com/mattn/go-runewidth v0.0.14 h1:+xnbZSEeDbOIg5/mE6JF0w6n9duR1l3/WmbinWVwUuU= 29 | github.com/mattn/go-runewidth v0.0.14/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= 30 | github.com/nsf/termbox-go v1.1.1 h1:nksUPLCb73Q++DwbYUBEglYBRPZyoXJdrj5L+TkjyZY= 31 | github.com/nsf/termbox-go v1.1.1/go.mod h1:T0cTdVuOwf7pHQNtfhnEbzHbcNyCEcVU4YPpouCbVxo= 32 | github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= 33 | github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= 34 | github.com/pkg/profile v1.6.0/go.mod h1:qBsxPvzyUincmltOk6iyRVxHYg4adc0OFOv72ZdLa18= 35 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= 36 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 37 | github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= 38 | github.com/rivo/uniseg v0.4.2 h1:YwD0ulJSJytLpiaWua0sBDusfsCZohxjxzVTYjwxfV8= 39 | github.com/rivo/uniseg v0.4.2/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= 40 | github.com/romana/rlog v0.0.0-20220412051723-c08f605858a9 h1:8tVb/1pwM1HrrK4HuBJIWREOSJ5Z1oouS6nilsXrL+Q= 41 | github.com/romana/rlog v0.0.0-20220412051723-c08f605858a9/go.mod h1:kPzumBKm/AKQWtDbtf8w0s/R+LwoYT1rTjsOYGcS82k= 42 | github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= 43 | github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= 44 | github.com/stretchr/objx v0.5.0 h1:1zr/of2m5FGMsad5YfcqgdqdWrIhu+EBEJRhR1U7z/c= 45 | github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= 46 | github.com/stretchr/testify v1.7.4/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= 47 | github.com/stretchr/testify v1.8.1 h1:w7B6lhMri9wdJUVmEZPGGhZzrYTPvgJArz7wNPgYKsk= 48 | github.com/xlzd/gotp v0.1.0 h1:37blvlKCh38s+fkem+fFh7sMnceltoIEBYTVXyoa5Po= 49 | github.com/xlzd/gotp v0.1.0/go.mod h1:ndLJ3JKzi3xLmUProq4LLxCuECL93dG9WASNLpHz8qg= 50 | github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= 51 | github.com/zalando/go-keyring v0.2.3 h1:v9CUu9phlABObO4LPWycf+zwMG7nlbb3t/B5wa97yms= 52 | github.com/zalando/go-keyring v0.2.3/go.mod h1:HL4k+OXQfJUWaMnqyuSOc0drfGPX2b51Du6K+MRgZMk= 53 | golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= 54 | golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= 55 | golang.org/x/crypto v0.5.0 h1:U/0M97KRkSFvyD/3FSmdP5W5swImpNgle/EHFhOsQPE= 56 | golang.org/x/crypto v0.5.0/go.mod h1:NK/OQwhpMQP3MwtdjgLlYHnH9ebylxKWv3e0fK+mkQU= 57 | golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= 58 | golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= 59 | golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= 60 | golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= 61 | golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 62 | golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 63 | golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= 64 | golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 65 | golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 66 | golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 67 | golang.org/x/sys v0.0.0-20220318055525-2edf467146b5/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 68 | golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 69 | golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 70 | golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 71 | golang.org/x/sys v0.8.0 h1:EBmGv8NaZBZTWvrbjNoL6HVt+IVy3QDQpJs7VRIw3tU= 72 | golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 73 | golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= 74 | golang.org/x/term v0.0.0-20201210144234-2321bbc49cbf/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= 75 | golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= 76 | golang.org/x/term v0.1.0/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= 77 | golang.org/x/term v0.4.0 h1:O7UWfv5+A2qiuulQk30kVinPoMtoIPeVaKLEgLpVkvg= 78 | golang.org/x/term v0.4.0/go.mod h1:9P2UbLfCdcvo3p/nzKvsmas4TnlujnuoV9hGgYzW1lQ= 79 | golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= 80 | golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= 81 | golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= 82 | golang.org/x/text v0.4.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= 83 | golang.org/x/text v0.6.0 h1:3XmdazWV+ubf7QgHSTWeykHOci5oeekaGJBLkrkaw4k= 84 | golang.org/x/text v0.6.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= 85 | golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= 86 | golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= 87 | golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= 88 | golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 89 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 90 | gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 91 | gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= 92 | gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 93 | -------------------------------------------------------------------------------- /main.go: -------------------------------------------------------------------------------- 1 | // This file is part of termOTP, a TOTP program for your terminal. 2 | // https://github.com/marcopaganini/termotp. 3 | // (C) 2024 by Marco Paganini 4 | package main 5 | 6 | import ( 7 | "encoding/json" 8 | "errors" 9 | "flag" 10 | "fmt" 11 | "os" 12 | "os/exec" 13 | "path/filepath" 14 | "regexp" 15 | "sort" 16 | "strings" 17 | "time" 18 | 19 | "github.com/jedib0t/go-pretty/v6/table" 20 | "github.com/jedib0t/go-pretty/v6/text" 21 | "github.com/romana/rlog" 22 | "github.com/zalando/go-keyring" 23 | ) 24 | 25 | // Build holds the current git head version number. 26 | // this is filled in by the build process (make). 27 | var Build string 28 | 29 | // otpEntry holds the representation of the internal vault. 30 | type otpEntry struct { 31 | Issuer string 32 | Account string 33 | Token string 34 | } 35 | 36 | // Keyring constants. User is not your user. 37 | const ( 38 | keyRingService = "termotp" 39 | keyRingUser = "anon" 40 | ) 41 | 42 | // cmdLineFlags contains the command-line flags. 43 | type cmdLineFlags struct { 44 | input string 45 | fuzzy bool 46 | fzf bool 47 | plain bool 48 | json bool 49 | setkeyring bool 50 | usekeyring bool 51 | version bool 52 | } 53 | 54 | // die logs a message with rlog.Critical and exits with a return code. 55 | func die(v ...any) { 56 | if v != nil { 57 | rlog.Critical(v...) 58 | } 59 | os.Exit(1) 60 | } 61 | 62 | // inputFile expands the glob passed as an argument and returns the file with 63 | // the most recent modification time in the list. 64 | func inputFile(fileglob string) (string, error) { 65 | files, err := filepath.Glob(fileglob) 66 | if err != nil { 67 | return "", err 68 | } 69 | if files == nil { 70 | return "", fmt.Errorf("no input files match %q", fileglob) 71 | } 72 | // Find the file with the newest mtime and return. 73 | var ( 74 | ntime time.Time 75 | nfile string 76 | ) 77 | for _, file := range files { 78 | fi, err := os.Stat(file) 79 | if err != nil { 80 | return "", err 81 | } 82 | if fi.ModTime().After(ntime) { 83 | ntime = fi.ModTime() 84 | nfile = file 85 | } 86 | } 87 | return nfile, nil 88 | } 89 | 90 | // outputTable returns a tabular representation of the vault. 91 | func outputTable(vault []otpEntry, flags cmdLineFlags) string { 92 | // Don't print anything (even a header) if vault is empty. 93 | if len(vault) == 0 { 94 | return "" 95 | } 96 | 97 | // If no interactive mode requested, print a table by default. 98 | tbl := table.NewWriter() 99 | 100 | // "Plain" style and box used for fzf compatible output. 101 | styleBoxPlain := table.BoxStyle{ 102 | BottomLeft: "", 103 | BottomRight: "", 104 | BottomSeparator: "", 105 | EmptySeparator: text.RepeatAndTrim(" ", text.RuneWidthWithoutEscSequences("+")), 106 | Left: "", 107 | LeftSeparator: "", 108 | MiddleHorizontal: "", 109 | MiddleSeparator: "", 110 | MiddleVertical: "", 111 | PaddingLeft: " ", 112 | PaddingRight: " ", 113 | PageSeparator: "", 114 | Right: "", 115 | RightSeparator: "", 116 | TopLeft: "", 117 | TopRight: "", 118 | TopSeparator: "", 119 | UnfinishedRow: "", 120 | } 121 | 122 | // Default style is "light" unless --fzf or --plan output requested. 123 | tbl.SetStyle(table.StyleLight) 124 | if flags.plain { 125 | stylePlain := table.StyleDefault 126 | stylePlain.Box = styleBoxPlain 127 | tbl.SetStyle(stylePlain) 128 | } 129 | 130 | // Don't automerge if plain or fzf. 131 | var automerge = true 132 | if flags.plain || flags.fzf { 133 | automerge = false 134 | } 135 | 136 | // Don't use headers in the output for fzf. 137 | if !flags.fzf { 138 | tbl.AppendHeader(table.Row{"Issuer", "Name", "OTP"}) 139 | } 140 | 141 | for _, v := range vault { 142 | tbl.AppendRow(table.Row{v.Issuer, v.Account, v.Token}) 143 | } 144 | 145 | tbl.SortBy([]table.SortBy{ 146 | {Name: "Issuer", Mode: table.Asc}, 147 | {Name: "Name", Mode: table.Asc}, 148 | }) 149 | tbl.SetColumnConfigs([]table.ColumnConfig{{Number: 1, AutoMerge: automerge}}) 150 | tbl.Style().Options.SeparateRows = false 151 | return tbl.Render() 152 | } 153 | 154 | // outputJSON outputs a JSON representation of the decrypted vault. 155 | func outputJSON(vault []otpEntry) (string, error) { 156 | output, err := json.Marshal(vault) 157 | if err != nil { 158 | return "", err 159 | } 160 | return string(output), nil 161 | } 162 | 163 | // parseFlags parses the command line flags and returns a cmdLineFlag struct. 164 | func parseFlags() (cmdLineFlags, error) { 165 | flags := cmdLineFlags{} 166 | 167 | flag.StringVar(&flags.input, "input", "", "Input (encrypted) JSON file glob.") 168 | flag.BoolVar(&flags.fuzzy, "fuzzy", false, "Use interactive fuzzy finder.") 169 | flag.BoolVar(&flags.fzf, "fzf", false, "Use fzf (needs external binary in path).") 170 | flag.BoolVar(&flags.json, "json", false, "Use JSON output.") 171 | flag.BoolVar(&flags.plain, "plain", false, "Use plain output (disables fuzzy finder and tabular output.)") 172 | flag.BoolVar(&flags.version, "version", false, "Show program version and exit.") 173 | flag.BoolVar(&flags.setkeyring, "set-keyring", false, "Set the keyring password and exit.") 174 | flag.BoolVar(&flags.usekeyring, "use-keyring", false, "Use keyring stored password.") 175 | 176 | flag.Parse() 177 | 178 | // --setkeyring requires nothing else. 179 | if flags.setkeyring { 180 | return flags, nil 181 | } 182 | 183 | if flags.version { 184 | fmt.Printf("Build Version: %s\n", Build) 185 | os.Exit(0) 186 | } 187 | 188 | // Flag sanity checking. 189 | if flags.input == "" { 190 | return cmdLineFlags{}, errors.New("please specify input file with --input") 191 | } 192 | 193 | // Only one output format allowed. 194 | n := 0 195 | for _, v := range []bool{flags.fuzzy, flags.fzf, flags.json, flags.plain} { 196 | if v { 197 | n++ 198 | } 199 | } 200 | if n > 1 { 201 | return cmdLineFlags{}, errors.New("please only specify ONE output format") 202 | } 203 | 204 | if len(flag.Args()) > 1 { 205 | return cmdLineFlags{}, errors.New("specify one or zero regular expressions to match") 206 | } 207 | 208 | // FZF uses plain output, with modifications (no headers, no automerge) 209 | if flags.fzf { 210 | flags.plain = true 211 | } 212 | 213 | return flags, nil 214 | } 215 | 216 | // fzf runs fzf on the output and return the chosen token. 217 | func fzf(table string) (string, error) { 218 | cmd := exec.Command("fzf", "--sync") 219 | cmd.Stderr = os.Stderr 220 | 221 | stdin, err := cmd.StdinPipe() 222 | if err != nil { 223 | return "", err 224 | } 225 | // Generate output for fzf's stdin. 226 | for _, line := range strings.Split(table, "\n") { 227 | // Remove lines containing only spaces added 228 | // by table. TODO: Find a better fix for this. 229 | if strings.TrimSpace(line) == "" { 230 | continue 231 | } 232 | fmt.Fprintln(stdin, line) 233 | } 234 | stdin.Close() 235 | 236 | output, err := cmd.Output() 237 | if err != nil { 238 | return "", err 239 | } 240 | 241 | c := strings.TrimSpace(string(output)) 242 | f := strings.Fields(c) 243 | // This should not happen (empty line) 244 | if len(f) < 1 { 245 | return "", nil 246 | } 247 | // FZF returns the entire line. The last element contains the token. 248 | return f[len(f)-1], nil 249 | } 250 | 251 | // setkeyring asks for a password and write it to the keyring. 252 | func setkeyring() error { 253 | password, err := readPassword() 254 | if err != nil { 255 | return err 256 | } 257 | 258 | if err = keyring.Set(keyRingService, keyRingUser, string(password)); err != nil { 259 | return err 260 | } 261 | return nil 262 | } 263 | 264 | func main() { 265 | // Usage prints the default usage for this program. 266 | flag.Usage = func() { 267 | _, program := filepath.Split(os.Args[0]) 268 | fmt.Fprintf(os.Stderr, "Usage:\n %s [options] [matching_regexp]\n\n", program) 269 | fmt.Fprintf(os.Stderr, "Options:\n") 270 | flag.PrintDefaults() 271 | } 272 | 273 | flags, err := parseFlags() 274 | if err != nil { 275 | die(err) 276 | } 277 | 278 | if flags.setkeyring { 279 | fmt.Println("Please enter the password to be stored in the keyring.") 280 | if err := setkeyring(); err != nil { 281 | die(err) 282 | } 283 | fmt.Println("Password set. Use --use-keyring to read the password from the keyring.") 284 | os.Exit(0) 285 | } 286 | 287 | // Get input file from the input files glob. 288 | input, err := inputFile(flags.input) 289 | if err != nil { 290 | die(err) 291 | } 292 | rlog.Debugf("Input file: %s", input) 293 | 294 | // By default, match everything (.) unless overridden by an argument. 295 | r := "." 296 | if len(flag.Args()) > 0 { 297 | r = "(?i)" + flag.Args()[0] 298 | } 299 | rematch, err := regexp.Compile(r) 300 | if err != nil { 301 | die(err) 302 | } 303 | 304 | // Read password (from keyboard or keyring) and decrypt aegis vault. 305 | var ( 306 | password []byte 307 | secret string 308 | ) 309 | 310 | if flags.usekeyring { 311 | secret, err = keyring.Get(keyRingService, keyRingUser) 312 | password = []byte(secret) 313 | } else { 314 | password, err = readPassword() 315 | } 316 | if err != nil { 317 | die(err) 318 | } 319 | 320 | db, err := aegisDecrypt(input, password) 321 | if err != nil { 322 | die(err) 323 | } 324 | rlog.Debugf("Decoded JSON:\n%s\n", string(db)) 325 | 326 | // Filter and sort vault. 327 | vault, err := filterAegisVault(db, rematch) 328 | if err != nil { 329 | die(err) 330 | } 331 | if len(vault) == 0 { 332 | rlog.Info("No matching entries found.") 333 | os.Exit(1) 334 | } 335 | sort.Slice(vault, func(i, j int) bool { 336 | key1 := vault[i].Issuer + "/" + vault[i].Account 337 | key2 := vault[j].Issuer + "/" + vault[j].Account 338 | return key1 > key2 339 | }) 340 | 341 | switch { 342 | case flags.fuzzy: 343 | // Interactive fuzzy finder. 344 | if flags.fuzzy { 345 | token, err := fuzzyFind(vault) 346 | if err != nil { 347 | die(err) 348 | } 349 | fmt.Println(token) 350 | } 351 | case flags.fzf: 352 | t, err := fzf(outputTable(vault, flags)) 353 | if err != nil { 354 | die(err) 355 | } 356 | fmt.Println(t) 357 | case flags.json: 358 | output, err := outputJSON(vault) 359 | if err != nil { 360 | die(err) 361 | } 362 | fmt.Println(output) 363 | default: 364 | fmt.Println(outputTable(vault, flags)) 365 | } 366 | } 367 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | 178 | APPENDIX: How to apply the Apache License to your work. 179 | 180 | To apply the Apache License to your work, attach the following 181 | boilerplate notice, with the fields enclosed by brackets "[]" 182 | replaced with your own identifying information. (Don't include 183 | the brackets!) The text should be enclosed in the appropriate 184 | comment syntax for the file format. We also recommend that a 185 | file or class name and description of purpose be included on the 186 | same "printed page" as the copyright notice for easier 187 | identification within third-party archives. 188 | 189 | Copyright [yyyy] [name of copyright owner] 190 | 191 | Licensed under the Apache License, Version 2.0 (the "License"); 192 | you may not use this file except in compliance with the License. 193 | You may obtain a copy of the License at 194 | 195 | http://www.apache.org/licenses/LICENSE-2.0 196 | 197 | Unless required by applicable law or agreed to in writing, software 198 | distributed under the License is distributed on an "AS IS" BASIS, 199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 200 | See the License for the specific language governing permissions and 201 | limitations under the License. 202 | --------------------------------------------------------------------------------