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
+115
View File
@@ -0,0 +1,115 @@
// Package blob stores the KDBX payloads. Each database keeps a numbered
// version history, which makes a rejected push recoverable and a bad save
// reversible.
package blob
import (
"crypto/sha256"
"encoding/hex"
"fmt"
"os"
"path/filepath"
"sort"
"strconv"
"strings"
)
// KeepVersions is how many historical versions of each database are retained.
const KeepVersions = 20
type Store struct{ dir string }
// Open prepares dir as a blob store.
func Open(dir string) (*Store, error) {
if err := os.MkdirAll(dir, 0o700); err != nil {
return nil, err
}
return &Store{dir: dir}, nil
}
// SHA256 is the lowercase hex digest used everywhere as a content fingerprint.
func SHA256(data []byte) string {
sum := sha256.Sum256(data)
return hex.EncodeToString(sum[:])
}
// Put writes one version of a database. Writing the same version twice with
// the same bytes is a no-op, so a retried request is harmless.
func (s *Store) Put(dbID string, version int64, data []byte) error {
dir := filepath.Join(s.dir, dbID)
if err := os.MkdirAll(dir, 0o700); err != nil {
return err
}
path := s.versionPath(dbID, version)
if old, err := os.ReadFile(path); err == nil && SHA256(old) == SHA256(data) {
return nil
}
tmp := path + ".tmp"
f, err := os.OpenFile(tmp, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0o600)
if err != nil {
return err
}
if _, err := f.Write(data); err != nil {
f.Close()
os.Remove(tmp)
return err
}
if err := f.Sync(); err != nil {
f.Close()
os.Remove(tmp)
return err
}
if err := f.Close(); err != nil {
os.Remove(tmp)
return err
}
return os.Rename(tmp, path)
}
// Get reads one version of a database.
func (s *Store) Get(dbID string, version int64) ([]byte, error) {
return os.ReadFile(s.versionPath(dbID, version))
}
// Versions lists the stored versions of a database, oldest first.
func (s *Store) Versions(dbID string) []int64 {
entries, err := os.ReadDir(filepath.Join(s.dir, dbID))
if err != nil {
return nil
}
out := []int64{}
for _, e := range entries {
name := e.Name()
if !strings.HasSuffix(name, ".kdbx") {
continue
}
if v, err := strconv.ParseInt(strings.TrimSuffix(name, ".kdbx"), 10, 64); err == nil {
out = append(out, v)
}
}
sort.Slice(out, func(i, j int) bool { return out[i] < out[j] })
return out
}
// Prune removes all but the newest KeepVersions versions.
func (s *Store) Prune(dbID string) error {
vs := s.Versions(dbID)
if len(vs) <= KeepVersions {
return nil
}
for _, v := range vs[:len(vs)-KeepVersions] {
if err := os.Remove(s.versionPath(dbID, v)); err != nil {
return err
}
}
return nil
}
// Forget deletes every version of a database.
func (s *Store) Forget(dbID string) error {
return os.RemoveAll(filepath.Join(s.dir, dbID))
}
func (s *Store) versionPath(dbID string, version int64) string {
return filepath.Join(s.dir, dbID, fmt.Sprintf("%020d.kdbx", version))
}
+42
View File
@@ -0,0 +1,42 @@
package blob
import "testing"
func TestPutGetIsIdempotentAndVersioned(t *testing.T) {
s, err := Open(t.TempDir())
if err != nil {
t.Fatal(err)
}
if err := s.Put("db", 1, []byte("v1")); err != nil {
t.Fatal(err)
}
if err := s.Put("db", 1, []byte("v1")); err != nil {
t.Fatal(err)
}
if err := s.Put("db", 2, []byte("v2")); err != nil {
t.Fatal(err)
}
got, err := s.Get("db", 1)
if err != nil || string(got) != "v1" {
t.Fatalf("get v1 = %q, %v", got, err)
}
if vs := s.Versions("db"); len(vs) != 2 || vs[0] != 1 || vs[1] != 2 {
t.Fatalf("versions = %v", vs)
}
}
func TestPruneKeepsNewest(t *testing.T) {
s, _ := Open(t.TempDir())
for i := int64(1); i <= KeepVersions+5; i++ {
if err := s.Put("db", i, []byte{byte(i)}); err != nil {
t.Fatal(err)
}
}
if err := s.Prune("db"); err != nil {
t.Fatal(err)
}
vs := s.Versions("db")
if len(vs) != KeepVersions || vs[0] != 6 {
t.Fatalf("after prune = %v", vs)
}
}