package kdbx import ( "bytes" "strings" "testing" "time" ) // fastKDF keeps the tests quick; the format does not care about the cost. var fastKDF = KDFParams{MemoryKiB: 64, Iterations: 1, Lanes: 1} func TestRoundTripPreservesEverything(t *testing.T) { db := New("Team Vault", fastKDF) entry := NewEntry("Router", "admin", "s3cret-pw", "https://router.lan", "line one\nline two") db.RootGroup().Nodes = append(db.RootGroup().Nodes, entry) // An element this package knows nothing about must survive the trip. db.RootGroup().Nodes = append(db.RootGroup().Nodes, &Node{Name: "FutureKeePassElement", Attrs: []Attr{{Name: "Mode", Value: "odd"}}, Nodes: []*Node{{Name: "Inner", Text: "keep me"}}}) data, err := db.Marshal("vault-password") if err != nil { t.Fatal(err) } if len(data) < 100 { t.Fatalf("file is only %d bytes", len(data)) } back, err := Open(data, "vault-password") if err != nil { t.Fatal(err) } if back.Name() != "Team Vault" { t.Fatalf("name = %q", back.Name()) } e := back.RootGroup().Find("Entry") if e == nil { t.Fatal("entry missing after round trip") } if got := entryValue(e, "Password"); got != "s3cret-pw" { t.Fatalf("password = %q", got) } if got := entryValue(e, "Notes"); got != "line one\nline two" { t.Fatalf("notes = %q", got) } fut := back.RootGroup().Find("FutureKeePassElement") if fut == nil || fut.Attr("Mode") != "odd" || fut.Value("Inner") != "keep me" { t.Fatalf("unknown element was not preserved: %+v", fut) } } func TestPasswordIsNotStoredInTheClear(t *testing.T) { db := New("V", fastKDF) db.RootGroup().Nodes = append(db.RootGroup().Nodes, NewEntry("T", "u", "needle-in-the-haystack", "", "")) data, err := db.Marshal("vault-password") if err != nil { t.Fatal(err) } if bytes.Contains(data, []byte("needle-in-the-haystack")) { t.Fatal("the entry password appears in the encrypted file") } // Nor in the compressed-but-decrypted XML: it is under the inner stream. db.Compression = 0 plain, err := db.Marshal("vault-password") if err != nil { t.Fatal(err) } if bytes.Contains(plain, []byte("needle-in-the-haystack")) { t.Fatal("the entry password survives as plaintext inside the payload") } } func TestWrongPasswordIsRejected(t *testing.T) { db := New("V", fastKDF) data, err := db.Marshal("right-password") if err != nil { t.Fatal(err) } if _, err := Open(data, "wrong-password"); err != ErrWrongPassword { t.Fatalf("err = %v, want ErrWrongPassword", err) } } func TestTamperingIsDetected(t *testing.T) { db := New("V", fastKDF) data, err := db.Marshal("pw") if err != nil { t.Fatal(err) } // Flip a byte in the encrypted payload, past the header and hashes. bad := append([]byte(nil), data...) bad[len(bad)-10] ^= 0xFF if _, err := Open(bad, "pw"); err == nil { t.Fatal("a modified payload was accepted") } // And one in the header. bad = append([]byte(nil), data...) bad[20] ^= 0xFF if _, err := Open(bad, "pw"); err == nil { t.Fatal("a modified header was accepted") } } func TestAESKDFFileOpens(t *testing.T) { db := New("V", fastKDF) // Switch the file to the older AES work factor and check both paths. db.KDF = variantDict{} db.KDF.set(vdByteArray, "$UUID", kdfAES) db.KDF.set(vdByteArray, "S", make([]byte, 32)) db.KDF.setUint64("R", 64) data, err := db.Marshal("pw") if err != nil { t.Fatal(err) } if _, err := Open(data, "pw"); err != nil { t.Fatalf("AES-KDF round trip: %v", err) } } func TestKDBX3IsReportedNotGuessed(t *testing.T) { db := New("V", fastKDF) data, _ := db.Marshal("pw") data[8], data[9], data[10], data[11] = 0x01, 0x00, 0x03, 0x00 // version 3.1 _, err := Open(data, "pw") var un *ErrUnsupported if err == nil || !strings.Contains(err.Error(), "KDBX 3") { t.Fatalf("err = %v, want an unsupported-KDBX-3 report", err) } if !asUnsupported(err, &un) { t.Fatalf("err %T is not ErrUnsupported", err) } } func TestTimeRoundTrip(t *testing.T) { now := time.Now().UTC().Truncate(time.Second) got, ok := DecodeTime(EncodeTime(now)) if !ok || !got.Equal(now) { t.Fatalf("decode(encode(%v)) = %v, %v", now, got, ok) } // KDBX 3's text form must still be readable. got, ok = DecodeTime("2026-10-29T12:00:00Z") if !ok || got.Year() != 2026 || got.Hour() != 12 { t.Fatalf("iso time = %v, %v", got, ok) } } // entryValue reads one string field out of an entry node. func entryValue(entry *Node, key string) string { for _, s := range entry.FindAll("String") { if s.Value("Key") == key { return s.Value("Value") } } return "" } func asUnsupported(err error, target **ErrUnsupported) bool { u, ok := err.(*ErrUnsupported) if ok { *target = u } return ok }