Files
root 5d66871527 Add a KDBX 4 codec: read, write, create
Why: merging two edits of a vault means decrypting it, so the server needs a
real KeePass implementation. Nothing else in this repository can be built on
guesswork about the format.

What: internal/kdbx reads and writes KDBX 4.0/4.1 with AES-256-CBC or
ChaCha20 payloads, AES-KDF or Argon2d/Argon2id derivation, and the ChaCha20
inner stream for protected values. ChaCha20, BLAKE2b and Argon2 are
implemented here, since the standard library has none of them and this
repository carries no third-party dependencies.

The XML payload is kept as a generic node tree rather than Go structs on
purpose: a merge server that dropped elements it did not model would silently
destroy data written by a newer KeePass. Anything unrecognised round-trips
untouched. Unsupported ciphers, KDFs and KDBX 3 files are reported as such
rather than guessed at, so a caller can fall back instead of losing data.

Proved: ChaCha20 against RFC 8439, BLAKE2b against RFC 7693, all three
Argon2 variants against RFC 9106. Round trips preserve unknown elements and
multi-line values; a wrong password, a flipped payload byte and a flipped
header byte are each rejected. Entry passwords appear nowhere in the file,
encrypted or merely compressed. And the part that actually matters:
keepassxc-cli 2.7.10 lists, reads and decrypts the protected values of a
vault this code wrote, including at the production Argon2d cost, and this
code reads back the file KeePassXC saves after editing it.
2026-10-29 17:10:37 +01:00

167 lines
4.6 KiB
Go

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
}