Why: the point of a sync server is that two people can work at once. Picking a winner was all this one could do while it could not read a vault. What, end to end: - A vault is created at /web or with `keeporg vault -create`: the server builds an empty KDBX 4 file, encrypts it with the password from the form, and stores it as version 1. An existing database can adopt a stored key with `keeporg db -id <id> -store-password`, which is verified against the stored file before it is accepted. - A stale push to a `merge` vault is no longer refused. The server opens the stored version and the pushed one in memory, folds them together, and stores the re-encrypted result. Clients push as usual and pull what comes back. Any failure answers 409 merge_failed and the client keeps its copy aside, so a failed merge still loses nothing. - At rest: vault contents stay KDBX-encrypted as always. A merge vault's password is kept AES-256-GCM-wrapped under a master key that lives outside the data directory, bound to the vault id, never returned by the API. A stolen data directory is ciphertext; a stolen data directory plus the master key is not. The server runs without a master key and simply refuses to create or merge vaults. This moves the server inside the trust boundary for vaults it can merge, which is inherent to merging and is stated plainly in the README, the handover notes and the web form itself. Key-less strategies are unchanged and remain the default for a database created without a password. Proved: two clients editing the same version are merged rather than refused, the newer edit of one entry wins, a merge failure leaves the stored vault untouched, `merge` is refused without a stored key, the wrapped key never appears in state.json or the API, and the web interface rejects a form without its CSRF token, withholds its cookie over plain http, and only offers groups the user administers. Checked by hand against the real thing: a vault created in a browser, edited by keepassxc-cli on two sides from the same version, merged by the server, and both entries read back out with keepassxc-cli. make check, make test-race, android lint and both .deb builds are clean.
514 lines
13 KiB
Go
514 lines
13 KiB
Go
package client
|
|
|
|
import (
|
|
"context"
|
|
"crypto/sha256"
|
|
"encoding/hex"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"log"
|
|
"net/http"
|
|
"os"
|
|
"path/filepath"
|
|
"sort"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
)
|
|
|
|
// stateFile holds the mirror's bookkeeping. It sits inside the mirror
|
|
// directory so moving the directory moves its history with it.
|
|
const stateFile = ".keeporg-state.json"
|
|
|
|
// fileState is what this client last agreed with the server about one
|
|
// database, and where it keeps it on disk.
|
|
type fileState struct {
|
|
Name string `json:"name"`
|
|
File string `json:"file"`
|
|
Version int64 `json:"version"`
|
|
SHA256 string `json:"sha256"`
|
|
ConflictMarker string `json:"conflict_marker,omitempty"`
|
|
}
|
|
|
|
// Status is a human-readable account of one mirrored database.
|
|
type Status struct {
|
|
DatabaseID string `json:"database_id"`
|
|
Name string `json:"name"`
|
|
File string `json:"file"`
|
|
Perm string `json:"perm"`
|
|
Strategy string `json:"merge_strategy"`
|
|
Version int64 `json:"version"`
|
|
State string `json:"state"`
|
|
Message string `json:"message,omitempty"`
|
|
LastSync time.Time `json:"last_sync"`
|
|
}
|
|
|
|
// Mirror keeps a directory of real .kdbx files in step with a server. Any
|
|
// KeePass application can open the files directly; the mirror notices a save
|
|
// and pushes it, and applies what other clients push.
|
|
//
|
|
// Every reconciliation holds m.mu, so there is exactly one writer to the
|
|
// directory and to the state file.
|
|
type Mirror struct {
|
|
api *API
|
|
dir string
|
|
log *log.Logger
|
|
poll time.Duration
|
|
|
|
mu sync.Mutex
|
|
st map[string]*fileState
|
|
status map[string]*Status
|
|
}
|
|
|
|
// NewMirror prepares dir and loads any previous state.
|
|
func NewMirror(api *API, dir string, lg *log.Logger, poll time.Duration) (*Mirror, error) {
|
|
if err := os.MkdirAll(dir, 0o700); err != nil {
|
|
return nil, err
|
|
}
|
|
if poll <= 0 {
|
|
poll = 2 * time.Second
|
|
}
|
|
m := &Mirror{api: api, dir: dir, log: lg, poll: poll,
|
|
st: map[string]*fileState{}, status: map[string]*Status{}}
|
|
b, err := os.ReadFile(filepath.Join(dir, stateFile))
|
|
switch {
|
|
case os.IsNotExist(err):
|
|
return m, nil
|
|
case err != nil:
|
|
return nil, err
|
|
}
|
|
if err := json.Unmarshal(b, &m.st); err != nil {
|
|
return nil, fmt.Errorf("mirror: parse %s: %w", stateFile, err)
|
|
}
|
|
return m, nil
|
|
}
|
|
|
|
// Dir is the mirror directory.
|
|
func (m *Mirror) Dir() string { return m.dir }
|
|
|
|
// Run reconciles until ctx is done, driven by server push events and by a
|
|
// local rescan for saves made by a KeePass application.
|
|
func (m *Mirror) Run(ctx context.Context) error {
|
|
trigger := make(chan struct{}, 1)
|
|
wake := func() {
|
|
select {
|
|
case trigger <- struct{}{}:
|
|
default:
|
|
}
|
|
}
|
|
|
|
go m.listen(ctx, wake)
|
|
|
|
if err := m.SyncAll(ctx); err != nil {
|
|
m.log.Printf("sync: %v", err)
|
|
}
|
|
t := time.NewTicker(m.poll)
|
|
defer t.Stop()
|
|
for {
|
|
select {
|
|
case <-ctx.Done():
|
|
return ctx.Err()
|
|
case <-t.C:
|
|
if err := m.SyncAll(ctx); err != nil {
|
|
m.log.Printf("sync: %v", err)
|
|
}
|
|
case <-trigger:
|
|
// Let a burst of events settle before reconciling.
|
|
time.Sleep(150 * time.Millisecond)
|
|
if err := m.SyncAll(ctx); err != nil {
|
|
m.log.Printf("sync: %v", err)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// listen keeps an event stream open, reconnecting with backoff. Losing the
|
|
// stream only delays a reconcile, it never loses data.
|
|
func (m *Mirror) listen(ctx context.Context, wake func()) {
|
|
backoff := time.Second
|
|
for ctx.Err() == nil {
|
|
err := m.api.Events(ctx, func(ev Event) {
|
|
backoff = time.Second
|
|
m.log.Printf("event %s %s v%d by %s", ev.Type, ev.Name, ev.Version, ev.By)
|
|
wake()
|
|
})
|
|
if ctx.Err() != nil {
|
|
return
|
|
}
|
|
if err != nil && !errors.Is(err, io.EOF) {
|
|
m.log.Printf("event stream: %v (retrying in %s)", err, backoff)
|
|
}
|
|
select {
|
|
case <-ctx.Done():
|
|
return
|
|
case <-time.After(backoff):
|
|
}
|
|
if backoff < 60*time.Second {
|
|
backoff *= 2
|
|
}
|
|
}
|
|
}
|
|
|
|
// SyncAll reconciles every assigned database once.
|
|
func (m *Mirror) SyncAll(ctx context.Context) error {
|
|
dbs, err := m.api.Databases(ctx)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
m.mu.Lock()
|
|
defer m.mu.Unlock()
|
|
|
|
seen := map[string]bool{}
|
|
var firstErr error
|
|
for _, db := range dbs {
|
|
seen[db.ID] = true
|
|
if err := m.reconcile(ctx, db); err != nil {
|
|
m.setStatus(db, "error", err.Error())
|
|
m.log.Printf("%s: %v", db.Name, err)
|
|
if firstErr == nil {
|
|
firstErr = err
|
|
}
|
|
}
|
|
}
|
|
// A database that is no longer assigned stops being tracked. Its file is
|
|
// left alone: deleting a user's local copy of a password database is not
|
|
// this program's decision.
|
|
for id := range m.st {
|
|
if !seen[id] {
|
|
delete(m.st, id)
|
|
delete(m.status, id)
|
|
}
|
|
}
|
|
if err := m.saveState(); err != nil && firstErr == nil {
|
|
firstErr = err
|
|
}
|
|
return firstErr
|
|
}
|
|
|
|
// Status reports the current state of each mirrored database.
|
|
func (m *Mirror) Status() []Status {
|
|
m.mu.Lock()
|
|
defer m.mu.Unlock()
|
|
out := make([]Status, 0, len(m.status))
|
|
for _, s := range m.status {
|
|
out = append(out, *s)
|
|
}
|
|
sort.Slice(out, func(i, j int) bool { return out[i].Name < out[j].Name })
|
|
return out
|
|
}
|
|
|
|
// reconcile brings one database and its local file into agreement.
|
|
func (m *Mirror) reconcile(ctx context.Context, db Database) error {
|
|
st := m.trackedState(db)
|
|
path := filepath.Join(m.dir, st.File)
|
|
|
|
local, exists, err := readFileIfExists(path)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
localSHA := ""
|
|
if exists {
|
|
localSHA = digest(local)
|
|
}
|
|
sit := situation{
|
|
LocalExists: exists, LocalSHA: localSHA,
|
|
SyncedSHA: st.SHA256, SyncedVersion: st.Version,
|
|
RemoteSHA: db.SHA256, RemoteVersion: db.Version,
|
|
CanWrite: db.CanWrite(), Strategy: db.MergeStrategy,
|
|
ConflictSeen: st.ConflictMarker == conflictMarker(db.Version, localSHA),
|
|
}
|
|
act := decide(sit)
|
|
if act != actManual && st.ConflictMarker != "" {
|
|
st.ConflictMarker = ""
|
|
}
|
|
|
|
switch act {
|
|
case actNothing:
|
|
if m.status[db.ID] == nil || m.status[db.ID].State == "" {
|
|
m.setStatus(db, "ok", "")
|
|
}
|
|
return nil
|
|
|
|
case actPull:
|
|
return m.pull(ctx, db, st, path)
|
|
|
|
case actPush, actPushForMerge:
|
|
return m.push(ctx, db, st, path, local, false)
|
|
|
|
case actForcePush:
|
|
return m.push(ctx, db, st, path, local, true)
|
|
|
|
case actServerWins, actRestore:
|
|
copyPath, err := m.keepConflictCopy(path, local)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if err := m.pull(ctx, db, st, path); err != nil {
|
|
return err
|
|
}
|
|
why := "the server copy was newer, your save was kept as " + filepath.Base(copyPath)
|
|
if act == actRestore {
|
|
why = "you have read-only access, your save was kept as " + filepath.Base(copyPath)
|
|
}
|
|
m.log.Printf("%s: %s", db.Name, why)
|
|
m.setStatus(db, "conflict-resolved", why)
|
|
return nil
|
|
|
|
case actManual:
|
|
remote, version, err := m.api.Pull(ctx, db.ID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
side := strings.TrimSuffix(path, ".kdbx") + fmt.Sprintf(".server-v%d.kdbx", version)
|
|
if err := writeFileAtomic(side, remote); err != nil {
|
|
return err
|
|
}
|
|
st.ConflictMarker = conflictMarker(db.Version, localSHA)
|
|
why := fmt.Sprintf("both copies changed; the server copy is in %s, merge it yourself",
|
|
filepath.Base(side))
|
|
m.log.Printf("%s: %s", db.Name, why)
|
|
m.setStatus(db, "conflict", why)
|
|
return nil
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (m *Mirror) pull(ctx context.Context, db Database, st *fileState, path string) error {
|
|
data, version, err := m.api.Pull(ctx, db.ID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if version == 0 || len(data) == 0 {
|
|
return nil // nothing stored yet
|
|
}
|
|
if err := writeFileAtomic(path, data); err != nil {
|
|
return err
|
|
}
|
|
st.Version, st.SHA256 = version, digest(data)
|
|
m.log.Printf("%s: pulled version %d into %s", db.Name, version, st.File)
|
|
m.setStatus(db, "ok", "")
|
|
return nil
|
|
}
|
|
|
|
func (m *Mirror) push(ctx context.Context, db Database, st *fileState, path string, data []byte, force bool) error {
|
|
res, err := m.api.Push(ctx, db.ID, st.Version, data, force)
|
|
switch {
|
|
case err == nil:
|
|
st.Version, st.SHA256 = res.Version, res.SHA256
|
|
if res.Merged {
|
|
// The server folded our save into someone else's. What it holds
|
|
// now is neither copy, so take it.
|
|
m.log.Printf("%s: server merged version %d (%s)", db.Name, res.Version, res.MergeSummary)
|
|
if err := m.pull(ctx, db, st, path); err != nil {
|
|
return err
|
|
}
|
|
m.setStatus(db, "merged", res.MergeSummary)
|
|
return nil
|
|
}
|
|
if res.SHA256 != digest(data) {
|
|
// Whatever the server stored differs from what we sent, so the
|
|
// local file is no longer what the vault holds.
|
|
return m.pull(ctx, db, st, path)
|
|
}
|
|
if !res.Unchanged {
|
|
m.log.Printf("%s: pushed version %d", db.Name, res.Version)
|
|
}
|
|
m.setStatus(db, "ok", "")
|
|
return nil
|
|
|
|
case IsConflict(err):
|
|
// The server moved while we were deciding. Re-read the policy from
|
|
// the server's own answer and resolve with it.
|
|
var e *Error
|
|
errors.As(err, &e)
|
|
strategy := e.MergeStrategy
|
|
if strategy == "" {
|
|
strategy = db.MergeStrategy
|
|
}
|
|
if strategy == "client-wins" && !force {
|
|
return m.push(ctx, db, st, path, data, true)
|
|
}
|
|
if e.Code == "merge_failed" {
|
|
// The server could not merge, most often because this vault's
|
|
// own password was changed. Keep our copy and take the server's,
|
|
// which is exactly what server-wins does.
|
|
m.log.Printf("%s: the server could not merge (%s)", db.Name, e.Message)
|
|
}
|
|
copyPath, cerr := m.keepConflictCopy(path, data)
|
|
if cerr != nil {
|
|
return cerr
|
|
}
|
|
if perr := m.pull(ctx, db, st, path); perr != nil {
|
|
return perr
|
|
}
|
|
why := "the server copy was newer, your save was kept as " + filepath.Base(copyPath)
|
|
m.log.Printf("%s: %s", db.Name, why)
|
|
m.setStatus(db, "conflict-resolved", why)
|
|
return nil
|
|
|
|
default:
|
|
var e *Error
|
|
if errors.As(err, &e) && e.Status == http.StatusForbidden {
|
|
copyPath, cerr := m.keepConflictCopy(path, data)
|
|
if cerr != nil {
|
|
return cerr
|
|
}
|
|
if perr := m.pull(ctx, db, st, path); perr != nil {
|
|
return perr
|
|
}
|
|
why := "you have read-only access, your save was kept as " + filepath.Base(copyPath)
|
|
m.setStatus(db, "conflict-resolved", why)
|
|
return nil
|
|
}
|
|
return err
|
|
}
|
|
}
|
|
|
|
// trackedState returns the state for a database, following a rename made on
|
|
// the server.
|
|
func (m *Mirror) trackedState(db Database) *fileState {
|
|
st := m.st[db.ID]
|
|
if st == nil {
|
|
st = &fileState{Name: db.Name, File: m.fileNameFor(db)}
|
|
m.st[db.ID] = st
|
|
return st
|
|
}
|
|
if st.File == "" {
|
|
st.File = m.fileNameFor(db)
|
|
}
|
|
if st.Name != db.Name {
|
|
want := m.fileNameFor(db)
|
|
old := filepath.Join(m.dir, st.File)
|
|
if _, err := os.Stat(old); err == nil && want != st.File {
|
|
if err := os.Rename(old, filepath.Join(m.dir, want)); err != nil {
|
|
m.log.Printf("%s: rename %s to %s: %v", db.Name, st.File, want, err)
|
|
return st
|
|
}
|
|
m.log.Printf("renamed %s to %s", st.File, want)
|
|
}
|
|
st.Name, st.File = db.Name, want
|
|
}
|
|
return st
|
|
}
|
|
|
|
// fileNameFor turns a database name into a filename, keeping it unique
|
|
// within the mirror directory.
|
|
func (m *Mirror) fileNameFor(db Database) string {
|
|
base := sanitize(db.Name)
|
|
if base == "" {
|
|
base = "database"
|
|
}
|
|
name := base + ".kdbx"
|
|
for id, st := range m.st {
|
|
if id != db.ID && st.File == name {
|
|
return fmt.Sprintf("%s-%s.kdbx", base, shortID(db.ID))
|
|
}
|
|
}
|
|
return name
|
|
}
|
|
|
|
// keepConflictCopy preserves bytes that are about to be overwritten.
|
|
func (m *Mirror) keepConflictCopy(path string, data []byte) (string, error) {
|
|
if len(data) == 0 {
|
|
return path, nil
|
|
}
|
|
copyPath := fmt.Sprintf("%s.conflict-%s.kdbx",
|
|
strings.TrimSuffix(path, ".kdbx"), time.Now().UTC().Format("20060102-150405"))
|
|
return copyPath, writeFileAtomic(copyPath, data)
|
|
}
|
|
|
|
func (m *Mirror) setStatus(db Database, state, msg string) {
|
|
st := m.st[db.ID]
|
|
s := &Status{DatabaseID: db.ID, Name: db.Name, Perm: db.Perm,
|
|
Strategy: db.MergeStrategy, State: state, Message: msg, LastSync: time.Now()}
|
|
if st != nil {
|
|
s.File, s.Version = st.File, st.Version
|
|
}
|
|
m.status[db.ID] = s
|
|
}
|
|
|
|
func (m *Mirror) saveState() error {
|
|
b, err := json.MarshalIndent(m.st, "", " ")
|
|
if err != nil {
|
|
return err
|
|
}
|
|
return writeFileAtomic(filepath.Join(m.dir, stateFile), append(b, '\n'))
|
|
}
|
|
|
|
// ---- small helpers ----
|
|
|
|
func digest(b []byte) string {
|
|
sum := sha256.Sum256(b)
|
|
return hex.EncodeToString(sum[:])
|
|
}
|
|
|
|
func conflictMarker(remoteVersion int64, localSHA string) string {
|
|
if localSHA == "" {
|
|
return ""
|
|
}
|
|
return fmt.Sprintf("%d/%s", remoteVersion, localSHA)
|
|
}
|
|
|
|
func readFileIfExists(path string) ([]byte, bool, error) {
|
|
b, err := os.ReadFile(path)
|
|
switch {
|
|
case os.IsNotExist(err):
|
|
return nil, false, nil
|
|
case err != nil:
|
|
return nil, false, err
|
|
}
|
|
return b, true, nil
|
|
}
|
|
|
|
// writeFileAtomic replaces path in one step, so a KeePass application never
|
|
// reads a half-written database.
|
|
func writeFileAtomic(path string, data []byte) error {
|
|
if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
|
|
return err
|
|
}
|
|
tmp := path + ".keeporg-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)
|
|
}
|
|
|
|
// sanitize keeps a database name usable as a filename on every platform.
|
|
func sanitize(name string) string {
|
|
var b strings.Builder
|
|
for _, r := range strings.TrimSpace(name) {
|
|
switch {
|
|
case r >= 'a' && r <= 'z', r >= 'A' && r <= 'Z', r >= '0' && r <= '9',
|
|
r == '-', r == '_', r == '.', r == ' ':
|
|
b.WriteRune(r)
|
|
default:
|
|
b.WriteRune('-')
|
|
}
|
|
}
|
|
return strings.Trim(strings.TrimSuffix(b.String(), ".kdbx"), " .-")
|
|
}
|
|
|
|
func shortID(id string) string {
|
|
if len(id) > 6 {
|
|
return id[:6]
|
|
}
|
|
return id
|
|
}
|