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.
116 lines
2.8 KiB
Go
116 lines
2.8 KiB
Go
// 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))
|
|
}
|