package kdbx import ( "bytes" "os" "os/exec" "path/filepath" "strings" "testing" ) // These tests prove the only thing that finally matters about a KDBX // implementation: that a real KeePass reads what this package writes, and // that this package reads what a real KeePass writes. They are skipped // where keepassxc-cli is not installed. func keepassxc(t *testing.T) string { t.Helper() path, err := exec.LookPath("keepassxc-cli") if err != nil { t.Skip("keepassxc-cli is not installed; skipping the interoperability test") } return path } // run feeds stdin lines to keepassxc-cli and returns its stdout. func run(t *testing.T, stdin string, args ...string) string { t.Helper() cmd := exec.Command(keepassxc(t), args...) cmd.Stdin = strings.NewReader(stdin) var out, errb bytes.Buffer cmd.Stdout, cmd.Stderr = &out, &errb if err := cmd.Run(); err != nil { t.Fatalf("keepassxc-cli %s: %v\nstdout: %s\nstderr: %s", strings.Join(args, " "), err, out.String(), errb.String()) } return out.String() } const vaultPassword = "interop-password-1" // writeVault creates a vault with this package and returns its path. func writeVault(t *testing.T, db *Database) string { t.Helper() data, err := db.Marshal(vaultPassword) if err != nil { t.Fatal(err) } path := filepath.Join(t.TempDir(), "vault.kdbx") if err := os.WriteFile(path, data, 0o600); err != nil { t.Fatal(err) } return path } func TestKeePassXCReadsWhatWeWrite(t *testing.T) { keepassxc(t) db := New("Interop Vault", fastKDF) db.RootGroup().Nodes = append(db.RootGroup().Nodes, NewEntry("Router", "admin", "s3cret-pw", "https://router.lan", "a note")) group := NewGroup("Servers") group.Nodes = append(group.Nodes, NewEntry("Backup", "root", "other-pw", "", "")) db.RootGroup().Nodes = append(db.RootGroup().Nodes, group) path := writeVault(t, db) listing := run(t, vaultPassword+"\n", "ls", "-q", "-R", "-f", path) for _, want := range []string{"Router", "Servers/Backup"} { if !strings.Contains(listing, want) { t.Errorf("KeePassXC listing lacks %q:\n%s", want, listing) } } // The protected value must decrypt to exactly what we stored. got := run(t, vaultPassword+"\n", "show", "-q", "-s", "-a", "Password", path, "Router") if strings.TrimSpace(got) != "s3cret-pw" { t.Errorf("KeePassXC read the password as %q", strings.TrimSpace(got)) } got = run(t, vaultPassword+"\n", "show", "-q", "-a", "Notes", path, "Router") if strings.TrimSpace(got) != "a note" { t.Errorf("KeePassXC read the notes as %q", strings.TrimSpace(got)) } } func TestWeReadWhatKeePassXCWrites(t *testing.T) { keepassxc(t) db := New("Interop Vault", fastKDF) db.RootGroup().Nodes = append(db.RootGroup().Nodes, NewEntry("Router", "admin", "s3cret-pw", "", "")) path := writeVault(t, db) // KeePassXC opens our file, adds an entry and saves it back. The saved // file is now KeePassXC's own output, in the format it chose to keep. run(t, vaultPassword+"\nkeepassxc-made-this\n", "add", "-q", "-u", "someone", "--url", "https://added.example", "-p", path, "Added By KeePassXC") data, err := os.ReadFile(path) if err != nil { t.Fatal(err) } back, err := Open(data, vaultPassword) if err != nil { t.Fatalf("reading KeePassXC's output: %v", err) } if back.Version>>16 != 4 { t.Fatalf("KeePassXC rewrote the file as version %d.%d", back.Version>>16, back.Version&0xFFFF) } titles := map[string]string{} back.Root.walk(func(n *Node) { if n.Name == "Entry" { titles[entryValue(n, "Title")] = entryValue(n, "Password") } }) if titles["Router"] != "s3cret-pw" { t.Errorf("our own entry came back as %q", titles["Router"]) } if titles["Added By KeePassXC"] != "keepassxc-made-this" { t.Errorf("KeePassXC's entry read as %q", titles["Added By KeePassXC"]) } } // A vault created with the production work factor must also interoperate: // the KDF is part of the file, and Argon2d is what this package writes. func TestKeePassXCOpensArgon2dVaultAtDefaultCost(t *testing.T) { keepassxc(t) db := New("Argon2 Vault", DefaultKDF) path := writeVault(t, db) out := run(t, vaultPassword+"\n", "db-info", "-q", path) if !strings.Contains(out, "Argon2") { t.Errorf("KeePassXC does not report Argon2:\n%s", out) } }