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.
This commit is contained in:
@@ -0,0 +1,166 @@
|
||||
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
|
||||
}
|
||||
Reference in New Issue
Block a user