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:
co-authored by
Claude Opus 5.5
parent
f8f634fbf7
commit
4ffc434b1b
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user