Keep server data in an encrypted database, and harden every part

Storage. The server keeps its data in SQLite (a file in the data
directory, the default) or PostgreSQL (database, KEEPORG_DATABASE_URL or
-database). Every row, the KDBX versions included, is sealed with
AES-256-GCM under a key derived from the master key and named by an HMAC.
The master key is required to start and a different one is refused. A data
directory from 0.10 or earlier is imported on first start. Several servers
may share one PostgreSQL database. Go goes to 1.26 for the pure-Go
drivers (modernc.org/sqlite, jackc/pgx).

Hardening, after an audit of the server, both clients and the deployment:
- Single sign-on approves a device only after an explicit Approve in the
  browser that started it, bound by a cookie and PKCE; identities are never
  linked by username, by verified email only when allowed, never to admins.
- Sign-in attempts are limited per address and per account, passwords are
  checked outside the store lock with uniform timing, one-time codes work
  once, and starting two-factor needs the password.
- Merge vaults bound KDF parameters, payload size and XML shape, check
  pushes before storing them, and run at most two merges at once. A version
  is written in the same transaction as its metadata.
- Tokens in URLs only on the event stream, which ends on revocation; CSP,
  frame-ancestors, no-store and HSTS; sign-in CSRF; 404 for databases
  without access; new trusted_proxies.
- Clients open only the server's approval page, keep case-variant and
  retired file names apart, never overwrite a save made during a pull,
  require https except locally, send the stream token in a header, bound
  their reads and escape notifications and terminal output.
- The .deb creates the master key, systemd is sandboxed further, nginx sets
  modern TLS and HSTS, and CI deploys from main only.

Server 0.12.0, desktop 0.6.0, Android 0.4.0 (versionCode 4).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Reactorcoremeltdown
2026-11-02 15:38:32 +01:00
co-authored by Claude Opus 5.5
parent f8f634fbf7
commit 4ffc434b1b
77 changed files with 4192 additions and 776 deletions
+3
View File
@@ -44,6 +44,9 @@ func deriveKeys(masterSeed []byte, kdf variantDict, password string) (keys, erro
// transformKey applies the file's KDF to the composite key.
func transformKey(kdf variantDict, composite []byte) ([]byte, error) {
if err := checkKDF(kdf); err != nil {
return nil, err
}
uuid := kdf.bytes("$UUID")
switch {
case bytes.Equal(uuid, kdfAES):
+58 -18
View File
@@ -51,18 +51,38 @@ type KDFParams struct {
// 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) {
// Inspect checks a KDBX 4 file's outer header without the password: the
// format, and that its key derivation stays within the limits Open accepts.
// It costs nothing, so a server can refuse a file it could not afford to
// open before storing it.
func Inspect(data []byte) error {
_, p, err := readHeader(data)
if err != nil {
return err
}
if p+64 > len(data) {
return errors.New("kdbx: file ends before its integrity hashes")
}
sum := sha256.Sum256(data[:p])
if subtle.ConstantTimeCompare(sum[:], data[p:p+32]) != 1 {
return errors.New("kdbx: header does not match its checksum")
}
return nil
}
// readHeader parses the outer header and returns it with the offset where it
// ends.
func readHeader(data []byte) (*Database, int, error) {
if len(data) < 12 {
return nil, errors.New("kdbx: file is too short")
return nil, 0, 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")
return nil, 0, 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",
return nil, 0, unsupported("KDBX %d.%d; this server stores KDBX 4 vaults",
major, version&0xFFFF)
}
@@ -70,13 +90,13 @@ func Open(data []byte, password string) (*Database, error) {
p := 12
for {
if p+5 > len(data) {
return nil, errors.New("kdbx: truncated header")
return nil, 0, 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")
return nil, 0, errors.New("kdbx: truncated header field")
}
field := data[p : p+size]
p += size
@@ -88,7 +108,7 @@ func Open(data []byte, password string) (*Database, error) {
db.Cipher = append([]byte(nil), field...)
case hdrCompression:
if len(field) != 4 {
return nil, errors.New("kdbx: malformed compression flags")
return nil, 0, errors.New("kdbx: malformed compression flags")
}
db.Compression = binary.LittleEndian.Uint32(field)
case hdrMasterSeed:
@@ -97,31 +117,44 @@ func Open(data []byte, password string) (*Database, error) {
case hdrKdfParameters:
vd, err := parseVariantDict(field)
if err != nil {
return nil, err
return nil, 0, err
}
db.KDF = vd
case hdrPublicCustomData:
vd, err := parseVariantDict(field)
if err != nil {
return nil, err
return nil, 0, 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)
return nil, 0, unsupported("KDBX 3 header field %d in a KDBX 4 file", id)
}
}
headerDone:
if len(headerField(data[12:p], hdrMasterSeed)) != 32 {
return nil, 0, errors.New("kdbx: master seed has the wrong length")
}
if len(db.Cipher) != 16 {
return nil, 0, errors.New("kdbx: file names no payload cipher")
}
if err := checkKDF(db.KDF); err != nil {
return nil, 0, err
}
return db, p, nil
}
// Open decrypts and parses a KDBX 4 file.
func Open(data []byte, password string) (*Database, error) {
db, p, err := readHeader(data)
if err != nil {
return nil, err
}
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")
@@ -420,7 +453,14 @@ func gunzip(b []byte) ([]byte, error) {
}
defer r.Close()
// A KDBX payload is a password database, not an archive: cap it.
return io.ReadAll(io.LimitReader(r, 512<<20))
out, err := io.ReadAll(io.LimitReader(r, int64(MaxXMLBytes)+1))
if err != nil {
return nil, err
}
if len(out) > MaxXMLBytes {
return nil, fmt.Errorf("kdbx: the payload decompresses to more than %d MiB", MaxXMLBytes>>20)
}
return out, nil
}
func randomBytes(n int) []byte {
+60
View File
@@ -0,0 +1,60 @@
package kdbx
import (
"bytes"
"fmt"
)
// Limits on what a file may demand of whoever opens it. The server opens
// files that its users upload — a merge decrypts both copies — so a header
// asking for a billion AES rounds, two gibibytes of Argon2 memory or a
// payload that inflates without end would otherwise be a way to stop it.
// The bounds sit well above what KeePassXC, KeePass and KeePassDX choose for
// a one-second unlock on a desktop, and far below what hurts a server.
var (
// MaxAESRounds bounds AES-KDF.
MaxAESRounds uint64 = 30_000_000
// MaxArgon2MemoryKiB bounds Argon2's memory, per derivation.
MaxArgon2MemoryKiB uint64 = 256 << 10
// MaxArgon2Iterations bounds Argon2's passes.
MaxArgon2Iterations uint64 = 64
// MaxArgon2Work bounds memory × passes, in MiB-passes, so a file cannot
// take the maximum of both at once.
MaxArgon2Work uint64 = 4096
// MaxArgon2Lanes bounds Argon2's parallelism.
MaxArgon2Lanes uint64 = 16
// MaxXMLBytes bounds the decompressed payload.
MaxXMLBytes = 128 << 20
// MaxXMLDepth and MaxXMLNodes bound the payload's shape. KeePass nests
// groups a few levels deep and stores thousands of entries, not millions.
MaxXMLDepth = 256
MaxXMLNodes = 4_000_000
)
// checkKDF refuses key derivation parameters beyond the limits.
func checkKDF(kdf variantDict) error {
uuid := kdf.bytes("$UUID")
switch {
case bytes.Equal(uuid, kdfAES):
if r, ok := kdf.uint64("R"); ok && r > MaxAESRounds {
return fmt.Errorf("kdbx: AES-KDF asks for %d rounds, refusing above %d", r, MaxAESRounds)
}
case bytes.Equal(uuid, kdfArgon2d), bytes.Equal(uuid, kdfArgon2id):
mem, _ := kdf.uint64("M")
iter, _ := kdf.uint64("I")
par, _ := kdf.uint64("P")
memKiB := mem / 1024
switch {
case memKiB > MaxArgon2MemoryKiB:
return fmt.Errorf("kdbx: Argon2 asks for %d MiB, refusing above %d", memKiB>>10, MaxArgon2MemoryKiB>>10)
case iter > MaxArgon2Iterations:
return fmt.Errorf("kdbx: Argon2 asks for %d passes, refusing above %d", iter, MaxArgon2Iterations)
case par > MaxArgon2Lanes:
return fmt.Errorf("kdbx: Argon2 asks for %d lanes, refusing above %d", par, MaxArgon2Lanes)
case (memKiB>>10)*iter > MaxArgon2Work:
return fmt.Errorf("kdbx: Argon2 asks for %d MiB × %d passes, refusing above %d MiB-passes",
memKiB>>10, iter, MaxArgon2Work)
}
}
return nil
}
+51
View File
@@ -0,0 +1,51 @@
package kdbx
import (
"strings"
"testing"
"time"
)
// A file whose header asks for more work than the limits allow is refused
// from its header alone — by Inspect, and by Open before any derivation.
func TestOverLimitKDFIsRefusedBeforeAnyWork(t *testing.T) {
saved := MaxArgon2Iterations
MaxArgon2Iterations = 10_000 // only to be able to write the file
data, err := New("Heavy", KDFParams{MemoryKiB: 8, Iterations: 5_000, Lanes: 1}).Marshal("pw-123456")
MaxArgon2Iterations = saved
if err != nil {
t.Fatal(err)
}
if err := Inspect(data); err == nil || !strings.Contains(err.Error(), "passes") {
t.Fatalf("Inspect = %v", err)
}
start := time.Now()
if _, err := Open(data, "pw-123456"); err == nil {
t.Fatal("Open accepted an over-limit file")
}
if time.Since(start) > 100*time.Millisecond {
t.Fatal("Open did the work before refusing")
}
// Within the limits, Inspect agrees with Open.
ok, err := New("Light", KDFParams{MemoryKiB: 64, Iterations: 1, Lanes: 1}).Marshal("pw-123456")
if err != nil {
t.Fatal(err)
}
if err := Inspect(ok); err != nil {
t.Fatalf("Inspect refused a normal file: %v", err)
}
}
func TestDeepOrHugeXMLIsRefused(t *testing.T) {
deep := strings.Repeat("<a>", MaxXMLDepth+1) + strings.Repeat("</a>", MaxXMLDepth+1)
if _, err := parseXML([]byte(deep)); err == nil || !strings.Contains(err.Error(), "deeper") {
t.Fatalf("deep xml: %v", err)
}
saved := MaxXMLNodes
MaxXMLNodes = 100
defer func() { MaxXMLNodes = saved }()
wide := "<r>" + strings.Repeat("<a/>", 200) + "</r>"
if _, err := parseXML([]byte(wide)); err == nil || !strings.Contains(err.Error(), "elements") {
t.Fatalf("wide xml: %v", err)
}
}
+10
View File
@@ -36,6 +36,7 @@ func parseXML(data []byte) (*Node, error) {
var stack []*Node
var root *Node
var text strings.Builder
nodes := 0
for {
tok, err := dec.Token()
@@ -47,6 +48,15 @@ func parseXML(data []byte) (*Node, error) {
}
switch t := tok.(type) {
case xml.StartElement:
// Bounded here so that nothing after parsing — walking,
// cloning, merging, writing, all recursive — sees a tree
// deeper or bigger than this.
if nodes++; nodes > MaxXMLNodes {
return nil, fmt.Errorf("kdbx: xml: more than %d elements", MaxXMLNodes)
}
if len(stack) >= MaxXMLDepth {
return nil, fmt.Errorf("kdbx: xml: nested deeper than %d", MaxXMLDepth)
}
n := &Node{Name: t.Name.Local}
for _, a := range t.Attr {
name := a.Name.Local