diff --git a/internal/kdbx/argon2.go b/internal/kdbx/argon2.go new file mode 100644 index 0000000..f55585f --- /dev/null +++ b/internal/kdbx/argon2.go @@ -0,0 +1,29 @@ +package kdbx + +import "errors" + +// argon2 variants, filled in by argon2impl.go. +const ( + argon2d = 0 + argon2i = 1 + argon2id = 2 +) + +// argon2KDF derives the transformed key for a file that uses Argon2. +func argon2KDF(kdf variantDict, composite []byte, variant int) ([]byte, error) { + salt := kdf.bytes("S") + par, okP := kdf.uint64("P") + mem, okM := kdf.uint64("M") + iter, okI := kdf.uint64("I") + if len(salt) == 0 || !okP || !okM || !okI { + return nil, errors.New("kdbx: Argon2 parameters are missing or malformed") + } + if ver, ok := kdf.uint64("V"); ok && ver != 0x13 { + return nil, unsupported("Argon2 version %#x", ver) + } + if len(kdf.bytes("K")) != 0 || len(kdf.bytes("A")) != 0 { + return nil, unsupported("Argon2 secret key or associated data") + } + // M is in bytes in KDBX, in kibibytes in Argon2 itself. + return argon2Key(composite, salt, uint32(iter), uint32(mem/1024), uint8(par), 32, variant) +} diff --git a/internal/kdbx/argon2_test.go b/internal/kdbx/argon2_test.go new file mode 100644 index 0000000..47516f0 --- /dev/null +++ b/internal/kdbx/argon2_test.go @@ -0,0 +1,50 @@ +package kdbx + +import ( + "encoding/hex" + "testing" +) + +// RFC 9106 section 5: p=4, T=32, m=32, t=3, v=0x13, with a secret key and +// associated data. +func TestArgon2RFC9106Vectors(t *testing.T) { + password := repeat(0x01, 32) + salt := repeat(0x02, 16) + secret := repeat(0x03, 8) + assoc := repeat(0x04, 12) + + for _, tc := range []struct { + name string + variant int + want string + }{ + {"argon2d", argon2d, "512b391b6f1162975371d3091973429" + + "4f868e3be3984f3c1a13a4db9fabe4acb"}, + {"argon2i", argon2i, "c814d9d1dc7f37aa13f0d77f2494bda1" + + "c8de6b016dd388d29952a4c4672b6ce8"}, + {"argon2id", argon2id, "0d640df58d78766c08c037a34a8b53c9" + + "d01ef0452d75b65eb52520e96b01e659"}, + } { + got, err := argon2Full(password, salt, secret, assoc, 3, 32, 4, 32, tc.variant) + if err != nil { + t.Fatalf("%s: %v", tc.name, err) + } + if hex.EncodeToString(got) != tc.want { + t.Errorf("%s =\n%x\nwant\n%s", tc.name, got, tc.want) + } + } +} + +func TestArgon2RefusesAbsurdMemory(t *testing.T) { + if _, err := argon2Key([]byte("x"), repeat(0, 16), 1, argon2MaxMemory+1, 1, 32, argon2d); err == nil { + t.Fatal("a file asking for unbounded memory was accepted") + } +} + +func repeat(b byte, n int) []byte { + out := make([]byte, n) + for i := range out { + out[i] = b + } + return out +} diff --git a/internal/kdbx/argon2impl.go b/internal/kdbx/argon2impl.go new file mode 100644 index 0000000..640f48e --- /dev/null +++ b/internal/kdbx/argon2impl.go @@ -0,0 +1,302 @@ +package kdbx + +import ( + "encoding/binary" + "errors" + "fmt" +) + +// Argon2 (RFC 9106), the key derivation function KeePass 2 uses by default +// for KDBX 4. Implemented here because the standard library has no Argon2 +// and this package carries no third-party dependencies. + +const ( + argon2BlockSize = 1024 // bytes + argon2BlockWords = argon2BlockSize / 8 + argon2SyncPoints = 4 + argon2Version = 0x13 + + // argon2MaxMemory bounds what a file may ask the server to allocate. A + // malicious or corrupt header must not be able to exhaust memory. + argon2MaxMemory = 1 << 21 // KiB, i.e. 2 GiB +) + +type argon2Block [argon2BlockWords]uint64 + +// argon2Key derives tagLen bytes. variant is argon2d or argon2id. +func argon2Key(password, salt []byte, iterations, memoryKiB uint32, parallelism uint8, + tagLen uint32, variant int) ([]byte, error) { + return argon2Full(password, salt, nil, nil, iterations, memoryKiB, parallelism, tagLen, variant) +} + +// argon2Full is the complete function including the secret key and +// associated data, which KDBX never uses but the RFC's test vectors do. +func argon2Full(password, salt, secret, assoc []byte, iterations, memoryKiB uint32, + parallelism uint8, tagLen uint32, variant int) ([]byte, error) { + if parallelism == 0 || iterations == 0 || tagLen < 4 { + return nil, errors.New("kdbx: Argon2 parameters out of range") + } + if len(salt) < 8 { + return nil, errors.New("kdbx: Argon2 salt is too short") + } + if memoryKiB > argon2MaxMemory { + return nil, fmt.Errorf("kdbx: Argon2 asks for %d KiB of memory, refusing above %d", + memoryKiB, argon2MaxMemory) + } + lanes := uint32(parallelism) + if memoryKiB < 8*lanes { + memoryKiB = 8 * lanes + } + // Round the block count down so every lane holds the same whole segments. + blocks := memoryKiB / (argon2SyncPoints * lanes) * (argon2SyncPoints * lanes) + laneLen := blocks / lanes + segLen := laneLen / argon2SyncPoints + + h0 := argon2InitialHash(password, salt, secret, assoc, iterations, memoryKiB, + lanes, tagLen, variant) + + mem := make([]argon2Block, blocks) + for lane := uint32(0); lane < lanes; lane++ { + for i := uint32(0); i < 2; i++ { + var tail [8]byte + binary.LittleEndian.PutUint32(tail[0:], i) + binary.LittleEndian.PutUint32(tail[4:], lane) + b := argon2Hash(argon2BlockSize, append(append([]byte{}, h0...), tail[:]...)) + loadBlock(&mem[lane*laneLen+i], b) + } + } + + for pass := uint32(0); pass < iterations; pass++ { + for slice := uint32(0); slice < argon2SyncPoints; slice++ { + for lane := uint32(0); lane < lanes; lane++ { + argon2Segment(mem, pass, lane, slice, lanes, laneLen, segLen, + iterations, variant) + } + } + } + + var final argon2Block + for lane := uint32(0); lane < lanes; lane++ { + b := &mem[lane*laneLen+laneLen-1] + for i := range final { + final[i] ^= b[i] + } + } + return argon2Hash(int(tagLen), storeBlock(&final)), nil +} + +func argon2InitialHash(password, salt, secret, assoc []byte, iterations, memoryKiB, + lanes, tagLen uint32, variant int) []byte { + h := newBlake2b(64, nil) + var u [4]byte + put := func(v uint32) { + binary.LittleEndian.PutUint32(u[:], v) + h.Write(u[:]) + } + putBytes := func(b []byte) { + put(uint32(len(b))) + h.Write(b) + } + put(lanes) + put(tagLen) + put(memoryKiB) + put(iterations) + put(argon2Version) + put(uint32(variant)) + putBytes(password) + putBytes(salt) + putBytes(secret) + putBytes(assoc) + return h.Sum() +} + +// argon2Hash is the RFC's variable-length hash H', which chains BLAKE2b when +// more than 64 bytes are wanted. +func argon2Hash(outLen int, input []byte) []byte { + var prefix [4]byte + binary.LittleEndian.PutUint32(prefix[:], uint32(outLen)) + if outLen <= 64 { + return blake2bSum(outLen, prefix[:], input) + } + out := make([]byte, 0, outLen) + v := blake2bSum(64, prefix[:], input) + out = append(out, v[:32]...) + for len(out)+64 < outLen { + v = blake2bSum(64, v) + out = append(out, v[:32]...) + } + v = blake2bSum(outLen-len(out), v) + return append(out, v...) +} + +// argon2Segment fills one lane's slice of blocks for one pass. +func argon2Segment(mem []argon2Block, pass, lane, slice, lanes, laneLen, segLen, + iterations uint32, variant int) { + dataIndependent := variant == argon2i || + (variant == argon2id && pass == 0 && slice < argon2SyncPoints/2) + + var addresses, input, zero argon2Block + if dataIndependent { + input[0] = uint64(pass) + input[1] = uint64(lane) + input[2] = uint64(slice) + input[3] = uint64(len(mem)) + input[4] = uint64(iterations) + input[5] = uint64(variant) + } + + start := uint32(0) + if pass == 0 && slice == 0 { + start = 2 // the first two blocks of each lane are the initial hashes + if dataIndependent { + input[6]++ + argon2Addresses(&addresses, &input, &zero) + } + } + + offset := lane*laneLen + slice*segLen + start + for index := start; index < segLen; index, offset = index+1, offset+1 { + prev := offset - 1 + if offset%laneLen == 0 { + prev = offset + laneLen - 1 // wrapped to the end of this lane + } + + var rand uint64 + if dataIndependent { + if index%argon2BlockWords == 0 { + input[6]++ + argon2Addresses(&addresses, &input, &zero) + } + rand = addresses[index%argon2BlockWords] + } else { + rand = mem[prev][0] + } + + refLane := uint32(rand>>32) % lanes + if pass == 0 && slice == 0 { + refLane = lane + } + refIndex := argon2RefIndex(uint32(rand), pass, slice, index, refLane == lane, + laneLen, segLen) + + argon2Mix(&mem[offset], &mem[prev], &mem[refLane*laneLen+refIndex], pass > 0) + } +} + +// argon2RefIndex picks which earlier block this one mixes with, following +// the RFC's reference-area rules. +func argon2RefIndex(j1 uint32, pass, slice, index uint32, sameLane bool, + laneLen, segLen uint32) uint32 { + var refAreaSize uint32 + switch { + case pass == 0 && slice == 0: + refAreaSize = index - 1 + case pass == 0 && sameLane: + refAreaSize = slice*segLen + index - 1 + case pass == 0: + refAreaSize = slice * segLen + if index == 0 { + refAreaSize-- + } + case sameLane: + refAreaSize = laneLen - segLen + index - 1 + default: + refAreaSize = laneLen - segLen + if index == 0 { + refAreaSize-- + } + } + // The RFC's non-uniform mapping, biased towards recent blocks. + rel := uint64(j1) * uint64(j1) >> 32 + rel = uint64(refAreaSize) - 1 - (uint64(refAreaSize)*rel)>>32 + + start := uint32(0) + if pass != 0 && slice != argon2SyncPoints-1 { + start = (slice + 1) * segLen + } + return (start + uint32(rel)) % laneLen +} + +// argon2Addresses produces one block of data-independent addresses. +func argon2Addresses(addresses, input, zero *argon2Block) { + var tmp argon2Block + argon2Mix(&tmp, zero, input, false) + argon2Mix(addresses, zero, &tmp, false) +} + +// argon2Mix is the compression function G: out = Z ^ (X^Y), where Z is the +// BLAKE2b round permutation applied to the rows and then the columns. +func argon2Mix(out, x, y *argon2Block, xorExisting bool) { + var r argon2Block + for i := range r { + r[i] = x[i] ^ y[i] + } + var z argon2Block + copy(z[:], r[:]) + + for i := 0; i < 8; i++ { // rows + row := z[i*16 : i*16+16] + argon2Permute(row) + } + for i := 0; i < 8; i++ { // columns + var col [16]uint64 + for k := 0; k < 8; k++ { + col[k*2] = z[k*16+i*2] + col[k*2+1] = z[k*16+i*2+1] + } + argon2Permute(col[:]) + for k := 0; k < 8; k++ { + z[k*16+i*2] = col[k*2] + z[k*16+i*2+1] = col[k*2+1] + } + } + for i := range z { + v := z[i] ^ r[i] + if xorExisting { + out[i] ^= v + } else { + out[i] = v + } + } +} + +// argon2Permute is one BLAKE2b round over 16 words, with Argon2's extra +// 32-bit multiplication. +func argon2Permute(v []uint64) { + ga := func(a, b, c, d int) { + v[a] = argon2Add(v[a], v[b]) + v[d] = rotr64(v[d]^v[a], 32) + v[c] = argon2Add(v[c], v[d]) + v[b] = rotr64(v[b]^v[c], 24) + v[a] = argon2Add(v[a], v[b]) + v[d] = rotr64(v[d]^v[a], 16) + v[c] = argon2Add(v[c], v[d]) + v[b] = rotr64(v[b]^v[c], 63) + } + ga(0, 4, 8, 12) + ga(1, 5, 9, 13) + ga(2, 6, 10, 14) + ga(3, 7, 11, 15) + ga(0, 5, 10, 15) + ga(1, 6, 11, 12) + ga(2, 7, 8, 13) + ga(3, 4, 9, 14) +} + +func argon2Add(a, b uint64) uint64 { + return a + b + 2*uint64(uint32(a))*uint64(uint32(b)) +} + +func loadBlock(b *argon2Block, data []byte) { + for i := range b { + b[i] = binary.LittleEndian.Uint64(data[i*8:]) + } +} + +func storeBlock(b *argon2Block) []byte { + out := make([]byte, argon2BlockSize) + for i, v := range b { + binary.LittleEndian.PutUint64(out[i*8:], v) + } + return out +} diff --git a/internal/kdbx/blake2b.go b/internal/kdbx/blake2b.go new file mode 100644 index 0000000..2670610 --- /dev/null +++ b/internal/kdbx/blake2b.go @@ -0,0 +1,122 @@ +package kdbx + +import "encoding/binary" + +// BLAKE2b (RFC 7693). Argon2 is defined on top of it and the Go standard +// library does not expose it, so it lives here with the RFC's test vectors. + +var blake2bIV = [8]uint64{ + 0x6a09e667f3bcc908, 0xbb67ae8584caa73b, 0x3c6ef372fe94f82b, 0xa54ff53a5f1d36f1, + 0x510e527fade682d1, 0x9b05688c2b3e6c1f, 0x1f83d9abfb41bd6b, 0x5be0cd19137e2179, +} + +var blake2bSigma = [10][16]byte{ + {0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15}, + {14, 10, 4, 8, 9, 15, 13, 6, 1, 12, 0, 2, 11, 7, 5, 3}, + {11, 8, 12, 0, 5, 2, 15, 13, 10, 14, 3, 6, 7, 1, 9, 4}, + {7, 9, 3, 1, 13, 12, 11, 14, 2, 6, 5, 10, 4, 0, 15, 8}, + {9, 0, 5, 7, 2, 4, 10, 15, 14, 1, 11, 12, 6, 8, 3, 13}, + {2, 12, 6, 10, 0, 11, 8, 3, 4, 13, 7, 5, 15, 14, 1, 9}, + {12, 5, 1, 15, 14, 13, 4, 10, 0, 7, 6, 3, 9, 2, 8, 11}, + {13, 11, 7, 14, 12, 1, 3, 9, 5, 0, 15, 4, 8, 6, 2, 10}, + {6, 15, 14, 9, 11, 3, 0, 8, 12, 2, 13, 7, 1, 4, 10, 5}, + {10, 2, 8, 4, 7, 6, 1, 5, 15, 11, 9, 14, 3, 12, 13, 0}, +} + +type blake2b struct { + h [8]uint64 + buf [128]byte + n int // bytes buffered + t uint64 // bytes compressed so far + outLen int +} + +// newBlake2b starts a hash producing outLen bytes, optionally keyed. +func newBlake2b(outLen int, key []byte) *blake2b { + b := &blake2b{outLen: outLen} + b.h = blake2bIV + b.h[0] ^= 0x01010000 ^ uint64(len(key))<<8 ^ uint64(outLen) + if len(key) > 0 { + var block [128]byte + copy(block[:], key) + b.Write(block[:]) + } + return b +} + +func (b *blake2b) Write(p []byte) { + for len(p) > 0 { + if b.n == 128 { + b.t += 128 + b.compress(b.buf[:], false) + b.n = 0 + } + n := copy(b.buf[b.n:], p) + b.n += n + p = p[n:] + } +} + +func (b *blake2b) Sum() []byte { + for i := b.n; i < 128; i++ { + b.buf[i] = 0 + } + b.t += uint64(b.n) + b.compress(b.buf[:], true) + out := make([]byte, 0, 64) + for _, v := range b.h { + out = binary.LittleEndian.AppendUint64(out, v) + } + return out[:b.outLen] +} + +func (b *blake2b) compress(block []byte, last bool) { + var m [16]uint64 + for i := range m { + m[i] = binary.LittleEndian.Uint64(block[i*8:]) + } + var v [16]uint64 + copy(v[:8], b.h[:]) + copy(v[8:], blake2bIV[:]) + v[12] ^= b.t + // v[13] ^= high 64 bits of the counter, always zero for our inputs. + if last { + v[14] = ^v[14] + } + for r := 0; r < 12; r++ { + s := blake2bSigma[r%10] + gb2(&v, 0, 4, 8, 12, m[s[0]], m[s[1]]) + gb2(&v, 1, 5, 9, 13, m[s[2]], m[s[3]]) + gb2(&v, 2, 6, 10, 14, m[s[4]], m[s[5]]) + gb2(&v, 3, 7, 11, 15, m[s[6]], m[s[7]]) + gb2(&v, 0, 5, 10, 15, m[s[8]], m[s[9]]) + gb2(&v, 1, 6, 11, 12, m[s[10]], m[s[11]]) + gb2(&v, 2, 7, 8, 13, m[s[12]], m[s[13]]) + gb2(&v, 3, 4, 9, 14, m[s[14]], m[s[15]]) + } + for i := range b.h { + b.h[i] ^= v[i] ^ v[i+8] + } +} + +func gb2(v *[16]uint64, a, b, c, d int, x, y uint64) { + v[a] = v[a] + v[b] + x + v[d] = rotr64(v[d]^v[a], 32) + v[c] = v[c] + v[d] + v[b] = rotr64(v[b]^v[c], 24) + v[a] = v[a] + v[b] + y + v[d] = rotr64(v[d]^v[a], 16) + v[c] = v[c] + v[d] + v[b] = rotr64(v[b]^v[c], 63) +} + +func rotr64(v uint64, n uint) uint64 { return v>>n | v<<(64-n) } + +// blake2bSum hashes the concatenation of parts into outLen bytes. +func blake2bSum(outLen int, parts ...[]byte) []byte { + h := newBlake2b(outLen, nil) + for _, p := range parts { + h.Write(p) + } + return h.Sum() +} diff --git a/internal/kdbx/blake2b_test.go b/internal/kdbx/blake2b_test.go new file mode 100644 index 0000000..8d63296 --- /dev/null +++ b/internal/kdbx/blake2b_test.go @@ -0,0 +1,57 @@ +package kdbx + +import ( + "encoding/hex" + "testing" +) + +// RFC 7693 appendix A, and the standard unkeyed vectors for the empty input +// and "abc". +func TestBlake2bVectors(t *testing.T) { + for _, tc := range []struct { + in, want string + outLen int + }{ + {"", "786a02f742015903c6c6fd852552d272912f4740e15847618a86e217f71f5419" + + "d25e1031afee585313896444934eb04b903a685b1448b755d56f701afe9be2ce", 64}, + {"abc", "ba80a53f981c4d0d6a2797b69f12f6e94c212f14685ac4b74b12bb6fdbffa2d1" + + "7d87c5392aab792dc252d5de4533cc9518d38aa8dbf1925ab92386edd4009923", 64}, + {"abc", "bddd813c634239723171ef3fee98579b94964e3bb1cb3e427262c8c068d52319", 32}, + } { + got := hex.EncodeToString(blake2bSum(tc.outLen, []byte(tc.in))) + if got != tc.want { + t.Errorf("blake2b-%d(%q) =\n%s\nwant\n%s", tc.outLen*8, tc.in, got, tc.want) + } + } +} + +func TestBlake2bKeyed(t *testing.T) { + // RFC 7693 appendix A uses a 64-byte key of 0x00..0x3f with input 0x00. + key := make([]byte, 64) + for i := range key { + key[i] = byte(i) + } + h := newBlake2b(64, key) + h.Write([]byte{0x00}) + want := "961f6dd1e4dd30f63901690c512e78e4b45e4742ed197c3c5e45c549fd25f2e4" + + "187b0bc9fe30492b16b0d0bc4ef9b0f34c7003fac09a5ef1532e69430234cebd" + if got := hex.EncodeToString(h.Sum()); got != want { + t.Fatalf("keyed blake2b =\n%s\nwant\n%s", got, want) + } +} + +func TestBlake2bAcrossWriteBoundaries(t *testing.T) { + data := make([]byte, 300) + for i := range data { + data[i] = byte(i) + } + want := hex.EncodeToString(blake2bSum(64, data)) + h := newBlake2b(64, nil) + for _, n := range []int{1, 127, 1, 128, 43} { + h.Write(data[:n]) + data = data[n:] + } + if got := hex.EncodeToString(h.Sum()); got != want { + t.Fatal("chunked writes differ from a single write") + } +} diff --git a/internal/kdbx/chacha20.go b/internal/kdbx/chacha20.go new file mode 100644 index 0000000..b26cab3 --- /dev/null +++ b/internal/kdbx/chacha20.go @@ -0,0 +1,74 @@ +package kdbx + +import "encoding/binary" + +// ChaCha20 (RFC 8439) is the inner stream cipher KDBX 4 uses to protect +// individual values inside the XML, and an optional outer cipher. The Go +// standard library does not export it, so it is implemented here: the +// algorithm is 60 lines and comes with published test vectors. +type chacha20 struct { + state [16]uint32 + block [64]byte + used int // bytes of block already consumed +} + +// newChaCha20 starts a keystream for a 32-byte key and 12-byte nonce. +func newChaCha20(key, nonce []byte, counter uint32) *chacha20 { + c := &chacha20{used: 64} + c.state[0], c.state[1], c.state[2], c.state[3] = + 0x61707865, 0x3320646e, 0x79622d32, 0x6b206574 // "expand 32-byte k" + for i := 0; i < 8; i++ { + c.state[4+i] = binary.LittleEndian.Uint32(key[i*4:]) + } + c.state[12] = counter + for i := 0; i < 3; i++ { + c.state[13+i] = binary.LittleEndian.Uint32(nonce[i*4:]) + } + return c +} + +// XORKeyStream applies the keystream to src, continuing where the previous +// call stopped. KDBX protects its values as one continuous stream, so this +// must survive being called once per value. +func (c *chacha20) XORKeyStream(dst, src []byte) { + for i := range src { + if c.used == 64 { + c.nextBlock() + } + dst[i] = src[i] ^ c.block[c.used] + c.used++ + } +} + +func (c *chacha20) nextBlock() { + var x [16]uint32 + copy(x[:], c.state[:]) + for i := 0; i < 10; i++ { // 20 rounds, two per iteration + quarter(&x, 0, 4, 8, 12) + quarter(&x, 1, 5, 9, 13) + quarter(&x, 2, 6, 10, 14) + quarter(&x, 3, 7, 11, 15) + quarter(&x, 0, 5, 10, 15) + quarter(&x, 1, 6, 11, 12) + quarter(&x, 2, 7, 8, 13) + quarter(&x, 3, 4, 9, 14) + } + for i := range x { + binary.LittleEndian.PutUint32(c.block[i*4:], x[i]+c.state[i]) + } + c.state[12]++ + c.used = 0 +} + +func quarter(x *[16]uint32, a, b, cc, d int) { + x[a] += x[b] + x[d] = rotl(x[d]^x[a], 16) + x[cc] += x[d] + x[b] = rotl(x[b]^x[cc], 12) + x[a] += x[b] + x[d] = rotl(x[d]^x[a], 8) + x[cc] += x[d] + x[b] = rotl(x[b]^x[cc], 7) +} + +func rotl(v uint32, n uint) uint32 { return v<>(32-n) } diff --git a/internal/kdbx/chacha20_test.go b/internal/kdbx/chacha20_test.go new file mode 100644 index 0000000..6add6e1 --- /dev/null +++ b/internal/kdbx/chacha20_test.go @@ -0,0 +1,58 @@ +package kdbx + +import ( + "bytes" + "encoding/hex" + "testing" +) + +// RFC 8439 section 2.4.2. +func TestChaCha20RFC8439(t *testing.T) { + key := make([]byte, 32) + for i := range key { + key[i] = byte(i) + } + nonce, _ := hex.DecodeString("000000000000004a00000000") + plain := []byte("Ladies and Gentlemen of the class of '99: If I could offer you " + + "only one tip for the future, sunscreen would be it.") + want, _ := hex.DecodeString( + "6e2e359a2568f98041ba0728dd0d6981e97e7aec1d4360c20a27afccfd9fae0b" + + "f91b65c5524733ab8f593dabcd62b3571639d624e65152ab8f530c359f0861d8" + + "07ca0dbf500d6a6156a38e088a22b65e52bc514d16ccf806818ce91ab7793736" + + "5af90bbf74a35be6b40b8eedf2785e42874d") + + got := make([]byte, len(plain)) + newChaCha20(key, nonce, 1).XORKeyStream(got, plain) + if !bytes.Equal(got, want) { + t.Fatalf("ciphertext =\n%x\nwant\n%x", got, want) + } +} + +// The keystream must be continuous across calls, because KDBX protects one +// value at a time from a single stream. +func TestChaCha20StreamIsContinuousAcrossCalls(t *testing.T) { + key := make([]byte, 32) + nonce := make([]byte, 12) + src := make([]byte, 200) + + whole := make([]byte, len(src)) + newChaCha20(key, nonce, 0).XORKeyStream(whole, src) + + piecewise := make([]byte, len(src)) + c := newChaCha20(key, nonce, 0) + for _, n := range []int{1, 7, 56, 64, 72} { + c.XORKeyStream(piecewise[:n], src[:n]) + piecewise = piecewise[n:] + src = src[n:] + } + piecewise = make([]byte, 200) + c = newChaCha20(key, nonce, 0) + off := 0 + for _, n := range []int{1, 7, 56, 64, 72} { + c.XORKeyStream(piecewise[off:off+n], make([]byte, n)) + off += n + } + if !bytes.Equal(whole, piecewise) { + t.Fatal("keystream differs when produced in pieces") + } +} diff --git a/internal/kdbx/crypto.go b/internal/kdbx/crypto.go new file mode 100644 index 0000000..5c6d7db --- /dev/null +++ b/internal/kdbx/crypto.go @@ -0,0 +1,232 @@ +package kdbx + +import ( + "bytes" + "crypto/aes" + "crypto/cipher" + "crypto/hmac" + "crypto/sha256" + "crypto/sha512" + "crypto/subtle" + "encoding/binary" + "errors" + "fmt" +) + +// maxBlock is the payload block size written into the HMAC block stream. +const maxBlock = 1 << 20 + +// keys are everything derived from the credentials and the file's seeds. +type keys struct { + payload []byte // 32 bytes, the cipher key for the payload + hmac []byte // 64 bytes, the base from which per-block keys come +} + +// compositeKey is SHA-256 over the concatenated credential keys. With a +// password alone that is SHA-256(SHA-256(password)). +func compositeKey(password string) []byte { + pw := sha256.Sum256([]byte(password)) + comp := sha256.Sum256(pw[:]) + return comp[:] +} + +// deriveKeys runs the file's key derivation function and produces the +// payload and HMAC keys. +func deriveKeys(masterSeed []byte, kdf variantDict, password string) (keys, error) { + transformed, err := transformKey(kdf, compositeKey(password)) + if err != nil { + return keys{}, err + } + pk := sha256.Sum256(append(append([]byte{}, masterSeed...), transformed...)) + hk := sha512.Sum512(append(append(append([]byte{}, masterSeed...), transformed...), 0x01)) + return keys{payload: pk[:], hmac: hk[:]}, nil +} + +// transformKey applies the file's KDF to the composite key. +func transformKey(kdf variantDict, composite []byte) ([]byte, error) { + uuid := kdf.bytes("$UUID") + switch { + case bytes.Equal(uuid, kdfAES): + seed := kdf.bytes("S") + rounds, ok := kdf.uint64("R") + if len(seed) != 32 || !ok { + return nil, errors.New("kdbx: AES-KDF parameters are missing or malformed") + } + return aesKDF(composite, seed, rounds) + case bytes.Equal(uuid, kdfArgon2d): + return argon2KDF(kdf, composite, argon2d) + case bytes.Equal(uuid, kdfArgon2id): + return argon2KDF(kdf, composite, argon2id) + case len(uuid) == 0: + return nil, errors.New("kdbx: file names no key derivation function") + default: + return nil, unsupported("key derivation function %x", uuid) + } +} + +// aesKDF encrypts the composite key with itself, round after round. It is +// KeePass's original work factor and needs only the standard library. +func aesKDF(composite, seed []byte, rounds uint64) ([]byte, error) { + block, err := aes.NewCipher(seed) + if err != nil { + return nil, err + } + data := make([]byte, 32) + copy(data, composite) + for i := uint64(0); i < rounds; i++ { + block.Encrypt(data[0:16], data[0:16]) + block.Encrypt(data[16:32], data[16:32]) + } + sum := sha256.Sum256(data) + return sum[:], nil +} + +// blockKey is the HMAC key for one block of the payload stream. +func blockKey(base []byte, index uint64) []byte { + buf := make([]byte, 8, 8+len(base)) + binary.LittleEndian.PutUint64(buf, index) + sum := sha512.Sum512(append(buf, base...)) + return sum[:] +} + +// headerHMAC authenticates the outer header. KeePass uses the maximum block +// index for it, so it cannot be confused with a payload block. +func headerHMAC(base, header []byte) []byte { + mac := hmac.New(sha256.New, blockKey(base, ^uint64(0))) + mac.Write(header) + return mac.Sum(nil) +} + +// readBlocks verifies and concatenates the HMAC block stream. +func readBlocks(data, base []byte) ([]byte, error) { + var out []byte + for index := uint64(0); ; index++ { + if len(data) < 36 { + return nil, errors.New("kdbx: truncated payload block header") + } + gotMAC := data[:32] + size := binary.LittleEndian.Uint32(data[32:36]) + if int(size) < 0 || 36+int(size) > len(data) { + return nil, errors.New("kdbx: truncated payload block") + } + body := data[36 : 36+int(size)] + + mac := hmac.New(sha256.New, blockKey(base, index)) + var meta [12]byte + binary.LittleEndian.PutUint64(meta[0:], index) + binary.LittleEndian.PutUint32(meta[8:], size) + mac.Write(meta[:]) + mac.Write(body) + if subtle.ConstantTimeCompare(mac.Sum(nil), gotMAC) != 1 { + return nil, fmt.Errorf("kdbx: payload block %d failed authentication", index) + } + data = data[36+int(size):] + if size == 0 { + return out, nil + } + out = append(out, body...) + } +} + +// writeBlocks produces the HMAC block stream, ending with the empty block +// that marks the end of the file. +func writeBlocks(payload, base []byte) []byte { + out := []byte{} + index := uint64(0) + for len(payload) > 0 { + n := min(len(payload), maxBlock) + out = appendBlock(out, base, index, payload[:n]) + payload = payload[n:] + index++ + } + return appendBlock(out, base, index, nil) +} + +func appendBlock(out, base []byte, index uint64, body []byte) []byte { + var meta [12]byte + binary.LittleEndian.PutUint64(meta[0:], index) + binary.LittleEndian.PutUint32(meta[8:], uint32(len(body))) + mac := hmac.New(sha256.New, blockKey(base, index)) + mac.Write(meta[:]) + mac.Write(body) + out = append(out, mac.Sum(nil)...) + out = append(out, meta[8:12]...) + return append(out, body...) +} + +// decryptPayload reverses the file's payload cipher. +func decryptPayload(cipherID, key, iv, data []byte) ([]byte, error) { + switch { + case bytes.Equal(cipherID, cipherAES256CBC): + block, err := aes.NewCipher(key) + if err != nil { + return nil, err + } + if len(iv) != block.BlockSize() { + return nil, errors.New("kdbx: encryption IV has the wrong length") + } + if len(data) == 0 || len(data)%block.BlockSize() != 0 { + return nil, ErrWrongPassword + } + out := make([]byte, len(data)) + cipher.NewCBCDecrypter(block, iv).CryptBlocks(out, data) + return unpad(out, block.BlockSize()) + case bytes.Equal(cipherID, cipherChaCha20Outer): + if len(iv) != 12 { + return nil, errors.New("kdbx: ChaCha20 nonce has the wrong length") + } + out := make([]byte, len(data)) + newChaCha20(key, iv, 0).XORKeyStream(out, data) + return out, nil + default: + return nil, unsupported("payload cipher %x", cipherID) + } +} + +// encryptPayload applies the file's payload cipher. +func encryptPayload(cipherID, key, iv, data []byte) ([]byte, error) { + switch { + case bytes.Equal(cipherID, cipherAES256CBC): + block, err := aes.NewCipher(key) + if err != nil { + return nil, err + } + padded := pad(data, block.BlockSize()) + out := make([]byte, len(padded)) + cipher.NewCBCEncrypter(block, iv).CryptBlocks(out, padded) + return out, nil + case bytes.Equal(cipherID, cipherChaCha20Outer): + out := make([]byte, len(data)) + newChaCha20(key, iv, 0).XORKeyStream(out, data) + return out, nil + default: + return nil, unsupported("payload cipher %x", cipherID) + } +} + +func pad(b []byte, size int) []byte { + n := size - len(b)%size + out := make([]byte, len(b)+n) + copy(out, b) + for i := len(b); i < len(out); i++ { + out[i] = byte(n) + } + return out +} + +func unpad(b []byte, size int) ([]byte, error) { + if len(b) == 0 || len(b)%size != 0 { + return nil, ErrWrongPassword + } + n := int(b[len(b)-1]) + if n == 0 || n > size || n > len(b) { + // Wrong key almost always shows up here first. + return nil, ErrWrongPassword + } + for _, v := range b[len(b)-n:] { + if int(v) != n { + return nil, ErrWrongPassword + } + } + return b[:len(b)-n], nil +} diff --git a/internal/kdbx/format.go b/internal/kdbx/format.go new file mode 100644 index 0000000..c9ae8cb --- /dev/null +++ b/internal/kdbx/format.go @@ -0,0 +1,220 @@ +// Package kdbx reads, writes and merges KeePass 2 (KDBX 4) databases. +// +// It exists so a synchronization server can merge two edits of the same +// vault instead of picking a winner. Merging means decrypting, which means +// the holder of this code holds the vault's secrets: see the package's +// Merge documentation and the server's handover notes for what that costs. +// +// The implementation is deliberately narrow. It reads and writes KDBX 4.0 +// and 4.1 with AES-256-CBC or ChaCha20 payloads, AES-KDF or Argon2 key +// derivation, and the ChaCha20 inner stream. It reports anything else as +// unsupported rather than guessing, and it preserves every XML element it +// does not understand. +package kdbx + +import ( + "encoding/binary" + "errors" + "fmt" +) + +const ( + signature1 = 0x9AA2D903 + signature2 = 0xB54BFB67 + + // Version4 is the file version this package writes: KDBX 4.0. + Version4 uint32 = 4<<16 | 0 + // version41 is also readable; 4.1 only adds XML elements, which the + // generic tree carries through untouched. + version41 uint32 = 4<<16 | 1 +) + +// Outer header field identifiers. +const ( + hdrEnd = 0 + hdrComment = 1 + hdrCipherID = 2 + hdrCompression = 3 + hdrMasterSeed = 4 + hdrTransformSeed = 5 // KDBX 3 only + hdrTransformRounds = 6 // KDBX 3 only + hdrEncryptionIV = 7 + hdrInnerStreamKey3 = 8 // KDBX 3 only + hdrStreamStartBytes = 9 // KDBX 3 only + hdrInnerStreamID3 = 10 // KDBX 3 only + hdrKdfParameters = 11 + hdrPublicCustomData = 12 +) + +// Inner header field identifiers. +const ( + innerEnd = 0 + innerStreamID = 1 + innerStreamKey = 2 + innerBinary = 3 +) + +// Inner random stream identifiers. +const ( + streamNone = 0 + streamSalsa20 = 2 + streamChaCha20 = 3 +) + +// Cipher and KDF identifiers, stored as raw 16-byte UUIDs. +var ( + cipherAES256CBC = []byte{0x31, 0xC1, 0xF2, 0xE6, 0xBF, 0x71, 0x43, 0x50, + 0xBE, 0x58, 0x05, 0x21, 0x6A, 0xFC, 0x5A, 0xFF} + cipherChaCha20Outer = []byte{0xD6, 0x03, 0x8A, 0x2B, 0x8B, 0x6F, 0x4C, 0xB5, + 0xA5, 0x24, 0x33, 0x9A, 0x31, 0xDB, 0xB5, 0x9A} + + kdfAES = []byte{0xC9, 0xD9, 0xF3, 0x9A, 0x62, 0x8A, 0x44, 0x60, + 0xBF, 0x74, 0x0D, 0x08, 0xC1, 0x8A, 0x4F, 0xEA} + kdfArgon2d = []byte{0xEF, 0x63, 0x6D, 0xDF, 0x8C, 0x29, 0x44, 0x4B, + 0x91, 0xF7, 0xA9, 0xA4, 0x03, 0xE3, 0x0A, 0x0C} + kdfArgon2id = []byte{0x9E, 0x29, 0x8B, 0x19, 0x56, 0xDB, 0x47, 0x73, + 0xB2, 0x3D, 0xFC, 0x3E, 0xC6, 0xF0, 0xA1, 0xE1} +) + +// ErrWrongPassword is returned when the credentials do not open the file. +var ErrWrongPassword = errors.New("kdbx: wrong password or corrupted file") + +// ErrUnsupported is returned for a file this package will not touch, such as +// one using a cipher it cannot compute. A caller that cannot merge should +// fall back to a non-merging strategy rather than lose data. +type ErrUnsupported struct{ Reason string } + +func (e *ErrUnsupported) Error() string { return "kdbx: unsupported: " + e.Reason } + +func unsupported(format string, args ...any) error { + return &ErrUnsupported{Reason: fmt.Sprintf(format, args...)} +} + +// variantDict is KeePass's typed key-value container, used for the KDF +// parameters and public custom data. Order is preserved so an unchanged +// dictionary round-trips byte for byte. +type variantDict struct { + items []variantItem +} + +type variantItem struct { + typ byte + key string + value []byte +} + +const ( + vdUint32 = 0x04 + vdUint64 = 0x05 + vdBool = 0x08 + vdInt32 = 0x0C + vdInt64 = 0x0D + vdString = 0x18 + vdByteArray = 0x42 + + vdVersion = 0x0100 +) + +func parseVariantDict(b []byte) (variantDict, error) { + var vd variantDict + if len(b) < 2 { + return vd, errors.New("kdbx: variant dictionary too short") + } + if v := binary.LittleEndian.Uint16(b); v>>8 != vdVersion>>8 { + return vd, unsupported("variant dictionary version %#04x", v) + } + p := 2 + for { + if p >= len(b) { + return vd, errors.New("kdbx: variant dictionary is not terminated") + } + typ := b[p] + p++ + if typ == 0 { + return vd, nil + } + if p+4 > len(b) { + return vd, errors.New("kdbx: truncated variant dictionary key") + } + kl := int(binary.LittleEndian.Uint32(b[p:])) + p += 4 + if kl < 0 || p+kl > len(b) { + return vd, errors.New("kdbx: truncated variant dictionary key") + } + key := string(b[p : p+kl]) + p += kl + if p+4 > len(b) { + return vd, errors.New("kdbx: truncated variant dictionary value") + } + vl := int(binary.LittleEndian.Uint32(b[p:])) + p += 4 + if vl < 0 || p+vl > len(b) { + return vd, errors.New("kdbx: truncated variant dictionary value") + } + val := make([]byte, vl) + copy(val, b[p:p+vl]) + p += vl + vd.items = append(vd.items, variantItem{typ: typ, key: key, value: val}) + } +} + +func (vd variantDict) marshal() []byte { + out := make([]byte, 2) + binary.LittleEndian.PutUint16(out, vdVersion) + for _, it := range vd.items { + out = append(out, it.typ) + out = binary.LittleEndian.AppendUint32(out, uint32(len(it.key))) + out = append(out, it.key...) + out = binary.LittleEndian.AppendUint32(out, uint32(len(it.value))) + out = append(out, it.value...) + } + return append(out, 0) +} + +func (vd variantDict) get(key string) (variantItem, bool) { + for _, it := range vd.items { + if it.key == key { + return it, true + } + } + return variantItem{}, false +} + +func (vd variantDict) bytes(key string) []byte { + if it, ok := vd.get(key); ok { + return it.value + } + return nil +} + +func (vd variantDict) uint64(key string) (uint64, bool) { + it, ok := vd.get(key) + if !ok { + return 0, false + } + switch { + case len(it.value) == 8: + return binary.LittleEndian.Uint64(it.value), true + case len(it.value) == 4: + return uint64(binary.LittleEndian.Uint32(it.value)), true + } + return 0, false +} + +func (vd *variantDict) set(typ byte, key string, value []byte) { + for i := range vd.items { + if vd.items[i].key == key { + vd.items[i].typ, vd.items[i].value = typ, value + return + } + } + vd.items = append(vd.items, variantItem{typ: typ, key: key, value: value}) +} + +func (vd *variantDict) setUint64(key string, v uint64) { + vd.set(vdUint64, key, binary.LittleEndian.AppendUint64(nil, v)) +} + +func (vd *variantDict) setUint32(key string, v uint32) { + vd.set(vdUint32, key, binary.LittleEndian.AppendUint32(nil, v)) +} diff --git a/internal/kdbx/interop_test.go b/internal/kdbx/interop_test.go new file mode 100644 index 0000000..ac4d96b --- /dev/null +++ b/internal/kdbx/interop_test.go @@ -0,0 +1,132 @@ +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) + } +} diff --git a/internal/kdbx/kdbx.go b/internal/kdbx/kdbx.go new file mode 100644 index 0000000..322f467 --- /dev/null +++ b/internal/kdbx/kdbx.go @@ -0,0 +1,432 @@ +package kdbx + +import ( + "bytes" + "compress/gzip" + "crypto/rand" + "crypto/sha256" + "crypto/sha512" + "crypto/subtle" + "encoding/base64" + "encoding/binary" + "errors" + "fmt" + "io" +) + +// Binary is an attachment, stored once in the file's pool and referenced +// from entries by index. +type Binary struct { + // Flags is the KDBX protection flag byte; bit 0 asks the client to keep + // the content in protected memory. + Flags byte + Data []byte +} + +// Database is a decrypted KDBX file: its format parameters, its attachment +// pool and its XML document. +type Database struct { + Version uint32 + Cipher []byte + Compression uint32 + KDF variantDict + PublicData variantDict + Binaries []Binary + // Root is the KeePassFile element. Everything the format carries lives + // under it, including elements this package does not interpret. + Root *Node +} + +// KDFParams describes the work factor a new database is created with. +type KDFParams struct { + // Argon2 parameters. MemoryKiB and Iterations are what make an attack + // expensive; they are also what a merging server pays on every push. + MemoryKiB uint32 + Iterations uint32 + Lanes uint8 +} + +// DefaultKDF is deliberately lighter than the KeePass desktop default: a +// server re-derives the key three times per merge, so the cost is paid on +// every save by every member of the vault. +var DefaultKDF = KDFParams{MemoryKiB: 32 * 1024, Iterations: 2, Lanes: 2} + +// Open decrypts and parses a KDBX 4 file. +func Open(data []byte, password string) (*Database, error) { + if len(data) < 12 { + return nil, errors.New("kdbx: file is too short") + } + if binary.LittleEndian.Uint32(data[0:]) != signature1 || + binary.LittleEndian.Uint32(data[4:]) != signature2 { + return nil, errors.New("kdbx: not a KeePass 2 database") + } + version := binary.LittleEndian.Uint32(data[8:]) + if major := version >> 16; major != 4 { + return nil, unsupported("KDBX %d.%d; this server stores KDBX 4 vaults", + major, version&0xFFFF) + } + + db := &Database{Version: version} + p := 12 + for { + if p+5 > len(data) { + return nil, errors.New("kdbx: truncated header") + } + id := data[p] + size := int(binary.LittleEndian.Uint32(data[p+1:])) + p += 5 + if size < 0 || p+size > len(data) { + return nil, errors.New("kdbx: truncated header field") + } + field := data[p : p+size] + p += size + + switch id { + case hdrEnd: + goto headerDone + case hdrCipherID: + db.Cipher = append([]byte(nil), field...) + case hdrCompression: + if len(field) != 4 { + return nil, errors.New("kdbx: malformed compression flags") + } + db.Compression = binary.LittleEndian.Uint32(field) + case hdrMasterSeed: + // Kept locally; it never leaves this function. + case hdrEncryptionIV: + case hdrKdfParameters: + vd, err := parseVariantDict(field) + if err != nil { + return nil, err + } + db.KDF = vd + case hdrPublicCustomData: + vd, err := parseVariantDict(field) + if err != nil { + return nil, err + } + db.PublicData = vd + case hdrComment: + case hdrTransformSeed, hdrTransformRounds, hdrInnerStreamKey3, + hdrStreamStartBytes, hdrInnerStreamID3: + return nil, unsupported("KDBX 3 header field %d in a KDBX 4 file", id) + } + } +headerDone: + headerBytes := data[:p] + masterSeed := headerField(data[12:p], hdrMasterSeed) + iv := headerField(data[12:p], hdrEncryptionIV) + if len(masterSeed) != 32 { + return nil, errors.New("kdbx: master seed has the wrong length") + } + if len(db.Cipher) != 16 { + return nil, errors.New("kdbx: file names no payload cipher") + } + + if p+64 > len(data) { + return nil, errors.New("kdbx: file ends before its integrity hashes") + } + storedSHA := data[p : p+32] + storedHMAC := data[p+32 : p+64] + sum := sha256.Sum256(headerBytes) + if subtle.ConstantTimeCompare(sum[:], storedSHA) != 1 { + return nil, errors.New("kdbx: header does not match its checksum") + } + + k, err := deriveKeys(masterSeed, db.KDF, password) + if err != nil { + return nil, err + } + if subtle.ConstantTimeCompare(headerHMAC(k.hmac, headerBytes), storedHMAC) != 1 { + return nil, ErrWrongPassword + } + + payload, err := readBlocks(data[p+64:], k.hmac) + if err != nil { + return nil, err + } + plain, err := decryptPayload(db.Cipher, k.payload, iv, payload) + if err != nil { + return nil, err + } + if db.Compression == 1 { + if plain, err = gunzip(plain); err != nil { + return nil, fmt.Errorf("kdbx: decompress: %w", err) + } + } else if db.Compression != 0 { + return nil, unsupported("compression algorithm %d", db.Compression) + } + + streamID, streamKey, xmlBytes, err := db.readInnerHeader(plain) + if err != nil { + return nil, err + } + root, err := parseXML(xmlBytes) + if err != nil { + return nil, err + } + db.Root = root + if err := db.applyInnerStream(streamID, streamKey, false); err != nil { + return nil, err + } + return db, nil +} + +// Marshal encrypts the database back into a KDBX 4 file. Every save gets +// fresh seeds, a fresh IV and a fresh inner stream key. +func (d *Database) Marshal(password string) ([]byte, error) { + if d.Root == nil { + return nil, errors.New("kdbx: database has no document") + } + cipherID := d.Cipher + if len(cipherID) != 16 { + cipherID = cipherAES256CBC + } + ivLen := 16 + if bytes.Equal(cipherID, cipherChaCha20Outer) { + ivLen = 12 + } + masterSeed := randomBytes(32) + iv := randomBytes(ivLen) + streamKey := randomBytes(64) + + kdf := d.KDF + if _, ok := kdf.get("$UUID"); !ok { + kdf = newArgon2Params(DefaultKDF) + } + // A new salt per save, so two saves never share a transformed key. + switch { + case bytes.Equal(kdf.bytes("$UUID"), kdfAES): + kdf.set(vdByteArray, "S", randomBytes(32)) + default: + kdf.set(vdByteArray, "S", randomBytes(32)) + } + d.KDF = kdf + + // Protect values on a copy so the in-memory tree stays plaintext and can + // be marshalled again. + protectedRoot := d.Root.clone() + if err := applyStream(protectedRoot, streamChaCha20, streamKey, true); err != nil { + return nil, err + } + + var payload bytes.Buffer + payload.Write(d.innerHeader(streamChaCha20, streamKey)) + payload.Write(marshalXML(protectedRoot)) + + body := payload.Bytes() + if d.Compression == 1 { + var gz bytes.Buffer + w := gzip.NewWriter(&gz) + if _, err := w.Write(body); err != nil { + return nil, err + } + if err := w.Close(); err != nil { + return nil, err + } + body = gz.Bytes() + } + + version := d.Version + if version>>16 != 4 { + version = Version4 + } + header := d.outerHeader(version, cipherID, masterSeed, iv, kdf) + + k, err := deriveKeys(masterSeed, kdf, password) + if err != nil { + return nil, err + } + encrypted, err := encryptPayload(cipherID, k.payload, iv, body) + if err != nil { + return nil, err + } + + out := make([]byte, 0, len(header)+64+len(encrypted)+64) + out = append(out, header...) + sum := sha256.Sum256(header) + out = append(out, sum[:]...) + out = append(out, headerHMAC(k.hmac, header)...) + return append(out, writeBlocks(encrypted, k.hmac)...), nil +} + +// outerHeader serializes the unencrypted header. +func (d *Database) outerHeader(version uint32, cipherID, masterSeed, iv []byte, + kdf variantDict) []byte { + out := make([]byte, 12) + binary.LittleEndian.PutUint32(out[0:], signature1) + binary.LittleEndian.PutUint32(out[4:], signature2) + binary.LittleEndian.PutUint32(out[8:], version) + + add := func(id byte, data []byte) { + out = append(out, id) + out = binary.LittleEndian.AppendUint32(out, uint32(len(data))) + out = append(out, data...) + } + add(hdrCipherID, cipherID) + add(hdrCompression, binary.LittleEndian.AppendUint32(nil, d.Compression)) + add(hdrMasterSeed, masterSeed) + add(hdrEncryptionIV, iv) + add(hdrKdfParameters, kdf.marshal()) + if len(d.PublicData.items) > 0 { + add(hdrPublicCustomData, d.PublicData.marshal()) + } + add(hdrEnd, []byte{0x0D, 0x0A, 0x0D, 0x0A}) + return out +} + +// readInnerHeader splits the decrypted payload into its inner header, which +// carries the attachment pool, and the XML that follows it. +func (d *Database) readInnerHeader(plain []byte) (streamID uint32, streamKey, xmlBytes []byte, err error) { + p := 0 + d.Binaries = nil + for { + if p+5 > len(plain) { + return 0, nil, nil, errors.New("kdbx: truncated inner header") + } + id := plain[p] + size := int(binary.LittleEndian.Uint32(plain[p+1:])) + p += 5 + if size < 0 || p+size > len(plain) { + return 0, nil, nil, errors.New("kdbx: truncated inner header field") + } + field := plain[p : p+size] + p += size + switch id { + case innerEnd: + return streamID, streamKey, plain[p:], nil + case innerStreamID: + if len(field) != 4 { + return 0, nil, nil, errors.New("kdbx: malformed inner stream id") + } + streamID = binary.LittleEndian.Uint32(field) + case innerStreamKey: + streamKey = append([]byte(nil), field...) + case innerBinary: + if len(field) < 1 { + return 0, nil, nil, errors.New("kdbx: malformed attachment") + } + d.Binaries = append(d.Binaries, Binary{ + Flags: field[0], + Data: append([]byte(nil), field[1:]...), + }) + } + } +} + +func (d *Database) innerHeader(streamID uint32, streamKey []byte) []byte { + out := []byte{} + add := func(id byte, data []byte) { + out = append(out, id) + out = binary.LittleEndian.AppendUint32(out, uint32(len(data))) + out = append(out, data...) + } + add(innerStreamID, binary.LittleEndian.AppendUint32(nil, streamID)) + add(innerStreamKey, streamKey) + for _, b := range d.Binaries { + add(innerBinary, append([]byte{b.Flags}, b.Data...)) + } + add(innerEnd, nil) + return out +} + +// applyInnerStream decrypts the protected values of this database's tree. +func (d *Database) applyInnerStream(streamID uint32, key []byte, protect bool) error { + return applyStream(d.Root, streamID, key, protect) +} + +// applyStream runs the inner stream cipher across every protected value in +// document order, which is the order KeePass itself uses. +func applyStream(root *Node, streamID uint32, key []byte, protect bool) error { + var stream *chacha20 + switch streamID { + case streamNone: + case streamChaCha20: + h := sha512.Sum512(key) + stream = newChaCha20(h[0:32], h[32:44], 0) + case streamSalsa20: + return unsupported("Salsa20 inner stream (KDBX 3)") + default: + return unsupported("inner stream cipher %d", streamID) + } + + var err error + root.walk(func(n *Node) { + if err != nil || !n.Protected || len(n.Nodes) > 0 { + return + } + if stream == nil { + return // nothing to do, values are stored in the clear + } + if protect { + plain := []byte(n.Text) + out := make([]byte, len(plain)) + stream.XORKeyStream(out, plain) + n.Text = base64.StdEncoding.EncodeToString(out) + return + } + raw, derr := base64.StdEncoding.DecodeString(n.Text) + if derr != nil { + err = fmt.Errorf("kdbx: protected value in <%s> is not base64: %w", n.Name, derr) + return + } + out := make([]byte, len(raw)) + stream.XORKeyStream(out, raw) + n.Text = string(out) + }) + return err +} + +func newArgon2Params(p KDFParams) variantDict { + if p.MemoryKiB == 0 { + p = DefaultKDF + } + var vd variantDict + vd.set(vdByteArray, "$UUID", kdfArgon2d) + vd.set(vdByteArray, "S", randomBytes(32)) + vd.setUint32("P", uint32(max(p.Lanes, 1))) + vd.setUint64("M", uint64(p.MemoryKiB)*1024) + vd.setUint64("I", uint64(max(p.Iterations, 1))) + vd.setUint32("V", argon2Version) + return vd +} + +// headerField re-reads one field out of the raw header, used for values that +// are needed as bytes but not kept on the Database. +func headerField(fields []byte, want byte) []byte { + p := 0 + for p+5 <= len(fields) { + id := fields[p] + size := int(binary.LittleEndian.Uint32(fields[p+1:])) + p += 5 + if size < 0 || p+size > len(fields) { + return nil + } + if id == want { + return fields[p : p+size] + } + p += size + if id == hdrEnd { + return nil + } + } + return nil +} + +func gunzip(b []byte) ([]byte, error) { + r, err := gzip.NewReader(bytes.NewReader(b)) + if err != nil { + return nil, err + } + defer r.Close() + // A KDBX payload is a password database, not an archive: cap it. + return io.ReadAll(io.LimitReader(r, 512<<20)) +} + +func randomBytes(n int) []byte { + b := make([]byte, n) + if _, err := rand.Read(b); err != nil { + panic("kdbx: " + err.Error()) + } + return b +} diff --git a/internal/kdbx/kdbx_test.go b/internal/kdbx/kdbx_test.go new file mode 100644 index 0000000..e58e5c3 --- /dev/null +++ b/internal/kdbx/kdbx_test.go @@ -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 +} diff --git a/internal/kdbx/newdb.go b/internal/kdbx/newdb.go new file mode 100644 index 0000000..08a8160 --- /dev/null +++ b/internal/kdbx/newdb.go @@ -0,0 +1,165 @@ +package kdbx + +import ( + "encoding/base64" + "encoding/binary" + "time" +) + +// Generator is the name this package writes into a file's Meta. +const Generator = "KeepOrg" + +// epochOffset converts between the Unix epoch and KDBX's, which counts +// seconds from 0001-01-01 00:00:00 UTC. +const epochOffset = 62135596800 + +// EncodeTime renders a timestamp the way KDBX 4 stores it: a little-endian +// int64 of seconds since year one, base64 encoded. +func EncodeTime(t time.Time) string { + var b [8]byte + binary.LittleEndian.PutUint64(b[:], uint64(t.UTC().Unix()+epochOffset)) + return base64.StdEncoding.EncodeToString(b[:]) +} + +// DecodeTime reads either KDBX 4's binary form or KDBX 3's ISO 8601 text, +// because a file may have been written by either and merged here. +func DecodeTime(s string) (time.Time, bool) { + if s == "" { + return time.Time{}, false + } + if raw, err := base64.StdEncoding.DecodeString(s); err == nil && len(raw) == 8 { + secs := int64(binary.LittleEndian.Uint64(raw)) - epochOffset + return time.Unix(secs, 0).UTC(), true + } + if t, err := time.Parse(time.RFC3339, s); err == nil { + return t.UTC(), true + } + return time.Time{}, false +} + +// NewUUID returns a fresh KDBX object identifier: 16 random bytes in base64, +// which is how the format stores them. +func NewUUID() string { + return base64.StdEncoding.EncodeToString(randomBytes(16)) +} + +var zeroUUID = base64.StdEncoding.EncodeToString(make([]byte, 16)) + +// New builds an empty database: one root group, no entries, ready to be +// marshalled and handed to a KeePass application. +func New(name string, kdf KDFParams) *Database { + now := EncodeTime(time.Now()) + db := &Database{ + Version: Version4, + Cipher: cipherAES256CBC, + Compression: 1, + KDF: newArgon2Params(kdf), + } + + meta := &Node{Name: "Meta", Nodes: []*Node{ + {Name: "Generator", Text: Generator}, + {Name: "DatabaseName", Text: name}, + {Name: "DatabaseNameChanged", Text: now}, + {Name: "DatabaseDescription"}, + {Name: "DatabaseDescriptionChanged", Text: now}, + {Name: "DefaultUserName"}, + {Name: "DefaultUserNameChanged", Text: now}, + {Name: "MaintenanceHistoryDays", Text: "365"}, + {Name: "Color"}, + {Name: "MasterKeyChanged", Text: now}, + {Name: "MasterKeyChangeRec", Text: "-1"}, + {Name: "MasterKeyChangeForce", Text: "-1"}, + {Name: "MemoryProtection", Nodes: []*Node{ + {Name: "ProtectTitle", Text: "False"}, + {Name: "ProtectUserName", Text: "False"}, + {Name: "ProtectPassword", Text: "True"}, + {Name: "ProtectURL", Text: "False"}, + {Name: "ProtectNotes", Text: "False"}, + }}, + {Name: "RecycleBinEnabled", Text: "True"}, + {Name: "RecycleBinUUID", Text: zeroUUID}, + {Name: "RecycleBinChanged", Text: now}, + {Name: "EntryTemplatesGroup", Text: zeroUUID}, + {Name: "EntryTemplatesGroupChanged", Text: now}, + {Name: "LastSelectedGroup", Text: zeroUUID}, + {Name: "LastTopVisibleGroup", Text: zeroUUID}, + {Name: "HistoryMaxItems", Text: "10"}, + {Name: "HistoryMaxSize", Text: "6291456"}, + {Name: "SettingsChanged", Text: now}, + {Name: "CustomData"}, + }} + + rootGroup := NewGroup(name) + root := &Node{Name: "Root", Nodes: []*Node{ + rootGroup, + {Name: "DeletedObjects"}, + }} + db.Root = &Node{Name: "KeePassFile", Nodes: []*Node{meta, root}} + return db +} + +// NewGroup builds an empty group node. +func NewGroup(name string) *Node { + return &Node{Name: "Group", Nodes: []*Node{ + {Name: "UUID", Text: NewUUID()}, + {Name: "Name", Text: name}, + {Name: "Notes"}, + {Name: "IconID", Text: "48"}, + newTimes(), + {Name: "IsExpanded", Text: "True"}, + {Name: "DefaultAutoTypeSequence"}, + {Name: "EnableAutoType", Text: "null"}, + {Name: "EnableSearching", Text: "null"}, + {Name: "LastTopVisibleEntry", Text: zeroUUID}, + }} +} + +// NewEntry builds an entry with the standard string fields. The password is +// marked protected, so it is stored under the inner stream cipher. +func NewEntry(title, username, password, url, notes string) *Node { + str := func(key, value string, protected bool) *Node { + v := &Node{Name: "Value", Text: value, Protected: protected} + return &Node{Name: "String", Nodes: []*Node{{Name: "Key", Text: key}, v}} + } + return &Node{Name: "Entry", Nodes: []*Node{ + {Name: "UUID", Text: NewUUID()}, + {Name: "IconID", Text: "0"}, + {Name: "ForegroundColor"}, + {Name: "BackgroundColor"}, + {Name: "OverrideURL"}, + {Name: "Tags"}, + newTimes(), + str("Title", title, false), + str("UserName", username, false), + str("Password", password, true), + str("URL", url, false), + str("Notes", notes, false), + {Name: "AutoType", Nodes: []*Node{ + {Name: "Enabled", Text: "True"}, + {Name: "DataTransferObfuscation", Text: "0"}, + }}, + }} +} + +func newTimes() *Node { + now := EncodeTime(time.Now()) + return &Node{Name: "Times", Nodes: []*Node{ + {Name: "LastModificationTime", Text: now}, + {Name: "CreationTime", Text: now}, + {Name: "LastAccessTime", Text: now}, + {Name: "ExpiryTime", Text: now}, + {Name: "Expires", Text: "False"}, + {Name: "UsageCount", Text: "0"}, + {Name: "LocationChanged", Text: now}, + }} +} + +// RootGroup returns the database's top-level group. +func (d *Database) RootGroup() *Node { + return d.Root.Path("Root", "Group") +} + +// Name is the database's display name from its Meta. +func (d *Database) Name() string { + return d.Root.Value("Meta", "DatabaseName") +} diff --git a/internal/kdbx/xmltree.go b/internal/kdbx/xmltree.go new file mode 100644 index 0000000..ff69389 --- /dev/null +++ b/internal/kdbx/xmltree.go @@ -0,0 +1,252 @@ +package kdbx + +import ( + "bytes" + "encoding/xml" + "fmt" + "io" + "strings" +) + +// A KDBX file's payload is one XML document. It is kept here as a generic +// tree rather than a set of Go structs on purpose: a merge server that +// dropped elements it did not model would silently destroy data written by a +// newer KeePass. Every element, attribute and value survives a round trip, +// and the merge logic reaches into the few paths it actually understands. +type Node struct { + Name string + Attrs []Attr + Nodes []*Node + // Text is the character data of a leaf element, already decrypted if the + // element was protected. + Text string + // Protected records that this value was stored under the inner stream + // cipher, so it is written back the same way. + Protected bool +} + +type Attr struct { + Name string + Value string +} + +// parseXML reads a KDBX payload into a tree. +func parseXML(data []byte) (*Node, error) { + dec := xml.NewDecoder(bytes.NewReader(data)) + var stack []*Node + var root *Node + var text strings.Builder + + for { + tok, err := dec.Token() + if err == io.EOF { + break + } + if err != nil { + return nil, fmt.Errorf("kdbx: xml: %w", err) + } + switch t := tok.(type) { + case xml.StartElement: + n := &Node{Name: t.Name.Local} + for _, a := range t.Attr { + name := a.Name.Local + if a.Name.Space != "" { + name = a.Name.Space + ":" + a.Name.Local + } + if name == protectedAttr && strings.EqualFold(a.Value, "True") { + n.Protected = true + continue + } + n.Attrs = append(n.Attrs, Attr{Name: name, Value: a.Value}) + } + if len(stack) > 0 { + parent := stack[len(stack)-1] + parent.Nodes = append(parent.Nodes, n) + } else if root == nil { + root = n + } + stack = append(stack, n) + text.Reset() + case xml.CharData: + text.Write(t) + case xml.EndElement: + if len(stack) == 0 { + return nil, fmt.Errorf("kdbx: xml: unbalanced ", t.Name.Local) + } + n := stack[len(stack)-1] + if len(n.Nodes) == 0 { + n.Text = text.String() + } + stack = stack[:len(stack)-1] + text.Reset() + } + } + if root == nil { + return nil, fmt.Errorf("kdbx: xml: empty document") + } + return root, nil +} + +const protectedAttr = "Protected" + +// marshalXML writes the tree back out, indented the way KeePass writes it. +func marshalXML(root *Node) []byte { + var b bytes.Buffer + b.WriteString(`` + "\n") + writeNode(&b, root, 0) + return b.Bytes() +} + +func writeNode(b *bytes.Buffer, n *Node, depth int) { + indent := strings.Repeat("\t", depth) + b.WriteString(indent) + b.WriteByte('<') + b.WriteString(n.Name) + for _, a := range n.Attrs { + fmt.Fprintf(b, " %s=\"", a.Name) + xml.EscapeText(b, []byte(a.Value)) + b.WriteByte('"') + } + if n.Protected { + fmt.Fprintf(b, " %s=\"True\"", protectedAttr) + } + if len(n.Nodes) == 0 && n.Text == "" { + b.WriteString("/>\n") + return + } + b.WriteByte('>') + if len(n.Nodes) == 0 { + xml.EscapeText(b, []byte(n.Text)) + b.WriteString("\n") + return + } + b.WriteByte('\n') + for _, c := range n.Nodes { + writeNode(b, c, depth+1) + } + b.WriteString(indent + "\n") +} + +// ---- navigation helpers used by the merge logic ---- + +// Find returns the first child with this name, or nil. +func (n *Node) Find(name string) *Node { + if n == nil { + return nil + } + for _, c := range n.Nodes { + if c.Name == name { + return c + } + } + return nil +} + +// FindAll returns every child with this name. +func (n *Node) FindAll(name string) []*Node { + if n == nil { + return nil + } + out := []*Node{} + for _, c := range n.Nodes { + if c.Name == name { + out = append(out, c) + } + } + return out +} + +// Path walks a chain of child names. +func (n *Node) Path(names ...string) *Node { + cur := n + for _, name := range names { + cur = cur.Find(name) + if cur == nil { + return nil + } + } + return cur +} + +// Value is the text at a path, or "". +func (n *Node) Value(names ...string) string { + if t := n.Path(names...); t != nil { + return t.Text + } + return "" +} + +// Set replaces, or creates, a direct child holding this text. +func (n *Node) Set(name, text string) *Node { + if c := n.Find(name); c != nil { + c.Text = text + c.Nodes = nil + return c + } + c := &Node{Name: name, Text: text} + n.Nodes = append(n.Nodes, c) + return c +} + +// Remove drops a direct child node by identity. +func (n *Node) Remove(target *Node) bool { + for i, c := range n.Nodes { + if c == target { + n.Nodes = append(n.Nodes[:i], n.Nodes[i+1:]...) + return true + } + } + return false +} + +// Attr returns an attribute value. +func (n *Node) Attr(name string) string { + if n == nil { + return "" + } + for _, a := range n.Attrs { + if a.Name == name { + return a.Value + } + } + return "" +} + +// SetAttr sets or replaces an attribute. +func (n *Node) SetAttr(name, value string) { + for i := range n.Attrs { + if n.Attrs[i].Name == name { + n.Attrs[i].Value = value + return + } + } + n.Attrs = append(n.Attrs, Attr{Name: name, Value: value}) +} + +// walk visits every node in document order, which is the order the inner +// stream cipher processes protected values in. +func (n *Node) walk(fn func(*Node)) { + if n == nil { + return + } + fn(n) + for _, c := range n.Nodes { + c.walk(fn) + } +} + +// clone deep-copies a subtree, so a node taken from an incoming database does +// not stay aliased to it. +func (n *Node) clone() *Node { + if n == nil { + return nil + } + out := &Node{Name: n.Name, Text: n.Text, Protected: n.Protected} + if len(n.Attrs) > 0 { + out.Attrs = append([]Attr(nil), n.Attrs...) + } + for _, c := range n.Nodes { + out.Nodes = append(out.Nodes, c.clone()) + } + return out +}