Add local-first remote binding model

This commit is contained in:
Joe Julian
2026-04-06 11:28:50 -07:00
parent 70f18e89bf
commit 32c7b49f4f
6 changed files with 438 additions and 7 deletions
+40 -3
View File
@@ -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:
+44
View File
@@ -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()
+49 -4
View File
@@ -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,35 @@ func (m *Model) UpsertEntry(entry Entry) {
m.Entries = append(m.Entries, cloneEntry(entry))
}
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) 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 {