package server import ( "errors" "fmt" "net/http" "time" "cc.tiredsysadmin.keeporg/internal/blob" "cc.tiredsysadmin.keeporg/internal/kdbx" "cc.tiredsysadmin.keeporg/internal/store" ) // mergePush resolves a stale push by decrypting both copies in memory, // folding them together and storing the result as a new version. Nothing is // written to disk in the clear: the plaintext exists only as these two // kdbx.Database values, and what is stored is an encrypted KDBX file again. // // Any failure answers with a conflict instead, so the client falls back to // keeping its copy aside rather than losing an edit. func (s *Server) mergePush(w http.ResponseWriter, u *store.User, id string, basedOn int64, incoming []byte) { var ( wrapped string current int64 name string ) s.st.Read(func(st *store.State) { if d := st.Databases[id]; d != nil { wrapped, current, name = d.WrappedKey, d.Version, d.Name } }) result, merged, err := s.mergeInMemory(id, wrapped, current, incoming) if err != nil { s.log.Printf("merge %s (%s): %v", name, id, err) w.Header().Set("ETag", etag(current)) w.Header().Set("X-KeepOrg-Strategy", string(store.StrategyMerge)) writeJSON(w, http.StatusConflict, map[string]any{ "code": "merge_failed", "error": err.Error(), "current_version": current, "merge_strategy": store.StrategyMerge, }) return } sum := blob.SHA256(merged) var ( next int64 watchers []string stale bool ) if err := s.st.Update(func(st *store.State) error { d := st.Databases[id] if d == nil { return errors.New("no such database") } if d.Version != current { // Someone else landed a version while this merge was running. stale = true return nil } next = d.Version + 1 if err := s.blobs.Put(id, next, merged); err != nil { return err } d.Version, d.SHA256, d.Size = next, sum, int64(len(merged)) d.UpdatedAt, d.UpdatedBy = time.Now(), u.Username watchers = st.Watchers(id) return nil }); err != nil { fail(w, http.StatusInternalServerError, "internal", err.Error()) return } if stale { // Ask the client to come back with the newer base; its next attempt // merges against what just landed. w.Header().Set("ETag", etag(current)) w.Header().Set("X-KeepOrg-Strategy", string(store.StrategyMerge)) writeJSON(w, http.StatusConflict, map[string]any{ "code": "conflict", "error": "another merge landed first, retry", "current_version": current, "merge_strategy": store.StrategyMerge, }) return } _ = s.blobs.Prune(id) s.log.Printf("merged %s into version %d for %s: %s", name, next, u.Username, result) s.hub.Publish(watchers, Event{Type: EventUpdated, DatabaseID: id, Name: name, Version: next, SHA256: sum, By: u.Username}) w.Header().Set("ETag", etag(next)) writeJSON(w, http.StatusOK, map[string]any{ "version": next, "sha256": sum, "unchanged": false, "merged": true, "merge_summary": result.String(), "based_on": basedOn, }) } // mergeInMemory does the cryptography and the merge. It is separated so the // handler holds no plaintext longer than it must. func (s *Server) mergeInMemory(id, wrapped string, current int64, incoming []byte) ( kdbx.MergeResult, []byte, error) { var empty kdbx.MergeResult password, err := s.vaultPassword(id, wrapped) if err != nil { return empty, nil, err } if current == 0 { return empty, nil, errors.New("this vault has no stored version to merge with") } stored, err := s.blobs.Get(id, current) if err != nil { return empty, nil, fmt.Errorf("reading version %d: %w", current, err) } target, err := kdbx.Open(stored, password) if err != nil { return empty, nil, fmt.Errorf("opening the stored vault: %w", err) } source, err := kdbx.Open(incoming, password) if err != nil { // Most often this means the vault's own password was changed by a // client; the server's stored key is then out of date. return empty, nil, fmt.Errorf("opening the pushed vault: %w", err) } result, err := kdbx.Merge(target, source) if err != nil { return empty, nil, err } out, err := target.Marshal(password) if err != nil { return empty, nil, fmt.Errorf("re-encrypting the merged vault: %w", err) } return result, out, nil }