├── .githooks └── pre-commit ├── version.go ├── .vscode ├── settings.json ├── tasks.json └── launch.json ├── internal ├── backend │ ├── README.md │ ├── common │ │ ├── README.md │ │ ├── backend.go │ │ ├── util.go │ │ ├── value.go │ │ ├── group.go │ │ ├── database.go │ │ └── entry.go │ ├── tests │ │ ├── util.go │ │ ├── group.go │ │ └── entry.go │ ├── keepassv1 │ │ ├── shared_entry_test.go │ │ ├── database_test.go │ │ ├── shared_group_test.go │ │ ├── group_test.go │ │ ├── entry_test.go │ │ ├── util_test.go │ │ ├── group.go │ │ ├── database.go │ │ └── entry.go │ ├── keepassv2 │ │ ├── database_test.go │ │ ├── group_test.go │ │ ├── util_test.go │ │ ├── entry_test.go │ │ ├── rootgroup_test.go │ │ ├── rootgroup.go │ │ ├── group.go │ │ ├── entry.go │ │ └── database.go │ └── types │ │ └── types.go └── commands │ ├── xx.go │ ├── pwd.go │ ├── xw.go │ ├── xp.go │ ├── xu.go │ ├── search_test.go │ ├── save.go │ ├── show.go │ ├── edit.go │ ├── saveas.go │ ├── ls_test.go │ ├── cd.go │ ├── cd_test.go │ ├── ls.go │ ├── search.go │ ├── new.go │ ├── show_test.go │ ├── mkdir.go │ ├── select.go │ ├── rm.go │ ├── mkdir_test.go │ ├── new_test.go │ ├── attach.go │ ├── mv.go │ ├── commands_test.go │ ├── mv_test.go │ ├── firefox-import.go │ └── commands.go ├── README.md ├── scripts └── lint.sh ├── .gitignore ├── LICENSE ├── go.mod ├── Makefile ├── go.sum └── main.go /.githooks/pre-commit: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | if ! ./scripts/lint.sh; then 3 | echo "linter failed!" 4 | exit 1 5 | fi 6 | -------------------------------------------------------------------------------- /version.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | var VersionBuildTZ, VersionRelease, VersionBranch, VersionRevision, VersionBuildDate, VersionHostname string 4 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "makefile.extensionOutputFolder": "./.vscode", 3 | "go.buildFlags": [ 4 | "-mod=readonly" 5 | ], 6 | "gopls": { 7 | 8 | } 9 | } -------------------------------------------------------------------------------- /internal/backend/README.md: -------------------------------------------------------------------------------- 1 | This subpackage abstracts interactions with keepass groups, databases, entries. The subgroups for v1/v2 implement the interfaces in this package so as to allow the main package to use one set of commands for both. 2 | -------------------------------------------------------------------------------- /internal/backend/common/README.md: -------------------------------------------------------------------------------- 1 | # common 2 | The types here are meant to be used as abstract classes and be extended by the actual implementations of each of the defined types. They provide implementations of functions where the logic is implementation-agnostic and can be shared across all backends 3 | -------------------------------------------------------------------------------- /.vscode/tasks.json: -------------------------------------------------------------------------------- 1 | { 2 | // See https://go.microsoft.com/fwlink/?LinkId=733558 3 | // for the documentation about the tasks.json format 4 | "version": "2.0.0", 5 | "tasks": [ 6 | { 7 | "label": "prep", 8 | "command": "make -B prepForDebug", 9 | "type": "shell" 10 | }, 11 | ], 12 | } -------------------------------------------------------------------------------- /internal/backend/tests/util.go: -------------------------------------------------------------------------------- 1 | package tests 2 | 3 | import ( 4 | t "github.com/mostfunkyduck/kp/internal/backend/types" 5 | ) 6 | 7 | type Resources struct { 8 | Db t.Database 9 | Entry t.Entry 10 | Group t.Group 11 | // BlankEntry and BlankGroup are empty resources for testing freshly 12 | // allocated structs 13 | BlankEntry t.Entry 14 | BlankGroup t.Group 15 | } 16 | -------------------------------------------------------------------------------- /internal/commands/xx.go: -------------------------------------------------------------------------------- 1 | package commands 2 | 3 | import ( 4 | "github.com/atotto/clipboard" 5 | "github.com/mostfunkyduck/ishell" 6 | ) 7 | 8 | func Xx(shell *ishell.Shell) (f func(c *ishell.Context)) { 9 | return func(c *ishell.Context) { 10 | if err := clipboard.WriteAll(""); err != nil { 11 | shell.Println("could not clear password from clipboard") 12 | return 13 | } 14 | shell.Println("clipboard cleared!") 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /internal/commands/pwd.go: -------------------------------------------------------------------------------- 1 | package commands 2 | 3 | import ( 4 | "github.com/mostfunkyduck/ishell" 5 | t "github.com/mostfunkyduck/kp/internal/backend/types" 6 | ) 7 | 8 | func Pwd(shell *ishell.Shell) (f func(c *ishell.Context)) { 9 | return func(c *ishell.Context) { 10 | db := shell.Get("db").(t.Database) 11 | path, err := db.Path() 12 | if err != nil { 13 | shell.Printf("could not retrieve current path: %s\n", err) 14 | } 15 | shell.Println(path) 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /internal/commands/xw.go: -------------------------------------------------------------------------------- 1 | package commands 2 | 3 | import ( 4 | "github.com/mostfunkyduck/ishell" 5 | ) 6 | 7 | func Xw(shell *ishell.Shell) (f func(c *ishell.Context)) { 8 | return func(c *ishell.Context) { 9 | errString, ok := syntaxCheck(c, 1) 10 | path := buildPath(c.Args) 11 | if !ok { 12 | shell.Println(errString) 13 | return 14 | } 15 | 16 | if err := copyFromEntry(shell, path, "url"); err != nil { 17 | shell.Printf("could not copy url: %s", err) 18 | return 19 | } 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # kp 2 | This project is a reimplementation of [kpcli](http://kpcli.sourceforge.net/) with a few additional features thrown in. It provides a shell-like interface for navigating a keepass database and manipulating entries. 3 | 4 | This is a pure hobby project and is maintained when I feel up to it. To use it, install go >= 1.21 and run: 5 | 6 | ```sh 7 | make test # run tests 8 | make kp # build binary 9 | 10 | ./kp -h # print help 11 | 12 | ./kp -db /path/to/keepass.kdb # connect to a database 13 | ``` 14 | -------------------------------------------------------------------------------- /internal/commands/xp.go: -------------------------------------------------------------------------------- 1 | package commands 2 | 3 | import ( 4 | "github.com/mostfunkyduck/ishell" 5 | ) 6 | 7 | func Xp(shell *ishell.Shell) (f func(c *ishell.Context)) { 8 | return func(c *ishell.Context) { 9 | errString, ok := syntaxCheck(c, 1) 10 | path := buildPath(c.Args) 11 | if !ok { 12 | shell.Println(errString) 13 | return 14 | } 15 | if err := copyFromEntry(shell, path, "password"); err != nil { 16 | shell.Printf("could not copy password: %s", err) 17 | return 18 | } 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /internal/commands/xu.go: -------------------------------------------------------------------------------- 1 | package commands 2 | 3 | import ( 4 | "github.com/mostfunkyduck/ishell" 5 | ) 6 | 7 | func Xu(shell *ishell.Shell) (f func(c *ishell.Context)) { 8 | return func(c *ishell.Context) { 9 | errString, ok := syntaxCheck(c, 1) 10 | path := buildPath(c.Args) 11 | if !ok { 12 | shell.Println(errString) 13 | return 14 | } 15 | if err := copyFromEntry(shell, path, "username"); err != nil { 16 | shell.Printf("could not copy username: %s", err) 17 | return 18 | } 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /scripts/lint.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | EC=0 4 | CMD="gofmt -l -d" 5 | if [ $# -gt 0 ] && [[ $1 == "fix" ]]; then 6 | CMD="$CMD -w" 7 | fi 8 | 9 | for file in ./internal/commands ./internal/backend/types ./internal/backend/tests ./internal/backend/common ./internal/backend/keepassv1 ./internal/backend/keepassv2; do 10 | output=$($CMD ${file}) 11 | lines=$(echo -n "$output" | wc -l) 12 | if [[ $lines -gt 0 ]]; then 13 | echo "$file" failed 14 | echo -n "$output" 15 | EC=1 16 | fi 17 | done 18 | 19 | if [ ${EC} == 1 ] ; then 20 | exit 1 21 | fi 22 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # cscope 2 | cscope.* 3 | # Binaries for programs and plugins 4 | *.exe 5 | *.exe~ 6 | *.dll 7 | *.so 8 | *.dylib 9 | 10 | # Test binary, built with `go test -c` 11 | *.test 12 | 13 | # Output of the go coverage tool, specifically when used with LiteIDE 14 | *.out 15 | 16 | # Dependency directories (remove the comment below to include it) 17 | # vendor/ 18 | 19 | kp 20 | TODOS 21 | coverage.out 22 | 23 | local_artifacts/ 24 | 25 | # VSCode 26 | .vscode/* 27 | 28 | # Local History for Visual Studio Code 29 | .history/ 30 | 31 | # Built Visual Studio Code Extensions 32 | *.vsix -------------------------------------------------------------------------------- /internal/commands/search_test.go: -------------------------------------------------------------------------------- 1 | package commands_test 2 | 3 | import ( 4 | "regexp" 5 | "testing" 6 | ) 7 | 8 | func TestSearchFullPath(t *testing.T) { 9 | r := createTestResources(t) 10 | term, err := regexp.Compile(r.Entry.Title()) 11 | if err != nil { 12 | t.Fatal(err) 13 | } 14 | paths, err := r.Group.Search(term) 15 | if err != nil { 16 | t.Fatal(err) 17 | } 18 | // the group and entry should match 19 | if len(paths) != 2 { 20 | t.Fatalf("%d != %d", len(paths), 1) 21 | } 22 | 23 | path, err := r.Entry.Path() 24 | if err != nil { 25 | t.Fatalf(err.Error()) 26 | } 27 | if paths[1] != path { 28 | t.Fatalf("[%s] != [%s]", paths[0], path) 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /internal/backend/keepassv1/shared_entry_test.go: -------------------------------------------------------------------------------- 1 | package keepassv1_test 2 | 3 | import ( 4 | "testing" 5 | 6 | runner "github.com/mostfunkyduck/kp/internal/backend/tests" 7 | ) 8 | 9 | func TestRegularPath(t *testing.T) { 10 | r := createTestResources(t) 11 | runner.RunTestRegularPath(t, r) 12 | } 13 | 14 | func TestEntryTimeFuncs(t *testing.T) { 15 | r := createTestResources(t) 16 | runner.RunTestEntryTimeFuncs(t, r) 17 | } 18 | 19 | func TestEntryPasswordTitleFuncs(t *testing.T) { 20 | r := createTestResources(t) 21 | runner.RunTestEntryPasswordTitleFuncs(t, r) 22 | } 23 | 24 | func TestOutput(t *testing.T) { 25 | r := createTestResources(t) 26 | runner.RunTestOutput(t, r.Entry) 27 | } 28 | -------------------------------------------------------------------------------- /internal/commands/save.go: -------------------------------------------------------------------------------- 1 | package commands 2 | 3 | import ( 4 | "github.com/mostfunkyduck/ishell" 5 | t "github.com/mostfunkyduck/kp/internal/backend/types" 6 | ) 7 | 8 | func Save(shell *ishell.Shell) (f func(c *ishell.Context)) { 9 | return func(c *ishell.Context) { 10 | db := shell.Get("db").(t.Database) 11 | savePath := db.SavePath() 12 | if savePath == "" { 13 | shell.Println("no path associated with this database! use 'saveas' if this is the first time saving the file") 14 | return 15 | } 16 | 17 | if err := db.Save(); err != nil { 18 | shell.Printf("error saving database: %s\n", err) 19 | return 20 | } 21 | shell.Printf("saved to '%s'\n", savePath) 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | // Use IntelliSense to learn about possible attributes. 3 | // Hover to view descriptions of existing attributes. 4 | // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 5 | "version": "0.2.0", 6 | 7 | "configurations": [ 8 | { 9 | "name": "Launch With Test DB", 10 | "type": "go", 11 | "request": "launch", 12 | "mode": "exec", 13 | "program": "./kp", 14 | "env": { 15 | "KP_DATABASE": "./local_artifacts/test.kdb", 16 | "KP_PASSWORD": "password" 17 | }, 18 | "console": "integratedTerminal", 19 | "preLaunchTask": "prep", 20 | "buildFlags": "-mod=readonly" 21 | } 22 | ] 23 | } -------------------------------------------------------------------------------- /internal/commands/show.go: -------------------------------------------------------------------------------- 1 | package commands 2 | 3 | import ( 4 | "github.com/mostfunkyduck/ishell" 5 | ) 6 | 7 | func Show(shell *ishell.Shell) (f func(c *ishell.Context)) { 8 | return func(c *ishell.Context) { 9 | if len(c.Args) < 1 { 10 | shell.Println("syntax: " + c.Cmd.Help) 11 | return 12 | } 13 | 14 | fullMode := false 15 | path := buildPath(c.Args) 16 | 17 | for _, flag := range c.Flags { 18 | if flag == "-f" { 19 | fullMode = true 20 | path = buildPath(c.Args[1:]) 21 | } 22 | } 23 | 24 | entry, ok := getEntryByPath(shell, path) 25 | if !ok { 26 | shell.Printf("could not retrieve entry at path '%s'\n", path) 27 | return 28 | } 29 | 30 | shell.Println(entry.Output(fullMode)) 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /internal/commands/edit.go: -------------------------------------------------------------------------------- 1 | package commands 2 | 3 | import ( 4 | "strings" 5 | "time" 6 | 7 | "github.com/mostfunkyduck/ishell" 8 | ) 9 | 10 | func Edit(shell *ishell.Shell) (f func(c *ishell.Context)) { 11 | return func(c *ishell.Context) { 12 | if errString, ok := syntaxCheck(c, 1); !ok { 13 | shell.Println(errString) 14 | return 15 | } 16 | path := strings.Join(c.Args, " ") 17 | entry, ok := getEntryByPath(shell, path) 18 | if !ok { 19 | shell.Printf("couldn't find entry '%s'\n", path) 20 | return 21 | } 22 | shell.ShowPrompt(false) 23 | if err := promptForEntry(shell, entry, entry.Title()); err != nil { 24 | shell.Printf("couldn't edit entry: %s\n", err) 25 | } 26 | entry.SetLastModificationTime(time.Now()) 27 | 28 | shell.ShowPrompt(true) 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /internal/commands/saveas.go: -------------------------------------------------------------------------------- 1 | package commands 2 | 3 | import ( 4 | "github.com/mostfunkyduck/ishell" 5 | t "github.com/mostfunkyduck/kp/internal/backend/types" 6 | ) 7 | 8 | func SaveAs(shell *ishell.Shell) (f func(c *ishell.Context)) { 9 | return func(c *ishell.Context) { 10 | errString, ok := syntaxCheck(c, 1) 11 | if !ok { 12 | shell.Println(errString) 13 | return 14 | } 15 | 16 | savePath := c.Args[0] 17 | 18 | if !confirmOverwrite(shell, savePath) { 19 | shell.Println("not overwriting existing file") 20 | return 21 | } 22 | 23 | db := shell.Get("db").(t.Database) 24 | 25 | oldPath := db.SavePath() 26 | 27 | db.SetSavePath(savePath) 28 | if err := db.Save(); err != nil { 29 | shell.Printf("could not save database: %s\n", err) 30 | } 31 | 32 | db.SetSavePath(oldPath) 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /internal/backend/keepassv2/database_test.go: -------------------------------------------------------------------------------- 1 | package keepassv2_test 2 | 3 | import ( 4 | "regexp" 5 | "testing" 6 | ) 7 | 8 | func TestDbPath(t *testing.T) { 9 | r := createTestResources(t) 10 | path, err := r.Db.Path() 11 | if err != nil { 12 | t.Fatalf(err.Error()) 13 | } 14 | 15 | expected := "/" 16 | if path != expected { 17 | t.Fatalf("[%s] != [%s]", path, expected) 18 | } 19 | 20 | r.Db.SetCurrentLocation(r.Group) 21 | 22 | path, err = r.Db.Path() 23 | if err != nil { 24 | t.Fatalf(err.Error()) 25 | } 26 | 27 | expected += r.Group.Name() + "/" 28 | if path != expected { 29 | t.Fatalf("[%s] != [%s]", path, expected) 30 | } 31 | } 32 | 33 | func TestDBSearch(t *testing.T) { 34 | r := createTestResources(t) 35 | paths, err := r.Db.Search(regexp.MustCompile(r.Group.Name())) 36 | if err != nil { 37 | t.Fatal(err) 38 | } 39 | if len(paths) != 1 { 40 | t.Fatalf("%v", paths) 41 | } 42 | 43 | paths, err = r.Db.Search(regexp.MustCompile(r.Entry.Title())) 44 | if err != nil { 45 | t.Fatal(err) 46 | } 47 | if len(paths) != 1 { 48 | t.Fatalf("%v", paths) 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /internal/commands/ls_test.go: -------------------------------------------------------------------------------- 1 | package commands_test 2 | 3 | import ( 4 | "strings" 5 | "testing" 6 | 7 | main "github.com/mostfunkyduck/kp/internal/commands" 8 | ) 9 | 10 | // Tests ls within a group that contains a subgroup and an entry 11 | func TestLsNoArgsFromGroup(t *testing.T) { 12 | r := createTestResources(t) 13 | r.Context.Args = []string{} 14 | _, _ = r.Group.NewSubgroup("test") 15 | r.Db.SetCurrentLocation(r.Group) 16 | main.Ls(r.Shell)(r.Context) 17 | expected := "=== Groups ===\ntest/\n\n=== Entries ===\n0: test\n" 18 | if !strings.Contains(r.F.outputHolder.output, expected) { 19 | t.Fatalf("[%s] does not contain [%s]", r.F.outputHolder.output, expected) 20 | } 21 | } 22 | 23 | func TestLsEntryFromRoot(t *testing.T) { 24 | r := createTestResources(t) 25 | path, err := r.Entry.Path() 26 | if err != nil { 27 | t.Fatalf(err.Error()) 28 | } 29 | r.Context.Args = []string{path} 30 | r.Db.SetCurrentLocation(r.Db.Root()) 31 | main.Ls(r.Shell)(r.Context) 32 | if r.F.outputHolder.output != "test\n" { 33 | t.Fatalf("[%s] does not contain [%s]", r.F.outputHolder.output, "test") 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /internal/backend/keepassv1/database_test.go: -------------------------------------------------------------------------------- 1 | package keepassv1_test 2 | 3 | import ( 4 | "testing" 5 | 6 | v1 "github.com/mostfunkyduck/kp/internal/backend/keepassv1" 7 | ) 8 | 9 | func TestSavePath(t *testing.T) { 10 | sp := "adsfasdfjkalskdfj" 11 | db := &v1.Database{} 12 | 13 | db.SetSavePath(sp) 14 | dbSp := db.SavePath() 15 | if sp != dbSp { 16 | t.Errorf("%s != %s", sp, dbSp) 17 | } 18 | } 19 | 20 | func TestCurrentLocation(t *testing.T) { 21 | r := createTestResources(t) 22 | expectedName := "asdf" 23 | newGroup, err := r.Group.NewSubgroup(expectedName) 24 | if err != nil { 25 | t.Fatalf(err.Error()) 26 | } 27 | r.Db.SetCurrentLocation(newGroup) 28 | l := r.Db.CurrentLocation() 29 | if l == nil { 30 | t.Fatalf("could not retrieve current location") 31 | } 32 | name := l.Name() 33 | if name != expectedName { 34 | t.Fatalf("%s != %s", name, expectedName) 35 | } 36 | 37 | } 38 | 39 | func TestBinaries(t *testing.T) { 40 | r := createTestResources(t) 41 | b, _ := r.Db.Binary(10000, "blork blork") 42 | if b.Present { 43 | t.Fatal("got a binary from the v1 DB, this is not supported by v1, so it's a mystery to me") 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /internal/commands/cd.go: -------------------------------------------------------------------------------- 1 | package commands 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/mostfunkyduck/ishell" 7 | t "github.com/mostfunkyduck/kp/internal/backend/types" 8 | ) 9 | 10 | func Cd(shell *ishell.Shell) (f func(c *ishell.Context)) { 11 | return func(c *ishell.Context) { 12 | db := shell.Get("db").(t.Database) 13 | args := c.Args 14 | currentLocation := db.CurrentLocation() 15 | if len(c.Args) == 0 { 16 | currentLocation = db.Root() 17 | } else { 18 | newLocation, entry, err := TraversePath(db, currentLocation, args[0]) 19 | if err != nil { 20 | shell.Println(fmt.Sprintf("invalid path: %s", err)) 21 | return 22 | } 23 | 24 | if entry != nil { 25 | shell.Printf("'%s' is an entry, not a group\n", args[0]) 26 | return 27 | } 28 | currentLocation = newLocation 29 | } 30 | changeDirectory(db, currentLocation, shell) 31 | } 32 | } 33 | 34 | func changeDirectory(db t.Database, newLocation t.Group, shell *ishell.Shell) { 35 | db.SetCurrentLocation(newLocation) 36 | path, err := db.Path() 37 | if err != nil { 38 | shell.Println("could not render DB path: %s\n", err) 39 | return 40 | } 41 | shell.SetPrompt(fmt.Sprintf("%s > ", path)) 42 | } 43 | -------------------------------------------------------------------------------- /internal/backend/keepassv1/shared_group_test.go: -------------------------------------------------------------------------------- 1 | package keepassv1_test 2 | 3 | import ( 4 | "testing" 5 | 6 | runner "github.com/mostfunkyduck/kp/internal/backend/tests" 7 | ) 8 | 9 | func TestNestedSubGroupPath(t *testing.T) { 10 | r := createTestResources(t) 11 | runner.RunTestNestedSubGroupPath(t, r) 12 | } 13 | 14 | func TestDoubleNestedGroupPath(t *testing.T) { 15 | r := createTestResources(t) 16 | runner.RunTestDoubleNestedGroupPath(t, r) 17 | } 18 | 19 | func TestGroupParentFunctions(t *testing.T) { 20 | r := createTestResources(t) 21 | runner.RunTestGroupParentFunctions(t, r) 22 | } 23 | 24 | func TestGroupUniqueness(t *testing.T) { 25 | r := createTestResources(t) 26 | runner.RunTestGroupUniqueness(t, r) 27 | } 28 | 29 | func TestRemoveSubgroup(t *testing.T) { 30 | r := createTestResources(t) 31 | runner.RunTestRemoveSubgroup(t, r) 32 | } 33 | 34 | func TestGroupEntryFuncs(t *testing.T) { 35 | r := createTestResources(t) 36 | runner.RunTestGroupEntryFuncs(t, r) 37 | } 38 | 39 | func TestSubgroupSearch(t *testing.T) { 40 | r := createTestResources(t) 41 | runner.RunTestSubgroupSearch(t, r) 42 | } 43 | 44 | func TestIsRoot(t *testing.T) { 45 | r := createTestResources(t) 46 | runner.RunTestIsRoot(t, r) 47 | } 48 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | This is free and unencumbered software released into the public domain. 2 | 3 | Anyone is free to copy, modify, publish, use, compile, sell, or 4 | distribute this software, either in source code form or as a compiled 5 | binary, for any purpose, commercial or non-commercial, and by any 6 | means. 7 | 8 | In jurisdictions that recognize copyright laws, the author or authors 9 | of this software dedicate any and all copyright interest in the 10 | software to the public domain. We make this dedication for the benefit 11 | of the public at large and to the detriment of our heirs and 12 | successors. We intend this dedication to be an overt act of 13 | relinquishment in perpetuity of all present and future rights to this 14 | software under copyright law. 15 | 16 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 17 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 18 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. 19 | IN NO EVENT SHALL THE AUTHORS BE LIABLE FOR ANY CLAIM, DAMAGES OR 20 | OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, 21 | ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR 22 | OTHER DEALINGS IN THE SOFTWARE. 23 | 24 | For more information, please refer to 25 | -------------------------------------------------------------------------------- /internal/commands/cd_test.go: -------------------------------------------------------------------------------- 1 | package commands_test 2 | 3 | import ( 4 | "testing" 5 | 6 | main "github.com/mostfunkyduck/kp/internal/commands" 7 | ) 8 | 9 | func TestCdToGroup(t *testing.T) { 10 | r := createTestResources(t) 11 | r.Context.Args = []string{ 12 | r.Group.Name(), 13 | } 14 | 15 | r.Db.SetCurrentLocation(r.Db.Root()) 16 | main.Cd(r.Shell)(r.Context) 17 | 18 | currentLocation := r.Db.CurrentLocation() 19 | clPath, err := currentLocation.Path() 20 | if err != nil { 21 | t.Fatalf(err.Error()) 22 | } 23 | 24 | rGrpPath, err := r.Group.Path() 25 | if err != nil { 26 | t.Fatalf(err.Error()) 27 | } 28 | if clPath != rGrpPath { 29 | t.Fatalf("new location was not the one specified: %s != %s", clPath, rGrpPath) 30 | } 31 | } 32 | 33 | func TestCdToRoot(t *testing.T) { 34 | r := createTestResources(t) 35 | r.Context.Args = []string{} 36 | 37 | r.Db.SetCurrentLocation(r.Group) 38 | main.Cd(r.Shell)(r.Context) 39 | 40 | currentLocation := r.Db.CurrentLocation() 41 | clPath, err := currentLocation.Path() 42 | if err != nil { 43 | t.Fatalf(err.Error()) 44 | } 45 | rDbPath, err := r.Db.Root().Path() 46 | if err != nil { 47 | t.Fatalf(err.Error()) 48 | } 49 | if clPath != rDbPath { 50 | t.Fatalf("new location was not the one specified: %s != %s", clPath, rDbPath) 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /internal/commands/ls.go: -------------------------------------------------------------------------------- 1 | package commands 2 | 3 | import ( 4 | "fmt" 5 | "strings" 6 | 7 | "github.com/mostfunkyduck/ishell" 8 | t "github.com/mostfunkyduck/kp/internal/backend/types" 9 | ) 10 | 11 | func Ls(shell *ishell.Shell) (f func(c *ishell.Context)) { 12 | return func(c *ishell.Context) { 13 | db := shell.Get("db").(t.Database) 14 | currentLocation := db.CurrentLocation() 15 | location := currentLocation 16 | if len(c.Args) > 0 { 17 | path := strings.Join(c.Args, " ") 18 | newLocation, entry, err := TraversePath(db, currentLocation, path) 19 | if err != nil { 20 | shell.Printf("invalid path: %s\n", err) 21 | return 22 | } 23 | 24 | // if this is the path to an entry, just output that and be done with it 25 | if entry != nil { 26 | shell.Printf("%s\n", entry.Title()) 27 | return 28 | } 29 | 30 | location = newLocation 31 | } 32 | 33 | lines := []string{} 34 | lines = append(lines, "=== Groups ===") 35 | for _, group := range location.Groups() { 36 | lines = append(lines, fmt.Sprintf("%s/", group.Name())) 37 | } 38 | 39 | lines = append(lines, "\n=== Entries ===") 40 | for i, entry := range location.Entries() { 41 | lines = append(lines, fmt.Sprintf("%d: %s", i, entry.Title())) 42 | } 43 | for _, line := range lines { 44 | shell.Println(line) 45 | } 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/mostfunkyduck/kp 2 | 3 | go 1.21 4 | 5 | require ( 6 | github.com/AlecAivazis/survey/v2 v2.3.6 7 | github.com/abiosoft/readline v0.0.0-20180607040430-155bce2042db 8 | github.com/atotto/clipboard v0.1.4 9 | github.com/sethvargo/go-password v0.2.0 10 | github.com/tobischo/gokeepasslib/v3 v3.1.0 // cannot be upgraded past v3.1.0 due to a bug in encoding 11 | zombiezen.com/go/sandpass v1.1.0 12 | ) 13 | 14 | require ( 15 | github.com/mostfunkyduck/ishell v0.0.0-20230416142217-6b0f1edba07f 16 | golang.org/x/text v0.21.0 17 | ) 18 | 19 | require ( 20 | github.com/aead/argon2 v0.0.0-20180111183520-a87724528b07 // indirect 21 | github.com/aead/chacha20 v0.0.0-20180709150244-8b13a72661da // indirect 22 | github.com/chzyer/logex v1.2.1 // indirect 23 | github.com/fatih/color v1.15.0 // indirect 24 | github.com/flynn-archive/go-shlex v0.0.0-20150515145356-3f9db97f8568 // indirect 25 | github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 // indirect 26 | github.com/mattn/go-colorable v0.1.13 // indirect 27 | github.com/mattn/go-isatty v0.0.18 // indirect 28 | github.com/mgutz/ansi v0.0.0-20200706080929-d51e80ef957d // indirect 29 | golang.org/x/crypto v0.31.0 // indirect 30 | golang.org/x/sys v0.28.0 // indirect 31 | golang.org/x/term v0.27.0 // indirect 32 | ) 33 | 34 | replace zombiezen.com/go/sandpass => github.com/mostfunkyduck/sandpass v1.1.1-0.20200617090953-4e7550e75911 35 | -------------------------------------------------------------------------------- /internal/commands/search.go: -------------------------------------------------------------------------------- 1 | package commands 2 | 3 | import ( 4 | "regexp" 5 | 6 | "github.com/mostfunkyduck/ishell" 7 | t "github.com/mostfunkyduck/kp/internal/backend/types" 8 | ) 9 | 10 | // This implements the equivalent of kpcli's "find" command, just with a name 11 | // that won't be confused for the shell command of the same name 12 | func Search(shell *ishell.Shell) (f func(c *ishell.Context)) { 13 | return func(c *ishell.Context) { 14 | currentLocation := shell.Get("db").(t.Database).Root() 15 | errString, ok := syntaxCheck(c, 1) 16 | if !ok { 17 | shell.Println(errString) 18 | return 19 | } 20 | 21 | term, err := regexp.Compile(c.Args[0]) 22 | if err != nil { 23 | shell.Printf("could not compile search term into a regular expression: %s", err) 24 | return 25 | } 26 | 27 | // kpcli makes a fake group for search results, which gets into trouble when entries have the same name in different paths 28 | // this takes a different approach of printing out full paths and letting the user type them in later 29 | // a little more typing for the user, less oddness in the implementation though 30 | searchResults, err := currentLocation.Search(term) 31 | if err != nil { 32 | shell.Println("error during search: " + err.Error()) 33 | return 34 | } 35 | for _, result := range searchResults { 36 | // the tab makes it a little more readable 37 | shell.Printf("\t%s\n", result) 38 | } 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /internal/backend/common/backend.go: -------------------------------------------------------------------------------- 1 | package common 2 | 3 | import ( 4 | "errors" 5 | "fmt" 6 | "os" 7 | ) 8 | 9 | type Backend struct { 10 | hash string 11 | filename string 12 | } 13 | 14 | func InitBackend(filename string) (*Backend, error) { 15 | if _, err := os.Stat(filename); errors.Is(err, os.ErrNotExist) { 16 | // file doesn't exist, so return an empty hash, let the upstream handle creating 17 | return &Backend{ 18 | filename: filename, 19 | hash: "", 20 | }, nil 21 | } 22 | hash, err := GenerateFileHash(filename) 23 | if err != nil { 24 | return &Backend{}, fmt.Errorf("could not generate backend hash: %s", err) 25 | } 26 | return &Backend{ 27 | filename: filename, 28 | hash: hash, 29 | }, nil 30 | } 31 | 32 | // IsModified determines whether or not the underlying storage has been modified since the utility was opened, indicating that something will get stomped 33 | func (b Backend) IsModified() (bool, error) { 34 | if b.Hash() == "" { 35 | // this is a new file, consider it unmodified 36 | return false, nil 37 | } 38 | hash, err := GenerateFileHash(b.Filename()) 39 | if err != nil { 40 | return false, fmt.Errorf("could not generate hash of filename '%s': %s", b.filename, err) 41 | } 42 | return hash != b.Hash(), nil 43 | } 44 | 45 | // Accessor functions for private variables 46 | func (b Backend) Filename() string { 47 | return b.filename 48 | } 49 | 50 | func (b Backend) Hash() string { 51 | return b.hash 52 | } 53 | -------------------------------------------------------------------------------- /internal/backend/keepassv1/group_test.go: -------------------------------------------------------------------------------- 1 | package keepassv1_test 2 | 3 | import ( 4 | "strconv" 5 | "testing" 6 | 7 | v1 "github.com/mostfunkyduck/kp/internal/backend/keepassv1" 8 | "zombiezen.com/go/sandpass/pkg/keepass" 9 | ) 10 | 11 | func TestGroupFunctions(t *testing.T) { 12 | ttlEntries := 50 13 | testName := "test name" 14 | db, err := keepass.New(&keepass.Options{}) 15 | if err != nil { 16 | t.Fatalf(err.Error()) 17 | } 18 | group := db.Root().NewSubgroup() 19 | group.Name = testName 20 | 21 | for i := 0; i < ttlEntries; i++ { 22 | e, err := group.NewEntry() 23 | if err != nil { 24 | t.Fatalf(err.Error()) 25 | } 26 | e.Title = "entry #" + strconv.Itoa(i) 27 | g := group.NewSubgroup() 28 | g.Name = "group #" + strconv.Itoa(i) 29 | } 30 | 31 | groupWrapper := v1.WrapGroup(group, &v1.Database{}) 32 | // assuming stable ordering because the shell is premised on that for path traversal 33 | // (if the entries and groups change order, the user can't specify which one to change properly) 34 | for i, each := range groupWrapper.Groups() { 35 | name := "group #" + strconv.Itoa(i) 36 | if each.Name() != name { 37 | t.Errorf("%s != %s", each.Name(), name) 38 | } 39 | if each.Parent() == nil { 40 | t.Errorf("group %s had no parent!", each.Name()) 41 | } else if each.Parent().Name() != testName { 42 | t.Errorf("parent name was incorrect for %s: %s", each.Name(), testName) 43 | } 44 | } 45 | 46 | for i, each := range groupWrapper.Entries() { 47 | name := "entry #" + strconv.Itoa(i) 48 | title := each.Title() 49 | if title != name { 50 | t.Errorf("%s != %s", title, name) 51 | } 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /internal/backend/keepassv2/group_test.go: -------------------------------------------------------------------------------- 1 | package keepassv2_test 2 | 3 | import ( 4 | "testing" 5 | 6 | runner "github.com/mostfunkyduck/kp/internal/backend/tests" 7 | ) 8 | 9 | func TestNestedSubGroupPath(t *testing.T) { 10 | r := createTestResources(t) 11 | runner.RunTestNestedSubGroupPath(t, r) 12 | } 13 | 14 | func TestDoubleNestedGroupPath(t *testing.T) { 15 | r := createTestResources(t) 16 | runner.RunTestDoubleNestedGroupPath(t, r) 17 | } 18 | 19 | func TestPathOnOrphanedGroup(t *testing.T) { 20 | r := createTestResources(t) 21 | if err := r.Db.Root().RemoveSubgroup(r.Group); err != nil { 22 | t.Fatalf(err.Error()) 23 | } 24 | 25 | // if the path is obtained from root, there will be a preceding slash 26 | // otherwise, no slash 27 | if path, err := r.Group.Path(); path != r.Group.Name()+"/" { 28 | t.Fatalf("orphaned group somehow had a path: %s: %s", path, err) 29 | } 30 | 31 | } 32 | 33 | func TestGroupParentFunctions(t *testing.T) { 34 | r := createTestResources(t) 35 | runner.RunTestGroupParentFunctions(t, r) 36 | } 37 | 38 | func TestGroupUniqueness(t *testing.T) { 39 | r := createTestResources(t) 40 | runner.RunTestGroupUniqueness(t, r) 41 | } 42 | 43 | func TestRemoveSubgroup(t *testing.T) { 44 | r := createTestResources(t) 45 | runner.RunTestRemoveSubgroup(t, r) 46 | } 47 | 48 | func TestGroupEntryFuncs(t *testing.T) { 49 | r := createTestResources(t) 50 | runner.RunTestGroupEntryFuncs(t, r) 51 | } 52 | 53 | func TestSubgroupSearch(t *testing.T) { 54 | r := createTestResources(t) 55 | runner.RunTestSubgroupSearch(t, r) 56 | } 57 | 58 | func TestIsRoot(t *testing.T) { 59 | r := createTestResources(t) 60 | runner.RunTestIsRoot(t, r) 61 | } 62 | -------------------------------------------------------------------------------- /internal/backend/keepassv1/entry_test.go: -------------------------------------------------------------------------------- 1 | package keepassv1_test 2 | 3 | import ( 4 | "os" 5 | "regexp" 6 | "testing" 7 | 8 | v1 "github.com/mostfunkyduck/kp/internal/backend/keepassv1" 9 | "github.com/mostfunkyduck/kp/internal/backend/types" 10 | "zombiezen.com/go/sandpass/pkg/keepass" 11 | ) 12 | 13 | func TestTitle(t *testing.T) { 14 | title := "test" 15 | e := &keepass.Entry{Title: title} 16 | wrapper := v1.WrapEntry(e, &v1.Database{}) 17 | wrapperTitle := wrapper.Title() 18 | if wrapperTitle != title { 19 | t.Fatalf("%s != %s", title, wrapperTitle) 20 | } 21 | } 22 | 23 | func TestEntrySearch(t *testing.T) { 24 | title := "TestEntrySearch" 25 | tmpfile, err := os.CreateTemp("", "kp_unit_tests") 26 | if err != nil { 27 | t.Fatalf("could not create temp file for DB: %s", tmpfile.Name()) 28 | } 29 | tmpfile.Close() 30 | os.Remove(tmpfile.Name()) 31 | defer os.Remove(tmpfile.Name()) 32 | 33 | dbWrapper := v1.Database{} 34 | dbOptions := types.Options{ 35 | DBPath: tmpfile.Name(), 36 | KeyRounds: 1, 37 | } 38 | if err := dbWrapper.Init(dbOptions); err != nil { 39 | t.Fatal(err) 40 | } 41 | sg, err := dbWrapper.Root().NewSubgroup("DOESN'T MATCH") 42 | if err != nil { 43 | t.Fatal(err) 44 | } 45 | 46 | wrapper, err := sg.NewEntry(title) 47 | if err != nil { 48 | t.Fatal(err) 49 | } 50 | paths, err := wrapper.Search(regexp.MustCompile("TestEntry.*")) 51 | if err != nil { 52 | t.Fatal(err) 53 | } 54 | if len(paths) != 1 { 55 | t.Fatalf("%v", paths) 56 | } 57 | 58 | path, err := wrapper.Path() 59 | if err != nil { 60 | t.Fatalf(err.Error()) 61 | } 62 | if paths[0] != path { 63 | t.Fatalf("[%s] != [%s]", paths[0], path) 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /internal/commands/new.go: -------------------------------------------------------------------------------- 1 | package commands 2 | 3 | import ( 4 | "strings" 5 | "time" 6 | 7 | "github.com/mostfunkyduck/ishell" 8 | t "github.com/mostfunkyduck/kp/internal/backend/types" 9 | ) 10 | 11 | func NewEntry(shell *ishell.Shell) (f func(c *ishell.Context)) { 12 | return func(c *ishell.Context) { 13 | path := buildPath(c.Args) 14 | errString, ok := syntaxCheck(c, 1) 15 | if !ok { 16 | shell.Println(errString) 17 | return 18 | } 19 | if isPresent(shell, path) { 20 | shell.Printf("cannot create duplicate entity '%s'\n", path) 21 | return 22 | } 23 | 24 | db := shell.Get("db").(t.Database) 25 | 26 | pathBits := strings.Split(path, "/") 27 | parentPath := strings.Join(pathBits[0:len(pathBits)-1], "/") 28 | location, entry, err := TraversePath(db, db.CurrentLocation(), parentPath) 29 | if err != nil { 30 | shell.Println("invalid path: " + err.Error()) 31 | return 32 | } 33 | 34 | if entry != nil { 35 | shell.Printf("entry '%s' already exists!\n", entry.Title()) 36 | return 37 | } 38 | 39 | if location.IsRoot() { 40 | shell.Println("cannot add entries to root node") 41 | return 42 | } 43 | 44 | shell.ShowPrompt(false) 45 | entry, err = location.NewEntry(pathBits[len(pathBits)-1]) 46 | if err != nil { 47 | shell.Printf("error creating new entry: %s\n", err) 48 | return 49 | } 50 | entry.SetCreationTime(time.Now()) 51 | entry.SetLastModificationTime(time.Now()) 52 | entry.SetLastAccessTime(time.Now()) 53 | 54 | err = promptForEntry(shell, entry, entry.Title()) 55 | shell.ShowPrompt(true) 56 | if err != nil { 57 | shell.Printf("could not collect user input: %s\n", err) 58 | if err := location.RemoveEntry(entry); err != nil { 59 | shell.Printf("could not remove malformed entry from group: %s\n", err) 60 | } 61 | return 62 | } 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /internal/backend/keepassv2/util_test.go: -------------------------------------------------------------------------------- 1 | package keepassv2_test 2 | 3 | import ( 4 | "os" 5 | "testing" 6 | 7 | c "github.com/mostfunkyduck/kp/internal/backend/common" 8 | main "github.com/mostfunkyduck/kp/internal/backend/keepassv2" 9 | runner "github.com/mostfunkyduck/kp/internal/backend/tests" 10 | "github.com/mostfunkyduck/kp/internal/backend/types" 11 | g "github.com/tobischo/gokeepasslib/v3" 12 | ) 13 | 14 | func createTestResources(t *testing.T) runner.Resources { 15 | name := "test yo" 16 | groupName := "group" 17 | db := &main.Database{} 18 | tmpfile, err := os.CreateTemp("", "kp_unit_tests") 19 | if err != nil { 20 | t.Fatal(err) 21 | } 22 | fileName := tmpfile.Name() 23 | defer os.Remove(fileName) 24 | 25 | // remove the file before we init the DB so that it will create the file from scratch 26 | // we're really only creating the tempfile as a shortcut to generate an appropriate path 27 | tmpfile.Close() 28 | os.Remove(fileName) 29 | 30 | opts := types.Options{ 31 | DBPath: fileName, 32 | } 33 | if err := db.Init(opts); err != nil { 34 | t.Fatal(err) 35 | } 36 | newgrp := g.NewGroup() 37 | group := main.WrapGroup(&newgrp, db) 38 | group.SetName(groupName) 39 | if err := db.Root().AddSubgroup(group); err != nil { 40 | t.Fatal(err) 41 | } 42 | newEnt := g.NewEntry() 43 | entry := main.WrapEntry(&newEnt, db) 44 | if !entry.Set(c.NewValue( 45 | []byte(name), 46 | "Title", 47 | false, false, false, 48 | types.STRING, 49 | )) { 50 | t.Fatalf("could not set title") 51 | } 52 | if err := entry.SetParent(group); err != nil { 53 | t.Fatalf(err.Error()) 54 | } 55 | 56 | rawEnt := g.NewEntry() 57 | rawGrp := g.NewGroup() 58 | 59 | return runner.Resources{ 60 | Db: db, 61 | Group: group, 62 | Entry: entry, 63 | BlankEntry: main.WrapEntry(&rawEnt, db), 64 | BlankGroup: main.WrapGroup(&rawGrp, db), 65 | } 66 | } 67 | -------------------------------------------------------------------------------- /internal/commands/show_test.go: -------------------------------------------------------------------------------- 1 | package commands_test 2 | 3 | import ( 4 | "strings" 5 | "testing" 6 | 7 | "github.com/mostfunkyduck/ishell" 8 | c "github.com/mostfunkyduck/kp/internal/backend/common" 9 | "github.com/mostfunkyduck/kp/internal/backend/types" 10 | main "github.com/mostfunkyduck/kp/internal/commands" 11 | ) 12 | 13 | func testShowOutput(output string, substr string, t *testing.T) { 14 | if !strings.Contains(output, substr) { 15 | t.Errorf("output [%s] does not contain expected string [%s]", output, substr) 16 | } 17 | } 18 | 19 | // 'show' with no arguments should error out 20 | func TestShowNoArgs(t *testing.T) { 21 | r := createTestResources(t) 22 | r.Context.Args = []string{} 23 | cmd := ishell.Cmd{ 24 | Help: "test string", 25 | } 26 | r.Context.Cmd = cmd 27 | main.Show(r.Shell)(r.Context) 28 | expected := "syntax: " + r.Context.Cmd.Help + "\n" 29 | if r.F.outputHolder.output != expected { 30 | t.Fatalf("output was incorrect: %s != %s", r.F.outputHolder.output, expected) 31 | } 32 | } 33 | 34 | func TestShowValidArgs(t *testing.T) { 35 | r := createTestResources(t) 36 | path, err := r.Entry.Path() 37 | if err != nil { 38 | t.Fatalf(err.Error()) 39 | } 40 | r.Context.Args = []string{path} 41 | main.Show(r.Shell)(r.Context) 42 | 43 | testEntry(true, t, r) 44 | } 45 | 46 | func TestShowAttachment(t *testing.T) { 47 | r := createTestResources(t) 48 | r.Context.Args = []string{r.Path} 49 | att := c.Attachment{ 50 | EntryValue: c.NewValue( 51 | []byte("yaakov is cool"), 52 | "asdf", 53 | false, false, false, 54 | types.BINARY, 55 | )} 56 | 57 | r.Entry.Set(att) 58 | 59 | main.Show(r.Shell)(r.Context) 60 | 61 | testEntry(true, t, r) 62 | } 63 | 64 | func TestShowFullMode(t *testing.T) { 65 | r := createTestResources(t) 66 | r.Context.Args = []string{"-f", r.Path} 67 | r.Context.Flags = []string{"-f"} 68 | main.Show(r.Shell)(r.Context) 69 | testEntry(false, t, r) 70 | } 71 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | REVISION=`git show | head -1 | awk '{print $$NF}' | cut -c 1-5` 2 | HOSTNAME=`hostname` 3 | DATE=`date -u +%Y-%m-%d-%H-%M` 4 | BRANCH=`git branch 2>/dev/null | grep '\*' | sed "s/* //"` 5 | RELEASE=0.1.0 6 | 7 | .PHONY: test cscope install tidy fix lint testv1 testv2 vet 8 | 9 | # default to having lint be a prereq to build 10 | 11 | all: init lint nolint 12 | 13 | # sets up the git hooks in the repo 14 | init: 15 | git config core.hooksPath .githooks 16 | 17 | # allow nolint from when bad stuff creeps in and needs a separate commit 18 | nolint: test kp 19 | 20 | prepForDebug: kp 21 | test -d ./local_artifacts || mkdir ./local_artifacts 22 | 23 | kp: *.go internal/*/*.go internal/backend/*/*.go 24 | go build -mod=readonly -gcflags "-N -I ." -ldflags "-X main.VersionRevision=$(REVISION) -X main.VersionBuildDate=$(DATE) -X main.VersionBuildTZ=UTC -X main.VersionBranch=$(BRANCH) -X main.VersionRelease=$(RELEASE) -X main.VersionHostname=$(HOSTNAME)" 25 | 26 | modtidy: 27 | go mod tidy 28 | 29 | # non blocking linter run that fixes mistakes 30 | fix: modtidy 31 | ./scripts/lint.sh fix 32 | 33 | lint: 34 | ./scripts/lint.sh || exit 1 35 | 36 | install: kp 37 | install ./kp $(HOME)/.local/bin 38 | # allow testing v1 and v2 separately or together 39 | coveragecmd := -coverprofile coverage.out -coverpkg=./internal/commands,./internal/backend/types,./internal/backend/common 40 | internalpkgs := ./internal/commands 41 | testv1: 42 | KPVERSION=1 go test -mod=readonly $(internalpkgs) ./internal/backend/keepassv1 $(coveragecmd) 43 | 44 | testv2: 45 | KPVERSION=2 go test -mod=readonly $(internalpkgs) ./internal/backend/keepassv2 $(coveragecmd) 46 | 47 | test: testv1 testv2 48 | 49 | # quick command to vet the entire source tree, need to enumerate all targets because of linter pickiness 50 | vet: 51 | go vet . ./internal/commands ./internal/backend/types ./internal/backend/keepassv1 ./internal/backend/keepassv2 ./internal/backend/common ./internal/backend/tests 52 | -------------------------------------------------------------------------------- /internal/backend/keepassv1/util_test.go: -------------------------------------------------------------------------------- 1 | package keepassv1_test 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | "testing" 7 | 8 | v1 "github.com/mostfunkyduck/kp/internal/backend/keepassv1" 9 | runner "github.com/mostfunkyduck/kp/internal/backend/tests" 10 | t "github.com/mostfunkyduck/kp/internal/backend/types" 11 | ) 12 | 13 | func initDatabase() (t.Database, error) { 14 | dbWrapper := &v1.Database{} 15 | // yes, unit tests should avoid the file system. baby steps. 16 | tmpfile, err := os.CreateTemp("", "kp_unit_tests") 17 | if err != nil { 18 | return dbWrapper, fmt.Errorf("could not create temp file for DB: %s", tmpfile.Name()) 19 | } 20 | tmpfile.Close() 21 | os.Remove(tmpfile.Name()) 22 | defer os.Remove(tmpfile.Name()) 23 | 24 | dbOptions := t.Options{ 25 | DBPath: tmpfile.Name(), 26 | // each 'key round' takes quite a while, make sure to use the minimum 27 | KeyRounds: 1, 28 | } 29 | if err := dbWrapper.Init(dbOptions); err != nil { 30 | return dbWrapper, fmt.Errorf("could not init db with provided options: %s", err) 31 | } 32 | return dbWrapper, nil 33 | } 34 | 35 | func createTestResources(t *testing.T) runner.Resources { 36 | dbWrapper, err := initDatabase() 37 | if err != nil { 38 | t.Fatal(err) 39 | } 40 | sg, err := dbWrapper.Root().NewSubgroup("asdf asdf asdf test") 41 | if err != nil { 42 | t.Fatalf(err.Error()) 43 | } 44 | 45 | entry, err := sg.NewEntry("test test test") 46 | if err != nil { 47 | t.Fatalf(err.Error()) 48 | } 49 | 50 | blankGroup, err := dbWrapper.Root().NewSubgroup("") 51 | if err != nil { 52 | t.Fatalf(err.Error()) 53 | } 54 | blankEntry, err := blankGroup.NewEntry("") 55 | if err != nil { 56 | t.Fatalf(err.Error()) 57 | } 58 | // NOTE this library doesn't support blank objects, so the blanks are just separate groups 59 | return runner.Resources{ 60 | Db: dbWrapper, 61 | Entry: entry, 62 | Group: sg, 63 | BlankEntry: blankEntry, 64 | BlankGroup: blankGroup, 65 | } 66 | } 67 | -------------------------------------------------------------------------------- /internal/backend/common/util.go: -------------------------------------------------------------------------------- 1 | package common 2 | 3 | import ( 4 | "crypto/md5" 5 | "fmt" 6 | t "github.com/mostfunkyduck/kp/internal/backend/types" 7 | "io" 8 | "os" 9 | "time" 10 | ) 11 | 12 | func CompareUUIDs(me t.UUIDer, them t.UUIDer) (bool, error) { 13 | myUUID, err := me.UUIDString() 14 | if err != nil { 15 | return false, err 16 | } 17 | 18 | theirUUID, err := them.UUIDString() 19 | if err != nil { 20 | return false, err 21 | } 22 | 23 | return theirUUID == myUUID, nil 24 | } 25 | 26 | func FormatTime(t time.Time) (formatted string) { 27 | timeFormat := "Mon Jan 2 15:04:05 MST 2006" 28 | if (t == time.Time{}) { 29 | formatted = "unknown" 30 | } else { 31 | since := time.Since(t).Round(time.Duration(1) * time.Second) 32 | sinceString := since.String() 33 | 34 | // greater than or equal to 1 day 35 | if since.Hours() >= 24 { 36 | sinceString = fmt.Sprintf("%d day(s) ago", int(since.Hours()/24)) 37 | } 38 | 39 | // greater than or equal to ~1 month 40 | if since.Hours() >= 720 { 41 | // rough estimate, not accounting for non-30-day months 42 | months := int(since.Hours() / 720) 43 | sinceString = fmt.Sprintf("about %d month(s) ago", months) 44 | } 45 | 46 | // greater or equal to 1 year 47 | if since.Hours() >= 8760 { 48 | // yes yes yes, leap years aren't 365 days long 49 | years := int(since.Hours() / 8760) 50 | sinceString = fmt.Sprintf("about %d year(s) ago", years) 51 | } 52 | 53 | // less than a second 54 | if since.Seconds() < 1.0 { 55 | sinceString = "less than a second ago" 56 | } 57 | 58 | formatted = fmt.Sprintf("%s (%s)", t.Local().Format(timeFormat), sinceString) 59 | } 60 | return 61 | } 62 | 63 | func GenerateFileHash(filename string) (hash string, err error) { 64 | file, err := os.Open(filename) 65 | if err != nil { 66 | return "", fmt.Errorf("could not open file '%s': %s", filename, err) 67 | } 68 | 69 | defer file.Close() 70 | 71 | hasher := md5.New() 72 | _, err = io.Copy(hasher, file) 73 | 74 | if err != nil { 75 | return "", fmt.Errorf("could not hash file '%s': %s", filename, err) 76 | } 77 | 78 | return string(hasher.Sum(nil)), nil 79 | } 80 | -------------------------------------------------------------------------------- /internal/commands/mkdir.go: -------------------------------------------------------------------------------- 1 | package commands 2 | 3 | import ( 4 | "regexp" 5 | "strings" 6 | 7 | "github.com/mostfunkyduck/ishell" 8 | t "github.com/mostfunkyduck/kp/internal/backend/types" 9 | ) 10 | 11 | func NewGroup(shell *ishell.Shell) (f func(c *ishell.Context)) { 12 | return func(c *ishell.Context) { 13 | errString, ok := syntaxCheck(c, 1) 14 | if !ok { 15 | shell.Println(errString) 16 | return 17 | } 18 | 19 | // We need to remove the final slash as it will confuse the parser 20 | finalSlashRE := regexp.MustCompile(`/$`) 21 | newPath := string(finalSlashRE.ReplaceAll([]byte(c.Args[0]), []byte(""))) 22 | if isPresent(shell, newPath) { 23 | shell.Printf("cannot create duplicate entity '%s'\n", newPath) 24 | return 25 | } 26 | 27 | db := shell.Get("db").(t.Database) 28 | // the user may enter a path, either absolute or relative, in which case 29 | // we need to crawl to that location to make the new entry 30 | 31 | // If the path passed in has a slash, that means we need to crawl, otherwise 32 | // just stay put by 'crawling' to '.' 33 | targetPath := "." 34 | 35 | // if the path doesn't have a slash in it, then it represents the group name 36 | groupName := newPath 37 | if strings.Contains(newPath, "/") { 38 | // to reach the correct location, trim off everything after 39 | // the last slash and crawl to the path represented by what's left over 40 | r := regexp.MustCompile(`(?P.*)/(?P[^/]*)$`) 41 | matches := r.FindStringSubmatch(newPath) 42 | targetPath = string(matches[1]) 43 | // save off the group name for later use 44 | groupName = string(matches[2]) 45 | } 46 | 47 | // use TraversePath to crawl to the target path 48 | location, _, err := TraversePath(db, db.CurrentLocation(), targetPath) 49 | if err != nil { 50 | shell.Println("invalid path: " + err.Error()) 51 | return 52 | } 53 | 54 | l, err := location.NewSubgroup(groupName) 55 | if err != nil { 56 | shell.Printf("could not create subgroup: %s\n", err) 57 | return 58 | } 59 | 60 | p, err := l.Path() 61 | if err != nil { 62 | shell.Printf("error getting path: %s\n", p) 63 | } 64 | 65 | shell.Printf("new location: %s\n", p) 66 | 67 | if err := PromptAndSave(shell); err != nil { 68 | shell.Printf("could not save database: %s\n", err) 69 | } 70 | } 71 | } 72 | -------------------------------------------------------------------------------- /internal/commands/select.go: -------------------------------------------------------------------------------- 1 | package commands 2 | 3 | import ( 4 | "strings" 5 | 6 | "github.com/mostfunkyduck/ishell" 7 | // because ishell's checklist isn't rendering properly, at least on WSL 8 | "github.com/AlecAivazis/survey/v2" 9 | ) 10 | 11 | func Select(shell *ishell.Shell) (f func(c *ishell.Context)) { 12 | return func(c *ishell.Context) { 13 | if len(c.Args) < 1 { 14 | shell.Println("syntax: " + c.Cmd.Help) 15 | return 16 | } 17 | 18 | // FIXME (medium priority) make this use a library for arg parsing so that we can have 19 | // it select fields inline 20 | fullMode := false 21 | path := c.Args[0] 22 | for _, arg := range c.Args { 23 | if strings.HasPrefix(arg, "-") { 24 | if arg == "-f" { 25 | fullMode = true 26 | } 27 | continue 28 | } 29 | path = arg 30 | } 31 | 32 | entry, ok := getEntryByPath(shell, path) 33 | if !ok { 34 | shell.Printf("could not retrieve entry at path '%s'\n", path) 35 | return 36 | } 37 | 38 | // now, prepare the checklist of fields to select 39 | 40 | // what the actual options are 41 | options := []string{} 42 | 43 | // what field names we want selected by default (case insensitive) 44 | defaultsRaw := []string{"password"} 45 | 46 | // what the actual defaults will be 47 | defaultSelections := []string{} 48 | values, err := entry.Values() 49 | if err != nil { 50 | shell.Printf("error retrieving values for entry '%s': %s\n", entry.Title, err) 51 | return 52 | } 53 | for _, val := range values { 54 | options = append(options, val.Name()) 55 | for _, def := range defaultsRaw { 56 | if strings.EqualFold(def, val.Name()) { 57 | defaultSelections = append(defaultSelections, val.Name()) 58 | } 59 | } 60 | } 61 | selections := []string{} 62 | prompt := &survey.MultiSelect{ 63 | VimMode: true, // duh 64 | Message: "Select fields to display", 65 | Options: options, 66 | Default: defaultSelections, 67 | } 68 | if err := survey.AskOne(prompt, &selections); err != nil { 69 | shell.Printf("could not select fields: %s\n", err) 70 | return 71 | } 72 | 73 | for _, val := range selections { 74 | fullValue, present := entry.Get(val) 75 | if !present { 76 | shell.Printf("error retrieving value for %s\n", val) 77 | return 78 | } 79 | 80 | shell.Printf("%12s:\t%-12s\n", fullValue.Name(), fullValue.FormattedValue(fullMode)) 81 | } 82 | } 83 | } 84 | -------------------------------------------------------------------------------- /internal/backend/common/value.go: -------------------------------------------------------------------------------- 1 | package common 2 | 3 | import ( 4 | "fmt" 5 | "strings" 6 | 7 | t "github.com/mostfunkyduck/kp/internal/backend/types" 8 | "golang.org/x/text/cases" 9 | "golang.org/x/text/language" 10 | ) 11 | 12 | type EntryValue struct { 13 | value []byte 14 | name string 15 | searchable bool // indicates whether this value should be included in searches 16 | protected bool 17 | readOnly bool 18 | valueType t.ValueType 19 | } 20 | 21 | type Attachment struct { 22 | EntryValue 23 | } 24 | 25 | // NewValue initializes a value object 26 | func NewValue(value []byte, name string, searchable bool, protected bool, readOnly bool, valueType t.ValueType) EntryValue { 27 | return EntryValue{ 28 | value: value, 29 | name: name, 30 | searchable: searchable, 31 | protected: protected, 32 | readOnly: readOnly, 33 | valueType: valueType, 34 | } 35 | } 36 | 37 | func (a Attachment) FormattedValue(full bool) string { 38 | return fmt.Sprintf("binary: %d bytes", len(a.value)) 39 | } 40 | 41 | // FormattedValue returns the appropriately formatted value contents, with the `full` argument determining 42 | // whether protected values should be returned in cleartext 43 | func (v EntryValue) FormattedValue(full bool) string { 44 | 45 | if v.Protected() && !full { 46 | return "[protected]" 47 | } 48 | 49 | if v.Type() == t.LONGSTRING { 50 | value := string(v.Value()) 51 | // Long fields are going to need a line break so the first line isn't corrupted 52 | value = "\n" + value 53 | 54 | // Add indentations for all line breaks to differentiate note lines from field lines 55 | value = strings.ReplaceAll(value, "\n", "\n>\t") 56 | return value 57 | } 58 | return string(v.Value()) 59 | } 60 | 61 | func (v EntryValue) Value() []byte { 62 | return v.value 63 | } 64 | 65 | func (v EntryValue) NameTitle() string { 66 | return cases.Title(language.English, cases.NoLower).String(v.name) 67 | } 68 | 69 | func (v EntryValue) Output(showProtected bool) string { 70 | return fmt.Sprintf("%s:\t%s", v.NameTitle(), v.FormattedValue(showProtected)) 71 | } 72 | 73 | func (a Attachment) Output(showProtected bool) string { 74 | return fmt.Sprintf("Attachment:\n\tName:\t%s\n\tSize:\t%s", a.Name(), a.FormattedValue(showProtected)) 75 | } 76 | 77 | func (v EntryValue) Name() string { 78 | return v.name 79 | } 80 | 81 | func (v EntryValue) Searchable() bool { 82 | return v.searchable 83 | } 84 | 85 | func (v EntryValue) Protected() bool { 86 | return v.protected 87 | } 88 | 89 | func (v EntryValue) ReadOnly() bool { 90 | return v.readOnly 91 | } 92 | 93 | func (v EntryValue) Type() t.ValueType { 94 | return v.valueType 95 | } 96 | -------------------------------------------------------------------------------- /internal/backend/keepassv2/entry_test.go: -------------------------------------------------------------------------------- 1 | package keepassv2_test 2 | 3 | import ( 4 | "strings" 5 | "testing" 6 | 7 | c "github.com/mostfunkyduck/kp/internal/backend/common" 8 | main "github.com/mostfunkyduck/kp/internal/backend/keepassv2" 9 | runner "github.com/mostfunkyduck/kp/internal/backend/tests" 10 | "github.com/mostfunkyduck/kp/internal/backend/types" 11 | g "github.com/tobischo/gokeepasslib/v3" 12 | ) 13 | 14 | func TestNoParent(t *testing.T) { 15 | r := createTestResources(t) 16 | newEnt := g.NewEntry() 17 | r.Entry = main.WrapEntry(&newEnt, r.Db) 18 | 19 | runner.RunTestNoParent(t, r) 20 | } 21 | 22 | func TestNewEntry(t *testing.T) { 23 | r := createTestResources(t) 24 | newEnt, _ := r.Group.NewEntry("newentry") 25 | // loop through the default values and make sure that each of the expected ones are in there 26 | // this is indicated by flipping the bool in the expectedFields map to 'true', we will 27 | // then test that each field was set to 'true' during the loop 28 | expectedFields := map[string]bool{ 29 | "notes": false, 30 | "username": false, 31 | "password": false, 32 | "title": false, 33 | "url": false, 34 | } 35 | values, err := newEnt.Values() 36 | if err != nil { 37 | t.Fatal(err) 38 | } 39 | for _, val := range values { 40 | lcName := strings.ToLower(val.Name()) 41 | if _, present := expectedFields[lcName]; present { 42 | expectedFields[lcName] = true 43 | } 44 | } 45 | 46 | for k, v := range expectedFields { 47 | if !v { 48 | t.Fatalf("field [%s] was not present in new entry\n", k) 49 | } 50 | } 51 | } 52 | func TestRegularPath(t *testing.T) { 53 | r := createTestResources(t) 54 | runner.RunTestRegularPath(t, r) 55 | } 56 | 57 | func TestEntryGetSet(t *testing.T) { 58 | r := createTestResources(t) 59 | value := c.NewValue( 60 | []byte("test value"), 61 | "TestEntrySetGet", 62 | false, false, false, 63 | types.STRING, 64 | ) 65 | 66 | retVal, _ := r.BlankEntry.Get(value.Name()) 67 | if retVal != nil { 68 | t.Fatalf("%v", retVal) 69 | } 70 | if !r.BlankEntry.Set(value) { 71 | t.Fatalf("could not set value") 72 | } 73 | 74 | name := value.Name() 75 | entryValue, _ := r.BlankEntry.Get(name) 76 | if string(entryValue.Value()) != string(value.Value()) { 77 | t.Fatalf("[%s] != [%s], %v", entryValue, name, value) 78 | } 79 | 80 | secondValue := "asldkfj" 81 | newVal := c.NewValue( 82 | []byte(secondValue), 83 | value.Name(), 84 | value.Searchable(), 85 | value.Protected(), 86 | value.ReadOnly(), 87 | value.Type(), 88 | ) 89 | if !r.BlankEntry.Set(newVal) { 90 | t.Fatalf("could not overwrite value: %v", value) 91 | } 92 | 93 | entryValue, _ = r.BlankEntry.Get(value.Name()) 94 | if string(entryValue.Value()) != secondValue { 95 | t.Fatalf("[%s] != [%s] %v", entryValue, secondValue, value) 96 | } 97 | } 98 | 99 | func TestEntryTimeFuncs(t *testing.T) { 100 | r := createTestResources(t) 101 | runner.RunTestEntryTimeFuncs(t, r) 102 | } 103 | 104 | func TestEntryPasswordTitleFuncs(t *testing.T) { 105 | r := createTestResources(t) 106 | runner.RunTestEntryPasswordTitleFuncs(t, r) 107 | } 108 | 109 | func TestOutput(t *testing.T) { 110 | r := createTestResources(t) 111 | runner.RunTestOutput(t, r.Entry) 112 | } 113 | -------------------------------------------------------------------------------- /internal/backend/common/group.go: -------------------------------------------------------------------------------- 1 | package common 2 | 3 | import ( 4 | "fmt" 5 | "regexp" 6 | 7 | t "github.com/mostfunkyduck/kp/internal/backend/types" 8 | ) 9 | 10 | type Group struct { 11 | db t.Database 12 | driver t.Group 13 | } 14 | 15 | func (g *Group) Path() (rv string, err error) { 16 | if g.driver.IsRoot() { 17 | return "/", nil 18 | } 19 | pathGroups, err := FindPathToGroup(g.db.Root(), g.driver) 20 | if err != nil { 21 | return rv, fmt.Errorf("could not find path to group '%s'", g.driver.Name()) 22 | } 23 | for _, each := range pathGroups { 24 | rv = rv + each.Name() + "/" 25 | } 26 | return rv + g.driver.Name() + "/", nil 27 | } 28 | 29 | func FindPathToGroup(source t.Group, target t.Group) (rv []t.Group, err error) { 30 | // the v2 library doesn't appear to support child->parent links, so we have to find the needful ourselves 31 | 32 | // loop through every group in the top level of the path 33 | for _, group := range source.Groups() { 34 | same, err := CompareUUIDs(group, target) 35 | if err != nil { 36 | return []t.Group{}, fmt.Errorf("could not compare UUIDS: %s", err) 37 | } 38 | 39 | // If the group that we're looking at in the path is the target, 40 | // then the 'source' group at the top level is part of the final path 41 | if same { 42 | // this will essentially say that if the target group exists in the source group, build a path 43 | // to the source group 44 | ret := []t.Group{source} 45 | return ret, nil 46 | } 47 | 48 | // If the group is not the exact match, recurse into it looking for the target 49 | // If the target is in a subgroup tree here, the full path will end up being returned 50 | pathGroups, err := FindPathToGroup(group, target) 51 | if err != nil { 52 | return []t.Group{}, fmt.Errorf("could not find path from group '%s' to group '%s': %s", group.Name(), target.Name(), err) 53 | } 54 | 55 | // if the target group is a child of this group, return the full path 56 | if len(pathGroups) != 0 { 57 | ret := append([]t.Group{source}, pathGroups...) 58 | return ret, nil 59 | } 60 | } 61 | return []t.Group{}, nil 62 | } 63 | 64 | func (g *Group) DB() t.Database { 65 | return g.db 66 | } 67 | 68 | func (g *Group) SetDB(d t.Database) { 69 | g.db = d 70 | } 71 | 72 | // SetDriver sets pointer to the version of itself that can access child methods... FIXME this is a bit of a mind bender 73 | func (g *Group) SetDriver(gr t.Group) { 74 | g.driver = gr 75 | } 76 | 77 | func (g *Group) Search(term *regexp.Regexp) (paths []string, err error) { 78 | if term.FindString(g.driver.Name()) != "" { 79 | path, err := g.Path() 80 | if err == nil { 81 | // append slash so it's clear that it's a group, not an entry 82 | paths = append(paths, path) 83 | } 84 | } 85 | 86 | for _, e := range g.driver.Entries() { 87 | nestedSearch, err := e.Search(term) 88 | if err != nil { 89 | return []string{}, fmt.Errorf("search failed on entries: %s", err) 90 | } 91 | paths = append(paths, nestedSearch...) 92 | } 93 | 94 | for _, g := range g.driver.Groups() { 95 | nestedSearch, err := g.Search(term) 96 | if err != nil { 97 | return []string{}, fmt.Errorf("search failed while recursing into groups: %s", err) 98 | } 99 | paths = append(paths, nestedSearch...) 100 | } 101 | return paths, nil 102 | } 103 | -------------------------------------------------------------------------------- /internal/commands/rm.go: -------------------------------------------------------------------------------- 1 | package commands 2 | 3 | import ( 4 | "fmt" 5 | "strconv" 6 | "strings" 7 | 8 | "github.com/mostfunkyduck/ishell" 9 | t "github.com/mostfunkyduck/kp/internal/backend/types" 10 | ) 11 | 12 | // purgeGroup recursively removes all subgroups and entries from a group 13 | func purgeGroup(group t.Group) error { 14 | for _, e := range group.Entries() { 15 | if err := group.RemoveEntry(e); err != nil { 16 | return fmt.Errorf("could not remove entry '%s' from group '%s': %s", e.Title(), group.Name(), err) 17 | } 18 | } 19 | for _, g := range group.Groups() { 20 | if err := purgeGroup(g); err != nil { 21 | return fmt.Errorf("could not purge group %s: %s", g.Name(), err) 22 | } 23 | if err := group.RemoveSubgroup(g); err != nil { 24 | return fmt.Errorf("could not remove group %s: %s", g.Name(), err) 25 | } 26 | } 27 | return nil 28 | } 29 | 30 | func removeEntry(parentLocation t.Group, entryName string) error { 31 | for i, e := range parentLocation.Entries() { 32 | if e.Title() == entryName || strconv.Itoa(i) == entryName { 33 | if err := parentLocation.RemoveEntry(e); err != nil { 34 | return fmt.Errorf("could not remove entry: %s", err) 35 | } 36 | return nil 37 | } 38 | } 39 | return fmt.Errorf("could not find entry named '%s'", entryName) 40 | } 41 | 42 | func Rm(shell *ishell.Shell) (f func(c *ishell.Context)) { 43 | return func(c *ishell.Context) { 44 | groupMode := false 45 | if c.Cmd.HasFlag("-r") { 46 | groupMode = true 47 | } 48 | errString, ok := syntaxCheck(c, 1) 49 | if !ok { 50 | shell.Println(errString) 51 | return 52 | } 53 | 54 | targetPath := c.Args[len(c.Args)-1] 55 | 56 | db := shell.Get("db").(t.Database) 57 | currentLocation := db.CurrentLocation() 58 | newLocation, entry, err := TraversePath(db, currentLocation, targetPath) 59 | if err != nil { 60 | shell.Printf("could not reach location %s: %s\n", targetPath, err) 61 | return 62 | } 63 | 64 | // trim down to the actual name of the entity we want to kill 65 | pathbits := strings.Split(targetPath, "/") 66 | target := pathbits[len(pathbits)-1] 67 | 68 | // only remove groups if the specified target was a group 69 | if entry != nil { 70 | if err := removeEntry(newLocation, target); err != nil { 71 | shell.Printf("error removing entry: %s\n", err) 72 | return 73 | } 74 | } else if groupMode { 75 | if newLocation.Parent() == nil { 76 | shell.Println("cannot remove root node") 77 | return 78 | } 79 | if err := purgeGroup(newLocation); err != nil { 80 | shell.Printf("could not fully remove group '%s': %s\n", newLocation.Name, err) 81 | return 82 | } 83 | 84 | if currentLocation == newLocation { 85 | changeDirectory(db, currentLocation.Parent(), shell) 86 | } 87 | 88 | if err := newLocation.Parent().RemoveSubgroup(newLocation); err != nil { 89 | shell.Printf("could not fully remove group %s: %s\n", newLocation.Name, err) 90 | return 91 | } 92 | return 93 | } else { 94 | shell.Printf("'%s' is a group - try rerunning with '-r'\n", targetPath) 95 | return 96 | } 97 | 98 | shell.Printf("successfully removed '%s'\n", targetPath) 99 | 100 | if err := PromptAndSave(shell); err != nil { 101 | shell.Printf("could not save: %s\n", err) 102 | } 103 | } 104 | } 105 | -------------------------------------------------------------------------------- /internal/commands/mkdir_test.go: -------------------------------------------------------------------------------- 1 | package commands_test 2 | 3 | import ( 4 | "fmt" 5 | "strings" 6 | "testing" 7 | 8 | main "github.com/mostfunkyduck/kp/internal/commands" 9 | ) 10 | 11 | func createGroup(group string, r testResources) error { 12 | r.Context.Args = []string{ 13 | group, 14 | } 15 | if _, err := r.Readline.WriteStdin([]byte("n")); err != nil { 16 | return fmt.Errorf("could not write to readline: %s", err) 17 | } 18 | 19 | // FIXME the v2 tests won't pass without this repetiton 20 | if _, err := r.Readline.WriteStdin([]byte("n")); err != nil { 21 | return fmt.Errorf("could not write to readline: %s", err) 22 | } 23 | main.NewGroup(r.Shell)(r.Context) 24 | return nil 25 | } 26 | 27 | func verifyGroup(group string, r testResources) error { 28 | currentLocation := r.Db.CurrentLocation() 29 | l, e, err := main.TraversePath(r.Db, currentLocation, group) 30 | if err != nil { 31 | return fmt.Errorf("could not traverse path from [%s] to [%s]: %s", currentLocation.Name(), group, err) 32 | } 33 | 34 | if e != nil { 35 | return fmt.Errorf("entry found instead of target for new group") 36 | } 37 | 38 | path, err := r.Db.CurrentLocation().Path() 39 | if err != nil { 40 | return fmt.Errorf("could not locate path of current DB location: %s", err) 41 | } 42 | expected := path + group + "/" 43 | lPath, err := l.Path() 44 | if err != nil { 45 | return fmt.Errorf("could not locate location: %s", err) 46 | } 47 | if lPath != expected { 48 | return fmt.Errorf("[%s] != [%s]", lPath, expected) 49 | } 50 | return nil 51 | } 52 | 53 | func TestMkdir(t *testing.T) { 54 | // Happy path, testing the first group and the second will, in effect, test nested groups 55 | r := createTestResources(t) 56 | r.Db.SetCurrentLocation(r.Group) 57 | if err := createGroup("test2", r); err != nil { 58 | t.Fatal(err) 59 | } 60 | 61 | if err := verifyGroup("test2", r); err != nil { 62 | t.Fatal(err) 63 | } 64 | 65 | } 66 | 67 | func TestMkdirTerminalSlash(t *testing.T) { 68 | // Happy path, testing the first group and the second will, in effect, test nested groups 69 | r := createTestResources(t) 70 | r.Db.SetCurrentLocation(r.Group) 71 | if err := createGroup("test2/", r); err != nil { 72 | t.Fatal(err) 73 | } 74 | 75 | if err := verifyGroup("test2", r); err != nil { 76 | t.Fatal(err) 77 | } 78 | } 79 | 80 | func TestMkdirNestedSubgroup(t *testing.T) { 81 | // Happy path 82 | r := createTestResources(t) 83 | r.Db.SetCurrentLocation(r.Group) 84 | if err := createGroup("test2", r); err != nil { 85 | t.Fatalf("could not create group: %s\n", err) 86 | } 87 | if err := verifyGroup("test2", r); err != nil { 88 | t.Fatalf("could not verify group: %s\n", err) 89 | } 90 | 91 | r.Db.SetCurrentLocation(r.Group.Groups()[0]) 92 | if err := createGroup("test3", r); err != nil { 93 | t.Fatalf("could not create nested group: %s\n", err) 94 | } 95 | if err := verifyGroup("test3", r); err != nil { 96 | t.Fatalf("could not verify nested group: %s\n", err) 97 | } 98 | } 99 | 100 | func TestMkdirGroupNameIdenticalToEntry(t *testing.T) { 101 | r := createTestResources(t) 102 | r.Db.SetCurrentLocation(r.Group) 103 | 104 | if err := createGroup(r.Entry.Title(), r); err != nil { 105 | t.Fatalf("could not create nested group: %s\n", err) 106 | } 107 | } 108 | 109 | func TestMkdirGroupNameDuplicate(t *testing.T) { 110 | r := createTestResources(t) 111 | r.F.outputHolder.output = "" 112 | if err := createGroup(r.Group.Name(), r); err != nil { 113 | t.Fatal(err) 114 | } 115 | 116 | o := r.F.outputHolder.output 117 | expected := "cannot create duplicate" 118 | if !strings.Contains(o, expected) { 119 | t.Fatalf("[%s] does not contain [%s]", o, expected) 120 | } 121 | } 122 | -------------------------------------------------------------------------------- /internal/backend/keepassv2/rootgroup_test.go: -------------------------------------------------------------------------------- 1 | package keepassv2_test 2 | 3 | import ( 4 | "regexp" 5 | "testing" 6 | 7 | "github.com/mostfunkyduck/kp/internal/backend/types" 8 | ) 9 | 10 | func findGroupInGroup(parent types.Group, child types.Group) bool { 11 | for _, group := range parent.Groups() { 12 | if group.Name() == child.Name() { 13 | return true 14 | } 15 | } 16 | return false 17 | } 18 | 19 | func TestGroupFunctions(t *testing.T) { 20 | r := createTestResources(t) 21 | root := r.Db.Root() 22 | name := "TestGroupFunctions" 23 | sg, err := root.NewSubgroup(name) 24 | if err != nil { 25 | t.Fatalf(err.Error()) 26 | } 27 | 28 | found := findGroupInGroup(root, sg) 29 | if !found { 30 | t.Fatalf("added a subgroup, but couldn't find it afterwards in root's groups") 31 | } 32 | 33 | originalGroupCount := len(root.Groups()) 34 | _, err = root.NewSubgroup(name) 35 | if err == nil { 36 | t.Fatalf("added duplicate subgroup to root") 37 | } 38 | 39 | newGroupCount := len(root.Groups()) 40 | if originalGroupCount != newGroupCount { 41 | t.Fatalf("%d != %d", originalGroupCount, newGroupCount) 42 | } 43 | 44 | if err := root.RemoveSubgroup(sg); err != nil { 45 | t.Fatalf(err.Error()) 46 | } 47 | 48 | if findGroupInGroup(root, sg) { 49 | t.Fatalf("found group '%s' even after it was removed from too", sg.Name()) 50 | } 51 | 52 | if err := root.RemoveSubgroup(sg); err == nil { 53 | t.Fatalf("was able to remove subgroup twice") 54 | } 55 | } 56 | 57 | func TestParentFunctions(t *testing.T) { 58 | r := createTestResources(t) 59 | if err := r.Db.Root().SetParent(r.Group); err == nil { 60 | t.Fatalf("was able to set root's parent") 61 | } 62 | 63 | p := r.Db.Root().Parent() 64 | if p != nil { 65 | t.Fatalf("%v", p) 66 | } 67 | } 68 | 69 | func TestRootGroupIsRoot(t *testing.T) { 70 | r := createTestResources(t) 71 | if !r.Db.Root().IsRoot() { 72 | t.Fatalf("IsRoot is broken") 73 | } 74 | } 75 | 76 | func TestEntryFunctions(t *testing.T) { 77 | r := createTestResources(t) 78 | e, err := r.Group.NewEntry("test") 79 | if err != nil { 80 | t.Fatalf(err.Error()) 81 | } 82 | if err := r.Db.Root().AddEntry(e); err == nil { 83 | t.Fatalf("shouldn't be able to add an entry to root") 84 | } 85 | 86 | if err := r.Db.Root().RemoveEntry(e); err == nil { 87 | t.Fatalf("shouldn't be able to remove entry from root") 88 | } 89 | 90 | entriesLen := len(r.Db.Root().Entries()) 91 | if entriesLen != 0 { 92 | t.Fatalf("found %d entries in root group", entriesLen) 93 | } 94 | } 95 | 96 | func TestSearch(t *testing.T) { 97 | name := "askldfjhasl;kcvjs;lkjnsfasdlfkjas;ldvkjsdl;fgkja;skdlfgjnw;oeihaw;oifhjas;kldfjhasf" 98 | r := createTestResources(t) 99 | sg, err := r.Db.Root().NewSubgroup(name) 100 | if err != nil { 101 | t.Fatal(err) 102 | } 103 | // search for group using partial search 104 | paths, err := r.Db.Root().Search(regexp.MustCompile(name[0 : len(name)/2])) 105 | if err != nil { 106 | t.Fatal(err) 107 | } 108 | if len(paths) != 1 { 109 | t.Fatalf("too many paths returned from group search: %v", paths) 110 | } 111 | 112 | path, err := sg.Path() 113 | if err != nil { 114 | t.Fatalf(err.Error()) 115 | } 116 | 117 | if paths[0] != path { 118 | t.Fatalf("[%s] != [%s]", paths[0], path) 119 | } 120 | 121 | name = r.Entry.Title() 122 | paths, err = r.Db.Root().Search(regexp.MustCompile(name[0 : len(name)/2])) 123 | if err != nil { 124 | t.Fatal(err) 125 | } 126 | if len(paths) != 1 { 127 | t.Fatalf("wrong count of paths returned from entry search: %v", paths) 128 | } 129 | 130 | path, err = r.Entry.Path() 131 | if err != nil { 132 | t.Fatalf(err.Error()) 133 | } 134 | 135 | if paths[0] != path { 136 | t.Fatalf("[%s] != [%s]", paths[0], path) 137 | } 138 | } 139 | -------------------------------------------------------------------------------- /internal/commands/new_test.go: -------------------------------------------------------------------------------- 1 | package commands_test 2 | 3 | import ( 4 | "fmt" 5 | "testing" 6 | 7 | "github.com/mostfunkyduck/kp/internal/backend/types" 8 | main "github.com/mostfunkyduck/kp/internal/commands" 9 | ) 10 | 11 | // prepares stdin to fill out a new entry with default values and decline to save 12 | var entryValues = []string{ 13 | "first\n", 14 | "second\n", 15 | "third\n", 16 | "fourth\n", "fourth\n", // password confirmation 17 | "\n", // notes open in editor, needs manual verification 18 | } 19 | 20 | func fillOutEntry(r testResources) error { 21 | allValues := append(entryValues, []string{"N", "n"}...) 22 | for _, each := range allValues { 23 | if _, err := r.Readline.WriteStdin([]byte(each)); err != nil { 24 | return err 25 | } 26 | } 27 | return nil 28 | } 29 | 30 | // verifyDefaultEntry goes through each of the default v1 values 31 | // and test if they show up as expected in the entry passed in 32 | func verifyDefaultEntry(e types.Entry) error { 33 | // mild hack, but these are formatted in line with what v2 uses 34 | // v1 is good enough to do a case insensitive match 35 | // this could be improved in the future with calls to the utility functions, but works for now 36 | values := map[string]string{ 37 | "Title": "first", 38 | "URL": "second", 39 | "UserName": "third", 40 | "Password": "fourth", 41 | "Notes": "", 42 | } 43 | 44 | for k, v := range values { 45 | val, _ := e.Get(k) 46 | 47 | if string(val.Value()) != v { 48 | return fmt.Errorf("%s != %s", v, val) 49 | } 50 | } 51 | 52 | return nil 53 | } 54 | func TestNewEntry(t *testing.T) { 55 | r := createTestResources(t) 56 | r.Db.SetCurrentLocation(r.Group) 57 | originalEntriesLen := len(r.Group.Entries()) 58 | r.Context.Args = []string{ 59 | // will be overwritten by fillOutEntry 60 | "replaceme", 61 | } 62 | 63 | if err := fillOutEntry(r); err != nil { 64 | t.Fatalf(err.Error()) 65 | } 66 | main.NewEntry(r.Shell)(r.Context) 67 | output := r.F.outputHolder.output 68 | entries := r.Group.Entries() 69 | if len(entries) != originalEntriesLen+1 { 70 | t.Fatalf("wrong number of entries after initial entry creation: [%d] != [%d] (%s)", len(entries), originalEntriesLen+1, output) 71 | } 72 | 73 | expectedPath, err := r.Group.Path() 74 | if err != nil { 75 | t.Fatalf(err.Error()) 76 | } 77 | // the fillOutEntry form replaced the default title name with this one 78 | expectedPath += "first" 79 | // assuming that ordering is deterministic, if it isn't then this test will randomly fail 80 | entryPath, err := entries[1].Path() 81 | if err != nil { 82 | t.Fatalf(err.Error()) 83 | } 84 | if entryPath != expectedPath { 85 | t.Fatalf("[%s] != [%s] (%s)", entryPath, expectedPath, output) 86 | } 87 | if err := verifyDefaultEntry(entries[1]); err != nil { 88 | t.Fatalf(err.Error()) 89 | } 90 | } 91 | 92 | func TestNewAtRoot(t *testing.T) { 93 | r := createTestResources(t) 94 | entryName := "asdlfkjsdflkjasdflkj" 95 | r.Context.Args = []string{ 96 | "/" + entryName, 97 | } 98 | 99 | main.NewEntry(r.Shell)(r.Context) 100 | if len(r.Db.Root().Entries()) != 0 { 101 | t.Fatalf("entry created at root, [%d] != [%d]", len(r.Db.Root().Entries()), 0) 102 | } 103 | } 104 | 105 | func TestDuplicateEntry(t *testing.T) { 106 | r := createTestResources(t) 107 | entryName := "taslkfdj" 108 | r.Context.Args = []string{ 109 | entryName, 110 | } 111 | 112 | if err := fillOutEntry(r); err != nil { 113 | t.Fatalf(err.Error()) 114 | } 115 | main.NewEntry(r.Shell)(r.Context) 116 | originalEntriesLen := len(r.Db.CurrentLocation().Entries()) 117 | main.NewEntry(r.Shell)(r.Context) 118 | 119 | if len(r.Db.CurrentLocation().Entries()) != originalEntriesLen { 120 | t.Fatalf("created duplicate entry: [%d] != [%d]", len(r.Db.CurrentLocation().Entries()), originalEntriesLen) 121 | } 122 | } 123 | -------------------------------------------------------------------------------- /internal/backend/keepassv2/rootgroup.go: -------------------------------------------------------------------------------- 1 | package keepassv2 2 | 3 | // Unlike the keepass 1 library, this library doesn't represent Root as a group 4 | // which means that we have to dress up its 'RootData' object as a Group object 5 | 6 | import ( 7 | "fmt" 8 | "regexp" 9 | 10 | t "github.com/mostfunkyduck/kp/internal/backend/types" 11 | g "github.com/tobischo/gokeepasslib/v3" 12 | ) 13 | 14 | type RootGroup struct { 15 | db t.Database 16 | root *g.RootData 17 | } 18 | 19 | func (r *RootGroup) Raw() interface{} { 20 | return r.root 21 | } 22 | 23 | func (r *RootGroup) Groups() (rv []t.Group) { 24 | for i := range r.root.Groups { 25 | rv = append(rv, WrapGroup(&r.root.Groups[i], r.db)) 26 | } 27 | return 28 | } 29 | 30 | func (r *RootGroup) Path() (string, error) { 31 | return "/", nil 32 | } 33 | 34 | // technically, this could return all the entries in the database, but since 35 | // that's inconsistent with other groups, leaving it this way for now 36 | func (r *RootGroup) Entries() (rv []t.Entry) { 37 | return []t.Entry{} 38 | } 39 | 40 | func (r *RootGroup) Parent() t.Group { 41 | return nil 42 | } 43 | 44 | func (r *RootGroup) SetParent(parent t.Group) error { 45 | return fmt.Errorf("cannot set parent for root group") 46 | } 47 | 48 | func (r *RootGroup) Name() string { 49 | return "" 50 | } 51 | 52 | func (r *RootGroup) SetName(name string) { 53 | } 54 | 55 | func (r *RootGroup) IsRoot() bool { 56 | return true 57 | } 58 | 59 | // Creates a new subgroup with a given name under this group 60 | func (r *RootGroup) NewSubgroup(name string) (t.Group, error) { 61 | newGroup := g.NewGroup() 62 | newGroupWrapper := WrapGroup(&newGroup, r.db) 63 | newGroupWrapper.SetName(name) 64 | if err := newGroupWrapper.SetParent(r); err != nil { 65 | return &Group{}, fmt.Errorf("couldn't assign new group to parent '%s'; %s", r.Name(), err) 66 | } 67 | return newGroupWrapper, nil 68 | } 69 | 70 | func (r *RootGroup) RemoveSubgroup(subgroup t.Group) error { 71 | subUUID, err := subgroup.UUIDString() 72 | if err != nil { 73 | return fmt.Errorf("could not read UUID on '%s': %s", subgroup.Name(), err) 74 | } 75 | 76 | for i, each := range r.root.Groups { 77 | eachWrapper := WrapGroup(&each, r.db) 78 | eachUUID, err := eachWrapper.UUIDString() 79 | if err != nil { 80 | return fmt.Errorf("could not read UUID on '%s': %s", eachWrapper.Name(), err) 81 | } 82 | 83 | if eachUUID == subUUID { 84 | // remove it 85 | raw := r.root 86 | groupLen := len(raw.Groups) 87 | raw.Groups = append(raw.Groups[0:i], raw.Groups[i+1:groupLen]...) 88 | return nil 89 | } 90 | } 91 | return fmt.Errorf("could not find group with UUID '%s'", subUUID) 92 | } 93 | 94 | func (r *RootGroup) AddEntry(e t.Entry) error { 95 | return fmt.Errorf("cannot add entries to root group") 96 | } 97 | func (r *RootGroup) NewEntry(name string) (t.Entry, error) { 98 | return nil, fmt.Errorf("cannot add entries to root group") 99 | } 100 | 101 | func (r *RootGroup) RemoveEntry(entry t.Entry) error { 102 | return fmt.Errorf("root group does not hold entries") 103 | } 104 | 105 | func (r *RootGroup) Search(term *regexp.Regexp) (paths []string, err error) { 106 | for _, g := range r.Groups() { 107 | nestedSearch, err := g.Search(term) 108 | if err != nil { 109 | return []string{}, fmt.Errorf("search failed: %s", err) 110 | } 111 | paths = append(paths, nestedSearch...) 112 | } 113 | return paths, nil 114 | } 115 | 116 | func (r *RootGroup) UUIDString() (string, error) { 117 | return "", nil 118 | } 119 | 120 | func (r *RootGroup) AddSubgroup(subgroup t.Group) error { 121 | for _, each := range r.Groups() { 122 | if each.Name() == subgroup.Name() { 123 | return fmt.Errorf("group named '%s' already exists", each.Name()) 124 | } 125 | } 126 | 127 | // FIXME this pointer abomination needs to go 128 | r.root.Groups = append(r.root.Groups, *subgroup.Raw().(*g.Group)) 129 | subgroup.(*Group).updateWrapper(&r.root.Groups[len(r.root.Groups)-1]) 130 | return nil 131 | } 132 | -------------------------------------------------------------------------------- /internal/commands/attach.go: -------------------------------------------------------------------------------- 1 | package commands 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | "strconv" 7 | "strings" 8 | 9 | "github.com/mostfunkyduck/ishell" 10 | c "github.com/mostfunkyduck/kp/internal/backend/common" 11 | t "github.com/mostfunkyduck/kp/internal/backend/types" 12 | ) 13 | 14 | func listAttachment(entry t.Entry) (s string, err error) { 15 | attachment, present := entry.Get("attachment") 16 | if !present { 17 | err = fmt.Errorf("entry has no attachment") 18 | return 19 | } 20 | s = fmt.Sprintf("Name: %s\nSize: %d bytes", attachment.Name(), len(attachment.Value())) 21 | return 22 | } 23 | 24 | func getAttachment(entry t.Entry, outputLocation string) (s string, err error) { 25 | f, err := os.Create(outputLocation) 26 | if err != nil { 27 | err = fmt.Errorf("could not open [%s]", outputLocation) 28 | return 29 | } 30 | defer f.Close() 31 | 32 | attachment, present := entry.Get("attachment") 33 | if !present { 34 | err = fmt.Errorf("entry has no attachment") 35 | return 36 | } 37 | 38 | written, err := f.Write(attachment.Value()) 39 | if err != nil { 40 | err = fmt.Errorf("could not write to [%s]", outputLocation) 41 | return 42 | } 43 | 44 | s = fmt.Sprintf("wrote %s (%d bytes) to %s\n", attachment.Name(), written, outputLocation) 45 | return 46 | } 47 | 48 | func Attach(shell *ishell.Shell, cmd string) (f func(c *ishell.Context)) { 49 | return func(c *ishell.Context) { 50 | if len(c.Args) < 1 { 51 | shell.Println("syntax: " + c.Cmd.Help) 52 | return 53 | } 54 | 55 | args := c.Args 56 | path := args[0] 57 | db := shell.Get("db").(t.Database) 58 | currentLocation := db.CurrentLocation() 59 | location, _, err := TraversePath(db, currentLocation, path) 60 | if err != nil { 61 | shell.Printf("error traversing path: %s\n", err) 62 | return 63 | } 64 | 65 | pieces := strings.Split(path, "/") 66 | name := pieces[len(pieces)-1] 67 | var intVersion int 68 | intVersion, err = strconv.Atoi(name) 69 | if err != nil { 70 | intVersion = -1 // assuming that this will never be a valid entry 71 | } 72 | for i, entry := range location.Entries() { 73 | 74 | if entry.Title() == name || (intVersion >= 0 && i == intVersion) { 75 | output, err := runAttachCommands(args, cmd, entry, shell) 76 | if err != nil { 77 | shell.Printf("could not run command [%s]: %s\n", cmd, err) 78 | return 79 | } 80 | shell.Println(output) 81 | return 82 | } 83 | } 84 | shell.Printf("could not find entry at path %s\n", path) 85 | } 86 | } 87 | 88 | func createAttachment(entry t.Entry, name string, path string) (output string, err error) { 89 | data, err := os.ReadFile(path) 90 | if err != nil { 91 | return "", fmt.Errorf("could not open %s: %s", path, err) 92 | } 93 | 94 | entry.Set(c.Attachment{ 95 | EntryValue: c.NewValue( 96 | data, 97 | name, 98 | false, 99 | false, 100 | false, 101 | t.BINARY, 102 | ), 103 | }) 104 | 105 | return "added attachment to database", nil 106 | } 107 | 108 | // helper function run running attach commands. 'args' are all arguments after the attach command 109 | // for instance, 'attach get foo bar' will result in args being '[foo, bar]' 110 | func runAttachCommands(args []string, cmd string, entry t.Entry, shell *ishell.Shell) (output string, err error) { 111 | switch cmd { 112 | // attach create attachmentName /path/to/file 113 | case "create": 114 | if len(args) < 3 { 115 | return "", fmt.Errorf("bad syntax") 116 | } 117 | return createAttachment(entry, args[1], args[2]) 118 | case "get": 119 | if len(args) < 2 { 120 | return "", fmt.Errorf("bad syntax") 121 | } 122 | 123 | outputLocation := args[1] 124 | if _, err := os.Stat(outputLocation); os.IsNotExist(err) { 125 | if !confirmOverwrite(shell, outputLocation) { 126 | return "aborting", nil 127 | } 128 | } 129 | return getAttachment(entry, outputLocation) 130 | case "details": 131 | return listAttachment(entry) 132 | default: 133 | return "", fmt.Errorf("invalid attach command") 134 | } 135 | } 136 | -------------------------------------------------------------------------------- /internal/backend/keepassv1/group.go: -------------------------------------------------------------------------------- 1 | package keepassv1 2 | 3 | import ( 4 | "fmt" 5 | 6 | c "github.com/mostfunkyduck/kp/internal/backend/common" 7 | t "github.com/mostfunkyduck/kp/internal/backend/types" 8 | "zombiezen.com/go/sandpass/pkg/keepass" 9 | ) 10 | 11 | type Group struct { 12 | c.Group 13 | group *keepass.Group 14 | } 15 | 16 | func (g *Group) EntryExists(name string) bool { 17 | for _, each := range g.Entries() { 18 | if each.Title() == name { 19 | return true 20 | } 21 | } 22 | return false 23 | } 24 | func WrapGroup(group *keepass.Group, db t.Database) t.Group { 25 | if group == nil { 26 | return nil 27 | } 28 | g := &Group{ 29 | group: group, 30 | } 31 | 32 | g.SetDB(db) 33 | g.SetDriver(g) 34 | return g 35 | } 36 | 37 | func (g *Group) AddSubgroup(subgroup t.Group) error { 38 | for _, group := range g.Groups() { 39 | if group.Name() == subgroup.Name() { 40 | return fmt.Errorf("group named '%s' already exists at this location", group.Name()) 41 | } 42 | } 43 | if err := subgroup.SetParent(g); err != nil { 44 | return fmt.Errorf("could not set subgroup parent: %s", err) 45 | } 46 | return nil 47 | } 48 | 49 | func (g *Group) AddEntry(e t.Entry) error { 50 | for _, each := range g.Entries() { 51 | if each.Title() == e.Title() { 52 | return fmt.Errorf("entry named '%s' already exists at this location", e.Title()) 53 | } 54 | } 55 | if err := e.SetParent(g); err != nil { 56 | return fmt.Errorf("could not add entry: %s", err) 57 | } 58 | return nil 59 | } 60 | 61 | func (g *Group) Name() string { 62 | if g.IsRoot() { 63 | return "" 64 | } 65 | return g.group.Name 66 | } 67 | 68 | func (g *Group) SetName(name string) { 69 | g.group.Name = name 70 | } 71 | 72 | func (g *Group) Parent() t.Group { 73 | return WrapGroup(g.group.Parent(), g.DB()) 74 | } 75 | 76 | func (g *Group) SetParent(parent t.Group) error { 77 | if err := g.group.SetParent(parent.Raw().(*keepass.Group)); err != nil { 78 | return fmt.Errorf("could not change group parent: %s", err) 79 | } 80 | return nil 81 | } 82 | 83 | func (g *Group) Entries() (rv []t.Entry) { 84 | for _, each := range g.group.Entries() { 85 | rv = append(rv, WrapEntry(each, g.DB())) 86 | } 87 | return rv 88 | } 89 | 90 | func (g *Group) Groups() (rv []t.Group) { 91 | for _, each := range g.group.Groups() { 92 | rv = append(rv, WrapGroup(each, g.DB())) 93 | } 94 | return rv 95 | } 96 | 97 | func (g *Group) IsRoot() bool { 98 | return g.Parent() == nil 99 | } 100 | 101 | func (g *Group) NewSubgroup(name string) (t.Group, error) { 102 | for _, group := range g.Groups() { 103 | if group.Name() == name { 104 | return nil, fmt.Errorf("group named '%s' already exists", name) 105 | } 106 | } 107 | newGroup := g.group.NewSubgroup() 108 | newGroup.Name = name 109 | return WrapGroup(newGroup, g.DB()), nil 110 | } 111 | 112 | func (g *Group) RemoveSubgroup(subgroup t.Group) error { 113 | for _, each := range subgroup.Groups() { 114 | if err := subgroup.RemoveSubgroup(each); err != nil { 115 | return fmt.Errorf("could not purge subgroups in group '%s': %s", each.Name(), err) 116 | } 117 | } 118 | for _, e := range subgroup.Entries() { 119 | if err := subgroup.RemoveEntry(e); err != nil { 120 | return fmt.Errorf("could not purge entries in group '%s': %s", e.Title(), err) 121 | } 122 | } 123 | return g.group.RemoveSubgroup(subgroup.Raw().(*keepass.Group)) 124 | } 125 | 126 | func (g *Group) Raw() interface{} { 127 | return g.group 128 | } 129 | 130 | func (g *Group) NewEntry(name string) (t.Entry, error) { 131 | // FIXME allows dupe entries 132 | if g.EntryExists(name) { 133 | return nil, fmt.Errorf("entry '%s' already exists in location '%s'", name, g.Name()) 134 | } 135 | entry, err := g.group.NewEntry() 136 | if err != nil { 137 | return nil, err 138 | } 139 | entry.Title = name 140 | return WrapEntry(entry, g.DB()), nil 141 | } 142 | 143 | func (g *Group) RemoveEntry(e t.Entry) error { 144 | return g.group.RemoveEntry(e.Raw().(*keepass.Entry)) 145 | } 146 | 147 | func (g *Group) UUIDString() (string, error) { 148 | return fmt.Sprint(g.group.ID), nil 149 | } 150 | -------------------------------------------------------------------------------- /internal/backend/keepassv1/database.go: -------------------------------------------------------------------------------- 1 | package keepassv1 2 | 3 | // wraps a v1 database with utility functions that allow it to be integrated 4 | // into the shell. 5 | 6 | import ( 7 | "fmt" 8 | "io" 9 | "os" 10 | 11 | c "github.com/mostfunkyduck/kp/internal/backend/common" 12 | t "github.com/mostfunkyduck/kp/internal/backend/types" 13 | "zombiezen.com/go/sandpass/pkg/keepass" 14 | ) 15 | 16 | type Database struct { 17 | c.Database 18 | db *keepass.Database 19 | } 20 | 21 | // Init initializes the v1 database based on the provided options 22 | func (d *Database) Init(options t.Options) error { 23 | var err error 24 | var keyReader io.Reader 25 | 26 | d.SetDriver(d) 27 | backend, err := c.InitBackend(options.DBPath) 28 | if err != nil { 29 | return fmt.Errorf("could not init backend: %s", err) 30 | } 31 | d.SetBackend(backend) 32 | if options.KeyPath != "" { 33 | keyReader, err = os.Open(options.KeyPath) 34 | if err != nil { 35 | return fmt.Errorf("could not open key file [%s]: %s\n", options.KeyPath, err) 36 | } 37 | } 38 | 39 | opts := &keepass.Options{ 40 | Password: options.Password, 41 | KeyFile: keyReader, 42 | KeyRounds: options.KeyRounds, 43 | } 44 | 45 | savePath := d.Backend().Filename() 46 | if _, err := os.Stat(savePath); err == nil { 47 | dbReader, err := os.Open(savePath) 48 | if err != nil { 49 | return fmt.Errorf("could not open db file [%s]: %s\n", savePath, err) 50 | } 51 | 52 | db, err := keepass.Open(dbReader, opts) 53 | if err != nil { 54 | return fmt.Errorf("could not open database: %s\n", err) 55 | } 56 | d.db = db 57 | } else { 58 | db, err := keepass.New(opts) 59 | if err != nil { 60 | return fmt.Errorf("could not create new database with provided options: %s", err) 61 | } 62 | // need to set the internal db pointer before saving 63 | d.db = db 64 | 65 | if err := d.Save(); err != nil { 66 | return fmt.Errorf("could not save newly created database: %s", err) 67 | } 68 | } 69 | 70 | d.SetCurrentLocation(d.Root()) 71 | return nil 72 | } 73 | 74 | // Root returns the DB root 75 | func (d *Database) Root() t.Group { 76 | return WrapGroup(d.db.Root(), d) 77 | } 78 | 79 | // Save will backup the DB, save it, then remove the backup is the save was successful. it will also check to make sure the file has not changed. 80 | func (d *Database) Save() error { 81 | savePath := d.Backend().Filename() 82 | 83 | if savePath == "" { 84 | return fmt.Errorf("no save path specified") 85 | } 86 | 87 | modified, err := d.Backend().IsModified() 88 | if err != nil { 89 | return fmt.Errorf("could not verify that the backend was unmodified: %s", err) 90 | } 91 | 92 | if modified { 93 | return fmt.Errorf("backend storage has been modified! please reopen before modifying to avoid corrupting or overwriting changes! (changes made since the last save will not be persisted)") 94 | } 95 | 96 | if err := d.Backup(); err != nil { 97 | return fmt.Errorf("could not back up database: %s", err) 98 | } 99 | 100 | w, err := os.Create(savePath) 101 | if err != nil { 102 | return fmt.Errorf("could not open/create db save location [%s]: %s", savePath, err) 103 | } 104 | 105 | if err = d.db.Write(w); err != nil { 106 | return fmt.Errorf("error writing database to [%s]: %s", savePath, err) 107 | } 108 | 109 | if err := d.RemoveBackup(); err != nil { 110 | return fmt.Errorf("could not remove backup after saving: %s", err) 111 | } 112 | 113 | backend, err := c.InitBackend(savePath) 114 | if err != nil { 115 | return fmt.Errorf("error initializing new backend type after save: %s", err) 116 | } 117 | d.SetBackend(backend) 118 | return nil 119 | } 120 | 121 | func (d *Database) Raw() interface{} { 122 | return d.db 123 | } 124 | 125 | // Binary returns an OptionalWrapper with Present sent to false as v1 doesn't handle binaries 126 | // through the database 127 | func (d *Database) Binary(id int, name string) (t.OptionalWrapper, error) { 128 | return t.OptionalWrapper{ 129 | Present: false, 130 | }, nil 131 | } 132 | 133 | // Version returns the t.Version enum representing this DB 134 | func (d *Database) Version() t.Version { 135 | return t.V1 136 | } 137 | -------------------------------------------------------------------------------- /internal/commands/mv.go: -------------------------------------------------------------------------------- 1 | package commands 2 | 3 | import ( 4 | "fmt" 5 | "strings" 6 | 7 | "github.com/mostfunkyduck/ishell" 8 | t "github.com/mostfunkyduck/kp/internal/backend/types" 9 | ) 10 | 11 | func finish(shell *ishell.Shell) { 12 | if err := PromptAndSave(shell); err != nil { 13 | shell.Printf("error saving database: %s\n", err) 14 | return 15 | } 16 | } 17 | 18 | func moveEntry(shell *ishell.Shell, e t.Entry, db t.Database, location string) error { 19 | parent, existingEntry, err := TraversePath(db, db.CurrentLocation(), location) 20 | if existingEntry != nil { 21 | shell.Printf("'%s' already exists! overwrite? [y/N] ", existingEntry.Title()) 22 | input, err := shell.ReadLineErr() 23 | if err != nil { 24 | return fmt.Errorf("error reading user input: %s\n", err) 25 | } 26 | 27 | if input != "y" { 28 | return fmt.Errorf("not overwriting") 29 | } 30 | 31 | if err := e.SetParent(existingEntry.Parent()); err != nil { 32 | return fmt.Errorf("could not move entry '%s' to group '%s': %s\n", string(e.Title()), existingEntry.Parent().Name(), err) 33 | } 34 | 35 | if err := existingEntry.Parent().RemoveEntry(existingEntry); err != nil { 36 | return fmt.Errorf("error removing entry '%s' from group '%s': %s\n", existingEntry.Title(), existingEntry.Parent().Name(), err) 37 | } 38 | return nil 39 | } 40 | 41 | title := "" 42 | if err != nil { 43 | // there's no group or entry at this location, attempt to process this as a rename 44 | // trim the path so that we're only looking at the parent group 45 | pathBits := strings.Split(location, "/") 46 | path := strings.Join(pathBits[0:len(pathBits)-1], "/") 47 | var entry t.Entry 48 | parent, entry, err = TraversePath(db, db.CurrentLocation(), path) 49 | if err != nil { 50 | return fmt.Errorf("error finding path '%s': %s\n", location, err) 51 | } 52 | 53 | if entry != nil { 54 | return fmt.Errorf("could not rename '%s' to '%s': '%s' is an existing entry", e.Title(), location, path) 55 | } 56 | title = pathBits[len(pathBits)-1] 57 | } 58 | 59 | if err := e.SetParent(parent); err != nil { 60 | return fmt.Errorf("error moving entry '%s' to new location '%s': %s\n", e.Title(), parent.Name(), err) 61 | } 62 | 63 | if title != "" { 64 | e.SetTitle(title) 65 | } 66 | return nil 67 | } 68 | 69 | func moveGroup(g t.Group, db t.Database, location string) error { 70 | newNameBits := strings.Split(location, "/") 71 | newName := newNameBits[len(newNameBits)-1] 72 | if newName == "" { 73 | // this should happen if the user moves a group to be a subgroup of another group 74 | // i.e "mv foo bar/", expecting "foo" to become "bar/foo" 75 | newName = g.Name() 76 | } 77 | newNameParent := strings.Join(newNameBits[0:len(newNameBits)-1], "/") 78 | parent, _, err := TraversePath(db, db.CurrentLocation(), newNameParent) 79 | if err != nil { 80 | return err 81 | } 82 | 83 | for _, g := range parent.Groups() { 84 | if g.Name() == newName { 85 | // attempted to move a group into a group, i.e. '/foo' into '/bar/foo' when '/bar/foo' already exists 86 | // in this case, we want to move '/foo' to become '/bar/foo/foo', so set the parent to be the target group 87 | parent = g 88 | break 89 | } 90 | } 91 | 92 | for _, e := range parent.Entries() { 93 | if e.Title() == newName { 94 | return fmt.Errorf("entry named '%s' already exists at '%s'", newName, e.Title()) 95 | } 96 | } 97 | 98 | if err := g.SetParent(parent); err != nil { 99 | return err 100 | } 101 | g.SetName(newName) 102 | return nil 103 | } 104 | 105 | func Mv(shell *ishell.Shell) (f func(c *ishell.Context)) { 106 | return func(c *ishell.Context) { 107 | syntaxCheck(c, 2) 108 | srcPath := c.Args[0] 109 | dstPath := c.Args[1] 110 | db := shell.Get("db").(t.Database) 111 | 112 | l, e, err := TraversePath(db, db.CurrentLocation(), srcPath) 113 | if err != nil { 114 | shell.Printf("error parsing path %s: %s", srcPath, err) 115 | return 116 | } 117 | 118 | // is this an entry or a group? 119 | if e != nil { 120 | if err := moveEntry(shell, e, db, dstPath); err != nil { 121 | shell.Printf("couldn't move entry: %s\n", err) 122 | return 123 | } 124 | } else { 125 | // Not an entry, this is a group 126 | if err := moveGroup(l, db, dstPath); err != nil { 127 | shell.Printf("could not move group: %s\n", err) 128 | return 129 | } 130 | } 131 | finish(shell) 132 | } 133 | } 134 | -------------------------------------------------------------------------------- /internal/commands/commands_test.go: -------------------------------------------------------------------------------- 1 | package commands_test 2 | 3 | // Scaffolding for running shell command tests 4 | 5 | import ( 6 | "fmt" 7 | "os" 8 | "testing" 9 | 10 | "github.com/abiosoft/readline" 11 | "github.com/mostfunkyduck/ishell" 12 | c "github.com/mostfunkyduck/kp/internal/backend/common" 13 | v1 "github.com/mostfunkyduck/kp/internal/backend/keepassv1" 14 | v2 "github.com/mostfunkyduck/kp/internal/backend/keepassv2" 15 | "github.com/mostfunkyduck/kp/internal/backend/types" 16 | ) 17 | 18 | // the Write() method can't store output locally b/c it isn't a pointer target 19 | // this is the workaround 20 | type outputHolder struct { 21 | output string 22 | } 23 | type FakeWriter struct { 24 | outputHolder *outputHolder 25 | } 26 | 27 | func (f FakeWriter) Write(p []byte) (n int, err error) { 28 | // output will look a little funny... 29 | f.outputHolder.output += string(p) 30 | 31 | return len(p), nil 32 | } 33 | 34 | type testResources struct { 35 | Shell *ishell.Shell 36 | Context *ishell.Context 37 | Group types.Group 38 | Path string 39 | Db types.Database 40 | Entry types.Entry 41 | F FakeWriter 42 | Readline *readline.Instance 43 | } 44 | 45 | func createTestResources(t *testing.T) (r testResources) { 46 | var err error 47 | r.Readline, err = readline.New("") 48 | if err != nil { 49 | t.Fatal(err) 50 | } 51 | r.Shell = ishell.NewWithReadline(r.Readline) 52 | r.Path = "test/test" 53 | r.Context = &ishell.Context{} 54 | version := os.Getenv("KPVERSION") 55 | 56 | tmpFile, err := os.CreateTemp("", "kp_unit_tests") 57 | if err != nil { 58 | t.Fatal(err) 59 | } 60 | 61 | // this will allow the init code to create the db from scratch 62 | tmpFile.Close() 63 | os.Remove(tmpFile.Name()) 64 | 65 | // this will remove it afterwards, which will break everything if the tests try to save 66 | // but save tests can run their own init 67 | defer os.Remove(tmpFile.Name()) 68 | opts := types.Options{ 69 | DBPath: tmpFile.Name(), 70 | KeyRounds: 1, 71 | } 72 | if version == "1" { 73 | r.Db = &v1.Database{} 74 | } else if version == "2" { 75 | r.Db = &v2.Database{} 76 | } else { 77 | t.Fatalf("KPVERSION environment variable invalid (value: '%s'), rerun with it as either '1' or '2'", version) 78 | } 79 | 80 | if err := r.Db.Init(opts); err != nil { 81 | t.Fatal(err) 82 | } 83 | 84 | r.Shell.Set("db", r.Db) 85 | r.Group, _ = r.Db.Root().NewSubgroup("test") 86 | 87 | r.Entry, err = r.Group.NewEntry("test") 88 | if err != nil { 89 | t.Fatalf("could not create entry: %s", err) 90 | } 91 | settings := map[string]string{ 92 | "Title": "test", 93 | "URL": "example.com", 94 | "UserName": "username", 95 | "Password": "password", 96 | "Notes": "notes", 97 | } 98 | for key, v := range settings { 99 | val := c.NewValue( 100 | []byte(v), 101 | key, 102 | false, false, false, 103 | types.STRING, 104 | ) 105 | 106 | r.Entry.Set(val) 107 | } 108 | 109 | r.F = FakeWriter{ 110 | outputHolder: &outputHolder{}, 111 | } 112 | r.Shell.SetOut(r.F) 113 | return 114 | } 115 | 116 | func testEntry(full bool, t *testing.T, r testResources) { 117 | o := r.F.outputHolder.output 118 | path, err := r.Entry.Path() 119 | if err != nil { 120 | t.Fatalf(err.Error()) 121 | } 122 | testShowOutput(o, fmt.Sprintf("Location:\t%s", path), t) 123 | testShowOutput(o, fmt.Sprintf("Title:\t%s", r.Entry.Title()), t) 124 | urlValue, _ := r.Entry.Get("URL") 125 | testShowOutput(o, fmt.Sprintf("URL:\t%s", urlValue.Value()), t) 126 | // compensating for v1 and v2 formatting differently 127 | unFieldName := "Username" 128 | if os.Getenv("KPVERSION") == "2" { 129 | unFieldName = "UserName" 130 | } 131 | testShowOutput(o, fmt.Sprintf("%s:\t%s", unFieldName, r.Entry.Username()), t) 132 | if full { 133 | testShowOutput(o, "Password:\t[protected]", t) 134 | } else { 135 | testShowOutput(o, fmt.Sprintf("Password:\t%s", r.Entry.Password()), t) 136 | } 137 | 138 | // format the notes to match how the entry will format long strings for output, which is not how they're stored internally 139 | // This is ridiculously annoying to test properly, pushing it off for now, will test manually 140 | //testShowOutput(o, fmt.Sprintf("Notes:\t\n>\t%s", strings.ReplaceAll(string(r.Entry.Get("notes").Value), "\n", "\n>\t")), t) 141 | 142 | att, present := r.Entry.Get("attachment") 143 | if present { 144 | testShowOutput(o, att.Output(false), t) 145 | } 146 | } 147 | -------------------------------------------------------------------------------- /internal/commands/mv_test.go: -------------------------------------------------------------------------------- 1 | package commands_test 2 | 3 | import ( 4 | "testing" 5 | 6 | main "github.com/mostfunkyduck/kp/internal/commands" 7 | ) 8 | 9 | func TestMv(t *testing.T) { 10 | r := createTestResources(t) 11 | newName := "example" 12 | rGroupPath, err := r.Group.Path() 13 | if err != nil { 14 | t.Fatalf(err.Error()) 15 | } 16 | 17 | rootPath, err := r.Db.Root().Path() 18 | if err != nil { 19 | t.Fatalf(err.Error()) 20 | } 21 | r.Context.Args = []string{ 22 | rGroupPath, 23 | rootPath + newName, 24 | } 25 | main.Mv(r.Shell)(r.Context) 26 | rGroupPath, err = r.Group.Path() 27 | if err != nil { 28 | t.Fatalf(err.Error()) 29 | } 30 | rootPath, err = r.Db.Root().Path() 31 | if err != nil { 32 | t.Fatalf(err.Error()) 33 | } 34 | if rGroupPath != rootPath+newName+"/" { 35 | t.Fatalf("[%s] != [%s]", rGroupPath, rootPath+newName+"/") 36 | } 37 | } 38 | 39 | // Verify that you can't overwrite a group with a group 40 | func TestMvGroupOverwriteGroup(t *testing.T) { 41 | r := createTestResources(t) 42 | originalGroupCount := len(r.Db.Root().Groups()) 43 | g, _ := r.Group.NewSubgroup(r.Group.Name()) 44 | r.Db.SetCurrentLocation(r.Db.Root()) 45 | originalGroupPath, err := g.Path() 46 | if err != nil { 47 | t.Fatalf(err.Error()) 48 | } 49 | rootPath, err := r.Db.Root().Path() 50 | if err != nil { 51 | t.Fatalf(err.Error()) 52 | } 53 | r.Context.Args = []string{ 54 | originalGroupPath, 55 | rootPath, 56 | } 57 | main.Mv(r.Shell)(r.Context) 58 | // test that the group didn't get moved 59 | gPath, err := g.Path() 60 | if err != nil { 61 | t.Fatalf(err.Error()) 62 | } 63 | if gPath != originalGroupPath { 64 | t.Fatalf("[%s] != [%s] (%s)", gPath, originalGroupPath, r.F.outputHolder.output) 65 | } 66 | 67 | // make sure it didn't add a spurious third group during a botched move 68 | if len(r.Db.Root().Groups()) != originalGroupCount { 69 | t.Fatalf("[%d] != [%d] (%s)", len(r.Db.Root().Groups()), originalGroupCount, r.F.outputHolder.output) 70 | } 71 | } 72 | 73 | func TestMvGroupOverwriteEntry(t *testing.T) { 74 | r := createTestResources(t) 75 | originalGroupPath, err := r.Group.Path() 76 | if err != nil { 77 | t.Fatalf(err.Error()) 78 | } 79 | originalEntryPath, err := r.Entry.Path() 80 | if err != nil { 81 | t.Fatalf(err.Error()) 82 | } 83 | r.Context.Args = []string{ 84 | originalGroupPath, 85 | originalEntryPath, 86 | } 87 | main.Mv(r.Shell)(r.Context) 88 | groupPath, err := r.Group.Path() 89 | if err != nil { 90 | t.Fatalf(err.Error()) 91 | } 92 | // test that the group didn't get moved 93 | if groupPath != originalGroupPath { 94 | t.Fatalf("[%s] != [%s]", groupPath, originalGroupPath) 95 | } 96 | 97 | entryPath, err := r.Entry.Path() 98 | if err != nil { 99 | t.Fatalf(err.Error()) 100 | } 101 | if entryPath != originalEntryPath { 102 | t.Fatalf("[%s] != [%s]", entryPath, originalEntryPath) 103 | } 104 | } 105 | 106 | func TestMvEntryIntoGroup(t *testing.T) { 107 | r := createTestResources(t) 108 | 109 | newName := "test2" 110 | g, _ := r.Db.Root().NewSubgroup(newName) 111 | r.Db.SetCurrentLocation(r.Db.Root()) 112 | 113 | originalEntryPath, err := r.Entry.Path() 114 | if err != nil { 115 | t.Fatalf(err.Error()) 116 | } 117 | gPath, err := g.Path() 118 | if err != nil { 119 | t.Fatalf(err.Error()) 120 | } 121 | r.Context.Args = []string{ 122 | originalEntryPath, 123 | gPath, 124 | } 125 | main.Mv(r.Shell)(r.Context) 126 | 127 | gPath, err = g.Path() 128 | if err != nil { 129 | t.Fatalf(err.Error()) 130 | } 131 | expectedPath := gPath + r.Entry.Title() 132 | 133 | entryPath, err := r.Entry.Path() 134 | if err != nil { 135 | t.Fatalf(err.Error()) 136 | } 137 | 138 | if entryPath != expectedPath { 139 | t.Fatalf("[%s] != [%s]", entryPath, expectedPath) 140 | } 141 | } 142 | 143 | func TestMvGroupIntoGroup(t *testing.T) { 144 | r := createTestResources(t) 145 | newName := "test" 146 | g, _ := r.Group.NewSubgroup(newName) 147 | r.Db.SetCurrentLocation(r.Db.Root()) 148 | 149 | gPath, err := g.Path() 150 | if err != nil { 151 | t.Fatalf(err.Error()) 152 | } 153 | 154 | rGrpPath, err := r.Group.Path() 155 | if err != nil { 156 | t.Fatalf(err.Error()) 157 | } 158 | 159 | r.Context.Args = []string{ 160 | gPath, 161 | rGrpPath, 162 | } 163 | 164 | // NOTE this was broken beforehand 165 | main.Mv(r.Shell)(r.Context) 166 | // testing that the group is now a subgroup 167 | expectedPath := rGrpPath + g.Name() + "/" 168 | if gPath != expectedPath { 169 | t.Fatalf("[%s] != [%s] (%s)", gPath, expectedPath, r.F.outputHolder.output) 170 | } 171 | 172 | } 173 | -------------------------------------------------------------------------------- /internal/backend/common/database.go: -------------------------------------------------------------------------------- 1 | package common 2 | 3 | import ( 4 | "fmt" 5 | t "github.com/mostfunkyduck/kp/internal/backend/types" 6 | "os" 7 | "regexp" 8 | ) 9 | 10 | type Database struct { 11 | driver t.Database 12 | currentLocation t.Group 13 | changed bool 14 | backend *Backend 15 | } 16 | 17 | // SetDriver sets pointer to the version of itself that can access child methods... FIXME this is a bit of a mind bender 18 | func (d *Database) SetDriver(driver t.Database) { 19 | d.driver = driver 20 | } 21 | 22 | // SetBackend and Backend manage a cached hash representing the state of the backend 23 | func (d *Database) SetBackend(backend *Backend) { 24 | d.backend = backend 25 | } 26 | 27 | func (d *Database) Backend() t.Backend { 28 | return d.backend 29 | } 30 | 31 | func (d *Database) lockPath() string { 32 | path := d.Backend().Filename() 33 | if path == "" { 34 | return path 35 | } 36 | 37 | return path + ".lock" 38 | } 39 | 40 | // Lock generates a lockfile for the given database 41 | func (d *Database) Lock() error { 42 | path := d.lockPath() 43 | 44 | if path != "" { 45 | if _, err := os.Create(path); err != nil { 46 | return fmt.Errorf("could not create lock file at path [%s]: %s", path, err) 47 | } 48 | } 49 | return nil 50 | } 51 | 52 | // Unlock removes the lock file on the current savepath of the database 53 | func (d *Database) Unlock() error { 54 | path := d.lockPath() 55 | 56 | if path != "" { 57 | if err := os.Remove(path); err != nil { 58 | return fmt.Errorf("could not remove lock file at path [%s]: %s", path, err) 59 | } 60 | } 61 | return nil 62 | } 63 | 64 | // Locked returns whether or not the lockfile exists 65 | func (d *Database) Locked() bool { 66 | path := d.lockPath() 67 | if path == "" { 68 | return false 69 | } 70 | _, err := os.Stat(path) 71 | return err == nil 72 | } 73 | 74 | func (d *Database) Changed() bool { 75 | return d.changed 76 | } 77 | 78 | func (d *Database) SetChanged(changed bool) { 79 | d.changed = changed 80 | } 81 | 82 | var backupExtension = ".kpbackup" 83 | 84 | // BackupPath returns the path to which a backup can be written or restored 85 | func (d *Database) BackupPath() string { 86 | return d.Backend().Filename() + backupExtension 87 | } 88 | 89 | // Backup executes a backup, if the database exists, otherwise it will do nothing 90 | func (d *Database) Backup() error { 91 | path := d.Backend().Filename() 92 | if _, err := os.Stat(path); err != nil { 93 | // database path doesn't exist and doesn't need to be backed up 94 | return nil 95 | } 96 | 97 | data, err := os.ReadFile(path) 98 | if err != nil { 99 | return err 100 | } 101 | 102 | if err := os.WriteFile(d.BackupPath(), data, 0644); err != nil { 103 | return err 104 | } 105 | return nil 106 | } 107 | 108 | // RestoreBackup will restore a backup from the BackupPath() to the original file path. This will overwrite whatever's in the main location, handle with care 109 | func (d *Database) RestoreBackup() error { 110 | backupPath := d.BackupPath() 111 | 112 | path := d.Backend().Filename() 113 | 114 | if _, err := os.Stat(backupPath); err != nil { 115 | return fmt.Errorf("no backup exists at [%s] for [%s], cannot restore", backupPath, path) 116 | } 117 | 118 | if err := os.Rename(backupPath, path); err != nil { 119 | return fmt.Errorf("could not rename '%s' to '%s': %s", backupPath, path, err) 120 | } 121 | 122 | return nil 123 | } 124 | 125 | // RemoveBackup will delete the backup database 126 | func (d *Database) RemoveBackup() error { 127 | backupPath := d.BackupPath() 128 | 129 | if _, err := os.Stat(backupPath); err != nil { 130 | // no backup means we don't have to remove it either 131 | return nil 132 | } 133 | 134 | if err := os.Remove(backupPath); err != nil { 135 | return fmt.Errorf("could not remove file '%s': %s", backupPath, err) 136 | } 137 | return nil 138 | } 139 | 140 | // CurrentLocation returns the group currently used as the user's shell location in the DB 141 | func (d *Database) CurrentLocation() t.Group { 142 | return d.currentLocation 143 | } 144 | 145 | func (d *Database) SetCurrentLocation(g t.Group) { 146 | d.currentLocation = g 147 | } 148 | 149 | // Path will walk up the group hierarchy to determine the path to the current location 150 | func (d *Database) Path() (string, error) { 151 | path, err := d.CurrentLocation().Path() 152 | if err != nil { 153 | return path, fmt.Errorf("could not find path to current location in database: %s", err) 154 | } 155 | return path, err 156 | } 157 | 158 | // Search looks through a database for an entry matching a given term 159 | func (d *Database) Search(term *regexp.Regexp) (paths []string, err error) { 160 | return d.driver.Root().Search(term) 161 | } 162 | 163 | // SavePath is a shortcut for getting the backend's filename 164 | func (d *Database) SavePath() string { 165 | return d.Backend().Filename() 166 | } 167 | 168 | // SetSavePath is a shortcut for setting the backend filename 169 | func (d *Database) SetSavePath(path string) { 170 | backend := &Backend{ 171 | filename: path, 172 | // hash is blank since this is considered a new file 173 | hash: "", 174 | } 175 | d.SetBackend(backend) 176 | } 177 | -------------------------------------------------------------------------------- /internal/backend/tests/group.go: -------------------------------------------------------------------------------- 1 | package tests 2 | 3 | import ( 4 | "regexp" 5 | "testing" 6 | ) 7 | 8 | func RunTestNestedSubGroupPath(t *testing.T, r Resources) { 9 | sgName := "blipblip" 10 | sg, err := r.Group.NewSubgroup(sgName) 11 | if err != nil { 12 | t.Fatalf(err.Error()) 13 | } 14 | 15 | path, err := sg.Path() 16 | if err != nil { 17 | t.Fatalf(err.Error()) 18 | } 19 | 20 | expected := "/" + r.Group.Name() + "/" + sgName + "/" 21 | if path != expected { 22 | t.Fatalf("[%s] != [%s]", path, expected) 23 | } 24 | } 25 | 26 | func RunTestDoubleNestedGroupPath(t *testing.T, r Resources) { 27 | sgName := "blipblip" 28 | sg, err := r.Group.NewSubgroup(sgName) 29 | if err != nil { 30 | t.Fatalf(err.Error()) 31 | } 32 | 33 | sg1, err := sg.NewSubgroup(sgName + "1") 34 | if err != nil { 35 | t.Fatalf(err.Error()) 36 | } 37 | 38 | sgPath, err := sg.Path() 39 | if err != nil { 40 | t.Fatalf(err.Error()) 41 | } 42 | 43 | sg1Path, err := sg1.Path() 44 | if err != nil { 45 | t.Fatalf(err.Error()) 46 | } 47 | 48 | sgExpected := "/" + r.Group.Name() + "/" + sgName + "/" 49 | if sgPath != sgExpected { 50 | t.Fatalf("[%s] != [%s]", sgPath, sgExpected) 51 | } 52 | 53 | sg1Expected := sgExpected + sgName + "1" + "/" 54 | if sg1Path != sg1Expected { 55 | t.Fatalf("[%s] != [%s]", sgPath, sg1Expected) 56 | } 57 | } 58 | 59 | func RunTestGroupParentFunctions(t *testing.T, r Resources) { 60 | name := "TestGroupParentFunctions" 61 | 62 | // first test 'parent' when it is returning the root group 63 | sg, err := r.Db.Root().NewSubgroup(name) 64 | if err != nil { 65 | t.Fatalf(err.Error()) 66 | } 67 | 68 | parent := sg.Parent() 69 | if !parent.IsRoot() { 70 | t.Fatalf("subgroup of root group was not pointing at root") 71 | } 72 | 73 | // now test when parent returns a regular group 74 | subsg, err := sg.NewSubgroup(name) 75 | if err != nil { 76 | t.Fatalf(err.Error()) 77 | } 78 | 79 | parentUUID, err := subsg.Parent().UUIDString() 80 | if err != nil { 81 | t.Fatalf(err.Error()) 82 | } 83 | sgUUID, err := sg.UUIDString() 84 | if err != nil { 85 | t.Fatalf(err.Error()) 86 | } 87 | if sgUUID != parentUUID { 88 | t.Fatalf("[%s] != [%s]", sgUUID, parentUUID) 89 | } 90 | } 91 | 92 | func RunTestGroupUniqueness(t *testing.T, r Resources) { 93 | newGroupWrapper := r.BlankGroup 94 | newGroupWrapper.SetName(r.Entry.Title()) 95 | 96 | // groups should be able to have the same names as entries 97 | if err := r.Group.AddSubgroup(newGroupWrapper); err != nil { 98 | t.Fatalf("wasn't able to add subgroup when name conflicted with entry name") 99 | } 100 | 101 | name := newGroupWrapper.Name() 102 | if _, err := r.Group.NewSubgroup(name); err == nil { 103 | t.Fatalf("was able to add new group named '%s' twice", name) 104 | } 105 | 106 | if err := r.Group.AddSubgroup(newGroupWrapper); err == nil { 107 | t.Fatalf("added subgroup with same name as other subgroup in group") 108 | } 109 | } 110 | 111 | func RunTestRemoveSubgroup(t *testing.T, r Resources) { 112 | name := "TestRemoveSubgroup" 113 | 114 | originalLen := len(r.Group.Groups()) 115 | sg, err := r.Group.NewSubgroup(name) 116 | if err != nil { 117 | t.Fatalf(err.Error()) 118 | } 119 | 120 | if len(r.Group.Groups()) != originalLen+1 { 121 | t.Fatalf("[%d] != [%d]", len(r.Group.Groups()), originalLen+1) 122 | } 123 | if err := r.Group.RemoveSubgroup(sg); err != nil { 124 | t.Fatalf(err.Error()) 125 | } 126 | 127 | if len(r.Group.Groups()) != originalLen { 128 | t.Fatalf("[%d] != [%d]", len(r.Group.Groups()), originalLen) 129 | } 130 | 131 | if err := r.Group.RemoveSubgroup(sg); err == nil { 132 | t.Fatalf("removed subgroup twice") 133 | } 134 | } 135 | 136 | func RunTestGroupEntryFuncs(t *testing.T, r Resources) { 137 | if err := r.Group.AddEntry(r.Entry); err == nil { 138 | t.Fatalf("added duplicate entry: [%v][%v]", r.Entry, r.Group) 139 | } 140 | 141 | originalLen := len(r.Group.Entries()) 142 | if err := r.Group.RemoveEntry(r.Entry); err != nil { 143 | t.Fatalf(err.Error()) 144 | } 145 | 146 | if len(r.Group.Entries()) != originalLen-1 { 147 | t.Fatalf("[%d] != [%d]", len(r.Group.Entries()), originalLen-1) 148 | } 149 | 150 | if err := r.Group.RemoveEntry(r.Entry); err == nil { 151 | t.Fatalf("successfully removed non existent entry") 152 | } 153 | } 154 | 155 | func RunTestSubgroupSearch(t *testing.T, r Resources) { 156 | name := "TestSubgroupSearch" 157 | sg, err := r.Group.NewSubgroup(name) 158 | if err != nil { 159 | t.Fatalf(err.Error()) 160 | } 161 | 162 | paths, err := r.Group.Search(regexp.MustCompile(sg.Name())) 163 | if err != nil { 164 | t.Fatal(err) 165 | } 166 | if len(paths) != 1 { 167 | t.Fatalf("incorrect # of search results [%d]", len(paths)) 168 | } 169 | 170 | sgPath, err := sg.Path() 171 | if err != nil { 172 | t.Fatalf(err.Error()) 173 | } 174 | 175 | if paths[0] != sgPath { 176 | t.Fatalf("[%s] != [%s]", paths[0], sgPath) 177 | } 178 | } 179 | 180 | func RunTestIsRoot(t *testing.T, r Resources) { 181 | if r.Group.IsRoot() { 182 | t.Fatalf("non root group thinks it's root") 183 | } 184 | 185 | newGroupWrapper := r.BlankGroup 186 | if newGroupWrapper.IsRoot() { 187 | t.Fatalf("orphaned group with no parent thinks it's root") 188 | } 189 | } 190 | -------------------------------------------------------------------------------- /internal/commands/firefox-import.go: -------------------------------------------------------------------------------- 1 | package commands 2 | 3 | import ( 4 | "encoding/csv" 5 | "fmt" 6 | "os" 7 | "strconv" 8 | "strings" 9 | "time" 10 | 11 | "github.com/mostfunkyduck/ishell" 12 | c "github.com/mostfunkyduck/kp/internal/backend/common" 13 | t "github.com/mostfunkyduck/kp/internal/backend/types" 14 | ) 15 | 16 | const ( 17 | URL = "url" 18 | USERNAME = "username" 19 | PASSWORD = "password" 20 | HTTP_REALM = "httpRealm" 21 | FORM_ACTION_ORIGIN = "formActionOrigin" 22 | GUID = "guid" 23 | TIME_CREATED = "timeCreated" 24 | TIME_LAST_USED = "timeLastUsed" 25 | TIME_PASSWORD_CHANGED = "timePasswordChanged" 26 | ) 27 | 28 | var allFields = []string{ 29 | URL, USERNAME, PASSWORD, HTTP_REALM, FORM_ACTION_ORIGIN, 30 | GUID, TIME_CREATED, TIME_LAST_USED, TIME_PASSWORD_CHANGED, 31 | } 32 | 33 | func parseTimestamp(input string) (time.Time, error) { 34 | i, err := strconv.ParseInt(input[:10], 10, 64) 35 | if err != nil { 36 | return time.Time{}, err 37 | } 38 | return time.Unix(i, 0), nil 39 | } 40 | 41 | func parseCSV(shell *ishell.Shell, path string, location t.Group) (int, int, error) { 42 | updated := 0 43 | broken := 0 44 | f, err := os.Open(path) 45 | if err != nil { 46 | return updated, broken, fmt.Errorf("error opening %s", err) 47 | } 48 | defer f.Close() 49 | 50 | r := csv.NewReader(f) 51 | records, err := r.ReadAll() 52 | if err != nil { 53 | return updated, broken, fmt.Errorf("error reading CSV: %s", err) 54 | } 55 | // cache the header indices 56 | headers := make(map[string]int) 57 | for idx, val := range records[0] { 58 | headers[val] = idx 59 | } 60 | 61 | // verifying that the fields i expect to be there are there 62 | for _, v := range allFields { 63 | if _, exists := headers[v]; !exists { 64 | return updated, broken, fmt.Errorf("field '%s' not found in header row", v) 65 | } 66 | } 67 | 68 | for _, record := range records[1:] { 69 | username := record[headers[USERNAME]] 70 | password := record[headers[PASSWORD]] 71 | timeCreated := record[headers[TIME_CREATED]] 72 | timeLastUsed := record[headers[TIME_LAST_USED]] 73 | timePasswordChanged := record[headers[TIME_PASSWORD_CHANGED]] 74 | url := record[headers[URL]] 75 | title := fmt.Sprintf("%s (%s)", strings.TrimPrefix(url, "https://"), username) 76 | entry, err := location.NewEntry(title) 77 | if err != nil { 78 | shell.Println(fmt.Sprintf("error creating entry for '%s': %s", title, err)) 79 | broken++ 80 | continue 81 | } 82 | 83 | updated++ 84 | entry.SetUsername(username) 85 | entry.SetPassword(password) 86 | if uTime, err := parseTimestamp(timeCreated); err == nil { 87 | entry.SetCreationTime(uTime) 88 | } else { 89 | shell.Println(err) 90 | } 91 | if uTime, err := parseTimestamp(timeLastUsed); err == nil { 92 | entry.SetLastAccessTime(uTime) 93 | } else { 94 | shell.Println(err) 95 | } 96 | 97 | if uTime, err := parseTimestamp(timePasswordChanged); err == nil { 98 | entry.SetLastModificationTime(uTime) 99 | } else { 100 | shell.Println(err) 101 | } 102 | 103 | // FIXME: this is assuming kpv1 format, not a huge deal rn, but not what it should be 104 | entry.Set(c.NewValue( 105 | []byte(url), 106 | "URL", 107 | true, 108 | false, 109 | false, 110 | t.STRING, 111 | )) 112 | record[headers[PASSWORD]] = "REDACTED" 113 | entry.Set(c.NewValue( 114 | fmt.Appendf(nil, "original record: %v\n", record), 115 | "notes", 116 | true, 117 | false, 118 | false, 119 | t.LONGSTRING, 120 | )) 121 | } 122 | return updated, broken, nil 123 | } 124 | 125 | func FirefoxImport(shell *ishell.Shell) (f func(c *ishell.Context)) { 126 | return func(c *ishell.Context) { 127 | errString, ok := syntaxCheck(c, 2) 128 | if !ok { 129 | shell.Println(errString) 130 | return 131 | } 132 | 133 | csvPath := c.Args[0] 134 | dbPath := c.Args[1] 135 | db := shell.Get("db").(t.Database) 136 | 137 | pathBits := strings.Split(dbPath, "/") 138 | parentPath := strings.Join(pathBits[0:len(pathBits)-1], "/") 139 | location, entry, err := TraversePath(db, db.CurrentLocation(), parentPath) 140 | if err != nil { 141 | shell.Println("invalid path: " + err.Error()) 142 | return 143 | } 144 | 145 | if location == nil { 146 | shell.Println("location does not exist: " + dbPath) 147 | return 148 | } 149 | 150 | if entry != nil { 151 | shell.Println("path points to entry: %s" + dbPath) 152 | return 153 | } 154 | 155 | if location.IsRoot() { 156 | shell.Println("cannot import entries to root node") 157 | return 158 | } 159 | 160 | if len(location.Entries()) != 0 { 161 | shell.Printf("'%s' contains entries, this could cause conflicts, continue? (y/n)\n", dbPath) 162 | input := shell.ReadLine() 163 | if input != "y" { 164 | return 165 | } 166 | } 167 | 168 | updated, broken, err := parseCSV(shell, csvPath, location) 169 | if err != nil { 170 | shell.Printf("error importing '%s': %s\n", csvPath, err) 171 | if updated == 0 { 172 | return 173 | } 174 | } 175 | shell.Printf("%d entries were imported, %d entries were skipped\n", updated, broken) 176 | 177 | if err := PromptAndSave(shell); err != nil { 178 | shell.Printf("could not save database: %s\n", err) 179 | } 180 | } 181 | } 182 | -------------------------------------------------------------------------------- /internal/backend/common/entry.go: -------------------------------------------------------------------------------- 1 | package common 2 | 3 | // Entry is a wrapper around an entry driver, holding functions 4 | // common to both kp1 and kp2 5 | import ( 6 | "encoding/base64" 7 | "fmt" 8 | "regexp" 9 | "strings" 10 | "time" 11 | 12 | t "github.com/mostfunkyduck/kp/internal/backend/types" 13 | ) 14 | 15 | type Entry struct { 16 | db t.Database 17 | driver t.Entry 18 | } 19 | 20 | // findPathToEntry returns all the groups in the path leading to an entry *but not the entry itself* 21 | // the path returned will also not include the source group 22 | func findPathToEntry(source t.Group, target t.Entry) (rv []t.Group, err error) { 23 | // this library doesn't appear to support child->parent links, so we have to find the needful ourselves 24 | for _, entry := range source.Entries() { 25 | equal, err := CompareUUIDs(target, entry) 26 | if err != nil { 27 | return []t.Group{}, err 28 | } 29 | if equal { 30 | return []t.Group{source}, nil 31 | } 32 | } 33 | 34 | groups := source.Groups() 35 | for _, group := range groups { 36 | newGroups, err := findPathToEntry(group, target) 37 | if err != nil { 38 | // not putting the path in this error message because it might trigger an infinite loop 39 | // since this is part of the path traversal algo 40 | return []t.Group{}, fmt.Errorf("error finding path to '%s' from '%s': %s", target.Title(), group.Name(), err) 41 | } 42 | if len(newGroups) > 0 { 43 | return append([]t.Group{source}, newGroups...), nil 44 | } 45 | } 46 | return []t.Group{}, nil 47 | } 48 | 49 | // Path returns the fully qualified path to the entry, if there's no parent, only the name is returned 50 | func (e *Entry) Path() (path string, err error) { 51 | pathGroups, err := findPathToEntry(e.DB().Root(), e.driver) 52 | if err != nil { 53 | return path, fmt.Errorf("could not find path from root to %s: %s", e.driver.Title(), err) 54 | } 55 | 56 | for _, each := range pathGroups { 57 | path = path + each.Name() + "/" 58 | } 59 | path = path + e.driver.Title() 60 | return 61 | } 62 | 63 | func (e *Entry) Parent() t.Group { 64 | pathGroups, err := findPathToEntry(e.DB().Root(), e.driver) 65 | if err != nil { 66 | return nil 67 | } 68 | if len(pathGroups) == 0 { 69 | return nil 70 | } 71 | 72 | return pathGroups[len(pathGroups)-1] 73 | } 74 | 75 | func (e *Entry) SetParent(g t.Group) error { 76 | pathGroups, err := FindPathToGroup(e.DB().Root(), g) 77 | if len(pathGroups) == 0 || err != nil { 78 | errorString := fmt.Sprintf("could not find a path from the db root to '%s', is this a valid group?", g.Name()) 79 | 80 | if err != nil { 81 | errorString = errorString + fmt.Sprintf(" (error occurred: %s)", err) 82 | } 83 | return fmt.Errorf(errorString) 84 | } 85 | 86 | // this constitutes a move, so remove the entry from its old parent and put it in the new one 87 | if parent := e.Parent(); parent != nil { 88 | if err := parent.RemoveEntry(e.driver); err != nil { 89 | return fmt.Errorf("could not remove entry from old parent: %s", err) 90 | } 91 | } 92 | 93 | // add the now-orphaned entry to the new parent 94 | if err := g.AddEntry(e.driver); err != nil { 95 | return fmt.Errorf("cannot add entry to group: %s", err) 96 | } 97 | return nil 98 | } 99 | 100 | func (e *Entry) Output(full bool) (val string) { 101 | var b strings.Builder 102 | fmt.Fprintf(&b, "\n") 103 | // Output all the metadata first 104 | uuidString, err := e.driver.UUIDString() 105 | if err != nil { 106 | uuidString = fmt.Sprintf("", err) 107 | } 108 | 109 | // b64 the UUID string since it sometimes contains garbage characters, esp in v2 110 | fmt.Fprintf(&b, "UUID:\t%s\n", base64.StdEncoding.EncodeToString([]byte(uuidString))) 111 | fmt.Fprintf(&b, "Creation Time:\t%s\n", FormatTime(e.driver.CreationTime())) 112 | fmt.Fprintf(&b, "Last Modified:\t%s\n", FormatTime(e.driver.LastModificationTime())) 113 | fmt.Fprintf(&b, "Last Accessed:\t%s\n", FormatTime(e.driver.LastAccessTime())) 114 | expiredTime := e.driver.ExpiredTime() 115 | // do we want to highlight this as expired? 116 | highlightExpiry := false 117 | if expiredTime != (time.Time{}) && expiredTime.Before(time.Now()) { 118 | highlightExpiry = true 119 | fmt.Fprintf(&b, "\033[31m") 120 | } 121 | fmt.Fprintf(&b, "Expiration Date:\t%s\n", FormatTime(e.driver.ExpiredTime())) 122 | if highlightExpiry { 123 | fmt.Fprintf(&b, "\033[0m") 124 | } 125 | 126 | values, err := e.driver.Values() 127 | if err != nil { 128 | val = "error while reading values: " + err.Error() 129 | return 130 | } 131 | for _, val := range values { 132 | fmt.Fprintln(&b, val.Output(full)) 133 | } 134 | return b.String() 135 | } 136 | 137 | // TODO test various fields to make sure they are searchable, consider adding searchability toggle 138 | func (e *Entry) Search(term *regexp.Regexp) (paths []string, err error) { 139 | values, err := e.driver.Values() 140 | if err != nil { 141 | return []string{}, fmt.Errorf("error reading values from entry: %s", err) 142 | } 143 | for _, val := range values { 144 | if !val.Searchable() { 145 | continue 146 | } 147 | content := string(val.FormattedValue(true)) 148 | if term.FindString(content) != "" { 149 | // something in this entry matched, let's return it 150 | path, _ := e.Path() 151 | paths = append(paths, path) 152 | break 153 | } 154 | } 155 | 156 | return 157 | } 158 | 159 | func (e *Entry) DB() t.Database { 160 | return e.db 161 | } 162 | 163 | func (e *Entry) SetDB(db t.Database) { 164 | e.db = db 165 | } 166 | 167 | // SetDriver sets pointer to the version of itself that can access child methods... FIXME this is a bit of a mind bender 168 | func (e *Entry) SetDriver(entry t.Entry) { 169 | e.driver = entry 170 | } 171 | -------------------------------------------------------------------------------- /internal/backend/keepassv1/entry.go: -------------------------------------------------------------------------------- 1 | package keepassv1 2 | 3 | import ( 4 | "fmt" 5 | "strings" 6 | "time" 7 | 8 | c "github.com/mostfunkyduck/kp/internal/backend/common" 9 | t "github.com/mostfunkyduck/kp/internal/backend/types" 10 | "zombiezen.com/go/sandpass/pkg/keepass" 11 | ) 12 | 13 | // field name constants 14 | const ( 15 | fieldUn = "username" 16 | fieldPw = "password" 17 | fieldUrl = "URL" 18 | fieldNotes = "notes" 19 | fieldTitle = "title" 20 | fieldAttachment = "attachment" 21 | ) 22 | 23 | type Entry struct { 24 | c.Entry 25 | entry *keepass.Entry 26 | } 27 | 28 | func WrapEntry(entry *keepass.Entry, db t.Database) t.Entry { 29 | e := &Entry{ 30 | entry: entry, 31 | } 32 | e.SetDB(db) 33 | e.SetDriver(e) 34 | return e 35 | } 36 | 37 | func (e *Entry) UUIDString() (string, error) { 38 | return e.entry.UUID.String(), nil 39 | } 40 | 41 | func (e *Entry) Get(field string) (rv t.Value, present bool) { 42 | var value []byte 43 | var name = field 44 | searchable := true 45 | protected := false 46 | valueType := t.STRING 47 | switch strings.ToLower(field) { 48 | case strings.ToLower(fieldTitle): 49 | value = []byte(e.entry.Title) 50 | case strings.ToLower(fieldUn): 51 | value = []byte(e.entry.Username) 52 | case strings.ToLower(fieldPw): 53 | searchable = false 54 | protected = true 55 | value = []byte(e.entry.Password) 56 | case strings.ToLower(fieldUrl): 57 | value = []byte(e.entry.URL) 58 | case strings.ToLower(fieldNotes): 59 | value = []byte(e.entry.Notes) 60 | valueType = t.LONGSTRING 61 | case strings.ToLower(fieldAttachment): 62 | if !e.entry.HasAttachment() { 63 | return nil, false 64 | } 65 | return c.Attachment{ 66 | EntryValue: c.NewValue( 67 | e.entry.Attachment.Data, 68 | e.entry.Attachment.Name, 69 | searchable, 70 | protected, 71 | false, 72 | t.BINARY, 73 | ), 74 | }, true 75 | default: 76 | return nil, false 77 | } 78 | 79 | return c.NewValue( 80 | value, 81 | name, 82 | searchable, 83 | protected, 84 | false, 85 | valueType, 86 | ), true 87 | } 88 | 89 | func (e *Entry) Set(value t.Value) (updated bool) { 90 | updated = true 91 | field := value.Name() 92 | fieldValue := value.Value() 93 | 94 | if value.Type() == t.BINARY { 95 | e.entry.Attachment.Name = field 96 | e.entry.Attachment.Data = fieldValue 97 | return true 98 | } 99 | 100 | switch strings.ToLower(field) { 101 | case strings.ToLower(fieldTitle): 102 | e.entry.Title = string(fieldValue) 103 | case strings.ToLower(fieldUn): 104 | e.entry.Username = string(fieldValue) 105 | case strings.ToLower(fieldPw): 106 | e.entry.Password = string(fieldValue) 107 | case strings.ToLower(fieldUrl): 108 | e.entry.URL = string(fieldValue) 109 | case strings.ToLower(fieldNotes): 110 | e.entry.Notes = string(fieldValue) 111 | default: 112 | updated = false 113 | } 114 | 115 | return 116 | } 117 | 118 | func (e *Entry) LastAccessTime() time.Time { 119 | return e.entry.LastAccessTime 120 | } 121 | 122 | func (e *Entry) SetLastAccessTime(t time.Time) { 123 | e.entry.LastAccessTime = t 124 | } 125 | 126 | func (e *Entry) LastModificationTime() time.Time { 127 | return e.entry.LastModificationTime 128 | } 129 | 130 | func (e *Entry) SetLastModificationTime(t time.Time) { 131 | e.entry.LastModificationTime = t 132 | } 133 | 134 | func (e *Entry) CreationTime() time.Time { 135 | return e.entry.CreationTime 136 | } 137 | 138 | func (e *Entry) SetCreationTime(t time.Time) { 139 | e.entry.CreationTime = t 140 | } 141 | 142 | func (e *Entry) ExpiredTime() time.Time { 143 | return e.entry.ExpiryTime 144 | } 145 | 146 | func (e *Entry) SetExpiredTime(t time.Time) { 147 | e.entry.ExpiryTime = t 148 | } 149 | func (e *Entry) SetParent(g t.Group) error { 150 | if err := e.entry.SetParent(g.Raw().(*keepass.Group)); err != nil { 151 | return fmt.Errorf("could not set entry's group: %s", err) 152 | } 153 | return nil 154 | } 155 | 156 | func (e *Entry) Parent() t.Group { 157 | group := e.entry.Parent() 158 | if group == nil { 159 | return nil 160 | } 161 | return WrapGroup(group, e.DB()) 162 | } 163 | 164 | func (e *Entry) Path() (string, error) { 165 | parent := e.Parent() 166 | if parent == nil { 167 | // orphaned entry 168 | return e.Title(), nil 169 | } 170 | groupPath, err := e.Parent().Path() 171 | if err != nil { 172 | return "", fmt.Errorf("could not find path to entry: %s", err) 173 | } 174 | return groupPath + e.Title(), nil 175 | } 176 | 177 | func (e *Entry) Raw() interface{} { 178 | return e.entry 179 | } 180 | 181 | func (e *Entry) Password() string { 182 | v, _ := e.Get("password") 183 | return string(v.Value()) 184 | } 185 | 186 | func (e *Entry) SetPassword(password string) { 187 | e.Set(c.NewValue( 188 | []byte(password), 189 | "password", 190 | false, 191 | true, 192 | false, 193 | t.STRING, 194 | )) 195 | } 196 | 197 | func (e *Entry) Title() string { 198 | v, _ := e.Get("title") 199 | return string(v.Value()) 200 | } 201 | 202 | func (e *Entry) SetTitle(title string) { 203 | e.Set(c.NewValue( 204 | []byte(title), 205 | "title", 206 | true, 207 | false, 208 | false, 209 | t.STRING, 210 | )) 211 | } 212 | 213 | func (e *Entry) Values() (vals []t.Value, err error) { 214 | path, _ := e.Path() 215 | vals = append(vals, c.NewValue([]byte(path), "location", false, false, true, t.STRING)) 216 | for _, field := range []string{fieldTitle, fieldUrl, fieldUn, fieldPw, fieldNotes, fieldAttachment} { 217 | if v, present := e.Get(field); present { 218 | vals = append(vals, v) 219 | } 220 | } 221 | return 222 | } 223 | 224 | func (e *Entry) Username() string { 225 | v, _ := e.Get(fieldUn) 226 | return v.FormattedValue(true) 227 | } 228 | 229 | func (e *Entry) SetUsername(name string) { 230 | e.Set(c.NewValue( 231 | []byte(name), 232 | fieldUn, 233 | true, false, false, 234 | t.STRING, 235 | )) 236 | } 237 | -------------------------------------------------------------------------------- /internal/backend/types/types.go: -------------------------------------------------------------------------------- 1 | package types 2 | 3 | import ( 4 | "regexp" 5 | "time" 6 | ) 7 | 8 | type Version int 9 | 10 | const ( 11 | V1 Version = iota 12 | V2 13 | ) 14 | 15 | // abstracts a wrapper for v1 or v2 implementations to use to describe the database and to implement shell commands 16 | type KeepassWrapper interface { 17 | // Raw returns the underlying object that the wrapper wraps aroud 18 | Raw() interface{} 19 | 20 | // Path returns the path to the object's location 21 | Path() (string, error) 22 | 23 | // Search searches this object and all nested objects for a given regular expression 24 | Search(*regexp.Regexp) ([]string, error) 25 | } 26 | 27 | type Backend interface { 28 | // Filename returns the file name for the backend storage 29 | Filename() string 30 | 31 | // Hash returns the cached hash representing a unique state of the backend storage 32 | Hash() string 33 | 34 | // IsModified returns whether or not the backend has been modified since it was last hashed 35 | IsModified() (bool, error) 36 | } 37 | 38 | type Database interface { 39 | KeepassWrapper 40 | // Backend returns the functions backend struct 41 | Backend() Backend 42 | 43 | // Binary returns a binary with a given ID, naming it with a given name 44 | // the OptionalWrapper is used because v2 is the only version that implements this 45 | Binary(id int, name string) (OptionalWrapper, error) 46 | 47 | // Changed indicates whether the DB has been changed during the user's session 48 | Changed() bool 49 | SetChanged(bool) 50 | 51 | // CurrentLocation returns the current location for the shell 52 | CurrentLocation() Group 53 | SetCurrentLocation(Group) 54 | Root() Group 55 | Save() error 56 | 57 | // Init initializes a database wrapper, using the given parameters. Existing DB will be opened, otherwise the wrapper will be configured to save to that location 58 | Init(Options) error 59 | 60 | // Lock will lock the database by dropping a lockfile 61 | Lock() error 62 | 63 | // Unlock will remove the lockfile created by Lock() 64 | Unlock() error 65 | 66 | // Locked will determine if the lockfile is in place 67 | Locked() bool 68 | 69 | // SavePath and SetSavePath are shortcuts for managing the backend filename 70 | SavePath() string 71 | SetSavePath(string) 72 | 73 | // Version will return the Version enum for this database 74 | Version() Version 75 | } 76 | 77 | // Options are parameters to use for calls to the database interface's Init function 78 | type Options struct { 79 | // the path to the database 80 | DBPath string 81 | 82 | // the path to the key 83 | KeyPath string 84 | 85 | // the password for the database 86 | Password string 87 | 88 | // How many rounds of encryption to use for the new key (currently only supported by keepassv1) 89 | KeyRounds int 90 | } 91 | 92 | type UUIDer interface { 93 | // UUIDString returns the string form of this object's UUID 94 | UUIDString() (string, error) 95 | } 96 | type Group interface { 97 | KeepassWrapper 98 | UUIDer 99 | // Returns all entries in this group 100 | Entries() []Entry 101 | 102 | // Returns all groups nested in this group 103 | Groups() []Group 104 | 105 | // Returns this group's parent, if it has one 106 | Parent() Group 107 | SetParent(Group) error 108 | // inverse of 'SetParent', needed mainly for the internals of keepassv2 109 | AddEntry(Entry) error 110 | 111 | Name() string 112 | SetName(string) 113 | 114 | IsRoot() bool 115 | 116 | // Creates a new subgroup with a given name under this group 117 | NewSubgroup(name string) (Group, error) 118 | RemoveSubgroup(Group) error 119 | AddSubgroup(Group) error 120 | 121 | NewEntry(name string) (Entry, error) 122 | RemoveEntry(Entry) error 123 | } 124 | 125 | type Entry interface { 126 | UUIDer 127 | KeepassWrapper 128 | // Returns the value for a given field, or an empty struct if the field doesn't exist 129 | Get(string) (Value, bool) 130 | 131 | // Title and Password are needed to ensure that v1 and v2 both render 132 | // their specific representations of that data (they access it in different ways, fun times) 133 | Title() string 134 | SetTitle(string) 135 | 136 | Password() string 137 | SetPassword(string) 138 | 139 | Username() string 140 | SetUsername(name string) 141 | 142 | // Sets a given field to a given value, returns bool indicating whether or not the field was updated 143 | Set(value Value) bool 144 | 145 | LastAccessTime() time.Time 146 | SetLastAccessTime(time.Time) 147 | 148 | LastModificationTime() time.Time 149 | SetLastModificationTime(time.Time) 150 | 151 | CreationTime() time.Time 152 | SetCreationTime(time.Time) 153 | 154 | ExpiredTime() time.Time 155 | SetExpiredTime(time.Time) 156 | 157 | Parent() Group 158 | SetParent(Group) error 159 | 160 | // Formats an entry for printing 161 | Output(full bool) string 162 | 163 | // Values returns all referencable value fields from the database 164 | // 165 | // NOTE: values are not references, updating them must be done through the Set* functions 166 | Values() (values []Value, err error) 167 | 168 | // DB returns the Database this entry is associated with 169 | DB() Database 170 | SetDB(Database) 171 | } 172 | 173 | type ValueType int 174 | 175 | const ( 176 | STRING ValueType = iota 177 | LONGSTRING 178 | BINARY 179 | ) 180 | 181 | // OptionalWrapper wraps Values with functions that force the caller of a function to detect whether the value being 182 | // returned is implemented by the function, this is to help bridge the gap between v2 and v1 183 | // Proper usage: 184 | // 185 | // if wrapper.Present { 186 | // 187 | // } else { 188 | // 189 | // 190 | // } 191 | type OptionalWrapper struct { 192 | Present bool 193 | Value Value 194 | } 195 | 196 | type Value interface { 197 | FormattedValue(full bool) string 198 | Value() []byte 199 | Name() string 200 | NameTitle() string 201 | Searchable() bool 202 | Protected() bool 203 | ReadOnly() bool 204 | Output(showProtected bool) string 205 | Type() ValueType 206 | } 207 | -------------------------------------------------------------------------------- /internal/backend/tests/entry.go: -------------------------------------------------------------------------------- 1 | package tests 2 | 3 | import ( 4 | "fmt" 5 | "regexp" 6 | "strings" 7 | "testing" 8 | "time" 9 | 10 | c "github.com/mostfunkyduck/kp/internal/backend/common" 11 | "github.com/mostfunkyduck/kp/internal/backend/types" 12 | ) 13 | 14 | func RunTestNoParent(t *testing.T, r Resources) { 15 | name := "shmoo" 16 | e := r.Entry 17 | newVal := c.NewValue( 18 | []byte(name), 19 | "Title", 20 | false, 21 | false, 22 | false, 23 | types.STRING, 24 | ) 25 | 26 | if !e.Set(newVal) { 27 | t.Fatalf("could not set title") 28 | } 29 | output, err := e.Path() 30 | if err != nil { 31 | t.Fatalf(err.Error()) 32 | } 33 | // this guy has no parent, shouldn't even have the root "/" in the path 34 | if output != name { 35 | t.Fatalf("[%s] !+ [%s]", output, name) 36 | } 37 | 38 | if parent := e.Parent(); parent != nil { 39 | t.Fatalf("%v", parent) 40 | } 41 | } 42 | 43 | func RunTestRegularPath(t *testing.T, r Resources) { 44 | name := "asldkfjalskdfjasldkfjasfd" 45 | e, err := r.Group.NewEntry(name) 46 | if err != nil { 47 | t.Fatalf(err.Error()) 48 | } 49 | 50 | path, err := e.Path() 51 | if err != nil { 52 | t.Fatalf(err.Error()) 53 | } 54 | expected, err := r.Group.Path() 55 | if err != nil { 56 | t.Fatalf(err.Error()) 57 | } 58 | expected += name 59 | if path != expected { 60 | t.Fatalf("[%s] != [%s]", path, expected) 61 | } 62 | 63 | parent := r.Entry.Parent() 64 | if parent == nil { 65 | t.Fatalf("%v", r) 66 | } 67 | 68 | parentPath, err := parent.Path() 69 | if err != nil { 70 | t.Fatalf(err.Error()) 71 | } 72 | 73 | groupPath, err := r.Group.Path() 74 | if err != nil { 75 | t.Fatalf(err.Error()) 76 | } 77 | if parentPath != groupPath { 78 | t.Fatalf("[%s] != [%s]", parentPath, groupPath) 79 | } 80 | 81 | newEntry := r.BlankEntry 82 | if err := newEntry.SetParent(r.Group); err != nil { 83 | t.Fatalf(err.Error()) 84 | } 85 | 86 | entryPath, err := newEntry.Path() 87 | if err != nil { 88 | t.Fatalf(err.Error()) 89 | } 90 | 91 | groupPath, err = r.Group.Path() 92 | if err != nil { 93 | t.Fatalf(err.Error()) 94 | } 95 | 96 | expected = groupPath + newEntry.Title() 97 | if entryPath != expected { 98 | t.Fatalf("[%s] != [%s]", entryPath, expected) 99 | } 100 | } 101 | 102 | // kpv1 only supports a limited set of fields, so we have to let the caller 103 | // specify what value to set 104 | 105 | func RunTestEntryTimeFuncs(t *testing.T, r Resources) { 106 | newTime := time.Now().Add(time.Duration(1) * time.Hour) 107 | r.Entry.SetCreationTime(newTime) 108 | if !r.Entry.CreationTime().Equal(newTime) { 109 | t.Fatalf("%v, %v", newTime, r.Entry.CreationTime()) 110 | } 111 | 112 | newTime = newTime.Add(time.Duration(1) * time.Hour) 113 | r.Entry.SetLastModificationTime(newTime) 114 | if !r.Entry.LastModificationTime().Equal(newTime) { 115 | t.Fatalf("%v, %v", newTime, r.Entry.LastModificationTime()) 116 | } 117 | 118 | newTime = newTime.Add(time.Duration(1) * time.Hour) 119 | r.Entry.SetLastAccessTime(newTime) 120 | if !r.Entry.LastAccessTime().Equal(newTime) { 121 | t.Fatalf("%v, %v", newTime, r.Entry.LastAccessTime()) 122 | } 123 | } 124 | func RunTestEntryPasswordTitleFuncs(t *testing.T, r Resources) { 125 | password := "swordfish" 126 | r.Entry.SetPassword(password) 127 | if r.Entry.Password() != password { 128 | t.Fatalf("[%s] != [%s]", r.Entry.Password(), password) 129 | } 130 | 131 | title := "blobulence" 132 | r.Entry.SetTitle(title) 133 | if r.Entry.Title() != title { 134 | t.Fatalf("[%s] != [%s]", r.Entry.Title(), title) 135 | } 136 | } 137 | 138 | func RunTestSearchInNestedSubgroup(t *testing.T, r Resources) { 139 | sg, err := r.Group.NewSubgroup("RunTestSearchInNestedSubgroup") 140 | if err != nil { 141 | t.Fatalf(err.Error()) 142 | } 143 | 144 | e, err := sg.NewEntry("askdfhjaskjfhasf") 145 | if err != nil { 146 | t.Fatalf(err.Error()) 147 | } 148 | 149 | paths, err := r.Db.Root().Search(regexp.MustCompile(e.Title())) 150 | if err != nil { 151 | t.Fatal(err) 152 | } 153 | 154 | expected := "/" + r.Group.Name() + "/" + sg.Name() + "/" + e.Title() 155 | if paths[0] != expected { 156 | t.Fatalf("[%s] != [%s]", paths[0], expected) 157 | } 158 | } 159 | 160 | func testOutput(e types.Entry, full bool) (output string, failures string) { 161 | output = e.Output(full) 162 | values, err := e.Values() 163 | if err != nil { 164 | failures = "an error occurred: " + err.Error() 165 | return 166 | } 167 | for _, value := range values { 168 | expected := value.Output(full) 169 | if !strings.Contains(output, expected) { 170 | failures = fmt.Sprintf("%svalue [%s] should have been in output\n", failures, expected) 171 | } 172 | } 173 | return 174 | } 175 | 176 | func RunTestOutput(t *testing.T, e types.Entry) { 177 | // layer on a bunch of test values on top of the defaults, using the entry generated by the caller 178 | newVal := c.NewValue( 179 | []byte("this is a test binary"), 180 | "test binary", 181 | false, false, false, 182 | types.BINARY, 183 | ) 184 | e.Set(newVal) 185 | 186 | newVal = c.NewValue( 187 | []byte("this is a test longstring\nlong string\nlongstring"), 188 | "test protected longstring", 189 | false, true, false, 190 | types.LONGSTRING, 191 | ) 192 | e.Set(newVal) 193 | 194 | if output, failures := testOutput(e, true); failures != "" { 195 | t.Fatalf("testing full output failed: \n output:\n%s\nfailures:\n%s", output, failures) 196 | } 197 | 198 | if output, failures := testOutput(e, false); failures != "" { 199 | t.Fatalf("testing full output failed: \n output:\n%s\nfailures:\n%s", output, failures) 200 | } 201 | 202 | if failures := RunTestFormatTime(e); failures != "" { 203 | t.Fatalf("testing time formatting failed: %s", failures) 204 | } 205 | } 206 | 207 | // This tests the FormatTime utility function via the creation time field output of an entry 208 | func RunTestFormatTime(e types.Entry) (failures string) { 209 | tests := map[*regexp.Regexp]time.Time{ 210 | // test exactly one day 211 | regexp.MustCompile(`Creation Time:\t.*\(1 day\(s\) ago\)`): time.Now().Add(time.Duration(-1) * time.Hour * 24), 212 | // test a full 2 days 213 | regexp.MustCompile(`Creation Time:\t.*\(2 day\(s\) ago\)`): time.Now().Add(time.Duration(-1) * time.Hour * 24 * 2), 214 | // test a month 215 | regexp.MustCompile(`Creation Time:\t.*\(about 1 month\(s\) ago\)`): time.Now().Add(time.Duration(-1) * time.Hour * 24 * 32), 216 | // test more than 2 months 217 | regexp.MustCompile(`Creation Time:\t.*\(about 2 month\(s\) ago\)`): time.Now().Add(time.Duration(-1) * time.Hour * 24 * 65), 218 | // test a year 219 | regexp.MustCompile(`Creation Time:\t.*\(about 1 year\(s\) ago\)`): time.Now().Add(time.Duration(-1) * time.Hour * 24 * 365), 220 | // test 2 years 221 | regexp.MustCompile(`Creation Time:\t.*\(about 2 year\(s\) ago\)`): time.Now().Add(time.Duration(-1) * time.Hour * 24 * 365 * 2), 222 | } 223 | 224 | for expression, timestamp := range tests { 225 | e.SetCreationTime(timestamp) 226 | str := e.Output(true) 227 | if !expression.Match([]byte(str)) { 228 | failures = fmt.Sprintf("%soutput [%s] doesn't contain creation time [%s]\n", failures, str, expression) 229 | } 230 | } 231 | return failures 232 | } 233 | -------------------------------------------------------------------------------- /internal/backend/keepassv2/group.go: -------------------------------------------------------------------------------- 1 | package keepassv2 2 | 3 | import ( 4 | "encoding/base64" 5 | "fmt" 6 | 7 | c "github.com/mostfunkyduck/kp/internal/backend/common" 8 | t "github.com/mostfunkyduck/kp/internal/backend/types" 9 | gokeepasslib "github.com/tobischo/gokeepasslib/v3" 10 | ) 11 | 12 | type Group struct { 13 | c.Group 14 | group *gokeepasslib.Group 15 | } 16 | 17 | func (g *Group) Raw() interface{} { 18 | return g.group 19 | } 20 | 21 | // WrapGroup wraps a bare gokeepasslib.Group and a database in a Group wrapper 22 | func WrapGroup(g *gokeepasslib.Group, db t.Database) t.Group { 23 | if g == nil { 24 | return nil 25 | } 26 | gr := &Group{ 27 | group: g, 28 | } 29 | gr.SetDB(db) 30 | gr.SetDriver(gr) 31 | return gr 32 | } 33 | 34 | func (g *Group) Groups() (rv []t.Group) { 35 | for i := range g.group.Groups { 36 | rv = append(rv, WrapGroup(&g.group.Groups[i], g.DB())) 37 | } 38 | return 39 | } 40 | 41 | func (g *Group) Entries() (rv []t.Entry) { 42 | for i := range g.group.Entries { 43 | rv = append(rv, WrapEntry(&g.group.Entries[i], g.DB())) 44 | } 45 | return 46 | } 47 | 48 | func (g *Group) Parent() t.Group { 49 | pathGroups, err := c.FindPathToGroup(g.DB().Root(), g) 50 | if err != nil { 51 | return nil 52 | } 53 | if len(pathGroups) > 0 { 54 | return pathGroups[len(pathGroups)-1] 55 | } 56 | return nil 57 | } 58 | 59 | func (g *Group) SetParent(parent t.Group) error { 60 | oldParent := g.Parent() 61 | 62 | // If the group is being renamed, the parents will be the same 63 | if oldParent != nil { 64 | sameParent, err := c.CompareUUIDs(oldParent, parent) 65 | if err != nil { 66 | return fmt.Errorf("error comparing new parent UUID to old parent UUID: %s", err) 67 | } 68 | if sameParent { 69 | return nil 70 | } 71 | } 72 | 73 | // Since there's no child->parent relationship in this library, we need 74 | // to rely on the parent->child connection to get this to work 75 | if err := parent.AddSubgroup(g); err != nil { 76 | return err 77 | } 78 | 79 | // Since "parent" is defined as "being in a group's subgroup", the group may now have two of them, 80 | // we need to make sure it's only in one 81 | if oldParent != nil { 82 | if err := oldParent.RemoveSubgroup(g); err != nil { 83 | // the group doesn't exist in the parent anymore or the UUIDs got corrupted 84 | // stop at this point since something got corrupted 85 | return fmt.Errorf("error removing group from existing parent, possible data corruption has occurred: %s", err) 86 | } 87 | } 88 | return nil 89 | } 90 | 91 | func (g *Group) Name() string { 92 | return g.group.Name 93 | } 94 | 95 | func (g *Group) SetName(name string) { 96 | g.group.Name = name 97 | } 98 | 99 | func (g *Group) IsRoot() bool { 100 | // there's a separate struct for root, this one is always used for subgroups 101 | return false 102 | } 103 | 104 | // Creates a new subgroup with a given name under this group 105 | func (g *Group) NewSubgroup(name string) (t.Group, error) { 106 | newGroup := gokeepasslib.NewGroup() 107 | newGroupWrapper := WrapGroup(&newGroup, g.DB()) 108 | newGroupWrapper.SetName(name) 109 | if err := newGroupWrapper.SetParent(g); err != nil { 110 | return &Group{}, fmt.Errorf("couldn't assign new group to parent '%s'; %s", g.Name(), err) 111 | } 112 | return newGroupWrapper, nil 113 | } 114 | 115 | func (g *Group) updateWrapper(group *gokeepasslib.Group) { 116 | g.group = group 117 | } 118 | 119 | func (g *Group) AddSubgroup(subgroup t.Group) error { 120 | for _, each := range g.Groups() { 121 | if each.Name() == subgroup.Name() { 122 | return fmt.Errorf("group named '%s' already exists", each.Name()) 123 | } 124 | } 125 | 126 | g.group.Groups = append(g.group.Groups, *subgroup.Raw().(*gokeepasslib.Group)) 127 | subgroup.(*Group).updateWrapper(&g.group.Groups[len(g.group.Groups)-1]) 128 | return nil 129 | } 130 | 131 | // RemoveSubgroup will remove a group from a parent group 132 | // If this function returns an error, that means that either the UUIDs on the parent or child 133 | // were corrupted or the group didn't actually exist in the parent 134 | func (g *Group) RemoveSubgroup(subgroup t.Group) error { 135 | subUUID, err := subgroup.UUIDString() 136 | if err != nil { 137 | return fmt.Errorf("could not read UUID on subgroup '%s': %s", subgroup.Name(), err) 138 | } 139 | 140 | for i, each := range g.group.Groups { 141 | eachWrapper := WrapGroup(&each, g.DB()) 142 | eachUUID, err := eachWrapper.UUIDString() 143 | if err != nil { 144 | return fmt.Errorf("could not read UUID on '%s': %s", eachWrapper.Name(), err) 145 | } 146 | 147 | if eachUUID == subUUID { 148 | // remove it 149 | raw := g.group 150 | groupLen := len(raw.Groups) 151 | raw.Groups = append(raw.Groups[0:i], raw.Groups[i+1:groupLen]...) 152 | return nil 153 | } 154 | } 155 | return fmt.Errorf("could not find group with UUID '%s'", subUUID) 156 | } 157 | 158 | func (g *Group) AddEntry(e t.Entry) error { 159 | for _, each := range g.Entries() { 160 | if each.Title() == e.Title() { 161 | return fmt.Errorf("entry named '%s' already exists", each.Title()) 162 | } 163 | } 164 | g.group.Entries = append(g.group.Entries, *e.Raw().(*gokeepasslib.Entry)) 165 | // TODO update entry wrapper 166 | return nil 167 | } 168 | func (g *Group) NewEntry(name string) (t.Entry, error) { 169 | entry := gokeepasslib.NewEntry() 170 | entryWrapper := WrapEntry(&entry, g.DB()) 171 | // the order in which these values are added determines how they are output in the terminal 172 | // both for prompts and output 173 | entryWrapper.SetTitle(name) 174 | entryWrapper.Set(c.NewValue( 175 | []byte(""), 176 | "URL", 177 | true, false, false, 178 | t.STRING, 179 | )) 180 | entryWrapper.Set(c.NewValue( 181 | // This needs to be formatted this way to tie in to how keepass2 looks for usernames 182 | []byte(""), 183 | "UserName", 184 | true, false, false, 185 | t.STRING, 186 | )) 187 | entryWrapper.SetPassword("") 188 | entryWrapper.Set(c.NewValue( 189 | []byte(""), 190 | "Notes", 191 | true, false, false, 192 | t.LONGSTRING, 193 | )) 194 | if err := entryWrapper.SetParent(g); err != nil { 195 | return nil, fmt.Errorf("could not add entry to group: %s", err) 196 | } 197 | return entryWrapper, nil 198 | } 199 | 200 | func (g *Group) RemoveEntry(entry t.Entry) error { 201 | raw := g.group 202 | entryUUID, err := entry.UUIDString() 203 | if err != nil { 204 | return fmt.Errorf("cannot read UUID string on target entry '%s': %s", entry.Title(), err) 205 | } 206 | for i, each := range raw.Entries { 207 | eachWrapper := WrapEntry(&each, g.DB()) 208 | eachUUID, err := eachWrapper.UUIDString() 209 | if err != nil { 210 | return fmt.Errorf("cannot read UUID string on individual entry '%s': %s", eachWrapper.Title(), err) 211 | } 212 | if eachUUID == entryUUID { 213 | entriesLen := len(raw.Entries) 214 | raw.Entries = append(raw.Entries[0:i], raw.Entries[i+1:entriesLen]...) 215 | return nil 216 | } 217 | } 218 | return fmt.Errorf("could not find entry with UUID '%s'", entryUUID) 219 | } 220 | 221 | func (g *Group) UUIDString() (string, error) { 222 | encodedUUID, err := g.group.UUID.MarshalText() 223 | if err != nil { 224 | return "", fmt.Errorf("could not encode UUID: %s", err) 225 | } 226 | str, err := base64.StdEncoding.DecodeString(string(encodedUUID)) 227 | if err != nil { 228 | return "", fmt.Errorf("could not decode b64: %s", err) 229 | } 230 | return string(str), nil 231 | } 232 | -------------------------------------------------------------------------------- /internal/backend/keepassv2/entry.go: -------------------------------------------------------------------------------- 1 | package keepassv2 2 | 3 | import ( 4 | "encoding/base64" 5 | "fmt" 6 | "strings" 7 | "time" 8 | 9 | c "github.com/mostfunkyduck/kp/internal/backend/common" 10 | t "github.com/mostfunkyduck/kp/internal/backend/types" 11 | g "github.com/tobischo/gokeepasslib/v3" 12 | w "github.com/tobischo/gokeepasslib/v3/wrappers" 13 | ) 14 | 15 | // field name constants 16 | const ( 17 | fieldUn = "UserName" 18 | fieldPw = "Password" 19 | fieldUrl = "URL" 20 | fieldNotes = "Notes" 21 | fieldTitle = "Title" 22 | ) 23 | 24 | type Entry struct { 25 | c.Entry 26 | entry *g.Entry 27 | } 28 | 29 | func WrapEntry(entry *g.Entry, db t.Database) t.Entry { 30 | wrapper := &Entry{ 31 | entry: entry, 32 | } 33 | wrapper.SetDB(db) 34 | wrapper.SetDriver(wrapper) 35 | return wrapper 36 | } 37 | 38 | func (e *Entry) Raw() interface{} { 39 | return e.entry 40 | } 41 | 42 | // returns the fully qualified path to the entry, if there's no parent, only the name is returned 43 | func (e *Entry) UUIDString() (string, error) { 44 | encodedUUID, err := e.entry.UUID.MarshalText() 45 | if err != nil { 46 | return "", fmt.Errorf("could not encode UUID: %s", err) 47 | } 48 | str, err := base64.StdEncoding.DecodeString(string(encodedUUID)) 49 | if err != nil { 50 | return "", fmt.Errorf("could not decode b64: %s", err) 51 | } 52 | return string(str), nil 53 | } 54 | 55 | func (e Entry) Get(field string) (t.Value, bool) { 56 | values, err := e.Values() 57 | if err != nil { 58 | // swallowing 59 | return nil, false 60 | } 61 | 62 | for _, value := range values { 63 | if value.Name() == field { 64 | return value, true 65 | } 66 | } 67 | return nil, false 68 | } 69 | 70 | func (e *Entry) Set(value t.Value) bool { 71 | for i, each := range e.entry.Values { 72 | if each.Key == value.Name() { 73 | oldContent := each.Value.Content 74 | 75 | // TODO filter for binaries here, bad shit will happen if you try to attach this way :D 76 | each.Value.Content = string(value.Value()) 77 | 78 | // since we don't get to use pointers, update the slice directly 79 | e.entry.Values[i] = each 80 | 81 | return (oldContent != string(value.Value())) 82 | } 83 | } 84 | // no existing value to update, create it fresh 85 | e.entry.Values = append(e.entry.Values, g.ValueData{ 86 | Key: value.Name(), 87 | Value: g.V{ 88 | Content: string(value.Value()), 89 | Protected: w.NewBoolWrapper(value.Protected()), 90 | }, 91 | }) 92 | return true 93 | } 94 | 95 | func (e *Entry) LastAccessTime() time.Time { 96 | if e.entry.Times.LastAccessTime == nil { 97 | return time.Time{} 98 | } 99 | return e.entry.Times.LastAccessTime.Time 100 | } 101 | 102 | func (e *Entry) SetLastAccessTime(t time.Time) { 103 | e.entry.Times.LastAccessTime = &w.TimeWrapper{Time: t} 104 | } 105 | 106 | func (e *Entry) LastModificationTime() time.Time { 107 | if e.entry.Times.LastModificationTime == nil { 108 | return time.Time{} 109 | } 110 | return e.entry.Times.LastModificationTime.Time 111 | } 112 | func (e *Entry) SetLastModificationTime(t time.Time) { 113 | e.entry.Times.LastModificationTime = &w.TimeWrapper{Time: t} 114 | } 115 | 116 | func (e *Entry) CreationTime() time.Time { 117 | if e.entry.Times.CreationTime == nil { 118 | return time.Time{} 119 | } 120 | return e.entry.Times.CreationTime.Time 121 | } 122 | 123 | func (e *Entry) SetCreationTime(t time.Time) { 124 | e.entry.Times.CreationTime = &w.TimeWrapper{Time: t} 125 | } 126 | 127 | func (e *Entry) ExpiredTime() time.Time { 128 | if e.entry.Times.ExpiryTime == nil { 129 | return time.Time{} 130 | } 131 | return e.entry.Times.ExpiryTime.Time 132 | } 133 | 134 | func (e *Entry) SetExpiredTime(t time.Time) { 135 | e.entry.Times.ExpiryTime = &w.TimeWrapper{Time: t} 136 | } 137 | 138 | func (e *Entry) Values() (values []t.Value, err error) { 139 | // we need to arrange this with the regular, "default" values that appear in v1 coming first 140 | // to preserve UX and predictability of where the fields appear 141 | 142 | // NOTE: the capitalization/formatting here will be how the default value for the field is rendered 143 | // default values will be set by comparing the actual values in the underlying entry to this list using strings.EqualFold 144 | // the code uses the existing formatting if it exists, otherwise it will pull from here 145 | orderedDefaultValues := []string{fieldTitle, fieldUrl, fieldUn, fieldPw, fieldNotes} 146 | 147 | defaultValues := map[string]t.Value{} 148 | for _, each := range e.entry.Values { 149 | valueType := t.STRING 150 | 151 | // notes are always "long", as are strings where the user already entered a lot of spew 152 | if len(each.Value.Content) > 30 || strings.ToLower(each.Key) == "notes" { 153 | valueType = t.LONGSTRING 154 | } 155 | 156 | // build the Value object that will wrap this actual value 157 | newValue := c.NewValue( 158 | []byte(each.Value.Content), 159 | each.Key, 160 | true, // this may have to change if location is embedded in an entry like it is in v1 161 | each.Value.Protected.Bool, false, 162 | valueType, 163 | ) 164 | defaultValue := false 165 | for _, val := range orderedDefaultValues { 166 | if strings.EqualFold(each.Key, val) { 167 | defaultValue = true 168 | defaultValues[val] = newValue 169 | } 170 | } 171 | if !defaultValue { 172 | values = append(values, newValue) 173 | } 174 | } 175 | 176 | // prepend the non-default values with the defaults, in expected order 177 | defaultValueObjects := []t.Value{} 178 | for _, val := range orderedDefaultValues { 179 | valObject := defaultValues[val] 180 | if valObject == nil { 181 | protected := strings.EqualFold(val, "password") 182 | valObject = c.NewValue( 183 | []byte(""), 184 | val, 185 | true, 186 | protected, 187 | false, 188 | t.STRING, 189 | ) 190 | } 191 | defaultValueObjects = append(defaultValueObjects, valObject) 192 | } 193 | values = append(defaultValueObjects, values...) 194 | 195 | // Prepend everything with the location 196 | path, err := e.Path() 197 | if err != nil { 198 | return []t.Value{}, fmt.Errorf("could not retrieve entry's path: %s", err) 199 | } 200 | 201 | values = append([]t.Value{ 202 | c.NewValue( 203 | []byte(path), 204 | "location", 205 | false, false, true, 206 | t.STRING, 207 | ), 208 | }, values...) 209 | 210 | // now append entries for the binaries 211 | for _, each := range e.entry.Binaries { 212 | binary, err := e.DB().Binary(each.Value.ID, each.Name) 213 | if err != nil { 214 | return []t.Value{}, fmt.Errorf("could not retrieve binary named '%s' with ID '%d': %s", each.Name, each.Value.ID, err) 215 | } 216 | if !binary.Present { 217 | return []t.Value{}, fmt.Errorf("binary retrieval not implemented, this shouldn't happen on v2, but here we are") 218 | } 219 | values = append(values, binary.Value) 220 | } 221 | 222 | return 223 | } 224 | 225 | func (e *Entry) SetPassword(password string) { 226 | e.Set(c.NewValue( 227 | []byte(password), 228 | "Password", 229 | false, true, false, 230 | t.STRING, 231 | )) 232 | } 233 | 234 | func (e *Entry) Password() string { 235 | return e.entry.GetPassword() 236 | } 237 | 238 | func (e *Entry) SetTitle(title string) { 239 | e.Set(c.NewValue( 240 | []byte(title), 241 | "Title", 242 | true, false, false, 243 | t.STRING, 244 | )) 245 | } 246 | func (e *Entry) Title() string { 247 | return e.entry.GetTitle() 248 | } 249 | 250 | func (e *Entry) Username() string { 251 | v, _ := e.Get(fieldUn) 252 | return v.FormattedValue(true) 253 | } 254 | 255 | func (e *Entry) SetUsername(name string) { 256 | e.Set(c.NewValue( 257 | []byte(name), 258 | fieldUn, 259 | true, false, false, 260 | t.STRING, 261 | )) 262 | } 263 | -------------------------------------------------------------------------------- /internal/backend/keepassv2/database.go: -------------------------------------------------------------------------------- 1 | package keepassv2 2 | 3 | import ( 4 | "fmt" 5 | "io" 6 | "os" 7 | 8 | c "github.com/mostfunkyduck/kp/internal/backend/common" 9 | t "github.com/mostfunkyduck/kp/internal/backend/types" 10 | g "github.com/tobischo/gokeepasslib/v3" 11 | ) 12 | 13 | type Database struct { 14 | c.Database 15 | db *g.Database 16 | } 17 | 18 | func (d *Database) Raw() interface{} { 19 | return d.db 20 | } 21 | 22 | func (d *Database) Root() t.Group { 23 | return &RootGroup{ 24 | db: d, 25 | root: d.db.Content.Root, 26 | } 27 | } 28 | 29 | func writeDB(db *g.Database, path string) error { 30 | f, err := os.Create(path) 31 | if err != nil { 32 | return fmt.Errorf("could not open file '%s': %s", path, err) 33 | } 34 | 35 | if err := db.LockProtectedEntries(); err != nil { 36 | panic(fmt.Sprintf("could not encrypt protected entries! database may be corrupted, save was not attempted: %s", err)) 37 | } 38 | defer func() { 39 | if err := db.UnlockProtectedEntries(); err != nil { 40 | panic(fmt.Sprintf("could not decrypt protected entries! database may be corrupted, save was attempted: %s", err)) 41 | } 42 | }() 43 | encoder := g.NewEncoder(f) 44 | if err := encoder.Encode(db); err != nil { 45 | return fmt.Errorf("could not write database: %s", err) 46 | } 47 | return nil 48 | } 49 | 50 | func (d *Database) Save() error { 51 | 52 | if err := d.Backup(); err != nil { 53 | return fmt.Errorf("could not back up database: %s", err) 54 | } 55 | 56 | modified, err := d.Backend().IsModified() 57 | if err != nil { 58 | return fmt.Errorf("could not verify that the backend was unmodified: %s", err) 59 | } 60 | 61 | if modified { 62 | return fmt.Errorf("backend storage has been modified! please reopen before modifying to avoid corrupting or overwriting changes! (changes made since the last save will not be persisted)") 63 | } 64 | 65 | path := d.Backend().Filename() 66 | 67 | if err := writeDB(d.db, path); err != nil { 68 | // TODO put this call in v1 too 69 | if backupErr := d.RestoreBackup(); backupErr != nil { 70 | return fmt.Errorf("could not save database: %s. also could not restore backup after failed save: %s", err, backupErr) 71 | } 72 | return fmt.Errorf("could not save database: %s", err) 73 | } 74 | if err := d.RemoveBackup(); err != nil { 75 | return fmt.Errorf("could not remove backup after successful save: %s", err) 76 | } 77 | 78 | backend, err := c.InitBackend(path) 79 | if err != nil { 80 | return fmt.Errorf("failed to initialize backend: %s", err) 81 | } 82 | 83 | d.SetBackend(backend) 84 | return nil 85 | } 86 | 87 | // Binary returns a Value in an OptionalWrapper representing a binary 88 | // because v2 stores half the metadata in the entry and half in the database, 89 | // this function takes a 'Name' parameter so it can properly create the values 90 | // Returns an empty Value (not even with a Name) if the binary doesn't exit, 91 | // Returns a full Value if it does 92 | func (d *Database) Binary(id int, name string) (t.OptionalWrapper, error) { 93 | binaryMeta := d.db.Content.Meta.Binaries 94 | meta := binaryMeta.Find(id) 95 | if meta == nil { 96 | return t.OptionalWrapper{ 97 | Present: true, 98 | Value: nil, 99 | }, nil 100 | } 101 | 102 | content, err := meta.GetContent() 103 | if err == io.EOF { 104 | content = "" 105 | } else if err != nil { 106 | return t.OptionalWrapper{Present: true}, err 107 | } 108 | return t.OptionalWrapper{ 109 | Present: true, 110 | Value: c.NewValue( 111 | []byte(content), 112 | name, 113 | false, false, false, 114 | t.BINARY, 115 | ), 116 | }, nil 117 | } 118 | 119 | // open is a utility function to open the path stored as a database's SavePath 120 | func (d *Database) open(opts t.Options) error { 121 | path := d.SavePath() 122 | 123 | dbReader, err := os.Open(path) 124 | if err != nil { 125 | return fmt.Errorf("could not open db file [%s]: %s", path, err) 126 | } 127 | 128 | err = g.NewDecoder(dbReader).Decode(d.db) 129 | if err != nil { 130 | // we need to swallow this error because it spews insane amounts of garbage for no good reason 131 | return fmt.Errorf("could not open database: is the password correct?") 132 | } 133 | if err := d.db.UnlockProtectedEntries(); err != nil { 134 | return fmt.Errorf("could not unlock protected entries: %s\n", err) 135 | } 136 | return nil 137 | } 138 | 139 | // Init will initialize the database. 140 | func (d *Database) Init(opts t.Options) error { 141 | d.SetDriver(d) 142 | // the gokeepasslib always wants to start with a fresh DB 143 | // to use a DB on the filesystem, we will stomp this with a call to the appropriate decode function 144 | d.db = g.NewDatabase() 145 | // the v2 library prepopulates the db with a bunch of sample data, let's purge it 146 | if err := d.purge(); err != nil { 147 | return fmt.Errorf("failed to clean DB of sample data after initialization: %s", err) 148 | } 149 | backend, err := c.InitBackend(opts.DBPath) 150 | if err != nil { 151 | return fmt.Errorf("failed to initialize backend: %s", err) 152 | } 153 | 154 | d.SetBackend(backend) 155 | creds, err := d.credentials(opts) 156 | if err != nil { 157 | return fmt.Errorf("could not build credentials for given password: %s", err) 158 | } 159 | d.db.Credentials = creds 160 | 161 | // if the db already exists, open it, otherwise do an initial save and create the file 162 | if _, err := os.Stat(opts.DBPath); err == nil { 163 | if err := d.open(opts); err != nil { 164 | return err 165 | } 166 | } else { 167 | // this is a new db 168 | if err := d.Save(); err != nil { 169 | return fmt.Errorf("could not save newly created database: %s", err) 170 | } 171 | } 172 | 173 | d.SetCurrentLocation(d.Root()) 174 | 175 | return nil 176 | } 177 | 178 | // purge will clean out all entries and groups from a database. Handle with care. Only provided to clean database of sample data on init 179 | // should not be exposed to the user b/c that's far too dangerous 180 | func (d *Database) purge() error { 181 | var failedRemovals string 182 | root := d.Root() 183 | for _, group := range root.Groups() { 184 | // removing the group from the root will orphan and effectively remove all the children of that group 185 | if err := root.RemoveSubgroup(group); err != nil { 186 | failedRemovals = failedRemovals + "," + group.Name() 187 | } 188 | } 189 | 190 | if failedRemovals != "" { 191 | return fmt.Errorf("failed to purge at least one group from the DB root, failed groups: [%s]", failedRemovals) 192 | } 193 | return nil 194 | } 195 | 196 | // credentials generates a the credentials for use in unlocking the database 197 | func (d *Database) credentials(opts t.Options) (*g.DBCredentials, error) { 198 | // an empty password here is treated as valid, no special handling needed, it can be passed straight 199 | // to the keepassv2 library 200 | if opts.KeyPath == "" { 201 | // no key, we only need password creds 202 | creds := g.NewPasswordCredentials(opts.Password) 203 | return creds, nil 204 | } 205 | 206 | // There's a key, we need key/password creds 207 | keyReader, err := os.Open(opts.KeyPath) 208 | if err != nil { 209 | return nil, fmt.Errorf("could not open key at path [%s]: %s", opts.KeyPath, err) 210 | } 211 | 212 | keyData, err := io.ReadAll(keyReader) 213 | if err != nil { 214 | return nil, fmt.Errorf("could not read key that was opened from path [%s]: %s", opts.KeyPath, err) 215 | } 216 | 217 | creds, err := g.NewPasswordAndKeyDataCredentials(opts.Password, keyData) 218 | if err != nil { 219 | return creds, fmt.Errorf("could not build key/password credentials: %s", err) 220 | } 221 | 222 | return creds, nil 223 | } 224 | 225 | // Version returns the t.Version enum for this DB 226 | func (d *Database) Version() t.Version { 227 | return t.V2 228 | } 229 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/AlecAivazis/survey/v2 v2.3.6 h1:NvTuVHISgTHEHeBFqt6BHOe4Ny/NwGZr7w+F8S9ziyw= 2 | github.com/AlecAivazis/survey/v2 v2.3.6/go.mod h1:4AuI9b7RjAR+G7v9+C4YSlX/YL3K3cWNXgWXOhllqvI= 3 | github.com/Netflix/go-expect v0.0.0-20220104043353-73e0943537d2 h1:+vx7roKuyA63nhn5WAunQHLTznkw5W8b1Xc0dNjp83s= 4 | github.com/Netflix/go-expect v0.0.0-20220104043353-73e0943537d2/go.mod h1:HBCaDeC1lPdgDeDbhX8XFpy1jqjK0IBG8W5K+xYqA0w= 5 | github.com/abiosoft/readline v0.0.0-20180607040430-155bce2042db h1:CjPUSXOiYptLbTdr1RceuZgSFDQ7U15ITERUGrUORx8= 6 | github.com/abiosoft/readline v0.0.0-20180607040430-155bce2042db/go.mod h1:rB3B4rKii8V21ydCbIzH5hZiCQE7f5E9SzUb/ZZx530= 7 | github.com/aead/argon2 v0.0.0-20180111183520-a87724528b07 h1:i9/M2RadeVsPBMNwXFiaYkXQi9lY9VuZeI4Onavd3pA= 8 | github.com/aead/argon2 v0.0.0-20180111183520-a87724528b07/go.mod h1:Tnm/osX+XXr9R+S71o5/F0E60sRkPVALdhWw25qPImQ= 9 | github.com/aead/chacha20 v0.0.0-20180709150244-8b13a72661da h1:KjTM2ks9d14ZYCvmHS9iAKVt9AyzRSqNU1qabPih5BY= 10 | github.com/aead/chacha20 v0.0.0-20180709150244-8b13a72661da/go.mod h1:eHEWzANqSiWQsof+nXEI9bUVUyV6F53Fp89EuCh2EAA= 11 | github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4= 12 | github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI= 13 | github.com/chzyer/logex v1.2.1 h1:XHDu3E6q+gdHgsdTPH6ImJMIp436vR6MPtH8gP05QzM= 14 | github.com/chzyer/logex v1.2.1/go.mod h1:JLbx6lG2kDbNRFnfkgvh4eRJRPX1QCoOIWomwysCBrQ= 15 | github.com/chzyer/test v1.0.0 h1:p3BQDXSxOhOG0P9z6/hGnII4LGiEPOYBhs8asl/fC04= 16 | github.com/chzyer/test v1.0.0/go.mod h1:2JlltgoNkt4TW/z9V/IzDdFaMTM2JPIi26O1pF38GC8= 17 | github.com/creack/pty v1.1.17 h1:QeVUsEDNrLBW4tMgZHvxy18sKtr6VI492kBhUfhDJNI= 18 | github.com/creack/pty v1.1.17/go.mod h1:MOBLtS5ELjhRRrroQr9kyvTxUAFNvYEK993ew/Vr4O4= 19 | github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 20 | github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= 21 | github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 22 | github.com/fatih/color v1.15.0 h1:kOqh6YHBtK8aywxGerMG2Eq3H6Qgoqeo13Bk2Mv/nBs= 23 | github.com/fatih/color v1.15.0/go.mod h1:0h5ZqXfHYED7Bhv2ZJamyIOUej9KtShiJESRwBDUSsw= 24 | github.com/flynn-archive/go-shlex v0.0.0-20150515145356-3f9db97f8568 h1:BMXYYRWTLOJKlh+lOBt6nUQgXAfB7oVIQt5cNreqSLI= 25 | github.com/flynn-archive/go-shlex v0.0.0-20150515145356-3f9db97f8568/go.mod h1:rZfgFAXFS/z/lEd6LJmf9HVZ1LkgYiHx5pHhV5DR16M= 26 | github.com/gorilla/mux v1.7.3/go.mod h1:1lud6UwP+6orDFRuTfBEV8e9/aOM/c4fVVCaMa2zaAs= 27 | github.com/hinshun/vt10x v0.0.0-20220119200601-820417d04eec h1:qv2VnGeEQHchGaZ/u7lxST/RaJw+cv273q79D81Xbog= 28 | github.com/hinshun/vt10x v0.0.0-20220119200601-820417d04eec/go.mod h1:Q48J4R4DvxnHolD5P8pOtXigYlRuPLGl6moFx3ulM68= 29 | github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 h1:Z9n2FFNUXsshfwJMBgNA0RU6/i7WVaAegv3PtuIHPMs= 30 | github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51/go.mod h1:CzGEWj7cYgsdH8dAjBGEr58BoE7ScuLd+fwFZ44+/x8= 31 | github.com/mattn/go-colorable v0.1.2/go.mod h1:U0ppj6V5qS13XJ6of8GYAs25YV2eR4EVcfRqFIhoBtE= 32 | github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA= 33 | github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= 34 | github.com/mattn/go-isatty v0.0.8/go.mod h1:Iq45c/XA43vh69/j3iqttzPXn0bhXyGjM0Hdxcsrc5s= 35 | github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= 36 | github.com/mattn/go-isatty v0.0.18 h1:DOKFKCQ7FNG2L1rbrmstDN4QVRdS89Nkh85u68Uwp98= 37 | github.com/mattn/go-isatty v0.0.18/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= 38 | github.com/mgutz/ansi v0.0.0-20170206155736-9520e82c474b/go.mod h1:01TrycV0kFyexm33Z7vhZRXopbI8J3TDReVlkTgMUxE= 39 | github.com/mgutz/ansi v0.0.0-20200706080929-d51e80ef957d h1:5PJl274Y63IEHC+7izoQE9x6ikvDFZS2mDVS3drnohI= 40 | github.com/mgutz/ansi v0.0.0-20200706080929-d51e80ef957d/go.mod h1:01TrycV0kFyexm33Z7vhZRXopbI8J3TDReVlkTgMUxE= 41 | github.com/mostfunkyduck/ishell v0.0.0-20230416142217-6b0f1edba07f h1:QrvSYhsDOhs7jSNWCipnEPaJ9ijcF8Bx2Eo+ABmZ8p4= 42 | github.com/mostfunkyduck/ishell v0.0.0-20230416142217-6b0f1edba07f/go.mod h1:lV6XtJyB1lqg+hQeltAxLpXENR/gTQ52T7LrNgLLwug= 43 | github.com/mostfunkyduck/sandpass v1.1.1-0.20200617090953-4e7550e75911 h1:7Gxt9A2uYmM79TxlO4bvntcKomDfiqBQ5B0deTCkfbM= 44 | github.com/mostfunkyduck/sandpass v1.1.1-0.20200617090953-4e7550e75911/go.mod h1:VC0meJ3VuSwZ4EJyVzfRoNcnb4ZFLYabCNbjmKdetqc= 45 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= 46 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 47 | github.com/sethvargo/go-password v0.2.0 h1:BTDl4CC/gjf/axHMaDQtw507ogrXLci6XRiLc7i/UHI= 48 | github.com/sethvargo/go-password v0.2.0/go.mod h1:Ym4Mr9JXLBycr02MFuVQ/0JHidNetSgbzutTr3zsYXE= 49 | github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= 50 | github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= 51 | github.com/stretchr/testify v1.8.1 h1:w7B6lhMri9wdJUVmEZPGGhZzrYTPvgJArz7wNPgYKsk= 52 | github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= 53 | github.com/tobischo/gokeepasslib/v3 v3.1.0 h1:FUpIHQlgDCtKQ9VSHwqmW1PxUcT96wAvPjmPypbR6Wg= 54 | github.com/tobischo/gokeepasslib/v3 v3.1.0/go.mod h1:SbRMQTuN5anbqQzWFS4NMcjVyyzgxt5owqvbNi1Vzsk= 55 | golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= 56 | golang.org/x/crypto v0.0.0-20190701094942-4def268fd1a4/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= 57 | golang.org/x/crypto v0.0.0-20200429183012-4b2356b1ed79/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= 58 | golang.org/x/crypto v0.31.0 h1:ihbySMvVjLAeSH1IbfcRTkD/iNscyz8rGzjF/E5hV6U= 59 | golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk= 60 | golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= 61 | golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= 62 | golang.org/x/sys v0.0.0-20190222072716-a9d3bda3a223/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= 63 | golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 64 | golang.org/x/sys v0.0.0-20200501145240-bc7a7d42d5c3/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-20220422013727-9388b58f7150/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 67 | golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 68 | golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 69 | golang.org/x/sys v0.28.0 h1:Fksou7UEQUWlKvIdsqzJmUmCX3cZuD2+P3XyyzwMhlA= 70 | golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= 71 | golang.org/x/term v0.0.0-20210503060354-a79de5458b56/go.mod h1:tfny5GFUkzUvx4ps4ajbZsCe5lw1metzhBm9T3x7oIY= 72 | golang.org/x/term v0.27.0 h1:WP60Sv1nlK1T6SupCHbXzSaN0b9wUmsPoRS9b61A23Q= 73 | golang.org/x/term v0.27.0/go.mod h1:iMsnZpn0cago0GOrHO2+Y7u7JPn5AylBrcoWkElMTSM= 74 | golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= 75 | golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk= 76 | golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= 77 | golang.org/x/text v0.21.0 h1:zyQAAkrwaneQ066sspRyJaG9VNi/YJ1NfzcGB3hZ/qo= 78 | golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ= 79 | golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= 80 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 81 | gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 82 | gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= 83 | gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 84 | -------------------------------------------------------------------------------- /main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "flag" 5 | "fmt" 6 | "os" 7 | "regexp" 8 | "strings" 9 | 10 | "github.com/mostfunkyduck/ishell" 11 | v1 "github.com/mostfunkyduck/kp/internal/backend/keepassv1" 12 | v2 "github.com/mostfunkyduck/kp/internal/backend/keepassv2" 13 | t "github.com/mostfunkyduck/kp/internal/backend/types" 14 | "github.com/mostfunkyduck/kp/internal/commands" 15 | ) 16 | 17 | var ( 18 | keyFile = flag.String("key", "", "a key file to use to unlock the db") 19 | dbFile = flag.String("db", "", "the db to open") 20 | keepassVersion = flag.Int("kpversion", 1, "which version of keepass to use (1 or 2)") 21 | version = flag.Bool("version", false, "print version and exit") 22 | noninteractive = flag.String("n", "", "execute a given command and exit") 23 | ) 24 | 25 | /* 26 | All examples assume a structure of: 27 | one two/ 28 | three four/ 29 | otherstuff 30 | stuff 31 | I had to hack the completions part of mostfunkyduck/ishell to take proposed completions instead of taking all potential completions 32 | The library had breaking on spaces, the following behavior was observed: 33 | 1. inputting "one two" would result in "one" being counted as a "priorWord" and "two" being the "wordToComplete" 34 | 2. it would then treat the returned values as *potential* matches, which had to match the prefix, which is the argument 35 | labeled "wordToComplete" 36 | 3. this meant that when "one two" came in, you COULD piece results back together as "one two/stuff", but returning that 37 | would not result in matches since it would only return things that started with "two" 38 | 4. This also meant that even if you stripped the part before the space (i.e. returning "two/stuff"), the results displayed would be 39 | prefixed with "two", which broke nested search 40 | Therefore, the hack on the ishell library takes the actual completions, so: 41 | 1. "one two/st" will return "uff" 42 | 2. "one" will return "one two/" 43 | 3. "one two/" will return "stuff" and "three four/" 44 | */ 45 | func fileCompleter(shell *ishell.Shell, printEntries bool) func(string, []string) []string { 46 | return func(wordToComplete string, priorWords []string) (ret []string) { 47 | searchLocation := "" 48 | if len(priorWords) > 0 { 49 | // join together all the previous tokens so that we compensate for spaces 50 | // priorWords will be ["one"], wordToComplete will be "two", we want "one two" 51 | wordToComplete = strings.Join(priorWords, " ") + " " + wordToComplete 52 | } 53 | 54 | if len(wordToComplete) == 0 { 55 | return 56 | } 57 | 58 | if wordToComplete[len(wordToComplete)-1] == '/' { 59 | // if the phrase we're completing is slash-terminated, it's a group that we're trying 60 | // to enumerate the contents of 61 | // i.e. "one two/" and we want to get "stuff" 62 | searchLocation = wordToComplete 63 | } else if strings.Contains(wordToComplete, "/") { 64 | // the wordToComplete is at least partially a path 65 | // i.e "one two/three", in which case we wanted to match things under "one two/" starting with "three" 66 | // this will strip out everything after the last "/" to find the search path 67 | rxp := regexp.MustCompile(`(.+\/).+$`) 68 | searchLocation = rxp.ReplaceAllString(wordToComplete, "$1") 69 | } 70 | 71 | // get the current location to search for matches 72 | db := shell.Get("db").(t.Database) 73 | location, _, err := commands.TraversePath(db, db.CurrentLocation(), searchLocation) 74 | // if the there was an error, assume path doesn't exist 75 | if err != nil { 76 | return 77 | } 78 | 79 | // helper function to identify completions 80 | f := func(token string) string { 81 | // trim the wordToComplete down to the part after the directory name 82 | // we have the directory name in searchLocation, we want to search that directory for the prefix 83 | // alternatively, we can determine that we're enumerating a directory, in which case no matching will be done 84 | reg := regexp.MustCompile(`.*/`) 85 | prefix := reg.ReplaceAllString(wordToComplete, "") 86 | 87 | if prefix == "" { 88 | // the wordToComplete was an entire path, we're enumerating the contents of a directory 89 | // return the token as-is since we don't have to do any matching to figure out potential completions 90 | // i.e. if the user inputted "one two/", we want to return "stuff" and "three four" 91 | return token 92 | } 93 | 94 | if strings.HasPrefix(token, prefix) { 95 | // the wordToComplete contained a partial prefix of an item in a directory to match 96 | // strip the already-matched prefix 97 | // i.e. if the user passed in "one two/st", we will scan "stuff" and "three four" and return "stuff" 98 | return strings.TrimPrefix(token, prefix) 99 | } 100 | return "" 101 | } 102 | 103 | // Loop through all the groups and entries in this group and check for matches 104 | for _, g := range location.Groups() { 105 | completion := f(g.Name() + "/") 106 | if completion != "" { 107 | ret = append(ret, completion) 108 | } 109 | } 110 | 111 | // loop through entries iff the command needs us to 112 | if printEntries { 113 | for _, e := range location.Entries() { 114 | completion := f(e.Title()) 115 | if completion != "" { 116 | ret = append(ret, completion) 117 | } 118 | } 119 | } 120 | 121 | return ret 122 | } 123 | } 124 | 125 | func buildVersionString() string { 126 | return fmt.Sprintf("%s.%s-%s.%s (built on %s from %s)", VersionRelease, VersionBuildDate, VersionBuildTZ, VersionBranch, VersionHostname, VersionRevision) 127 | } 128 | 129 | // promptForDBPassword will determine the password based on environment vars or, lacking those, a prompt to the user 130 | func promptForDBPassword(shell *ishell.Shell) (string, error) { 131 | // we are prompting for the password 132 | shell.Print("enter database password: ") 133 | return shell.ReadPasswordErr() 134 | } 135 | 136 | // newDB will create or open a DB with the parameters specified. `open` indicates whether the DB should be opened or not (vs created) 137 | func newDB(dbPath string, password string, keyPath string, version int) (t.Database, error) { 138 | var dbWrapper t.Database 139 | switch version { 140 | case 2: 141 | dbWrapper = &v2.Database{} 142 | case 1: 143 | dbWrapper = &v1.Database{} 144 | default: 145 | return nil, fmt.Errorf("invalid version '%d'", version) 146 | } 147 | dbOpts := t.Options{ 148 | DBPath: dbPath, 149 | Password: password, 150 | KeyPath: keyPath, 151 | } 152 | err := dbWrapper.Init(dbOpts) 153 | return dbWrapper, err 154 | } 155 | 156 | func main() { 157 | flag.Parse() 158 | 159 | shell := ishell.New() 160 | if *version { 161 | shell.Printf("version: %s\n", buildVersionString()) 162 | os.Exit(1) 163 | } 164 | 165 | var dbWrapper t.Database 166 | 167 | dbPath, exists := os.LookupEnv("KP_DATABASE") 168 | if !exists { 169 | dbPath = *dbFile 170 | } 171 | 172 | // default to the flag argument 173 | keyPath := *keyFile 174 | 175 | if envKeyfile, found := os.LookupEnv("KP_KEYFILE"); found && keyPath == "" { 176 | keyPath = envKeyfile 177 | } 178 | 179 | for { 180 | // if the password is coming from an environment variable, we need to terminate 181 | // after the first attempt or it will fall into an infinite loop 182 | var err error 183 | password, passwordInEnv := os.LookupEnv("KP_PASSWORD") 184 | if !passwordInEnv { 185 | password, err = promptForDBPassword(shell) 186 | 187 | if err != nil { 188 | shell.Printf("could not retrieve password: %s", err) 189 | os.Exit(1) 190 | } 191 | } 192 | 193 | dbWrapper, err = newDB(dbPath, password, keyPath, *keepassVersion) 194 | if err != nil { 195 | // typically, these errors will be a bad password, so we want to keep prompting until the user gives up 196 | // if, however, the password is in an environment variable, we want to abort immediately so the program doesn't fall 197 | // in to an infinite loop 198 | shell.Printf("could not open database: %s\n", err) 199 | if passwordInEnv { 200 | os.Exit(1) 201 | } 202 | continue 203 | } 204 | break 205 | } 206 | 207 | shell.Printf("opened database at %s\n", dbWrapper.SavePath()) 208 | 209 | shell.Set("db", dbWrapper) 210 | shell.SetPrompt(fmt.Sprintf("/%s > ", dbWrapper.CurrentLocation().Name())) 211 | 212 | shell.AddCmd(&ishell.Cmd{ 213 | Name: "firefox-import", 214 | Help: "firefox-import ", 215 | Func: commands.FirefoxImport(shell), 216 | CompleterWithPrefix: fileCompleter(shell, true), 217 | }) 218 | shell.AddCmd(&ishell.Cmd{ 219 | Name: "ls", 220 | Help: "ls [path]", 221 | Func: commands.Ls(shell), 222 | CompleterWithPrefix: fileCompleter(shell, true), 223 | }) 224 | shell.AddCmd(&ishell.Cmd{ 225 | Name: "new", 226 | Help: "new ", 227 | LongHelp: "creates a new entry at ", 228 | Func: commands.NewEntry(shell), 229 | CompleterWithPrefix: fileCompleter(shell, false), 230 | }) 231 | shell.AddCmd(&ishell.Cmd{ 232 | Name: "mkdir", 233 | LongHelp: "create a new group", 234 | Help: "mkdir ", 235 | Func: commands.NewGroup(shell), 236 | CompleterWithPrefix: fileCompleter(shell, false), 237 | }) 238 | shell.AddCmd(&ishell.Cmd{ 239 | Name: "saveas", 240 | LongHelp: "save this db to a new file, existing credentials will be used, the new location will /not/ be used as the main save path", 241 | Help: "saveas ", 242 | Func: commands.SaveAs(shell), 243 | }) 244 | 245 | if dbWrapper.Version() == t.V2 { 246 | shell.AddCmd(&ishell.Cmd{ 247 | Name: "select", 248 | Help: "select [-f] ", 249 | LongHelp: "shows details on a given value in an entry, passwords will be redacted unless '-f' is specified", 250 | Func: commands.Select(shell), 251 | CompleterWithPrefix: fileCompleter(shell, true), 252 | }) 253 | } 254 | 255 | shell.AddCmd(&ishell.Cmd{ 256 | Name: "show", 257 | Flags: []string{"-f"}, 258 | Help: "show [-f] ", 259 | LongHelp: "shows details on a given entry, passwords will be redacted unless '-f' is specified", 260 | Func: commands.Show(shell), 261 | CompleterWithPrefix: fileCompleter(shell, true), 262 | }) 263 | shell.AddCmd(&ishell.Cmd{ 264 | Name: "cd", 265 | Help: "cd ", 266 | LongHelp: "changes the current group to a different path", 267 | Func: commands.Cd(shell), 268 | CompleterWithPrefix: fileCompleter(shell, false), 269 | }) 270 | 271 | attachCmd := &ishell.Cmd{ 272 | Name: "attach", 273 | LongHelp: "manages the attachment for a given entry", 274 | Help: "attach ", 275 | } 276 | attachCmd.AddCmd(&ishell.Cmd{ 277 | Name: "create", 278 | Help: "attach create ", 279 | LongHelp: "creates a new attachment based on a local file", 280 | CompleterWithPrefix: fileCompleter(shell, true), 281 | Func: commands.Attach(shell, "create"), 282 | }) 283 | attachCmd.AddCmd(&ishell.Cmd{ 284 | Name: "get", 285 | Help: "attach get ", 286 | LongHelp: "retrieves an attachment and outputs it to a filesystem location", 287 | CompleterWithPrefix: fileCompleter(shell, true), 288 | Func: commands.Attach(shell, "get"), 289 | }) 290 | attachCmd.AddCmd(&ishell.Cmd{ 291 | Name: "details", 292 | Help: "attach details ", 293 | LongHelp: "shows the details of the attachment on an entry", 294 | CompleterWithPrefix: fileCompleter(shell, true), 295 | Func: commands.Attach(shell, "details"), 296 | }) 297 | shell.AddCmd(attachCmd) 298 | 299 | shell.AddCmd(&ishell.Cmd{ 300 | LongHelp: "searches for any entries with the regular expression '' in their titles or contents", 301 | Name: "search", 302 | Help: "search ", 303 | CompleterWithPrefix: fileCompleter(shell, true), 304 | Func: commands.Search(shell), 305 | }) 306 | 307 | shell.AddCmd(&ishell.Cmd{ 308 | Name: "rm", 309 | Flags: []string{"-r"}, 310 | Help: "rm [-r] ", 311 | LongHelp: "removes an entry", 312 | CompleterWithPrefix: fileCompleter(shell, true), 313 | Func: commands.Rm(shell), 314 | }) 315 | 316 | shell.AddCmd(&ishell.Cmd{ 317 | Name: "xp", 318 | Help: "xp ", 319 | LongHelp: "copies a password to the clipboard", 320 | CompleterWithPrefix: fileCompleter(shell, true), 321 | Func: commands.Xp(shell), 322 | }) 323 | 324 | shell.AddCmd(&ishell.Cmd{ 325 | Name: "edit", 326 | Help: "edit ", 327 | LongHelp: "edits an existing entry", 328 | CompleterWithPrefix: fileCompleter(shell, true), 329 | Func: commands.Edit(shell), 330 | }) 331 | 332 | shell.AddCmd(&ishell.Cmd{ 333 | Name: "pwd", 334 | Help: "pwd", 335 | LongHelp: "shows path of current group", 336 | Func: commands.Pwd(shell), 337 | }) 338 | 339 | shell.AddCmd(&ishell.Cmd{ 340 | Name: "save", 341 | Help: "save", 342 | LongHelp: "saves the database to its most recently used path", 343 | Func: commands.Save(shell), 344 | }) 345 | 346 | shell.AddCmd(&ishell.Cmd{ 347 | Name: "xx", 348 | Help: "xx", 349 | LongHelp: "clears the clipboard", 350 | Func: commands.Xx(shell), 351 | }) 352 | 353 | shell.AddCmd(&ishell.Cmd{ 354 | Name: "xu", 355 | Help: "xu", 356 | LongHelp: "copies username to the clipboard", 357 | CompleterWithPrefix: fileCompleter(shell, true), 358 | Func: commands.Xu(shell), 359 | }) 360 | 361 | shell.AddCmd(&ishell.Cmd{ 362 | Name: "xw", 363 | Help: "xw", 364 | LongHelp: "copies url to clipboard", 365 | CompleterWithPrefix: fileCompleter(shell, true), 366 | Func: commands.Xw(shell), 367 | }) 368 | 369 | shell.AddCmd(&ishell.Cmd{ 370 | Name: "mv", 371 | Help: "mv ", 372 | LongHelp: "moves entries between groups", 373 | CompleterWithPrefix: fileCompleter(shell, true), 374 | Func: commands.Mv(shell), 375 | }) 376 | 377 | shell.AddCmd(&ishell.Cmd{ 378 | Name: "version", 379 | Help: "version", 380 | LongHelp: "prints version", 381 | Func: func(c *ishell.Context) { 382 | shell.Printf("version: %s\n", buildVersionString()) 383 | }, 384 | }) 385 | 386 | if *noninteractive != "" { 387 | bits := strings.Split(*noninteractive, " ") 388 | if err := shell.Process([]string{bits[0], strings.Join(bits[1:], " ")}...); err != nil { 389 | shell.Printf("error processing command: %s\n", err) 390 | } 391 | } else { 392 | shell.Run() 393 | } 394 | 395 | // This will run after the shell exits 396 | fmt.Println("exiting") 397 | 398 | if dbWrapper.Changed() { 399 | if err := commands.PromptAndSave(shell); err != nil { 400 | fmt.Printf("error attempting to save database: %s\n", err) 401 | os.Exit(1) 402 | } 403 | } else { 404 | fmt.Println("no changes detected since last save.") 405 | } 406 | } 407 | -------------------------------------------------------------------------------- /internal/commands/commands.go: -------------------------------------------------------------------------------- 1 | package commands 2 | 3 | // All the commands that the shell will run 4 | // Note: do NOT use context.Err() here, it will impede testing. 5 | 6 | import ( 7 | "fmt" 8 | "math/rand" 9 | "os" 10 | "os/exec" 11 | "strconv" 12 | "strings" 13 | "time" 14 | 15 | "github.com/atotto/clipboard" 16 | "github.com/mostfunkyduck/ishell" 17 | c "github.com/mostfunkyduck/kp/internal/backend/common" 18 | t "github.com/mostfunkyduck/kp/internal/backend/types" 19 | "github.com/sethvargo/go-password/password" 20 | ) 21 | 22 | func syntaxCheck(c *ishell.Context, numArgs int) (errorString string, ok bool) { 23 | if len(c.Args) < numArgs { 24 | return "syntax: " + c.Cmd.Help, false 25 | } 26 | return "", true 27 | } 28 | 29 | // getEntryByPath returns the entry at path 'path' using context variables in shell 'shell' 30 | func getEntryByPath(shell *ishell.Shell, path string) (entry t.Entry, ok bool) { 31 | db := shell.Get("db").(t.Database) 32 | location, entry, err := TraversePath(db, db.CurrentLocation(), path) 33 | if err != nil { 34 | return nil, false 35 | } 36 | 37 | if entry == nil { 38 | return nil, false 39 | } 40 | 41 | // a little extra work so that we can search by criteria other than 'title' 42 | // get the base name of the entry so that we can compare it to the actual 43 | // entries in this group 44 | entryNameBits := strings.Split(path, "/") 45 | entryName := entryNameBits[len(entryNameBits)-1] 46 | // loop so that we can compare entry indices 47 | for i, entry := range location.Entries() { 48 | uuidString, err := entry.UUIDString() 49 | if err != nil { 50 | // TODO we're swallowing this error :( 51 | // this is an edge case though, only happens if the UUID string is corrupted 52 | return nil, false 53 | } 54 | if intVersion, err := strconv.Atoi(entryName); err == nil && intVersion == i || 55 | entryName == string(entry.Title()) || 56 | entryName == uuidString { 57 | return entry, true 58 | } 59 | } 60 | return nil, false 61 | } 62 | 63 | func isPresent(shell *ishell.Shell, path string) (ok bool) { 64 | db := shell.Get("db").(t.Database) 65 | l, e, err := TraversePath(db, db.CurrentLocation(), path) 66 | return err == nil && (l != nil || e != nil) 67 | } 68 | 69 | // doPrompt takes a t.Value, prompts for a new value, returns the value entered 70 | func doPrompt(shell *ishell.Shell, value t.Value) (string, error) { 71 | var err error 72 | var input string 73 | switch value.Type() { 74 | case t.STRING: 75 | shell.Printf("%s: [%s] ", value.Name(), value.FormattedValue(false)) 76 | if value.Protected() { 77 | input, err = GetProtected(shell, string(value.Value())) 78 | } else { 79 | input, err = shell.ReadLineErr() 80 | } 81 | case t.BINARY: 82 | return "", fmt.Errorf("tried to edit binary directly") 83 | case t.LONGSTRING: 84 | shell.Printf("'%s' is a long text field, open in editor? [y/N] ", value.Name()) 85 | edit, err1 := shell.ReadLineErr() 86 | if err1 != nil { 87 | return "", fmt.Errorf("could not read user input: %s", err) 88 | } 89 | if edit == "y" { 90 | input, err = GetLongString(value) 91 | // normally, the user will see their input echoed, but not if an editor was open 92 | shell.Println(input) 93 | } 94 | } 95 | if err != nil { 96 | return "", fmt.Errorf("could not read user input: %s", err) 97 | } 98 | 99 | if input == "" { 100 | return string(value.Value()), nil 101 | } 102 | 103 | return input, nil 104 | } 105 | 106 | // promptForEntry loops through all values in an entry, prompts to edit them, then applies any changes 107 | func promptForEntry(shell *ishell.Shell, e t.Entry, title string) error { 108 | // make a copy of the entry's values for modification 109 | vals, err := e.Values() 110 | if err != nil { 111 | return fmt.Errorf("error retrieving values for entry '%s': %s", e.Title(), err) 112 | } 113 | valsToUpdate := []t.Value{} 114 | for _, value := range vals { 115 | if value != nil && !value.ReadOnly() && value.Type() != t.BINARY { 116 | newValue, err := doPrompt(shell, value) 117 | if err != nil { 118 | return fmt.Errorf("could not get value for %s, %s", value.Name(), err) 119 | } 120 | updatedValue := c.NewValue( 121 | []byte(newValue), 122 | value.Name(), 123 | value.Searchable(), 124 | value.Protected(), 125 | value.ReadOnly(), 126 | value.Type(), 127 | ) 128 | valsToUpdate = append(valsToUpdate, updatedValue) 129 | } 130 | } 131 | 132 | // determine whether any of the provided values was an actual update meriting a save 133 | updated := false 134 | for _, value := range valsToUpdate { 135 | if e.Set(value) { 136 | updated = true 137 | } 138 | } 139 | 140 | if updated { 141 | shell.Println("edit successful, database has changed!") 142 | 143 | if err := PromptAndSave(shell); err != nil { 144 | shell.Printf("could not save: %s", err) 145 | } 146 | } 147 | return nil 148 | } 149 | 150 | // OpenFileInEditor opens filename in a text editor. 151 | func OpenFileInEditor(filename string) error { 152 | editor := os.Getenv("EDITOR") 153 | if editor == "" { 154 | editor = "vim" // because use vim or you're a troglodyte 155 | } 156 | 157 | // Get the full executable path for the editor. 158 | executable, err := exec.LookPath(editor) 159 | if err != nil { 160 | return err 161 | } 162 | 163 | cmd := exec.Command(executable, filename) 164 | cmd.Stdin = os.Stdin 165 | cmd.Stdout = os.Stdout 166 | cmd.Stderr = os.Stderr 167 | 168 | return cmd.Run() 169 | } 170 | 171 | func GetLongString(value t.Value) (text string, err error) { 172 | // https://samrapdev.com/capturing-sensitive-input-with-editor-in-golang-from-the-cli/ 173 | file, err := os.CreateTemp(os.TempDir(), "*") 174 | if err != nil { 175 | return "", err 176 | } 177 | 178 | filename := file.Name() 179 | 180 | defer os.Remove(filename) 181 | 182 | // start with what's already there 183 | if _, err = file.Write(value.Value()); err != nil { 184 | return "", err 185 | } 186 | 187 | if err = file.Close(); err != nil { 188 | return "", err 189 | } 190 | 191 | if err = OpenFileInEditor(filename); err != nil { 192 | return "", err 193 | } 194 | 195 | bytes, err := os.ReadFile(filename) 196 | if err != nil { 197 | return "", err 198 | } 199 | 200 | return string(bytes), nil 201 | } 202 | 203 | func GetProtected(shell *ishell.Shell, defaultPassword string) (pw string, err error) { 204 | for { 205 | shell.Printf("password: ('g' to generate with defaults, 'c' to generate with custom parameters) ") 206 | pw, err = shell.ReadPasswordErr() 207 | if err != nil { 208 | return "", fmt.Errorf("failed to read input: %s", err) 209 | } 210 | 211 | // default to whatever password was already set for the entry, if there is one 212 | if pw == "" && defaultPassword != "" { 213 | return defaultPassword, nil 214 | } 215 | 216 | // otherwise, we're either generating a new password or reading one from user input 217 | if pw == "g" { 218 | pw, err = password.Generate(20, 5, 5, false, false) 219 | if err != nil { 220 | return "", fmt.Errorf("failed to generate password: %s\n", err) 221 | } 222 | shell.Println("generated new password") 223 | break 224 | } 225 | 226 | if pw == "c" { 227 | pw, err = generatePassword(shell) 228 | if err != nil { 229 | return "", fmt.Errorf("failed to generate custom password: %s\n", err) 230 | } 231 | shell.Println("generated password") 232 | break 233 | } 234 | 235 | // the user is passing us a password, confirm it before saving 236 | shell.Printf("enter password again: ") 237 | pwConfirm, err := shell.ReadPasswordErr() 238 | if err != nil { 239 | return "", fmt.Errorf("failed to read input: %s", err) 240 | } 241 | 242 | if pwConfirm != pw { 243 | shell.Println("password mismatch!") 244 | continue 245 | } 246 | break 247 | } 248 | return pw, nil 249 | } 250 | 251 | // promptAndSave prompts the user to save and returns whether or not they agreed to do so. 252 | // it also makes sure that there's actually a path to save to 253 | func PromptAndSave(shell *ishell.Shell) error { 254 | 255 | shell.Printf("save database?: [Y/n] ") 256 | line, err := shell.ReadLineErr() 257 | if err != nil { 258 | return fmt.Errorf("could not read user input: %s", err) 259 | } 260 | 261 | if line == "n" { 262 | shell.Println("continuing without saving") 263 | return nil 264 | } 265 | 266 | db := shell.Get("db").(t.Database) 267 | if err := db.Save(); err != nil { 268 | return fmt.Errorf("could not save database: %s", err) 269 | } 270 | 271 | // FIXME this should be a property of the DB, not a global 272 | 273 | shell.Println("database saved!") 274 | return nil 275 | } 276 | 277 | // copyFromEntry will find an entry and copy a given field in the entry 278 | // to the clipboard 279 | func copyFromEntry(shell *ishell.Shell, targetPath string, entryData string) error { 280 | entry, ok := getEntryByPath(shell, targetPath) 281 | if !ok { 282 | return fmt.Errorf("could not retrieve entry at path '%s'\n", targetPath) 283 | } 284 | 285 | var data string 286 | switch strings.ToLower(entryData) { 287 | // FIXME hardcoded values 288 | case "username": 289 | // FIXME rewire this so that the entry provides the copy function 290 | v, _ := entry.Get("username") 291 | data = string(v.Value()) 292 | case "password": 293 | data = entry.Password() 294 | case "url": 295 | v, _ := entry.Get("URL") 296 | data = string(v.Value()) 297 | default: 298 | return fmt.Errorf("'%s' was not a valid entry data type", entryData) 299 | } 300 | 301 | if data == "" { 302 | shell.Printf("warning! '%s' is an empty field!\n", entryData) 303 | } 304 | 305 | if err := clipboard.WriteAll(data); err != nil { 306 | return fmt.Errorf("could not write %s to clipboard: %s\n", entryData, err) 307 | } 308 | entry.SetLastAccessTime(time.Now()) 309 | shell.Printf("%s copied!\n", entryData) 310 | shell.Println("(access time has been updated, will be persisted on next save)") 311 | return nil 312 | } 313 | 314 | // confirmOverwrite prompts the user about overwriting a given file 315 | // it returns whether or not the user wants to overwrite 316 | func confirmOverwrite(shell *ishell.Shell, path string) bool { 317 | shell.Printf("'%s' exists, overwrite? [y/N] ", path) 318 | line, err := shell.ReadLineErr() 319 | if err != nil { 320 | shell.Printf("could not read user input: %s\n", line) 321 | return false 322 | } 323 | 324 | if line == "y" { 325 | shell.Println("overwriting") 326 | return true 327 | } 328 | return false 329 | } 330 | 331 | // TraversePath, given a starting location and a UNIX-style path, will walk the path and return the final location or an error 332 | // if the path points to an entry, the parent group is returned as well as the entry. 333 | // If the path points to a group, the entry will be nil 334 | func TraversePath(d t.Database, startingLocation t.Group, fullPath string) (finalLocation t.Group, finalEntry t.Entry, err error) { 335 | currentLocation := startingLocation 336 | root := d.Root() 337 | if fullPath == "/" { 338 | // short circuit now 339 | return root, nil, nil 340 | } 341 | 342 | if strings.HasPrefix(fullPath, "/") { 343 | // the user entered a fully qualified path, so start at the top 344 | currentLocation = root 345 | } 346 | 347 | // break the path up into components, remove terminal slashes since they don't actually do anything 348 | path := strings.Split(strings.TrimSuffix(fullPath, "/"), "/") 349 | // tracks whether or not the traversal encountered an entry 350 | loop: 351 | for i, part := range path { 352 | if part == "." || part == "" { 353 | continue 354 | } 355 | 356 | if part == ".." { 357 | // if we're not at the root, go up a level 358 | if currentLocation.Parent() != nil { 359 | currentLocation = currentLocation.Parent() 360 | continue 361 | } 362 | // we're at the root, the user wanted to go higher, that's no bueno 363 | return nil, nil, fmt.Errorf("tried to go to parent directory of '/'") 364 | } 365 | 366 | // regular traversal 367 | for _, group := range currentLocation.Groups() { 368 | // was the entity being searched for this group? 369 | if group.Name() == part { 370 | currentLocation = group 371 | continue loop 372 | } 373 | } 374 | 375 | for j, entry := range currentLocation.Entries() { 376 | // is the entity we're looking for this index or this entry? 377 | if string(entry.Title()) == part || strconv.Itoa(j) == part { 378 | if i != len(path)-1 { 379 | // we encountered an entry before the end of the path, entries have no subgroups, 380 | // so this path is invalid 381 | 382 | return nil, nil, fmt.Errorf("invalid path '%s': '%s' is an entry, not a group", entry.Title(), fullPath) 383 | } 384 | // this is the end of the path, return the parent group and the entry 385 | return currentLocation, entry, nil 386 | } 387 | } 388 | // getting here means that we found neither a group nor an entry that matched 'part' 389 | // both of the loops looking for those short circuit when they find what they need 390 | return nil, nil, fmt.Errorf("could not find a group or entry named '%s'", part) 391 | } 392 | // we went all the way through the path and it points to currentLocation, 393 | // if it pointed to an entry, it would have returned above 394 | return currentLocation, nil, nil 395 | } 396 | 397 | // buildPath will take an array, presumably of the args to a function, and construct a path to a group or entry 398 | func buildPath(args []string) string { 399 | return strings.Join(args, " ") 400 | } 401 | 402 | // promptWithDefault will prompt for a value, reverting to a given default if no response is given 403 | func promptWithDefault(shell *ishell.Shell, prompt, varDefault string) (value string, err error) { 404 | shell.Printf("%s (%s): ", prompt, varDefault) 405 | line, err := shell.ReadLineErr() 406 | if err != nil { 407 | return "", fmt.Errorf("failed to get response to prompt %s: %s\n", prompt, err) 408 | } 409 | if line != "" { 410 | value = line 411 | } else { 412 | value = varDefault 413 | } 414 | return value, nil 415 | } 416 | 417 | // generatePassword will generate a new password based on user inputs 418 | func generatePassword(shell *ishell.Shell) (pw string, err error) { 419 | lengthString, err := promptWithDefault(shell, "password length", "20") 420 | if err != nil { 421 | return "", fmt.Errorf("failed to generate password length: %s", err) 422 | } 423 | 424 | lengthInt, err := strconv.Atoi(lengthString) 425 | if err != nil { 426 | return "", fmt.Errorf("error converting length string '%s' to int: %s", lengthString, err) 427 | } 428 | 429 | // subtract 2 so that there's always room for at least 1 char and 1 symbol 430 | numDigits := rand.Intn(lengthInt - 2) 431 | 432 | // likewise, subtract out the number of digits and then an additional 1 so that there's at least 1 character 433 | numSymbols := rand.Intn(lengthInt - numDigits - 1) 434 | 435 | symbols := password.Symbols 436 | symbolsBlocklist, err := promptWithDefault(shell, fmt.Sprintf("list any symbols to exclude from the symbol map (%s), non-symbols will be ignored", symbols), "") 437 | if err != nil { 438 | return "", fmt.Errorf("error generating symbol blocklist: %s", err) 439 | } 440 | 441 | // prune any symbols entered in the blocklist 442 | for _, char := range symbolsBlocklist { 443 | symbols = strings.ReplaceAll(symbols, string(char), "") 444 | } 445 | // if the user blocklisted all symbols, set numSymbols to 0 446 | if symbols == "" { 447 | numSymbols = 0 448 | } 449 | gInput := password.GeneratorInput{ 450 | Symbols: symbols, 451 | } 452 | 453 | generator, err := password.NewGenerator(&gInput) 454 | if err != nil { 455 | return "", fmt.Errorf("could not build password generator: %s", err) 456 | } 457 | 458 | pw, err = generator.Generate(lengthInt, numDigits, numSymbols, false, true) 459 | if err != nil { 460 | return "", fmt.Errorf("could not generate password: %s", err) 461 | } 462 | return pw, err 463 | } 464 | --------------------------------------------------------------------------------