├── .gitignore ├── pkg ├── keepassxc │ ├── client_windows.go │ ├── client_darwin.go │ ├── client_linux.go │ ├── types_test.go │ ├── types.go │ └── client.go └── keystore │ ├── keystore.go │ └── keystore_test.go ├── .github └── workflows │ ├── release.yml │ └── test.yml ├── internal ├── internal_test.go └── internal.go ├── go.mod ├── .goreleaser.yml ├── LICENSE ├── main.go ├── README.md ├── cmd ├── getTOTP.go ├── root.go └── getLogins.go ├── Taskfile.yml ├── CHANGELOG.md └── go.sum /.gitignore: -------------------------------------------------------------------------------- 1 | *.swp 2 | ci-build/ 3 | coverage/ 4 | bin/ 5 | -------------------------------------------------------------------------------- /pkg/keepassxc/client_windows.go: -------------------------------------------------------------------------------- 1 | //go:build windows 2 | // +build windows 3 | 4 | package keepassxc 5 | 6 | import ( 7 | "fmt" 8 | "net" 9 | "os" 10 | 11 | "github.com/Microsoft/go-winio" 12 | ) 13 | 14 | // SocketName is a standard KeepassXC socket name. 15 | const SocketName = "org.keepassxc.KeePassXC.BrowserServer" 16 | 17 | func SocketPath() (string, error) { 18 | return fmt.Sprintf(`\\.\pipe\%s_%s`, SocketName, os.Getenv("USERNAME")), nil 19 | } 20 | 21 | func connect(socketPath string) (net.Conn, error) { 22 | return winio.DialPipe(socketPath, nil) 23 | } 24 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: goreleaser 2 | 3 | on: 4 | push: 5 | tags: 6 | - '*' 7 | 8 | jobs: 9 | goreleaser: 10 | runs-on: ubuntu-latest 11 | steps: 12 | - name: Checkout 13 | uses: actions/checkout@v2 14 | with: 15 | fetch-depth: 0 16 | 17 | - name: Set up Go 18 | uses: actions/setup-go@v2 19 | with: 20 | go-version: 1.23.x 21 | 22 | - name: Run GoReleaser 23 | uses: goreleaser/goreleaser-action@v6 24 | with: 25 | version: latest 26 | args: release --clean 27 | env: 28 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 29 | -------------------------------------------------------------------------------- /internal/internal_test.go: -------------------------------------------------------------------------------- 1 | package internal_test 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/MarkusFreitag/keepassxc-go/internal" 7 | "github.com/kevinburke/nacl" 8 | "github.com/stretchr/testify/require" 9 | ) 10 | 11 | func TestNaclNonceEncoding(t *testing.T) { 12 | nonce := nacl.NewNonce() 13 | 14 | encodedNonce := internal.NaclNonceToB64(nonce) 15 | decodedNonce := internal.B64ToNaclNonce(encodedNonce) 16 | 17 | require.Equal(t, nonce, decodedNonce) 18 | } 19 | 20 | func TestNaclKeyEncoding(t *testing.T) { 21 | key := nacl.NewKey() 22 | 23 | encodedKey := internal.NaclKeyToB64(key) 24 | decodedKey := internal.B64ToNaclKey(encodedKey) 25 | 26 | require.Equal(t, key, decodedKey) 27 | } 28 | -------------------------------------------------------------------------------- /pkg/keepassxc/client_darwin.go: -------------------------------------------------------------------------------- 1 | //go:build darwin 2 | // +build darwin 3 | 4 | package keepassxc 5 | 6 | import ( 7 | "errors" 8 | "fmt" 9 | "net" 10 | "os" 11 | "path/filepath" 12 | ) 13 | 14 | func SocketPath() (string, error) { 15 | tmpDir, ok := os.LookupEnv("TMPDIR") 16 | if !ok { 17 | return "", errors.New("$TMPDIR not set, can not find socket") 18 | } 19 | 20 | path := filepath.Join(tmpDir, "/org.keepassxc.KeePassXC.BrowserServer") 21 | if _, err := os.Stat(path); os.IsNotExist(err) { 22 | return "", fmt.Errorf("keepassxc socket not found '%s'", path) 23 | } 24 | return path, nil 25 | } 26 | 27 | func connect(socketPath string) (net.Conn, error) { 28 | return net.DialUnix("unix", nil, &net.UnixAddr{Name: socketPath, Net: "unix"}) 29 | } 30 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/MarkusFreitag/keepassxc-go 2 | 3 | go 1.23.0 4 | 5 | toolchain go1.24.0 6 | 7 | require ( 8 | github.com/Microsoft/go-winio v0.6.2 9 | github.com/agiledragon/gomonkey/v2 v2.10.1 10 | github.com/kevinburke/nacl v0.0.0-20250518034207-4fa338b68f84 11 | github.com/spf13/cobra v1.9.1 12 | github.com/stretchr/testify v1.7.0 13 | ) 14 | 15 | require ( 16 | github.com/davecgh/go-spew v1.1.1 // indirect 17 | github.com/inconshreveable/mousetrap v1.1.0 // indirect 18 | github.com/kr/pretty v0.1.0 // indirect 19 | github.com/pmezard/go-difflib v1.0.0 // indirect 20 | github.com/spf13/pflag v1.0.6 // indirect 21 | golang.org/x/crypto v0.38.0 // indirect 22 | golang.org/x/sys v0.33.0 // indirect 23 | gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127 // indirect 24 | gopkg.in/yaml.v3 v3.0.1 // indirect 25 | ) 26 | -------------------------------------------------------------------------------- /internal/internal.go: -------------------------------------------------------------------------------- 1 | package internal 2 | 3 | import ( 4 | "encoding/base64" 5 | 6 | "github.com/kevinburke/nacl" 7 | ) 8 | 9 | func NaclNonceToB64(nonce nacl.Nonce) string { 10 | return base64.StdEncoding.EncodeToString((*nonce)[:]) 11 | } 12 | 13 | func B64ToNaclNonce(b64Nonce string) nacl.Nonce { 14 | decoded, err := base64.StdEncoding.DecodeString(b64Nonce) 15 | if err != nil { 16 | panic(err) 17 | } 18 | nonce := new([nacl.NonceSize]byte) 19 | copy(nonce[:], decoded) 20 | return nonce 21 | } 22 | 23 | func NaclKeyToB64(key nacl.Key) string { 24 | return base64.StdEncoding.EncodeToString((*key)[:]) 25 | } 26 | 27 | func B64ToNaclKey(b64Key string) nacl.Key { 28 | decoded, err := base64.StdEncoding.DecodeString(b64Key) 29 | if err != nil { 30 | panic(err) 31 | } 32 | key := new([nacl.KeySize]byte) 33 | copy(key[:], decoded) 34 | return key 35 | } 36 | -------------------------------------------------------------------------------- /.goreleaser.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | 3 | builds: 4 | - binary: keepassxc-go 5 | main: main.go 6 | ldflags: 7 | - > 8 | -s -w 9 | -X github.com/MarkusFreitag/keepassxc-go/cmd.buildVersion={{.Version}} 10 | -X github.com/MarkusFreitag/keepassxc-go/cmd.buildDate={{.Date}} 11 | goos: 12 | - linux 13 | - darwin 14 | goarch: 15 | - amd64 16 | - arm64 17 | env: 18 | - CGO_ENABLED=0 19 | 20 | archives: 21 | - name_template: "{{.Binary}}_{{.Os}}_{{.Arch}}" 22 | 23 | release: 24 | draft: true 25 | 26 | snapshot: 27 | version_template: "{{.Tag}}" 28 | 29 | checksum: 30 | name_template: "keepassxc-go_checksums.txt" 31 | 32 | nfpms: 33 | - package_name: keepassxc-go 34 | homepage: https://github.com/MarkusFreitag/keepassxc-go 35 | maintainer: Markus Freitag 36 | description: Golang library and CLI tool to interact with KeepassXC 37 | license: MIT 38 | formats: 39 | - deb 40 | - rpm 41 | file_name_template: "{{.ProjectName}}_{{.Os}}_{{.Arch}}" 42 | -------------------------------------------------------------------------------- /pkg/keepassxc/client_linux.go: -------------------------------------------------------------------------------- 1 | //go:build linux 2 | // +build linux 3 | 4 | package keepassxc 5 | 6 | import ( 7 | "errors" 8 | "fmt" 9 | "net" 10 | "os" 11 | "path" 12 | ) 13 | 14 | const SocketName = "org.keepassxc.KeePassXC.BrowserServer" 15 | 16 | var lookupPaths = []string{ 17 | os.Getenv("XDG_RUNTIME_DIR"), 18 | os.Getenv("TMPDIR"), 19 | path.Join(os.Getenv("HOME"), "/snap/keepassxc/common/"), 20 | fmt.Sprintf("/run/user/%d/", os.Getuid()), 21 | } 22 | 23 | func SocketPath() (string, error) { 24 | var filename string 25 | 26 | for _, base := range lookupPaths { 27 | filename = path.Join(base, SocketName) 28 | 29 | if _, err := os.Stat(filename); err != nil { 30 | if errors.Is(err, os.ErrNotExist) { 31 | continue 32 | } 33 | return "", fmt.Errorf("keepassxc socket lookup error: %s", err) 34 | } 35 | 36 | break 37 | } 38 | 39 | if filename == "" { 40 | return "", fmt.Errorf("keepassxc socket not found") 41 | } 42 | 43 | return filename, nil 44 | } 45 | 46 | func connect(socketPath string) (net.Conn, error) { 47 | return net.DialUnix("unix", nil, &net.UnixAddr{Name: socketPath, Net: "unix"}) 48 | } 49 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright © 2021 Markus Freitag 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 13 | all 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 21 | THE SOFTWARE. 22 | -------------------------------------------------------------------------------- /pkg/keepassxc/types_test.go: -------------------------------------------------------------------------------- 1 | package keepassxc_test 2 | 3 | import ( 4 | "encoding/json" 5 | "testing" 6 | 7 | "github.com/stretchr/testify/require" 8 | 9 | "github.com/MarkusFreitag/keepassxc-go/pkg/keepassxc" 10 | ) 11 | 12 | func TestTypePassword(t *testing.T) { 13 | pass := keepassxc.Password("supersecret") 14 | require.Equal(t, "*****", pass.String()) 15 | require.Equal(t, "supersecret", pass.Plaintext()) 16 | } 17 | 18 | func TestTypeBoolString(t *testing.T) { 19 | data := struct { 20 | Bool keepassxc.BoolString 21 | }{} 22 | err := json.Unmarshal([]byte(`{"bool": "true"}`), &data) 23 | require.Nil(t, err) 24 | require.True(t, bool(data.Bool)) 25 | 26 | data = struct { 27 | Bool keepassxc.BoolString 28 | }{} 29 | err = json.Unmarshal([]byte(`{"bool": "false"}`), &data) 30 | require.Nil(t, err) 31 | require.False(t, bool(data.Bool)) 32 | } 33 | 34 | func TestTypeFields(t *testing.T) { 35 | fields := make(keepassxc.Fields, 0) 36 | require.Equal(t, "", fields.String()) 37 | fields = append(fields, "first") 38 | require.Equal(t, "first", fields.String()) 39 | fields = append(fields, "second", "third") 40 | require.Equal(t, "first,second,third", fields.String()) 41 | } 42 | -------------------------------------------------------------------------------- /main.go: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright © 2021 Markus Freitag 3 | 4 | Permission is hereby granted, free of charge, to any person obtaining a copy 5 | of this software and associated documentation files (the "Software"), to deal 6 | in the Software without restriction, including without limitation the rights 7 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 8 | copies of the Software, and to permit persons to whom the Software is 9 | furnished to do so, subject to the following conditions: 10 | 11 | The above copyright notice and this permission notice shall be included in 12 | all copies or substantial portions of the Software. 13 | 14 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 15 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 16 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 17 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 18 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 19 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 20 | THE SOFTWARE. 21 | */ 22 | package main 23 | 24 | import "github.com/MarkusFreitag/keepassxc-go/cmd" 25 | 26 | func main() { 27 | cmd.Execute() 28 | } 29 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: Test 2 | on: [push, pull_request] 3 | jobs: 4 | test: 5 | name: Test 6 | strategy: 7 | matrix: 8 | go-version: [1.23.x, 1.24.x] 9 | platform: [ubuntu-latest] 10 | goos: [linux, windows, darwin] 11 | goarch: [amd64] 12 | runs-on: ${{matrix.platform}} 13 | steps: 14 | - name: Set up Go ${{matrix.go-version}} 15 | uses: actions/setup-go@v2 16 | with: 17 | go-version: ${{matrix.go-version}} 18 | id: go 19 | 20 | - name: Check out code into the Go module directory 21 | uses: actions/checkout@v2 22 | 23 | - name: Download Go modules 24 | run: go mod download 25 | env: 26 | GOPROXY: https://proxy.golang.org 27 | 28 | - name: Download tonobo task 29 | run: curl -Ls https://git.io/ttask.sh | sh 30 | 31 | - name: golangci-lint 32 | uses: golangci/golangci-lint-action@v3 33 | with: 34 | version: latest 35 | 36 | - name: build 37 | run: ./bin/task build 38 | env: 39 | GOOS: ${{matrix.goos}} 40 | GOARCH: ${{matrix.goarch}} 41 | 42 | - name: run unit-tests 43 | run: ./bin/task test 44 | # Run only on linux 45 | if: ${{matrix.goos == 'linux'}} 46 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # keepassxc-go 2 | 3 | This repository contains a library as well as a basic CLI tool to interact with KeepassXC using the provided unix socket. 4 | 5 | ## Installation 6 | 7 | To install it, you can download the binary or one of the packages (deb, rpm) from the [releases](https://github.com/MarkusFreitag/keepassxc-go/releases/latest). 8 | 9 | ## Usage 10 | 11 | ### CLI tool 12 | The CLI tool currently is quite limited as it only provides a way 13 | to search for an url. 14 | ``` 15 | $ ./ci-build/keepassxc-go --help 16 | interact with keepassxc via unix-socket 17 | 18 | Usage: 19 | keepassxc-go [command] 20 | 21 | Available Commands: 22 | get-logins query info for the specified url 23 | help Help about any command 24 | 25 | Flags: 26 | -h, --help help for keepassxc-go 27 | -p, --profile string Only necessary if keystore contains multiple profiles 28 | 29 | Use "keepassxc-go [command] --help" for more information about a command. 30 | ``` 31 | ``` 32 | $ ./ci-build/keepassxc-go get-logins --help 33 | query info for the specified url 34 | 35 | Usage: 36 | keepassxc-go get-logins URL [flags] 37 | 38 | Flags: 39 | --all show all matches otherwise only the first will be printed 40 | -h, --help help for get-logins 41 | --plaintext print out the password - BE CAREFUL 42 | 43 | Global Flags: 44 | -p, --profile string Only necessary if keystore contains multiple profiles 45 | ``` 46 | -------------------------------------------------------------------------------- /pkg/keepassxc/types.go: -------------------------------------------------------------------------------- 1 | package keepassxc 2 | 3 | import ( 4 | "encoding/json" 5 | "errors" 6 | "strings" 7 | ) 8 | 9 | var ErrInvalidResponse = errors.New("invalid response does not include entries") 10 | 11 | type Message map[string]interface{} 12 | 13 | type Response map[string]interface{} 14 | 15 | func (r Response) entries() (Entries, error) { 16 | var data []byte 17 | if msg, ok := r["message"]; ok { 18 | if v, ok := msg.(map[string]interface{})["entries"]; ok { 19 | var err error 20 | data, err = json.Marshal(v) 21 | if err != nil { 22 | return nil, err 23 | } 24 | } 25 | } 26 | if len(data) == 0 { 27 | return nil, ErrInvalidResponse 28 | } 29 | 30 | var entries Entries 31 | err := json.Unmarshal(data, &entries) 32 | return entries, err 33 | } 34 | 35 | type Password string 36 | 37 | func (p Password) String() string { 38 | return "*****" 39 | } 40 | 41 | func (p Password) Plaintext() string { 42 | return string(p) 43 | } 44 | 45 | type Entry struct { 46 | Name string `json:"name"` 47 | Login string `json:"login"` 48 | Password Password `json:"password"` 49 | Group string `json:"group"` 50 | UUID string `json:"uuid"` 51 | Fields Fields `json:"stringFields"` 52 | Expired BoolString `json:"expired"` 53 | } 54 | 55 | type Fields []string 56 | 57 | func (f Fields) String() string { 58 | return strings.Join(f, ",") 59 | } 60 | 61 | type BoolString bool 62 | 63 | func (bs *BoolString) UnmarshalJSON(b []byte) error { 64 | if strings.ToLower(strings.Trim(string(b), `"`)) == "true" { 65 | *bs = true 66 | } 67 | return nil 68 | } 69 | 70 | type Entries []*Entry 71 | 72 | type DBGroup struct{} 73 | -------------------------------------------------------------------------------- /cmd/getTOTP.go: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright © 2021 Markus Freitag 3 | 4 | Permission is hereby granted, free of charge, to any person obtaining a copy 5 | of this software and associated documentation files (the "Software"), to deal 6 | in the Software without restriction, including without limitation the rights 7 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 8 | copies of the Software, and to permit persons to whom the Software is 9 | furnished to do so, subject to the following conditions: 10 | 11 | The above copyright notice and this permission notice shall be included in 12 | all copies or substantial portions of the Software. 13 | 14 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 15 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 16 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 17 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 18 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 19 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 20 | THE SOFTWARE. 21 | */ 22 | package cmd 23 | 24 | import ( 25 | "fmt" 26 | 27 | "github.com/spf13/cobra" 28 | ) 29 | 30 | var getTOTPCmd = &cobra.Command{ 31 | Use: "get-totp URL", 32 | Short: "get TOTP for the specified url", 33 | Args: cobra.ExactArgs(1), 34 | RunE: func(cmd *cobra.Command, args []string) error { 35 | client, err := initializeClient() 36 | if err != nil { 37 | return err 38 | } 39 | 40 | entries, err := client.GetLogins(args[0]) 41 | if err != nil { 42 | return err 43 | } 44 | if len(entries) == 0 { 45 | return fmt.Errorf("could not find entries for '%s'", args[0]) 46 | } 47 | 48 | totp, err := client.GetTOTP(entries[0].UUID) 49 | if err != nil { 50 | return err 51 | } 52 | 53 | fmt.Println(totp) 54 | return nil 55 | }, 56 | } 57 | 58 | func init() { 59 | rootCmd.AddCommand(getTOTPCmd) 60 | } 61 | -------------------------------------------------------------------------------- /Taskfile.yml: -------------------------------------------------------------------------------- 1 | version: '2' 2 | output: 'prefixed' 3 | 4 | vars: 5 | CGO_ENABLED: 0 6 | PATH: 'github.com/MarkusFreitag/keepassxc-go' 7 | BINARY_NAME: 'keepassxc-go' 8 | 9 | tasks: 10 | all: 11 | deps: [build] 12 | build: 13 | desc: Start the build process 14 | deps: 15 | - task: clean 16 | - task: bin 17 | format: 18 | desc: Run gofmt for the project 19 | cmds: 20 | - bash -c "diff -u <(echo -n) <(go fmt ./...)" 21 | lint: 22 | desc: Run golangci-lint for the project 23 | deps: [format] 24 | cmds: 25 | - golangci-lint run 26 | test: 27 | desc: Run go test unittests 28 | cmds: 29 | - mkdir -p coverage 30 | - go test -v ./... -coverprofile=coverage/coverage.out 31 | coverage: 32 | desc: Generate coverage report 33 | deps: [test] 34 | cmds: 35 | - go tool cover -html=coverage/coverage.out -o coverage/coverage.html 36 | clean: 37 | desc: Clean up files of the build process 38 | cmds: 39 | - rm -rf ci-build/ coverage/ 40 | bin: 41 | desc: Build the go binary 42 | cmds: 43 | - go build -a -ldflags "-X {{.PATH}}/cmd.BuildDate={{.DATE}} -X {{.PATH}}/cmd.BuildVersion={{.VERSION}} -extldflags '-static' -s -w" -o ci-build/{{.BINARY_NAME}} 44 | vars: 45 | DATE: 46 | sh: date -Iseconds 47 | VERSION: 48 | sh: git describe --tags || git describe --always 49 | changelog: 50 | desc: Add a new changelog entry 51 | cmds: 52 | - ish: changelogger 53 | release: 54 | desc: Create a new release 55 | cmds: 56 | - ish: changelogger release new 57 | ignore_error: yes 58 | - git add CHANGELOG.md 59 | - git commit -m "Bump version to $(changelogger release last --version-only)" -m "$(changelogger release last)" 60 | - git tag -a "$(changelogger release last --version-only)" -m "$(changelogger release last)" 61 | - git push 62 | - git push --tags 63 | env: 64 | CHANGELOGGER_VERSION_FORMAT: semver 65 | dl-deps: 66 | desc: Downloads cli dependencies 67 | cmds: 68 | - go install github.com/golangci/golangci-lint@latest 69 | - go install github.com/goreleaser/goreleaser@latest 70 | - go install github.com/goreleaser/godownloader@latest 71 | generate-install-script: 72 | desc: Generate install script using https://github.com/goreleaser/godownloader 73 | cmds: 74 | - godownloader --repo MarkusFreitag/keepassxc-go -o install-keypassxc-go.sh 75 | - cp ./install-keypassxc-go.sh ./docs/install.sh 76 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Minor Release v1.6.0 (2025-05-20) 2 | * **Markus Freitag** 3 | * cmd: add get-totp command 4 | * go: update dependencies 5 | * **secDre4mer** 6 | * pkg/keepassxc/client: implement get-totp 7 | 8 | *Released by Markus Freitag * 9 | 10 | # Patch Release v1.5.1 (2023-08-11) 11 | * **Markus Freitag** 12 | * cmd: dont initialize the client within a persistentprerun 13 | As reported in issue#13 doing so will interfere with the auto-generated completion commands 14 | 15 | *Released by Markus Freitag * 16 | 17 | # Minor Release v1.5.0 (2023-06-27) 18 | * **Guilhem Bonnefille** 19 | * feat: add support for windows 20 | 21 | *Released by Markus Freitag * 22 | 23 | # Patch Release v1.4.1 (2023-06-22) 24 | * **Markus Freitag** 25 | * pkg/keepassxc/client_linux: fix socket-path lookup routine 26 | 27 | *Released by Markus Freitag * 28 | 29 | # Minor Release v1.4.0 (2023-06-21) 30 | * **robert-renk** 31 | * pkg/keepassxc/client_linux: add support for different socket locations 32 | Distributions store the keepassxc socket in different locations, e.g. with snap in $HOME/snap/... 33 | 34 | *Released by Markus Freitag * 35 | 36 | # Patch Release v1.3.1 (2023-06-01) 37 | * **Markus Freitag** 38 | * go: upgrade to 1.20 39 | * tests: replace original monkey with license-compatible version 40 | The original version of monkey by bouk has been archived in 2020 and 41 | its license prohibits the usage of it in any project. 42 | 43 | *Released by Markus Freitag * 44 | 45 | # Minor Release v1.3.0 (2022-09-30) 46 | * **Markus Freitag** 47 | * pkg/keystore: add convenience method for profile loading 48 | * pkg/keepassxc: add convenience method for client initialization 49 | 50 | *Released by Markus Freitag * 51 | 52 | # Minor Release v1.2.0 (2022-09-28) 53 | * **Markus Freitag** 54 | * cmd/getLogins: add flag to enable json output 55 | 56 | *Released by Markus Freitag * 57 | 58 | # Patch Release v1.1.1 (2022-09-02) 59 | * **Markus Freitag** 60 | * pkg/client: fix imports for darwin 61 | 62 | *Released by Markus Freitag * 63 | 64 | # Minor Release v1.1.0 (2022-09-02) 65 | * **Markus Freitag** 66 | * pkg/client: add darwin support, search socket in $TMPDIR 67 | 68 | *Released by Markus Freitag * 69 | 70 | # Patch Release v1.0.1 (2022-07-15) 71 | * **Markus Freitag** 72 | * pkg/keystore: fix keystore file permission 73 | 74 | *Released by Markus Freitag * 75 | 76 | # Major Release v1.0.0 (2022-07-14) 77 | * **Markus Freitag** 78 | * initial release 79 | * pkg/keystore: handle keepassxc access tokens 80 | * pkg/client: handle communication via the unix socket 81 | - change-public-keys 82 | - get-database-hash 83 | - associate 84 | - test-associate 85 | - get-logins 86 | * cmd: CLI tool using the client 87 | - search for credentials 88 | 89 | *Released by Markus Freitag * 90 | -------------------------------------------------------------------------------- /cmd/root.go: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright © 2021 Markus Freitag 3 | 4 | Licensed under the Apache License, Version 2.0 (the "License"); 5 | you may not use this file except in compliance with the License. 6 | You may obtain a copy of the License at 7 | 8 | http://www.apache.org/licenses/LICENSE-2.0 9 | 10 | Unless required by applicable law or agreed to in writing, software 11 | distributed under the License is distributed on an "AS IS" BASIS, 12 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | See the License for the specific language governing permissions and 14 | limitations under the License. 15 | */ 16 | package cmd 17 | 18 | import ( 19 | "errors" 20 | "fmt" 21 | "os" 22 | "strings" 23 | 24 | "github.com/kevinburke/nacl" 25 | "github.com/spf13/cobra" 26 | 27 | "github.com/MarkusFreitag/keepassxc-go/pkg/keepassxc" 28 | "github.com/MarkusFreitag/keepassxc-go/pkg/keystore" 29 | ) 30 | 31 | var profileName string 32 | 33 | var rootCmd = &cobra.Command{ 34 | Use: "keepassxc-go", 35 | Short: "interact with keepassxc via unix-socket", 36 | } 37 | 38 | func Execute() { 39 | if err := rootCmd.Execute(); err != nil { 40 | msg := err.Error() 41 | if strings.HasPrefix(msg, "error") { 42 | rootCmd.SilenceUsage = true 43 | } 44 | 45 | fmt.Fprintln(os.Stderr, msg) 46 | os.Exit(1) 47 | } 48 | } 49 | 50 | func init() { 51 | rootCmd.SilenceErrors = true 52 | 53 | rootCmd.PersistentFlags().StringVarP(&profileName, "profile", "p", "", "Only necessary if keystore contains multiple profiles") 54 | } 55 | 56 | func initializeClient() (*keepassxc.Client, error) { 57 | socketPath, err := keepassxc.SocketPath() 58 | if err != nil { 59 | return nil, err 60 | } 61 | 62 | store, err := keystore.Load() 63 | if err != nil { 64 | return nil, err 65 | } 66 | 67 | var key nacl.Key 68 | switch len(store.Profiles) { 69 | case 0: 70 | break 71 | case 1: 72 | key = store.Profiles[0].NaclKey() 73 | profileName = store.Profiles[0].Name 74 | default: 75 | if profileName == "" { 76 | return nil, errors.New("keystore has multiple profiles, please specify the one to use") 77 | } 78 | for _, profile := range store.Profiles { 79 | if profile.Name == profileName { 80 | key = profile.NaclKey() 81 | } 82 | } 83 | if key == nil { 84 | return nil, fmt.Errorf("could not find profile '%s'", profileName) 85 | } 86 | } 87 | 88 | client := keepassxc.NewClient(socketPath, profileName, key) 89 | if err := client.Connect(); err != nil { 90 | return nil, err 91 | } 92 | 93 | if err := client.ChangePublicKeys(); err != nil { 94 | return nil, err 95 | } 96 | 97 | if key == nil { 98 | if err := client.Associate(); err != nil { 99 | return nil, err 100 | } 101 | name, key := client.GetAssociatedProfile() 102 | err = store.Add(&keystore.Profile{Name: name, Key: key}) 103 | if err != nil { 104 | return nil, err 105 | } 106 | err = store.Save() 107 | if err != nil { 108 | return nil, err 109 | } 110 | } else { 111 | if err := client.TestAssociate(); err != nil { 112 | return nil, err 113 | } 114 | } 115 | 116 | return client, nil 117 | } 118 | -------------------------------------------------------------------------------- /cmd/getLogins.go: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright © 2021 Markus Freitag 3 | 4 | Permission is hereby granted, free of charge, to any person obtaining a copy 5 | of this software and associated documentation files (the "Software"), to deal 6 | in the Software without restriction, including without limitation the rights 7 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 8 | copies of the Software, and to permit persons to whom the Software is 9 | furnished to do so, subject to the following conditions: 10 | 11 | The above copyright notice and this permission notice shall be included in 12 | all copies or substantial portions of the Software. 13 | 14 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 15 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 16 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 17 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 18 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 19 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 20 | THE SOFTWARE. 21 | */ 22 | package cmd 23 | 24 | import ( 25 | "encoding/json" 26 | "fmt" 27 | 28 | "github.com/spf13/cobra" 29 | ) 30 | 31 | var ( 32 | plaintext bool 33 | showAll bool 34 | jsonOutput bool 35 | ) 36 | 37 | var getLoginsCmd = &cobra.Command{ 38 | Use: "get-logins URL", 39 | Short: "query info for the specified url", 40 | Args: cobra.ExactArgs(1), 41 | RunE: func(cmd *cobra.Command, args []string) error { 42 | client, err := initializeClient() 43 | if err != nil { 44 | return err 45 | } 46 | 47 | entries, err := client.GetLogins(args[0]) 48 | if err != nil { 49 | return err 50 | } 51 | if len(entries) == 0 { 52 | return fmt.Errorf("could not find entries for '%s'", args[0]) 53 | } 54 | 55 | if jsonOutput { 56 | out := make([]map[string]string, len(entries)) 57 | for idx, entry := range entries { 58 | pass := entry.Password.String() 59 | if plaintext { 60 | pass = entry.Password.Plaintext() 61 | } 62 | out[idx] = map[string]string{ 63 | "name": entry.Name, 64 | "user": entry.Login, 65 | "pass": pass, 66 | } 67 | } 68 | 69 | data, err := json.Marshal(out) 70 | if err != nil { 71 | return err 72 | } 73 | fmt.Println(string(data)) 74 | return nil 75 | } 76 | 77 | for _, entry := range entries { 78 | pass := entry.Password.String() 79 | if plaintext { 80 | pass = entry.Password.Plaintext() 81 | } 82 | fmt.Printf("%s %s %s", entry.Name, entry.Login, pass) 83 | if entry.Expired { 84 | fmt.Print(" EXPIRED") 85 | } 86 | fmt.Print("\n") 87 | 88 | if !showAll { 89 | break 90 | } 91 | } 92 | 93 | return nil 94 | }, 95 | } 96 | 97 | func init() { 98 | rootCmd.AddCommand(getLoginsCmd) 99 | 100 | getLoginsCmd.Flags().BoolVar(&plaintext, "plaintext", false, "print out the password - BE CAREFUL") 101 | getLoginsCmd.Flags().BoolVar(&showAll, "all", false, "show all matches otherwise only the first will be printed") 102 | getLoginsCmd.Flags().BoolVar(&jsonOutput, "json", false, "format output as json") 103 | } 104 | -------------------------------------------------------------------------------- /pkg/keystore/keystore.go: -------------------------------------------------------------------------------- 1 | package keystore 2 | 3 | import ( 4 | "encoding/json" 5 | "errors" 6 | "fmt" 7 | "os" 8 | "path/filepath" 9 | 10 | "github.com/MarkusFreitag/keepassxc-go/internal" 11 | "github.com/kevinburke/nacl" 12 | ) 13 | 14 | const FILENAME = "keepassxc.keystore" 15 | 16 | var ( 17 | ErrEmptyKeystore = errors.New("keystore does not contain any profiles") 18 | ErrToManyProfiles = errors.New("keystore has multiple profiles, please specify the one to use") 19 | ErrDefaultProfileDoesNotExist = errors.New("default profile does not exist") 20 | ) 21 | 22 | type Profile struct { 23 | Name string `json:"name"` 24 | Key string `json:"key"` 25 | } 26 | 27 | func (p *Profile) NaclKey() nacl.Key { 28 | if p.Key == "" { 29 | return nil 30 | } 31 | return internal.B64ToNaclKey(p.Key) 32 | } 33 | 34 | type Keystore struct { 35 | defaultProfile *Profile 36 | Default string `json:"default"` 37 | Profiles []*Profile `json:"profiles"` 38 | } 39 | 40 | func Load() (*Keystore, error) { 41 | dir, err := os.UserConfigDir() 42 | if err != nil { 43 | return nil, err 44 | } 45 | 46 | storePath := filepath.Join(dir, FILENAME) 47 | if _, err := os.Stat(storePath); !os.IsNotExist(err) { 48 | content, err := os.ReadFile(filepath.Join(dir, FILENAME)) 49 | if err != nil { 50 | return nil, err 51 | } 52 | 53 | store := new(Keystore) 54 | err = json.Unmarshal(content, store) 55 | if err != nil { 56 | return nil, err 57 | } 58 | 59 | if store.Default != "" { 60 | for _, profile := range store.Profiles { 61 | if store.Default == profile.Name { 62 | store.defaultProfile = profile 63 | } 64 | } 65 | if store.defaultProfile == nil { 66 | return nil, ErrDefaultProfileDoesNotExist 67 | } 68 | } 69 | 70 | if store.Default == "" && len(store.Profiles) == 1 { 71 | store.defaultProfile = store.Profiles[0] 72 | store.Default = store.defaultProfile.Name 73 | } 74 | 75 | return store, nil 76 | } 77 | 78 | return &Keystore{Profiles: make([]*Profile, 0)}, nil 79 | } 80 | 81 | func (k *Keystore) Add(prof *Profile) error { 82 | if p, err := k.Get(prof.Name); p != nil && err == nil { 83 | return fmt.Errorf("profile named '%s' already exists", prof.Name) 84 | } 85 | k.Profiles = append(k.Profiles, prof) 86 | return nil 87 | } 88 | 89 | func (k *Keystore) Get(name string) (*Profile, error) { 90 | switch len(k.Profiles) { 91 | case 0: 92 | return nil, ErrEmptyKeystore 93 | case 1: 94 | if profile := k.Profiles[0]; name == "" || profile.Name == name { 95 | return profile, nil 96 | } 97 | default: 98 | if name == "" { 99 | return nil, ErrToManyProfiles 100 | } 101 | for _, profile := range k.Profiles { 102 | if profile.Name == name { 103 | return profile, nil 104 | } 105 | } 106 | } 107 | return nil, fmt.Errorf("profile named '%s' not found", name) 108 | } 109 | 110 | func (k *Keystore) Save() error { 111 | content, err := json.Marshal(k) 112 | if err != nil { 113 | return err 114 | } 115 | 116 | dir, err := os.UserConfigDir() 117 | if err != nil { 118 | return err 119 | } 120 | 121 | return os.WriteFile(filepath.Join(dir, FILENAME), content, 0600) 122 | } 123 | 124 | func (k *Keystore) DefaultProfile() (*Profile, error) { 125 | if k.defaultProfile != nil { 126 | return k.defaultProfile, nil 127 | } 128 | 129 | if len(k.Profiles) > 1 { 130 | return nil, ErrToManyProfiles 131 | } 132 | 133 | return new(Profile), nil 134 | } 135 | -------------------------------------------------------------------------------- /pkg/keystore/keystore_test.go: -------------------------------------------------------------------------------- 1 | package keystore_test 2 | 3 | import ( 4 | "os" 5 | "testing" 6 | 7 | monkey "github.com/agiledragon/gomonkey/v2" 8 | "github.com/stretchr/testify/require" 9 | 10 | "github.com/MarkusFreitag/keepassxc-go/pkg/keystore" 11 | ) 12 | 13 | func TestKeystore(t *testing.T) { 14 | fakeUserConfigDir, err := os.MkdirTemp("", "fakeUserConfigDir") 15 | require.Nil(t, err) 16 | require.NotEmpty(t, fakeUserConfigDir) 17 | defer os.RemoveAll(fakeUserConfigDir) 18 | 19 | patch := monkey.ApplyFunc(os.UserConfigDir, func() (string, error) { 20 | return fakeUserConfigDir, nil 21 | }) 22 | defer patch.Reset() 23 | 24 | require.Nil(t, new(keystore.Profile).NaclKey()) 25 | 26 | store, err := keystore.Load() 27 | require.Nil(t, err) 28 | require.NotNil(t, store) 29 | require.Equal(t, 0, len(store.Profiles)) 30 | 31 | profile, err := store.Get("abc") 32 | require.Nil(t, profile) 33 | require.NotNil(t, err) 34 | require.Equal(t, keystore.ErrEmptyKeystore, err) 35 | 36 | expectedProfile := &keystore.Profile{Name: "abc", Key: "secretkeysecretkeysecretkeysecretkey"} 37 | err = store.Add(expectedProfile) 38 | require.Nil(t, err) 39 | 40 | err = store.Add(expectedProfile) 41 | require.NotNil(t, err) 42 | require.Equal(t, "profile named 'abc' already exists", err.Error()) 43 | 44 | profile, err = store.Get("abc") 45 | require.Nil(t, err) 46 | require.NotNil(t, profile) 47 | require.Equal(t, *expectedProfile, *profile) 48 | 49 | profile, err = store.Get("") 50 | require.Nil(t, err) 51 | require.NotNil(t, profile) 52 | require.Equal(t, *expectedProfile, *profile) 53 | 54 | err = store.Save() 55 | require.Nil(t, err) 56 | 57 | store, err = keystore.Load() 58 | require.Nil(t, err) 59 | require.NotNil(t, store) 60 | require.Equal(t, "abc", store.Default) 61 | require.Equal(t, 1, len(store.Profiles)) 62 | require.Equal(t, "abc", store.Profiles[0].Name) 63 | require.Equal(t, "secretkeysecretkeysecretkeysecretkey", store.Profiles[0].Key) 64 | require.NotNil(t, store.Profiles[0].NaclKey()) 65 | 66 | profile, err = store.DefaultProfile() 67 | require.Nil(t, err) 68 | require.NotNil(t, profile) 69 | require.Equal(t, "abc", profile.Name) 70 | 71 | expectedProfile = &keystore.Profile{Name: "def", Key: "secretkeysecretkeysecretkeysecretkey"} 72 | err = store.Add(expectedProfile) 73 | require.Nil(t, err) 74 | 75 | profile, err = store.Get("") 76 | require.NotNil(t, err) 77 | require.Equal(t, keystore.ErrToManyProfiles, err) 78 | require.Nil(t, profile) 79 | 80 | profile, err = store.Get("def") 81 | require.Nil(t, err) 82 | require.NotNil(t, profile) 83 | require.Equal(t, *expectedProfile, *profile) 84 | 85 | profile, err = store.DefaultProfile() 86 | require.Nil(t, err) 87 | require.NotNil(t, profile) 88 | require.Equal(t, "abc", profile.Name) 89 | 90 | store.Default = "" 91 | 92 | err = store.Save() 93 | require.Nil(t, err) 94 | 95 | store, err = keystore.Load() 96 | require.Nil(t, err) 97 | require.NotNil(t, store) 98 | require.Empty(t, "", store.Default) 99 | 100 | profile, err = store.DefaultProfile() 101 | require.Nil(t, profile) 102 | require.NotNil(t, err) 103 | require.Equal(t, keystore.ErrToManyProfiles, err) 104 | 105 | store.Default = "abc" 106 | require.Nil(t, store.Save()) 107 | 108 | store, err = keystore.Load() 109 | require.Nil(t, err) 110 | require.NotNil(t, store) 111 | require.Equal(t, "abc", store.Default) 112 | 113 | store.Default = "moo" 114 | err = store.Save() 115 | require.Nil(t, err) 116 | 117 | store, err = keystore.Load() 118 | require.Nil(t, store) 119 | require.NotNil(t, err) 120 | require.Equal(t, keystore.ErrDefaultProfileDoesNotExist, err) 121 | 122 | store = new(keystore.Keystore) 123 | profile, err = store.DefaultProfile() 124 | require.Nil(t, err) 125 | require.NotNil(t, profile) 126 | require.Empty(t, profile.Name) 127 | } 128 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY= 2 | github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU= 3 | github.com/agiledragon/gomonkey/v2 v2.10.1 h1:FPJJNykD1957cZlGhr9X0zjr291/lbazoZ/dmc4mS4c= 4 | github.com/agiledragon/gomonkey/v2 v2.10.1/go.mod h1:ap1AmDzcVOAz1YpeJ3TCzIgstoaWLA6jbbgxfB4w2iY= 5 | github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= 6 | github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 7 | github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= 8 | github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 9 | github.com/gopherjs/gopherjs v0.0.0-20181017120253-0766667cb4d1/go.mod h1:wJfORRmW1u3UXTncJ5qlYoELFm8eSnnEO6hX4iZ3EWY= 10 | github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= 11 | github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= 12 | github.com/jtolds/gls v4.20.0+incompatible/go.mod h1:QJZ7F/aHp+rZTRtaJ1ow/lLfFfVYBRgL+9YlvaHOwJU= 13 | github.com/kevinburke/nacl v0.0.0-20250518034207-4fa338b68f84 h1:iTqvXyDl/9+n22FRhntcjISNS8UKRInulitx6GT9NjY= 14 | github.com/kevinburke/nacl v0.0.0-20250518034207-4fa338b68f84/go.mod h1:5o1EWGIkvZdwX7G6ND7NFiNFV3DOfpjENjRLOPW9IhM= 15 | github.com/kr/pretty v0.1.0 h1:L/CwN0zerZDmRFUapSPitk6f+Q3+0za1rQkzVuMiMFI= 16 | github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= 17 | github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= 18 | github.com/kr/text v0.1.0 h1:45sCR5RtlFHMR4UwH9sdQ5TC8v0qDQCHnXt+kaKSTVE= 19 | github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= 20 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= 21 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 22 | github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= 23 | github.com/smartystreets/assertions v0.0.0-20180927180507-b2de0cb4f26d/go.mod h1:OnSkiWE9lh6wB0YB77sQom3nweQdgAjqCqsofrRNTgc= 24 | github.com/smartystreets/goconvey v1.6.4/go.mod h1:syvi0/a8iFYH4r/RixwvyeAJjdLS9QV7WQ/tjFTllLA= 25 | github.com/spf13/cobra v1.9.1 h1:CXSaggrXdbHK9CF+8ywj8Amf7PBRmPCOJugH954Nnlo= 26 | github.com/spf13/cobra v1.9.1/go.mod h1:nDyEzZ8ogv936Cinf6g1RU9MRY64Ir93oCnqb9wxYW0= 27 | github.com/spf13/pflag v1.0.6 h1:jFzHGLGAlb3ruxLB8MhbI6A8+AQX/2eW4qeyNZXNp2o= 28 | github.com/spf13/pflag v1.0.6/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= 29 | github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= 30 | github.com/stretchr/testify v1.7.0 h1:nwc3DEeHmmLAfoZucVR881uASk0Mfjw8xYJ99tb5CcY= 31 | github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= 32 | golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= 33 | golang.org/x/crypto v0.38.0 h1:jt+WWG8IZlBnVbomuhg2Mdq0+BBQaHbtqHEFEigjUV8= 34 | golang.org/x/crypto v0.38.0/go.mod h1:MvrbAqul58NNYPKnOra203SB9vpuZW0e+RRZV+Ggqjw= 35 | golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= 36 | golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= 37 | golang.org/x/sys v0.33.0 h1:q3i8TbbEz+JRD9ywIRlyRAQbM0qF7hu24q3teo2hbuw= 38 | golang.org/x/sys v0.33.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= 39 | golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= 40 | golang.org/x/tools v0.0.0-20190328211700-ab21143f2384/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= 41 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 42 | gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127 h1:qIbj1fsPNlZgppZ+VLlY7N33q108Sa+fhmuc+sWQYwY= 43 | gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 44 | gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 45 | gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= 46 | gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 47 | -------------------------------------------------------------------------------- /pkg/keepassxc/client.go: -------------------------------------------------------------------------------- 1 | package keepassxc 2 | 3 | import ( 4 | "encoding/base64" 5 | "encoding/json" 6 | "errors" 7 | "fmt" 8 | "net" 9 | 10 | "github.com/kevinburke/nacl" 11 | "github.com/kevinburke/nacl/box" 12 | "github.com/kevinburke/nacl/scalarmult" 13 | 14 | "github.com/MarkusFreitag/keepassxc-go/internal" 15 | "github.com/MarkusFreitag/keepassxc-go/pkg/keystore" 16 | ) 17 | 18 | const APPLICATIONNAME = "keepassxc-go" 19 | 20 | var ( 21 | ErrUnspecifiedSocketPath = errors.New("unspecified socket path") 22 | ErrInvalidPeerKey = errors.New("invalid peer key") 23 | ErrNotImplemented = errors.New("not implemented yet") 24 | ) 25 | 26 | type Client struct { 27 | socketPath string 28 | applicationName string 29 | socket net.Conn 30 | 31 | privateKey nacl.Key 32 | publicKey nacl.Key 33 | peerKey nacl.Key 34 | 35 | id string 36 | 37 | associatedName string 38 | associatedKey nacl.Key 39 | } 40 | 41 | type ClientOption func(*Client) 42 | 43 | func WithApplicationName(name string) ClientOption { 44 | return func(client *Client) { 45 | client.applicationName = name 46 | } 47 | } 48 | 49 | func NewClient(socketPath, assoName string, assoKey nacl.Key, options ...ClientOption) *Client { 50 | if assoKey == nil || len(assoKey) == 0 { 51 | assoKey = nacl.NewKey() 52 | } 53 | 54 | client := &Client{ 55 | socketPath: socketPath, 56 | applicationName: APPLICATIONNAME, 57 | 58 | privateKey: nacl.NewKey(), 59 | 60 | associatedName: assoName, 61 | associatedKey: assoKey, 62 | } 63 | client.publicKey = scalarmult.Base(client.privateKey) 64 | 65 | for _, option := range options { 66 | option(client) 67 | } 68 | 69 | client.id = client.applicationName + internal.NaclNonceToB64(nacl.NewNonce()) 70 | 71 | return client 72 | } 73 | 74 | func (c *Client) encryptMessage(msg Message) ([]byte, error) { 75 | if len(c.peerKey) == 0 { 76 | return nil, ErrInvalidPeerKey 77 | } 78 | msgData, err := json.Marshal(msg) 79 | if err != nil { 80 | return nil, err 81 | } 82 | return box.EasySeal(msgData, c.peerKey, c.privateKey), nil 83 | } 84 | 85 | func (c *Client) decryptResponse(encryptedMsg []byte) ([]byte, error) { 86 | if len(c.peerKey) == 0 { 87 | return nil, ErrInvalidPeerKey 88 | } 89 | return box.EasyOpen(encryptedMsg, c.peerKey, c.privateKey) 90 | } 91 | 92 | func (c *Client) sendMessage(msg Message, encrypted bool) (Response, error) { 93 | if encrypted { 94 | encryptedMsg, err := c.encryptMessage(msg) 95 | if err != nil { 96 | return nil, err 97 | } 98 | action := msg["action"] 99 | msg = Message{ 100 | "action": action, 101 | "message": base64.StdEncoding.EncodeToString(encryptedMsg[nacl.NonceSize:]), 102 | "nonce": base64.StdEncoding.EncodeToString(encryptedMsg[:nacl.NonceSize]), 103 | } 104 | } else { 105 | msg["nonce"] = internal.NaclNonceToB64(nacl.NewNonce()) 106 | } 107 | msg["clientID"] = c.id 108 | 109 | data, err := json.Marshal(msg) 110 | if err != nil { 111 | return nil, err 112 | } 113 | 114 | _, err = c.socket.Write(data) 115 | if err != nil { 116 | return nil, err 117 | } 118 | 119 | buf := make([]byte, 4096) 120 | count, err := c.socket.Read(buf) 121 | if err != nil { 122 | return nil, err 123 | } 124 | buf = buf[0:count] 125 | 126 | var resp Response 127 | err = json.Unmarshal(buf, &resp) 128 | if err != nil { 129 | return nil, err 130 | } 131 | 132 | if err, ok := resp["error"]; ok { 133 | return nil, fmt.Errorf("%v %s", resp["errorCode"], err.(string)) 134 | } 135 | 136 | if encrypted { 137 | decoded, err := base64.StdEncoding.DecodeString(resp["nonce"].(string) + resp["message"].(string)) 138 | if err != nil { 139 | return nil, err 140 | } 141 | decryptedMsg, err := c.decryptResponse(decoded) 142 | if err != nil { 143 | return nil, err 144 | } 145 | var msg map[string]interface{} 146 | err = json.Unmarshal(decryptedMsg, &msg) 147 | if err != nil { 148 | return nil, err 149 | } 150 | resp["message"] = msg 151 | } 152 | 153 | return resp, err 154 | } 155 | 156 | func (c *Client) GetAssociatedProfile() (string, string) { 157 | return c.associatedName, internal.NaclKeyToB64(c.associatedKey) 158 | } 159 | 160 | func (c *Client) Connect() error { 161 | if c.socketPath == "" { 162 | return ErrUnspecifiedSocketPath 163 | } 164 | 165 | var err error 166 | c.socket, err = connect(c.socketPath) 167 | return err 168 | } 169 | 170 | func (c *Client) Disconnect() error { 171 | if c.socket != nil { 172 | return c.socket.Close() 173 | } 174 | return nil 175 | } 176 | 177 | func (c *Client) ChangePublicKeys() error { 178 | resp, err := c.sendMessage(Message{ 179 | "action": "change-public-keys", 180 | "publicKey": internal.NaclKeyToB64(c.publicKey), 181 | }, false) 182 | if err != nil { 183 | return err 184 | } 185 | if peerKey, ok := resp["publicKey"]; ok { 186 | c.peerKey = internal.B64ToNaclKey(peerKey.(string)) 187 | return nil 188 | } 189 | return errors.New("change-public-keys failed") 190 | } 191 | 192 | func (c *Client) GetDatabaseHash() (string, error) { 193 | resp, err := c.sendMessage(Message{ 194 | "action": "get-databasehash", 195 | }, true) 196 | if err != nil { 197 | return "", err 198 | } 199 | if v, ok := resp["message"]; ok { 200 | if msg, ok := v.(map[string]interface{}); ok { 201 | if hash, ok := msg["hash"]; ok { 202 | return hash.(string), nil 203 | } 204 | } 205 | } 206 | return "", errors.New("get-databasehash failed") 207 | } 208 | 209 | func (c *Client) Associate() error { 210 | resp, err := c.sendMessage(Message{ 211 | "action": "associate", 212 | "key": internal.NaclKeyToB64(c.publicKey), 213 | "idKey": internal.NaclKeyToB64(c.associatedKey), 214 | }, true) 215 | if err != nil { 216 | return err 217 | } 218 | if v, ok := resp["message"]; ok { 219 | if msg, ok := v.(map[string]interface{}); ok { 220 | if id, ok := msg["id"]; ok { 221 | c.associatedName = id.(string) 222 | return nil 223 | } 224 | } 225 | } 226 | return errors.New("associate failed") 227 | } 228 | 229 | func (c *Client) TestAssociate() error { 230 | _, err := c.sendMessage(Message{ 231 | "action": "test-associate", 232 | "key": internal.NaclKeyToB64(c.associatedKey), 233 | "id": c.associatedName, 234 | }, true) 235 | return err 236 | } 237 | 238 | func (c *Client) GeneratePassword() (*Entry, error) { 239 | return nil, ErrNotImplemented 240 | } 241 | 242 | func (c *Client) GetLogins(url string) ([]*Entry, error) { 243 | msg := Message{ 244 | "action": "get-logins", 245 | "url": url, 246 | "keys": []map[string]string{ 247 | { 248 | "id": c.associatedName, 249 | "key": internal.NaclKeyToB64(c.associatedKey), 250 | }, 251 | }, 252 | } 253 | resp, err := c.sendMessage(msg, true) 254 | if err != nil { 255 | return nil, err 256 | } 257 | 258 | return resp.entries() 259 | } 260 | 261 | func (c *Client) SetLogin() error { 262 | return ErrNotImplemented 263 | } 264 | 265 | func (c *Client) LockDatabase() error { 266 | return ErrNotImplemented 267 | } 268 | 269 | func (c *Client) GetDatabaseGroups() ([]*DBGroup, error) { 270 | return nil, ErrNotImplemented 271 | } 272 | 273 | func (c *Client) CreateDatabaseGroup(name string) (string, string, error) { 274 | return "", "", ErrNotImplemented 275 | } 276 | 277 | func (c *Client) GetTOTP(uuid string) (string, error) { 278 | msg := Message{ 279 | "action": "get-totp", 280 | "uuid": uuid, 281 | } 282 | resp, err := c.sendMessage(msg, true) 283 | if err != nil { 284 | return "", err 285 | } 286 | 287 | message := resp["message"].(map[string]interface{}) 288 | 289 | success, ok := message["success"] 290 | if !ok { 291 | return "", ErrInvalidResponse 292 | } 293 | if successStr, ok := success.(string); !ok { 294 | return "", ErrInvalidResponse 295 | } else if successStr != "true" { 296 | return "", fmt.Errorf("failed to get TOTP for %s", uuid) 297 | } 298 | totp, ok := message["totp"] 299 | if !ok { 300 | return "", ErrInvalidResponse 301 | } 302 | if totpStr, ok := totp.(string); !ok { 303 | return "", ErrInvalidResponse 304 | } else { 305 | return totpStr, nil 306 | } 307 | } 308 | 309 | func DefaultClient() (*Client, error) { 310 | store, err := keystore.Load() 311 | if err != nil { 312 | return nil, err 313 | } 314 | 315 | profile, err := store.DefaultProfile() 316 | if err != nil { 317 | return nil, err 318 | } 319 | 320 | socketPath, err := SocketPath() 321 | if err != nil { 322 | return nil, err 323 | } 324 | 325 | client := NewClient( 326 | socketPath, 327 | profile.Name, 328 | profile.NaclKey(), 329 | ) 330 | 331 | if err := client.Connect(); err != nil { 332 | return nil, err 333 | } 334 | 335 | if err := client.ChangePublicKeys(); err != nil { 336 | return nil, err 337 | } 338 | 339 | if key := profile.NaclKey(); key == nil { 340 | if err := client.Associate(); err != nil { 341 | return nil, err 342 | } 343 | 344 | profile.Name, profile.Key = client.GetAssociatedProfile() 345 | 346 | if err := store.Add(profile); err != nil { 347 | return nil, err 348 | } 349 | 350 | if err := store.Save(); err != nil { 351 | return nil, err 352 | } 353 | } else { 354 | if err := client.TestAssociate(); err != nil { 355 | return nil, err 356 | } 357 | } 358 | 359 | return client, nil 360 | } 361 | --------------------------------------------------------------------------------