Merge concurrent saves on the server, with vaults created from the web

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.
This commit is contained in:
2026-10-29 17:34:47 +01:00
parent 4d2f0b047b
commit a9308a3e89
33 changed files with 2168 additions and 88 deletions
+80 -2
View File
@@ -375,16 +375,37 @@ func cmdDB(ctx context.Context, args []string) error {
path := configFlag(fs)
create := fs.String("create", "", "create a database with this name")
group := fs.String("group", "", "owning group id, required with -create")
strategy := fs.String("strategy", "", "merge strategy: server-wins, client-wins or manual")
strategy := fs.String("strategy", "",
"merge strategy: merge, server-wins, client-wins or manual")
id := fs.String("id", "", "database id, for -strategy, -rename or -delete")
rename := fs.String("rename", "", "new name for -id")
del := fs.Bool("delete", false, "delete the database given by -id")
storePassword := fs.Bool("store-password", false,
"give the server this vault's password so it can merge the vault")
forgetPassword := fs.Bool("forget-password", false,
"remove the vault password the server keeps")
_ = fs.Parse(args)
_, api, err := session(*path)
if err != nil {
return err
}
switch {
case *id != "" && *storePassword:
password, perr := readPassword("Vault password: ")
if perr != nil {
return perr
}
if err := api.StoreVaultKey(ctx, *id, password); err != nil {
return err
}
fmt.Println("stored; this database can now use -strategy merge")
return nil
case *id != "" && *forgetPassword:
if err := api.ForgetVaultKey(ctx, *id); err != nil {
return err
}
fmt.Println("the server no longer holds this vault's password")
return nil
case *create != "":
if *group == "" {
return errors.New("-group is required with -create")
@@ -414,11 +435,48 @@ func cmdDB(ctx context.Context, args []string) error {
}
fmt.Println("deleted on the server; local files were left in place")
default:
return errors.New("use -create <name> -group <id>, or -id <id> with -strategy, -rename or -delete")
return errors.New("use -create <name> -group <id>, or -id <id> with -strategy, " +
"-rename, -store-password, -forget-password or -delete")
}
return nil
}
func cmdVault(ctx context.Context, args []string) error {
fs := flag.NewFlagSet("vault", flag.ExitOnError)
path := configFlag(fs)
create := fs.String("create", "", "name of the vault to create")
group := fs.String("group", "", "owning group id")
strategy := fs.String("strategy", "merge",
"merge, server-wins, client-wins or manual")
_ = fs.Parse(args)
cfg, api, err := session(*path)
if err != nil {
return err
}
if *create == "" || *group == "" {
return errors.New("use -create <name> -group <id>")
}
if *strategy == "merge" {
fmt.Println("A merge vault is stored with its key on the server, which is what lets")
fmt.Println("the server fold two people's edits together. The server can then read")
fmt.Println("this vault. Use -strategy server-wins to keep the key off the server.")
fmt.Println()
}
password, err := readNewPassword("Vault password: ")
if err != nil {
return err
}
db, err := api.CreateVault(ctx, *create, *group, *strategy, password)
if err != nil {
return err
}
fmt.Printf("created vault %s (%s), strategy %s\n", db.Name, db.ID, db.MergeStrategy)
fmt.Printf("it will appear in %s as %s.kdbx once the agent syncs; open it with the\n",
cfg.MirrorDir, db.Name)
fmt.Println("password you just set.")
return nil
}
func cmdGrant(ctx context.Context, args []string) error {
fs := flag.NewFlagSet("grant", flag.ExitOnError)
path := configFlag(fs)
@@ -534,6 +592,26 @@ func readPassword(label string) (string, error) {
return strings.TrimSpace(line), err
}
// readNewPassword asks twice, so a vault is never created with a password
// its owner cannot reproduce.
func readNewPassword(label string) (string, error) {
first, err := readPassword(label)
if err != nil {
return "", err
}
if len(first) < 12 {
return "", errors.New("a vault password must be at least 12 characters")
}
again, err := readPassword("Repeat: ")
if err != nil {
return "", err
}
if first != again {
return "", errors.New("the two passwords do not match")
}
return first, nil
}
// hideInput turns terminal echo off where a terminal and stty are available.
// Elsewhere the password is typed visibly, which is stated by the prompt.
func hideInput() (restore func(), hidden bool) {
+1
View File
@@ -38,6 +38,7 @@ func main() {
{"groups", "list groups you can see", cmdGroups},
{"group", "create or delete a group", cmdGroup},
{"member", "add or remove a group member", cmdMember},
{"vault", "create a new KDBX vault on the server and set its password", cmdVault},
{"db", "create, rename, re-strategy or delete a database", cmdDB},
{"grant", "give or take a group's access to a database", cmdGrant},
{"invite", "create or list invite links", cmdInvite},