Add KeepOrg sync server: accounts, nested groups, versioned KDBX sync

Why: collaborative KeePass use needs one arbiter of "who wrote last" that
never sees a master password, plus access control that follows an org chart.

What: stdlib-only Go server. Metadata is a single JSON document with one
writer and atomic commit; KDBX payloads are versioned blobs on disk. Access
comes from a group tree where a grant on a group reaches every member below
it. Login is password+TOTP, invite links, or OIDC through a browser-approved
device flow. Pushes carry If-Match and are arbitrated by a per-database merge
strategy (server-wins by default). Updates are pushed to clients over SSE.

Proved: go test ./internal/... covers RFC 6238 vectors, ACL inheritance and
isolation, store rollback, blob pruning, every push verdict, and an
end-to-end HTTP pass over login, conflict, force, read-only grants, invite
redemption, TOTP enrolment, device login and the event stream.
This commit is contained in:
2026-10-29 15:54:10 +01:00
commit 8bbb3179a1
26 changed files with 3897 additions and 0 deletions
+96
View File
@@ -0,0 +1,96 @@
// Package secret holds the small amount of cryptography the server needs:
// random identifiers, bearer tokens and password verifiers.
package secret
import (
"crypto/pbkdf2"
"crypto/rand"
"crypto/sha256"
"crypto/subtle"
"encoding/base64"
"encoding/hex"
"errors"
"fmt"
"strconv"
"strings"
)
// iterations follows the OWASP recommendation for PBKDF2-HMAC-SHA256.
const iterations = 600000
// ID returns a short random identifier, safe in URLs and filenames.
func ID() string { return randHex(8) }
// Token returns a 256-bit bearer token.
func Token() string { return randHex(32) }
// UserCode returns a short, human-transcribable code for device login.
// The alphabet omits characters that are easy to confuse when read aloud.
func UserCode() string {
const alphabet = "BCDFGHJKLMNPQRSTVWXZ23456789"
b := randBytes(8)
out := make([]byte, 0, 9)
for i, v := range b {
if i == 4 {
out = append(out, '-')
}
out = append(out, alphabet[int(v)%len(alphabet)])
}
return string(out)
}
// Hash returns a verifier string for a password.
func Hash(password string) (string, error) {
if password == "" {
return "", errors.New("secret: empty password")
}
salt := randBytes(16)
dk, err := pbkdf2.Key(sha256.New, password, salt, iterations, 32)
if err != nil {
return "", err
}
return fmt.Sprintf("pbkdf2-sha256$%d$%s$%s", iterations,
base64.RawStdEncoding.EncodeToString(salt),
base64.RawStdEncoding.EncodeToString(dk)), nil
}
// Verify reports whether password matches a verifier produced by Hash.
// An unparsable or empty verifier never matches.
func Verify(verifier, password string) bool {
parts := strings.Split(verifier, "$")
if len(parts) != 4 || parts[0] != "pbkdf2-sha256" {
return false
}
iter, err := strconv.Atoi(parts[1])
if err != nil || iter < 1000 {
return false
}
salt, err := base64.RawStdEncoding.DecodeString(parts[2])
if err != nil {
return false
}
want, err := base64.RawStdEncoding.DecodeString(parts[3])
if err != nil {
return false
}
got, err := pbkdf2.Key(sha256.New, password, salt, iter, len(want))
if err != nil {
return false
}
return subtle.ConstantTimeCompare(got, want) == 1
}
// Equal compares two secrets without leaking their contents through timing.
func Equal(a, b string) bool {
return subtle.ConstantTimeCompare([]byte(a), []byte(b)) == 1
}
func randBytes(n int) []byte {
b := make([]byte, n)
if _, err := rand.Read(b); err != nil {
panic("secret: " + err.Error()) // crypto/rand never fails on supported platforms
}
return b
}
func randHex(n int) string { return hex.EncodeToString(randBytes(n)) }