package server import ( "bytes" "io" "net/http" "testing" "time" "cc.tiredsysadmin.keeporg/internal/kdbx" ) // testKDF keeps the vault work factor negligible in tests; the merge logic // does not care what the key cost was. var testKDF = kdbx.KDFParams{MemoryKiB: 64, Iterations: 1, Lanes: 1} const vaultPassword = "vault-password-12" // makeVault creates a merge vault through the API and returns its id. func (h *harness) makeVault(token, name string) string { h.t.Helper() status, body := h.call("POST", "/api/v1/vaults", token, map[string]string{ "name": name, "owner_group_id": "everyone", "merge_strategy": "merge", "password": vaultPassword, }) if status != http.StatusCreated { h.t.Fatalf("create vault: %d %v", status, body) } if body["merge_strategy"] != "merge" || body["version"].(float64) != 1 { h.t.Fatalf("new vault = %v", body) } return body["id"].(string) } // pull downloads the current bytes of a database. func (h *harness) pull(token, dbID string) []byte { h.t.Helper() req, _ := http.NewRequest("GET", h.http.URL+"/api/v1/databases/"+dbID+"/blob", nil) req.Header.Set("Authorization", "Bearer "+token) resp, err := h.http.Client().Do(req) if err != nil { h.t.Fatal(err) } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { h.t.Fatalf("pull: %d", resp.StatusCode) } data, _ := io.ReadAll(resp.Body) return data } // edit opens a vault, appends an entry and re-encrypts it, which is what a // KeePass application does on save. func edit(t *testing.T, data []byte, title, password string) []byte { t.Helper() db, err := kdbx.Open(data, vaultPassword) if err != nil { t.Fatal(err) } db.RootGroup().Nodes = append(db.RootGroup().Nodes, kdbx.NewEntry(title, "user", password, "", "")) out, err := db.Marshal(vaultPassword) if err != nil { t.Fatal(err) } return out } // entryTitles lists the live entry titles of a vault. func entryTitles(t *testing.T, data []byte) map[string]string { t.Helper() db, err := kdbx.Open(data, vaultPassword) if err != nil { t.Fatal(err) } out := map[string]string{} var visit func(g *kdbx.Node) visit = func(g *kdbx.Node) { for _, e := range g.FindAll("Entry") { title, pw := "", "" for _, s := range e.FindAll("String") { switch s.Value("Key") { case "Title": title = s.Value("Value") case "Password": pw = s.Value("Value") } } out[title] = pw } for _, sub := range g.FindAll("Group") { visit(sub) } } visit(db.RootGroup()) return out } func TestVaultCreatedByTheServerIsAUsableKDBX(t *testing.T) { h := newHarness(t) tok, _, _ := h.login("admin", adminPassword, "") id := h.makeVault(tok, "Team Vault") data := h.pull(tok, id) db, err := kdbx.Open(data, vaultPassword) if err != nil { t.Fatalf("the vault the server created does not open: %v", err) } if db.Name() != "Team Vault" { t.Fatalf("vault name = %q", db.Name()) } if _, err := kdbx.Open(data, "wrong-password-here"); err != kdbx.ErrWrongPassword { t.Fatalf("a wrong password opened the vault: %v", err) } // The vault password must not be readable from the server's state file. if bytes.Contains(h.stateBytes(), []byte(vaultPassword)) { t.Fatal("the vault password is stored in the clear in state.json") } } func TestConcurrentEditsAreMergedNotRejected(t *testing.T) { h := newHarness(t) tok, _, _ := h.login("admin", adminPassword, "") id := h.makeVault(tok, "Team Vault") base := h.pull(tok, id) // Two people open version 1 and each add an entry. fromA := edit(t, base, "From A", "a-password") fromB := edit(t, base, "From B", "b-password") status, body, _ := h.push(tok, id, `"1"`, fromA) if status != http.StatusOK || body["version"].(float64) != 2 { t.Fatalf("first push: %d %v", status, body) } // The second push is based on version 1, which is now stale. A // server-wins vault would refuse it; this one merges. status, body, hdr := h.push(tok, id, `"1"`, fromB) if status != http.StatusOK { t.Fatalf("second push: %d %v", status, body) } if body["merged"] != true || body["version"].(float64) != 3 { t.Fatalf("merge response = %v", body) } if hdr.Get("ETag") != `"3"` { t.Fatalf("etag = %q", hdr.Get("ETag")) } titles := entryTitles(t, h.pull(tok, id)) if titles["From A"] != "a-password" || titles["From B"] != "b-password" { t.Fatalf("merged vault holds %v, want both edits", titles) } } func TestMergeKeepsTheNewerEditOfTheSameEntry(t *testing.T) { h := newHarness(t) tok, _, _ := h.login("admin", adminPassword, "") id := h.makeVault(tok, "Team Vault") // Start from a vault that already holds the entry both people edit. base := edit(t, h.pull(tok, id), "Router", "original") if status, body, _ := h.push(tok, id, `"1"`, base); status != http.StatusOK { t.Fatalf("seed push: %d %v", status, body) } base = h.pull(tok, id) changeRouter := func(data []byte, password string) []byte { db, err := kdbx.Open(data, vaultPassword) if err != nil { t.Fatal(err) } for _, e := range db.RootGroup().FindAll("Entry") { for _, s := range e.FindAll("String") { if s.Value("Key") == "Password" { s.Find("Value").Text = password } } e.Find("Times").Set("LastModificationTime", kdbx.EncodeTime(time.Now().Add(clockSkew(password)))) } out, err := db.Marshal(vaultPassword) if err != nil { t.Fatal(err) } return out } first := changeRouter(base, "edited-first") second := changeRouter(base, "edited-second") if status, _, _ := h.push(tok, id, `"2"`, first); status != http.StatusOK { t.Fatalf("first edit push: %d", status) } status, body, _ := h.push(tok, id, `"2"`, second) if status != http.StatusOK || body["merged"] != true { t.Fatalf("second edit push: %d %v", status, body) } titles := entryTitles(t, h.pull(tok, id)) if titles["Router"] != "edited-second" { t.Fatalf("Router = %q, want the later edit to win", titles["Router"]) } } func TestMergeFailureFallsBackToAConflictInsteadOfLosingData(t *testing.T) { h := newHarness(t) tok, _, _ := h.login("admin", adminPassword, "") id := h.makeVault(tok, "Team Vault") base := h.pull(tok, id) if status, _, _ := h.push(tok, id, `"1"`, edit(t, base, "From A", "p")); status != http.StatusOK { t.Fatal("seed push failed") } // A client that changed the vault's own password pushes a file the // server's stored key no longer opens. db, err := kdbx.Open(base, vaultPassword) if err != nil { t.Fatal(err) } reencrypted, err := db.Marshal("a-completely-different-password") if err != nil { t.Fatal(err) } status, body, _ := h.push(tok, id, `"1"`, reencrypted) if status != http.StatusConflict || body["code"] != "merge_failed" { t.Fatalf("push = %d %v, want a merge_failed conflict", status, body) } // The stored vault is untouched. if titles := entryTitles(t, h.pull(tok, id)); titles["From A"] != "p" { t.Fatalf("the stored vault changed: %v", titles) } } func TestMergeStrategyRefusedWithoutAStoredKey(t *testing.T) { h := newHarness(t) tok, _, _ := h.login("admin", adminPassword, "") // A plain database, created without a password, cannot be switched to // merge until a key is stored for it. db := h.makeDB(tok, "No Key") if status, body := h.call("PATCH", "/api/v1/databases/"+db, tok, map[string]string{"merge_strategy": "merge"}); status != http.StatusBadRequest { t.Fatalf("patch to merge = %d %v, want a refusal", status, body) } } func TestAnExistingDatabaseCanAdoptAStoredKey(t *testing.T) { h := newHarness(t) tok, _, _ := h.login("admin", adminPassword, "") id := h.makeDB(tok, "Adopted") // Someone uploads a real KDBX file as the first version. first, err := kdbx.New("Adopted", testKDF).Marshal(vaultPassword) if err != nil { t.Fatal(err) } if status, _, _ := h.push(tok, id, `"0"`, first); status != http.StatusOK { t.Fatal("initial upload failed") } // A wrong password must not be accepted as the vault key. if status, body := h.call("POST", "/api/v1/databases/"+id+"/key", tok, map[string]string{"password": "not-the-vault-password"}); status != http.StatusBadRequest { t.Fatalf("wrong key accepted: %d %v", status, body) } if status, body := h.call("POST", "/api/v1/databases/"+id+"/key", tok, map[string]string{"password": vaultPassword}); status != http.StatusOK { t.Fatalf("storing the key: %d %v", status, body) } if status, body := h.call("PATCH", "/api/v1/databases/"+id, tok, map[string]string{"merge_strategy": "merge"}); status != http.StatusOK { t.Fatalf("switching to merge: %d %v", status, body) } // And now it merges. base := h.pull(tok, id) if status, _, _ := h.push(tok, id, `"1"`, edit(t, base, "From A", "a")); status != http.StatusOK { t.Fatal("push A failed") } status, body, _ := h.push(tok, id, `"1"`, edit(t, base, "From B", "b")) if status != http.StatusOK || body["merged"] != true { t.Fatalf("merge after adoption: %d %v", status, body) } } // clockSkew gives the two concurrent edits different timestamps, the way two // real saves a minute apart would have. func clockSkew(password string) time.Duration { if password == "edited-second" { return time.Minute } return 0 }