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:
@@ -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))
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user