From 32c7b49f4f9a560ef4c4cc4ae28523a7466fdaa7 Mon Sep 17 00:00:00 2001 From: Joe Julian Date: Mon, 6 Apr 2026 11:28:50 -0700 Subject: [PATCH] Add local-first remote binding model --- appstate/remote_binding.go | 41 ++++++++ appstate/remote_binding_test.go | 116 +++++++++++++++++++++ docs/local-first-remote-sync-plan.md | 148 +++++++++++++++++++++++++++ vault/kdbx.go | 43 +++++++- vault/kdbx_test.go | 44 ++++++++ vault/model.go | 53 +++++++++- 6 files changed, 438 insertions(+), 7 deletions(-) create mode 100644 appstate/remote_binding.go create mode 100644 appstate/remote_binding_test.go create mode 100644 docs/local-first-remote-sync-plan.md diff --git a/appstate/remote_binding.go b/appstate/remote_binding.go new file mode 100644 index 0000000..20e2366 --- /dev/null +++ b/appstate/remote_binding.go @@ -0,0 +1,41 @@ +package appstate + +import ( + "fmt" + + "git.julianfamily.org/keepassgo/vault" +) + +type SyncMode string + +const ( + SyncModeManual SyncMode = "manual" + SyncModeAutomaticOnOpenSave SyncMode = "automatic_on_open_save" +) + +type RemoteBinding struct { + LocalVaultPath string `json:"localVaultPath"` + RemoteProfileID string `json:"remoteProfileId"` + CredentialEntryID string `json:"credentialEntryId"` + SyncMode SyncMode `json:"syncMode,omitempty"` +} + +type ResolvedRemoteBinding struct { + Profile vault.RemoteProfile + Credentials vault.Entry +} + +func (b RemoteBinding) Resolve(model vault.Model) (ResolvedRemoteBinding, error) { + profile, err := model.RemoteProfileByID(b.RemoteProfileID) + if err != nil { + return ResolvedRemoteBinding{}, fmt.Errorf("resolve remote profile: %w", err) + } + credentials, err := model.EntryByID(b.CredentialEntryID) + if err != nil { + return ResolvedRemoteBinding{}, fmt.Errorf("resolve remote credentials: %w", err) + } + return ResolvedRemoteBinding{ + Profile: profile, + Credentials: credentials, + }, nil +} diff --git a/appstate/remote_binding_test.go b/appstate/remote_binding_test.go new file mode 100644 index 0000000..bc9e036 --- /dev/null +++ b/appstate/remote_binding_test.go @@ -0,0 +1,116 @@ +package appstate + +import ( + "encoding/json" + "errors" + "strings" + "testing" + + "git.julianfamily.org/keepassgo/vault" +) + +func TestRemoteBindingResolveUsesVaultProfileAndCredentialEntry(t *testing.T) { + t.Parallel() + + model := vault.Model{ + Entries: []vault.Entry{ + { + ID: "tjulian-webdav", + Title: "WebDAV Sign-In", + Username: "tjulian", + Password: "token-1", + Path: []string{"Crew", "Internet"}, + }, + }, + RemoteProfiles: []vault.RemoteProfile{ + { + ID: "family-webdav", + Name: "Family Vault", + Backend: vault.RemoteBackendWebDAV, + BaseURL: "https://dav.example.invalid/remote.php/dav", + Path: "files/family/keepass.kdbx", + }, + }, + } + + binding := RemoteBinding{ + LocalVaultPath: "/tmp/family.kdbx", + RemoteProfileID: "family-webdav", + CredentialEntryID: "tjulian-webdav", + SyncMode: SyncModeAutomaticOnOpenSave, + } + + resolved, err := binding.Resolve(model) + if err != nil { + t.Fatalf("Resolve() error = %v", err) + } + if got := resolved.Profile.BaseURL; got != "https://dav.example.invalid/remote.php/dav" { + t.Fatalf("resolved profile base URL = %q, want remote.php/dav URL", got) + } + if got := resolved.Profile.Path; got != "files/family/keepass.kdbx" { + t.Fatalf("resolved profile path = %q, want files/family/keepass.kdbx", got) + } + if got := resolved.Credentials.Username; got != "tjulian" { + t.Fatalf("resolved credentials username = %q, want tjulian", got) + } + if got := resolved.Credentials.Password; got != "token-1" { + t.Fatalf("resolved credentials password = %q, want token-1", got) + } +} + +func TestRemoteBindingResolveFailsWhenVaultReferenceIsMissing(t *testing.T) { + t.Parallel() + + model := vault.Model{ + Entries: []vault.Entry{ + {ID: "tjulian-webdav", Title: "WebDAV Sign-In"}, + }, + } + + _, err := (RemoteBinding{ + LocalVaultPath: "/tmp/family.kdbx", + RemoteProfileID: "family-webdav", + CredentialEntryID: "missing-creds", + }).Resolve(model) + if !errors.Is(err, vault.ErrRemoteProfileNotFound) { + t.Fatalf("Resolve() error = %v, want ErrRemoteProfileNotFound first", err) + } + + model.RemoteProfiles = []vault.RemoteProfile{{ + ID: "family-webdav", + Name: "Family Vault", + Backend: vault.RemoteBackendWebDAV, + BaseURL: "https://dav.example.invalid/remote.php/dav", + Path: "files/family/keepass.kdbx", + }} + + _, err = (RemoteBinding{ + LocalVaultPath: "/tmp/family.kdbx", + RemoteProfileID: "family-webdav", + CredentialEntryID: "missing-creds", + }).Resolve(model) + if !errors.Is(err, vault.ErrEntryNotFound) { + t.Fatalf("Resolve() error = %v, want ErrEntryNotFound", err) + } +} + +func TestRemoteBindingJSONStoresOnlyNonSecretReferences(t *testing.T) { + t.Parallel() + + content, err := json.Marshal(RemoteBinding{ + LocalVaultPath: "/tmp/family.kdbx", + RemoteProfileID: "family-webdav", + CredentialEntryID: "remote-creds-1", + SyncMode: SyncModeAutomaticOnOpenSave, + }) + if err != nil { + t.Fatalf("json.Marshal(RemoteBinding) error = %v", err) + } + + text := string(content) + for _, disallowed := range []string{"token-1", "password", "username", "baseUrl"} { + if strings.Contains(text, disallowed) { + t.Fatalf("binding JSON %q unexpectedly contains %q", text, disallowed) + } + } +} diff --git a/docs/local-first-remote-sync-plan.md b/docs/local-first-remote-sync-plan.md new file mode 100644 index 0000000..2f2a260 --- /dev/null +++ b/docs/local-first-remote-sync-plan.md @@ -0,0 +1,148 @@ +# Local-First Remote Sync Plan + +## Goal + +Redesign remote-backed vault handling so every platform uses the same local-first model: + +- every vault is a local KDBX file first +- remote sync is an optional binding on top of that local file +- shared remote configuration lives in the vault +- user-specific remote credentials live in the vault +- app-local state stores only non-secret binding metadata + +Android adds only one platform-specific capability on top of that model: + +- share/import the initial local KDBX file between devices + +## Product Rules + +1. A remote-backed vault must always have a local cache KDBX file. +2. Opening a remote-backed vault should open the local KDBX first. +3. Shared remote configuration must be stored in the vault, not only in app state. +4. Remote credentials must not be stored in plaintext app-local state. +5. Remote credentials should be stored in the vault and resolved by a stable reference. +6. The app state file should keep only the metadata needed to reopen the local vault and find the remote binding. +7. Sync must support both manual and automatic modes. +8. Android-specific sharing should transfer the KDBX file, not a bespoke remote-secret bundle. + +## Target Data Model + +### In-Vault Shared Remote Profile + +Store a reusable remote profile in the vault with fields such as: + +- profile ID +- profile name +- backend type, initially WebDAV +- base URL +- remote object path +- optional notes or labels +- default sync policy, if shared defaults are desirable + +### In-Vault User Credential Binding + +Store user-specific credentials in the vault as normal vault data, referenced by: + +- remote profile ID +- credential entry UUID, or another stable internal reference +- optional username field override if needed + +The credential entry should contain the actual username/password or token. + +### Local App State + +Persist only non-secret binding state such as: + +- local vault path +- selected remote profile ID +- selected credential entry reference +- sync policy override +- last sync metadata +- conflict or recovery markers + +## Core Flows + +### Create Or Configure Remote Sync + +1. Open or create a local vault. +2. Create or edit a shared remote profile in that vault. +3. Create or select a credential entry in that vault. +4. Bind the local vault to the selected remote profile and credential reference. +5. Choose manual or automatic sync behavior. + +### Reopen Existing Remote-Backed Vault + +1. Open the local vault file from app state. +2. Resolve the selected remote profile from vault contents. +3. Resolve the credential entry from vault contents. +4. Offer or perform sync based on the binding policy. + +### Bootstrap A New Android Device + +1. Share the local KDBX file through Android Sharesheet. +2. Import and open that KDBX locally on the new device. +3. Select a remote profile stored in the vault. +4. Select or create that user’s credential entry in the vault. +5. Bind the local vault as the cache for the remote-backed setup. + +## Migration Requirements + +1. Migrate existing remote connections that save credentials in app state. +2. On first open after upgrade, move any recoverable remote credentials into the vault. +3. Replace saved plaintext credential state with a vault credential reference. +4. If migration cannot write into the vault yet, hold the old state only long enough to prompt the user to complete migration. +5. Remove legacy local plaintext credential persistence after migration is complete. + +## Implementation Phases + +### Phase 1: Domain Model + +- define remote profile structures independent of Gio UI +- define credential reference structures independent of Gio UI +- define sync binding state independent of Gio UI +- add behavior tests for local-first remote-backed vaults + +### Phase 2: Vault Storage + +- persist remote profiles in the vault +- persist credential references in the vault +- resolve credentials from normal vault entries +- add behavior tests for read/write and lookup semantics + +### Phase 3: State And Open Flow + +- shrink app state to non-secret metadata only +- update open flows to always prefer the local cache vault +- update reopen behavior on all platforms to use the same model +- add migration coverage for old remote state + +### Phase 4: Sync Binding + +- bind a local vault to a selected remote profile +- support manual sync +- support automatic sync on open/save +- define conflict and remote-failure handling for the local cache model + +### Phase 5: Android Bootstrap + +- add Android Sharesheet export of the current local KDBX +- add Android import flow for a shared KDBX +- keep the remote pivot flow consistent with desktop after local open + +## Open Questions + +1. Where in the vault should remote profiles live: custom metadata, dedicated entries, or another KDBX-compatible structure? +2. Should credential references point to entry UUIDs directly, or should KeePassGO maintain an additional logical identifier? +3. Should automatic sync run only on open/save initially, or also on app resume? +4. How should multiple remote profiles per vault be presented in the UI? +5. What should happen when the credential entry reference no longer resolves? + +## Recommended First Slice + +Implement the shared domain model and tests first: + +- model a local vault plus optional remote binding +- define in-vault remote profile and credential reference semantics +- add tests proving app state no longer needs plaintext remote credentials + +That slice standardizes the architecture before any Android-specific sharing work begins. diff --git a/vault/kdbx.go b/vault/kdbx.go index ff63bda..b4e5850 100644 --- a/vault/kdbx.go +++ b/vault/kdbx.go @@ -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: diff --git a/vault/kdbx_test.go b/vault/kdbx_test.go index f856c72..8062add 100644 --- a/vault/kdbx_test.go +++ b/vault/kdbx_test.go @@ -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() diff --git a/vault/model.go b/vault/model.go index e2d12c9..22e1cc1 100644 --- a/vault/model.go +++ b/vault/model.go @@ -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 {