Implement local-first remote sync flow
This commit is contained in:
+40
-3
@@ -3,6 +3,7 @@ package vault
|
||||
import (
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
@@ -23,9 +24,10 @@ type KDBXConfig struct {
|
||||
var ErrInvalidMasterKey = errors.New("invalid master key")
|
||||
|
||||
const (
|
||||
templatesRoot = "Templates"
|
||||
recycleBinRoot = "Recycle Bin"
|
||||
keepassGOIDField = "KeePassGO-ID"
|
||||
templatesRoot = "Templates"
|
||||
recycleBinRoot = "Recycle Bin"
|
||||
keepassGOIDField = "KeePassGO-ID"
|
||||
remoteProfilesKey = "keepassgo.remoteProfiles"
|
||||
)
|
||||
|
||||
func LoadKDBX(r io.Reader, password string) (Model, error) {
|
||||
@@ -49,6 +51,7 @@ func SaveKDBXWithConfigAndKey(wr io.Writer, model Model, key MasterKey, config *
|
||||
db := gokeepasslib.NewDatabase(gokeepasslib.WithDatabaseKDBXVersion4())
|
||||
db.Credentials = credentials
|
||||
db.Content.Meta = gokeepasslib.NewMetaData()
|
||||
db.Content.Meta.CustomData = customDataForModel(model)
|
||||
db.Content.Root = &gokeepasslib.RootData{}
|
||||
if config != nil && config.Header != nil {
|
||||
db.Header = cloneHeader(config.Header)
|
||||
@@ -325,6 +328,7 @@ func LoadKDBXWithConfig(r io.Reader, key MasterKey) (Model, *KDBXConfig, error)
|
||||
for _, group := range db.Content.Root.Groups {
|
||||
appendGroupEntries(&model, db, group, nil)
|
||||
}
|
||||
model.RemoteProfiles = remoteProfilesFromMeta(db.Content.Meta)
|
||||
|
||||
return model, &KDBXConfig{
|
||||
Header: cloneHeader(db.Header),
|
||||
@@ -332,6 +336,39 @@ func LoadKDBXWithConfig(r io.Reader, key MasterKey) (Model, *KDBXConfig, error)
|
||||
}, nil
|
||||
}
|
||||
|
||||
func customDataForModel(model Model) []gokeepasslib.CustomData {
|
||||
if len(model.RemoteProfiles) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
content, err := json.Marshal(model.RemoteProfiles)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
return []gokeepasslib.CustomData{{
|
||||
Key: remoteProfilesKey,
|
||||
Value: string(content),
|
||||
}}
|
||||
}
|
||||
|
||||
func remoteProfilesFromMeta(meta *gokeepasslib.MetaData) []RemoteProfile {
|
||||
if meta == nil {
|
||||
return nil
|
||||
}
|
||||
for _, item := range meta.CustomData {
|
||||
if item.Key != remoteProfilesKey {
|
||||
continue
|
||||
}
|
||||
var profiles []RemoteProfile
|
||||
if err := json.Unmarshal([]byte(item.Value), &profiles); err != nil {
|
||||
return nil
|
||||
}
|
||||
return profiles
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func newCredentials(key MasterKey) (*gokeepasslib.DBCredentials, error) {
|
||||
switch {
|
||||
case key.Password != "" && len(key.KeyFileData) > 0:
|
||||
|
||||
+85
-41
@@ -23,10 +23,10 @@ func TestLoadKDBXBuildsModelFromNestedGroups(t *testing.T) {
|
||||
mustGroup("Root",
|
||||
mustGroup("Internet",
|
||||
mustEntry("Bellagio", "rustyryan", "https://bellagio.example.invalid", "hunter2"),
|
||||
mustEntry("Vault Console", "dannyocean", "https://vault.crew.example.invalid", "token-1"),
|
||||
mustEntry("Vault Console", "dannyocean", "https://vault.crew.example.invalid", "bellagio-pass-1"),
|
||||
),
|
||||
mustGroup("Home Assistant",
|
||||
mustEntry("Surveillance Console", "codex", "https://surveillance.crew.example.invalid", "token-2"),
|
||||
mustGroup("Security Office",
|
||||
mustEntry("Surveillance Console", "bashertarr", "https://surveillance.crew.example.invalid", "bellagio-pass-2"),
|
||||
),
|
||||
),
|
||||
},
|
||||
@@ -62,15 +62,15 @@ func TestLoadKDBXBuildsModelFromNestedGroups(t *testing.T) {
|
||||
}
|
||||
|
||||
groups := model.ChildGroups([]string{"Root"})
|
||||
if len(groups) != 2 || groups[0] != "Home Assistant" || groups[1] != "Internet" {
|
||||
t.Fatalf("ChildGroups() = %v, want [Home Assistant Internet]", groups)
|
||||
if len(groups) != 2 || groups[0] != "Internet" || groups[1] != "Security Office" {
|
||||
t.Fatalf("ChildGroups() = %v, want [Internet Security Office]", groups)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadKDBXPreservesEntryDetails(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
entry := mustEntry("Surveillance Console", "codex", "https://surveillance.crew.example.invalid", "token-2")
|
||||
entry := mustEntry("Surveillance Console", "bashertarr", "https://surveillance.crew.example.invalid", "bellagio-pass-2")
|
||||
entry.Tags = "automation; home"
|
||||
entry.Values = append(entry.Values,
|
||||
mkValue("Notes", "Long-lived token used by Codex for home automation tasks."),
|
||||
@@ -84,7 +84,7 @@ func TestLoadKDBXPreservesEntryDetails(t *testing.T) {
|
||||
Meta: gokeepasslib.NewMetaData(),
|
||||
Root: &gokeepasslib.RootData{
|
||||
Groups: []gokeepasslib.Group{
|
||||
mustGroup("Root", mustGroup("Home Assistant", entry)),
|
||||
mustGroup("Root", mustGroup("Security Office", entry)),
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -104,13 +104,13 @@ func TestLoadKDBXPreservesEntryDetails(t *testing.T) {
|
||||
t.Fatalf("LoadKDBX failed: %v", err)
|
||||
}
|
||||
|
||||
got := model.EntriesInPath([]string{"Root", "Home Assistant"})
|
||||
got := model.EntriesInPath([]string{"Root", "Security Office"})
|
||||
if len(got) != 1 {
|
||||
t.Fatalf("len(EntriesInPath()) = %d, want 1", len(got))
|
||||
}
|
||||
|
||||
if got[0].Password != "token-2" {
|
||||
t.Fatalf("Entry.Password = %q, want %q", got[0].Password, "token-2")
|
||||
if got[0].Password != "bellagio-pass-2" {
|
||||
t.Fatalf("Entry.Password = %q, want %q", got[0].Password, "bellagio-pass-2")
|
||||
}
|
||||
|
||||
if got[0].Notes != "Long-lived token used by Codex for home automation tasks." {
|
||||
@@ -135,7 +135,7 @@ func TestSaveKDBXRoundTripsModel(t *testing.T) {
|
||||
ID: "entry-1",
|
||||
Title: "Vault Console",
|
||||
Username: "dannyocean",
|
||||
Password: "token-1",
|
||||
Password: "bellagio-pass-1",
|
||||
URL: "https://vault.crew.example.invalid",
|
||||
Notes: "Personal git server token entry used for automation and CLI auth.",
|
||||
Tags: []string{"git", "infra"},
|
||||
@@ -147,12 +147,12 @@ func TestSaveKDBXRoundTripsModel(t *testing.T) {
|
||||
{
|
||||
ID: "entry-2",
|
||||
Title: "Surveillance Console",
|
||||
Username: "codex",
|
||||
Password: "token-2",
|
||||
Username: "bashertarr",
|
||||
Password: "bellagio-pass-2",
|
||||
URL: "https://surveillance.crew.example.invalid",
|
||||
Notes: "Long-lived token used by Codex for home automation tasks.",
|
||||
Tags: []string{"automation", "home"},
|
||||
Path: []string{"Root", "Home Assistant"},
|
||||
Path: []string{"Root", "Security Office"},
|
||||
},
|
||||
},
|
||||
}
|
||||
@@ -180,13 +180,13 @@ func TestSaveKDBXRoundTripsModel(t *testing.T) {
|
||||
t.Fatalf("Search(\"git\") X-Role = %q, want %q", got[0].Entry.Fields["X-Role"], "automation")
|
||||
}
|
||||
|
||||
homeAssistant := loaded.EntriesInPath([]string{"Root", "Home Assistant"})
|
||||
homeAssistant := loaded.EntriesInPath([]string{"Root", "Security Office"})
|
||||
if len(homeAssistant) != 1 {
|
||||
t.Fatalf("len(EntriesInPath(Home Assistant)) = %d, want 1", len(homeAssistant))
|
||||
t.Fatalf("len(EntriesInPath(Security Office)) = %d, want 1", len(homeAssistant))
|
||||
}
|
||||
|
||||
if homeAssistant[0].Password != "token-2" {
|
||||
t.Fatalf("Home Assistant password = %q, want %q", homeAssistant[0].Password, "token-2")
|
||||
if homeAssistant[0].Password != "bellagio-pass-2" {
|
||||
t.Fatalf("Security Office password = %q, want %q", homeAssistant[0].Password, "bellagio-pass-2")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -238,6 +238,50 @@ func TestSaveKDBXRoundTripsTemplates(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestSaveKDBXRoundTripsRemoteProfiles(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
model := Model{
|
||||
RemoteProfiles: []RemoteProfile{
|
||||
{
|
||||
ID: "family-webdav",
|
||||
Name: "Family Vault",
|
||||
Backend: RemoteBackendWebDAV,
|
||||
BaseURL: "https://dav.example.invalid/remote.php/dav",
|
||||
Path: "files/family/keepass.kdbx",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
var encoded bytes.Buffer
|
||||
if err := SaveKDBX(&encoded, model, "correct horse battery staple"); err != nil {
|
||||
t.Fatalf("SaveKDBX() error = %v", err)
|
||||
}
|
||||
|
||||
loaded, err := LoadKDBX(bytes.NewReader(encoded.Bytes()), "correct horse battery staple")
|
||||
if err != nil {
|
||||
t.Fatalf("LoadKDBX() error = %v", err)
|
||||
}
|
||||
|
||||
if len(loaded.RemoteProfiles) != 1 {
|
||||
t.Fatalf("len(RemoteProfiles) = %d, want 1", len(loaded.RemoteProfiles))
|
||||
}
|
||||
|
||||
got := loaded.RemoteProfiles[0]
|
||||
if got.ID != "family-webdav" || got.Name != "Family Vault" {
|
||||
t.Fatalf("loaded remote profile = %#v, want family-webdav Family Vault", got)
|
||||
}
|
||||
if got.Backend != RemoteBackendWebDAV {
|
||||
t.Fatalf("remote backend = %q, want %q", got.Backend, RemoteBackendWebDAV)
|
||||
}
|
||||
if got.BaseURL != "https://dav.example.invalid/remote.php/dav" {
|
||||
t.Fatalf("remote base URL = %q, want remote.php/dav URL", got.BaseURL)
|
||||
}
|
||||
if got.Path != "files/family/keepass.kdbx" {
|
||||
t.Fatalf("remote path = %q, want files/family/keepass.kdbx", got.Path)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSaveKDBXRoundTripsEntryHistory(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
@@ -297,10 +341,10 @@ func TestSaveKDBXRoundTripsRecycleBinEntries(t *testing.T) {
|
||||
{
|
||||
ID: "entry-1",
|
||||
Title: "Surveillance Console",
|
||||
Username: "codex",
|
||||
Password: "token-2",
|
||||
Username: "bashertarr",
|
||||
Password: "bellagio-pass-2",
|
||||
URL: "https://surveillance.crew.example.invalid",
|
||||
Path: []string{"Root", "Home Assistant"},
|
||||
Path: []string{"Root", "Security Office"},
|
||||
},
|
||||
},
|
||||
}
|
||||
@@ -323,8 +367,8 @@ func TestSaveKDBXRoundTripsRecycleBinEntries(t *testing.T) {
|
||||
t.Fatalf("RecycleBin[0].Title = %q, want %q", loaded.RecycleBin[0].Title, "Surveillance Console")
|
||||
}
|
||||
|
||||
if len(loaded.RecycleBin[0].Path) != 2 || loaded.RecycleBin[0].Path[0] != "Root" || loaded.RecycleBin[0].Path[1] != "Home Assistant" {
|
||||
t.Fatalf("RecycleBin[0].Path = %v, want [Root Home Assistant]", loaded.RecycleBin[0].Path)
|
||||
if len(loaded.RecycleBin[0].Path) != 2 || loaded.RecycleBin[0].Path[0] != "Root" || loaded.RecycleBin[0].Path[1] != "Security Office" {
|
||||
t.Fatalf("RecycleBin[0].Path = %v, want [Root Security Office]", loaded.RecycleBin[0].Path)
|
||||
}
|
||||
|
||||
if len(loaded.Entries) != 0 {
|
||||
@@ -358,7 +402,7 @@ func TestLoadKDBXWithKeyFileCredentials(t *testing.T) {
|
||||
Meta: gokeepasslib.NewMetaData(),
|
||||
Root: &gokeepasslib.RootData{
|
||||
Groups: []gokeepasslib.Group{
|
||||
mustGroup("Root", mustGroup("Internet", mustEntry("Vault Console", "dannyocean", "https://vault.crew.example.invalid", "token-1"))),
|
||||
mustGroup("Root", mustGroup("Internet", mustEntry("Vault Console", "dannyocean", "https://vault.crew.example.invalid", "bellagio-pass-1"))),
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -379,7 +423,7 @@ func TestLoadKDBXWithKeyFileCredentials(t *testing.T) {
|
||||
}
|
||||
|
||||
got := model.Search("vault")
|
||||
if len(got) != 1 || got[0].Entry.Password != "token-1" {
|
||||
if len(got) != 1 || got[0].Entry.Password != "bellagio-pass-1" {
|
||||
t.Fatalf("LoadKDBXWithKey() = %#v, want password-preserving vault entry", got)
|
||||
}
|
||||
}
|
||||
@@ -413,7 +457,7 @@ func TestLoadKDBXWithCompositeCredentials(t *testing.T) {
|
||||
Meta: gokeepasslib.NewMetaData(),
|
||||
Root: &gokeepasslib.RootData{
|
||||
Groups: []gokeepasslib.Group{
|
||||
mustGroup("Root", mustGroup("Home Assistant", mustEntry("Surveillance Console", "codex", "https://surveillance.crew.example.invalid", "token-2"))),
|
||||
mustGroup("Root", mustGroup("Security Office", mustEntry("Surveillance Console", "bashertarr", "https://surveillance.crew.example.invalid", "bellagio-pass-2"))),
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -436,9 +480,9 @@ func TestLoadKDBXWithCompositeCredentials(t *testing.T) {
|
||||
t.Fatalf("LoadKDBXWithKey() error = %v", err)
|
||||
}
|
||||
|
||||
got := model.EntriesInPath([]string{"Root", "Home Assistant"})
|
||||
if len(got) != 1 || got[0].Password != "token-2" {
|
||||
t.Fatalf("LoadKDBXWithKey() = %#v, want Home Assistant entry with password", got)
|
||||
got := model.EntriesInPath([]string{"Root", "Security Office"})
|
||||
if len(got) != 1 || got[0].Password != "bellagio-pass-2" {
|
||||
t.Fatalf("LoadKDBXWithKey() = %#v, want Security Office entry with password", got)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -452,7 +496,7 @@ func TestLoadKDBXReturnsInvalidCredentialsError(t *testing.T) {
|
||||
Meta: gokeepasslib.NewMetaData(),
|
||||
Root: &gokeepasslib.RootData{
|
||||
Groups: []gokeepasslib.Group{
|
||||
mustGroup("Root", mustGroup("Internet", mustEntry("Vault Console", "dannyocean", "https://vault.crew.example.invalid", "token-1"))),
|
||||
mustGroup("Root", mustGroup("Internet", mustEntry("Vault Console", "dannyocean", "https://vault.crew.example.invalid", "bellagio-pass-1"))),
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -493,7 +537,7 @@ func TestSaveKDBXWithKeyRoundTripsModel(t *testing.T) {
|
||||
ID: "vault-console",
|
||||
Title: "Vault Console",
|
||||
Username: "dannyocean",
|
||||
Password: "token-1",
|
||||
Password: "bellagio-pass-1",
|
||||
URL: "https://vault.crew.example.invalid",
|
||||
Path: []string{"Root", "Internet"},
|
||||
},
|
||||
@@ -511,7 +555,7 @@ func TestSaveKDBXWithKeyRoundTripsModel(t *testing.T) {
|
||||
}
|
||||
|
||||
got := loaded.Search("vault")
|
||||
if len(got) != 1 || got[0].Entry.Password != "token-1" {
|
||||
if len(got) != 1 || got[0].Entry.Password != "bellagio-pass-1" {
|
||||
t.Fatalf("round-trip with key file = %#v, want vault entry with password", got)
|
||||
}
|
||||
}
|
||||
@@ -535,10 +579,10 @@ func TestSaveKDBXWithCompositeKeyRoundTripsModel(t *testing.T) {
|
||||
{
|
||||
ID: "surveillance-console",
|
||||
Title: "Surveillance Console",
|
||||
Username: "codex",
|
||||
Password: "token-2",
|
||||
Username: "bashertarr",
|
||||
Password: "bellagio-pass-2",
|
||||
URL: "https://surveillance.crew.example.invalid",
|
||||
Path: []string{"Root", "Home Assistant"},
|
||||
Path: []string{"Root", "Security Office"},
|
||||
},
|
||||
},
|
||||
}
|
||||
@@ -558,9 +602,9 @@ func TestSaveKDBXWithCompositeKeyRoundTripsModel(t *testing.T) {
|
||||
t.Fatalf("LoadKDBXWithKey() error = %v", err)
|
||||
}
|
||||
|
||||
got := loaded.EntriesInPath([]string{"Root", "Home Assistant"})
|
||||
if len(got) != 1 || got[0].Password != "token-2" {
|
||||
t.Fatalf("composite key round-trip = %#v, want Home Assistant entry with password", got)
|
||||
got := loaded.EntriesInPath([]string{"Root", "Security Office"})
|
||||
if len(got) != 1 || got[0].Password != "bellagio-pass-2" {
|
||||
t.Fatalf("composite key round-trip = %#v, want Security Office entry with password", got)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -573,7 +617,7 @@ func TestKDBXRoundTripsEntryAttachments(t *testing.T) {
|
||||
ID: "vault-console",
|
||||
Title: "Vault Console",
|
||||
Username: "dannyocean",
|
||||
Password: "token-1",
|
||||
Password: "bellagio-pass-1",
|
||||
URL: "https://vault.crew.example.invalid",
|
||||
Path: []string{"Root", "Internet"},
|
||||
Attachments: map[string][]byte{
|
||||
@@ -612,7 +656,7 @@ func TestKDBXReopenCyclesPreserveStableIDsAndCrossFeatureState(t *testing.T) {
|
||||
ID: "entry-1",
|
||||
Title: "Vault Console",
|
||||
Username: "dannyocean",
|
||||
Password: "token-2",
|
||||
Password: "bellagio-pass-2",
|
||||
URL: "https://vault.crew.example.invalid",
|
||||
Notes: "Current credential",
|
||||
Path: []string{"Root", "Internet"},
|
||||
@@ -624,7 +668,7 @@ func TestKDBXReopenCyclesPreserveStableIDsAndCrossFeatureState(t *testing.T) {
|
||||
ID: "entry-1-history-1",
|
||||
Title: "Vault Console",
|
||||
Username: "dannyocean",
|
||||
Password: "token-1",
|
||||
Password: "bellagio-pass-1",
|
||||
URL: "https://vault.crew.example.invalid",
|
||||
Notes: "Original credential",
|
||||
Path: []string{"Root", "Internet"},
|
||||
|
||||
+71
-4
@@ -8,6 +8,21 @@ import (
|
||||
|
||||
var ErrEntryNotFound = errors.New("entry not found")
|
||||
var ErrGroupNotEmpty = errors.New("group is not empty")
|
||||
var ErrRemoteProfileNotFound = errors.New("remote profile not found")
|
||||
|
||||
type RemoteBackend string
|
||||
|
||||
const (
|
||||
RemoteBackendWebDAV RemoteBackend = "webdav"
|
||||
)
|
||||
|
||||
type RemoteProfile struct {
|
||||
ID string
|
||||
Name string
|
||||
Backend RemoteBackend
|
||||
BaseURL string
|
||||
Path string
|
||||
}
|
||||
|
||||
type Entry struct {
|
||||
ID string
|
||||
@@ -29,10 +44,11 @@ type SearchResult struct {
|
||||
}
|
||||
|
||||
type Model struct {
|
||||
Entries []Entry
|
||||
Templates []Entry
|
||||
RecycleBin []Entry
|
||||
Groups [][]string
|
||||
Entries []Entry
|
||||
Templates []Entry
|
||||
RecycleBin []Entry
|
||||
Groups [][]string
|
||||
RemoteProfiles []RemoteProfile
|
||||
}
|
||||
|
||||
func (m Model) ChildGroups(path []string) []string {
|
||||
@@ -168,6 +184,57 @@ func (m *Model) UpsertEntry(entry Entry) {
|
||||
m.Entries = append(m.Entries, cloneEntry(entry))
|
||||
}
|
||||
|
||||
func (m *Model) RemoveEntryByID(id string) bool {
|
||||
for i := range m.Entries {
|
||||
if m.Entries[i].ID != id {
|
||||
continue
|
||||
}
|
||||
m.Entries = append(m.Entries[:i], m.Entries[i+1:]...)
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func (m *Model) EntryByID(id string) (Entry, error) {
|
||||
for _, entry := range m.Entries {
|
||||
if entry.ID == id {
|
||||
return cloneEntry(entry), nil
|
||||
}
|
||||
}
|
||||
return Entry{}, ErrEntryNotFound
|
||||
}
|
||||
|
||||
func (m *Model) UpsertRemoteProfile(profile RemoteProfile) {
|
||||
for i := range m.RemoteProfiles {
|
||||
if m.RemoteProfiles[i].ID != profile.ID {
|
||||
continue
|
||||
}
|
||||
m.RemoteProfiles[i] = profile
|
||||
return
|
||||
}
|
||||
m.RemoteProfiles = append(m.RemoteProfiles, profile)
|
||||
}
|
||||
|
||||
func (m *Model) RemoveRemoteProfileByID(id string) bool {
|
||||
for i := range m.RemoteProfiles {
|
||||
if m.RemoteProfiles[i].ID != id {
|
||||
continue
|
||||
}
|
||||
m.RemoteProfiles = append(m.RemoteProfiles[:i], m.RemoteProfiles[i+1:]...)
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func (m Model) RemoteProfileByID(id string) (RemoteProfile, error) {
|
||||
for _, profile := range m.RemoteProfiles {
|
||||
if profile.ID == id {
|
||||
return profile, nil
|
||||
}
|
||||
}
|
||||
return RemoteProfile{}, ErrRemoteProfileNotFound
|
||||
}
|
||||
|
||||
func (m *Model) UpsertTemplate(entry Entry) {
|
||||
for i := range m.Templates {
|
||||
if m.Templates[i].ID != entry.ID {
|
||||
|
||||
Reference in New Issue
Block a user