Preserve single-root KDBX group trees
This commit is contained in:
+87
-7
@@ -24,6 +24,7 @@ type Manager struct {
|
|||||||
config *vault.KDBXConfig
|
config *vault.KDBXConfig
|
||||||
path string
|
path string
|
||||||
key vault.MasterKey
|
key vault.MasterKey
|
||||||
|
vaultRoot string
|
||||||
locked bool
|
locked bool
|
||||||
encoded []byte
|
encoded []byte
|
||||||
remoteClient *webdav.Client
|
remoteClient *webdav.Client
|
||||||
@@ -32,6 +33,8 @@ type Manager struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (m *Manager) Create(model vault.Model, key vault.MasterKey) error {
|
func (m *Manager) Create(model vault.Model, key vault.MasterKey) error {
|
||||||
|
root := detectSingleVaultRoot(model)
|
||||||
|
model = normalizeUnderRoot(model, root)
|
||||||
var encoded bytes.Buffer
|
var encoded bytes.Buffer
|
||||||
if err := vault.SaveKDBXWithConfigAndKey(&encoded, model, key, m.config); err != nil {
|
if err := vault.SaveKDBXWithConfigAndKey(&encoded, model, key, m.config); err != nil {
|
||||||
return fmt.Errorf("encode new vault: %w", err)
|
return fmt.Errorf("encode new vault: %w", err)
|
||||||
@@ -39,6 +42,7 @@ func (m *Manager) Create(model vault.Model, key vault.MasterKey) error {
|
|||||||
|
|
||||||
m.model = model
|
m.model = model
|
||||||
m.key = key
|
m.key = key
|
||||||
|
m.vaultRoot = root
|
||||||
m.encoded = encoded.Bytes()
|
m.encoded = encoded.Bytes()
|
||||||
m.locked = false
|
m.locked = false
|
||||||
return nil
|
return nil
|
||||||
@@ -71,6 +75,7 @@ func (m *Manager) Open(path string, key vault.MasterKey) error {
|
|||||||
m.config = config
|
m.config = config
|
||||||
m.path = path
|
m.path = path
|
||||||
m.key = key
|
m.key = key
|
||||||
|
m.vaultRoot = detectSingleVaultRoot(model)
|
||||||
m.encoded = content
|
m.encoded = content
|
||||||
m.locked = false
|
m.locked = false
|
||||||
return nil
|
return nil
|
||||||
@@ -102,6 +107,7 @@ func (m *Manager) OpenRemote(client webdav.Client, path string, key vault.Master
|
|||||||
m.model = model
|
m.model = model
|
||||||
m.config = config
|
m.config = config
|
||||||
m.key = key
|
m.key = key
|
||||||
|
m.vaultRoot = detectSingleVaultRoot(model)
|
||||||
m.encoded = content
|
m.encoded = content
|
||||||
m.locked = false
|
m.locked = false
|
||||||
m.remoteClient = &client
|
m.remoteClient = &client
|
||||||
@@ -162,10 +168,11 @@ func (m *Manager) SynchronizeToLocal(path string) error {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
merged = normalizeUnderRoot(merged, m.vaultRoot)
|
||||||
if err := saveModelToLocal(path, merged, m.key, configOrCurrent(config, m.config)); err != nil {
|
if err := saveModelToLocal(path, merged, m.key, configOrCurrent(config, m.config)); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
m.model = merged
|
m.model = normalizeUnderRoot(merged, m.vaultRoot)
|
||||||
m.locked = false
|
m.locked = false
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
@@ -191,10 +198,11 @@ func (m *Manager) SynchronizeToRemote(client webdav.Client, path string) error {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
merged = normalizeUnderRoot(merged, m.vaultRoot)
|
||||||
if err := saveModelToRemote(client, path, merged, m.key, configOrCurrent(config, m.config), version); err != nil {
|
if err := saveModelToRemote(client, path, merged, m.key, configOrCurrent(config, m.config), version); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
m.model = merged
|
m.model = normalizeUnderRoot(merged, m.vaultRoot)
|
||||||
m.locked = false
|
m.locked = false
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
@@ -209,7 +217,12 @@ func (m *Manager) SaveAs(path string) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (m *Manager) Replace(model vault.Model) {
|
func (m *Manager) Replace(model vault.Model) {
|
||||||
m.model = model
|
root := m.vaultRoot
|
||||||
|
if root == "" {
|
||||||
|
root = detectSingleVaultRoot(model)
|
||||||
|
}
|
||||||
|
m.model = normalizeUnderRoot(model, root)
|
||||||
|
m.vaultRoot = root
|
||||||
m.locked = false
|
m.locked = false
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -227,7 +240,8 @@ func (m *Manager) Lock() error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
var encoded bytes.Buffer
|
var encoded bytes.Buffer
|
||||||
if err := vault.SaveKDBXWithConfigAndKey(&encoded, m.model, m.key, m.config); err != nil {
|
model := normalizeUnderRoot(m.model, m.vaultRoot)
|
||||||
|
if err := vault.SaveKDBXWithConfigAndKey(&encoded, model, m.key, m.config); err != nil {
|
||||||
return fmt.Errorf("encode vault for lock: %w", err)
|
return fmt.Errorf("encode vault for lock: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -246,6 +260,7 @@ func (m *Manager) Unlock(key vault.MasterKey) error {
|
|||||||
m.model = model
|
m.model = model
|
||||||
m.config = config
|
m.config = config
|
||||||
m.key = key
|
m.key = key
|
||||||
|
m.vaultRoot = detectSingleVaultRoot(model)
|
||||||
m.locked = false
|
m.locked = false
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
@@ -303,9 +318,13 @@ func (m *Manager) persistableBytes() ([]byte, error) {
|
|||||||
if m.locked {
|
if m.locked {
|
||||||
return append([]byte(nil), m.encoded...), nil
|
return append([]byte(nil), m.encoded...), nil
|
||||||
}
|
}
|
||||||
|
model, err := m.currentModelForPersistence()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
var encoded bytes.Buffer
|
var encoded bytes.Buffer
|
||||||
if err := vault.SaveKDBXWithConfigAndKey(&encoded, m.model, m.key, m.config); err != nil {
|
if err := vault.SaveKDBXWithConfigAndKey(&encoded, model, m.key, m.config); err != nil {
|
||||||
return nil, fmt.Errorf("encode vault: %w", err)
|
return nil, fmt.Errorf("encode vault: %w", err)
|
||||||
}
|
}
|
||||||
return encoded.Bytes(), nil
|
return encoded.Bytes(), nil
|
||||||
@@ -336,6 +355,7 @@ func (m *Manager) synchronizeLocal() error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
merged := mergeModels(base, current, latest)
|
merged := mergeModels(base, current, latest)
|
||||||
|
merged = normalizeUnderRoot(merged, m.vaultRoot)
|
||||||
var encoded bytes.Buffer
|
var encoded bytes.Buffer
|
||||||
if err := vault.SaveKDBXWithConfigAndKey(&encoded, merged, m.key, config); err != nil {
|
if err := vault.SaveKDBXWithConfigAndKey(&encoded, merged, m.key, config); err != nil {
|
||||||
return fmt.Errorf("encode synchronized vault: %w", err)
|
return fmt.Errorf("encode synchronized vault: %w", err)
|
||||||
@@ -373,6 +393,7 @@ func (m *Manager) synchronizeRemote() error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
merged := mergeModels(base, current, latest)
|
merged := mergeModels(base, current, latest)
|
||||||
|
merged = normalizeUnderRoot(merged, m.vaultRoot)
|
||||||
var encoded bytes.Buffer
|
var encoded bytes.Buffer
|
||||||
if err := vault.SaveKDBXWithConfigAndKey(&encoded, merged, m.key, config); err != nil {
|
if err := vault.SaveKDBXWithConfigAndKey(&encoded, merged, m.key, config); err != nil {
|
||||||
return fmt.Errorf("encode synchronized remote vault: %w", err)
|
return fmt.Errorf("encode synchronized remote vault: %w", err)
|
||||||
@@ -393,9 +414,13 @@ func (m *Manager) synchronizeRemote() error {
|
|||||||
|
|
||||||
func (m *Manager) currentModelForPersistence() (vault.Model, error) {
|
func (m *Manager) currentModelForPersistence() (vault.Model, error) {
|
||||||
if m.locked {
|
if m.locked {
|
||||||
return vault.LoadKDBXWithKey(bytes.NewReader(m.encoded), m.key)
|
model, err := vault.LoadKDBXWithKey(bytes.NewReader(m.encoded), m.key)
|
||||||
|
if err != nil {
|
||||||
|
return vault.Model{}, err
|
||||||
|
}
|
||||||
|
return normalizeUnderRoot(model, m.vaultRoot), nil
|
||||||
}
|
}
|
||||||
return m.model, nil
|
return normalizeUnderRoot(m.model, m.vaultRoot), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *Manager) baseModel() (vault.Model, error) {
|
func (m *Manager) baseModel() (vault.Model, error) {
|
||||||
@@ -418,6 +443,7 @@ func (m *Manager) mergedWithPeer(other vault.Model) (vault.Model, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (m *Manager) persistMergedToCurrentSource(merged vault.Model) error {
|
func (m *Manager) persistMergedToCurrentSource(merged vault.Model) error {
|
||||||
|
merged = normalizeUnderRoot(merged, m.vaultRoot)
|
||||||
switch {
|
switch {
|
||||||
case m.remoteClient != nil && m.remotePath != "":
|
case m.remoteClient != nil && m.remotePath != "":
|
||||||
if err := saveModelToRemote(*m.remoteClient, m.remotePath, merged, m.key, configOrCurrent(m.config, nil), m.remoteVersion); err != nil {
|
if err := saveModelToRemote(*m.remoteClient, m.remotePath, merged, m.key, configOrCurrent(m.config, nil), m.remoteVersion); err != nil {
|
||||||
@@ -435,17 +461,22 @@ func (m *Manager) persistMergedToCurrentSource(merged vault.Model) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (m *Manager) reloadCurrentLocal(merged vault.Model) error {
|
func (m *Manager) reloadCurrentLocal(merged vault.Model) error {
|
||||||
|
merged = normalizeUnderRoot(merged, m.vaultRoot)
|
||||||
encoded, err := encodeModelWithConfig(merged, m.key, configOrCurrent(m.config, nil))
|
encoded, err := encodeModelWithConfig(merged, m.key, configOrCurrent(m.config, nil))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
m.model = merged
|
m.model = merged
|
||||||
|
if root := detectSingleVaultRoot(merged); root != "" {
|
||||||
|
m.vaultRoot = root
|
||||||
|
}
|
||||||
m.encoded = encoded
|
m.encoded = encoded
|
||||||
m.locked = false
|
m.locked = false
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *Manager) reloadCurrentRemote(merged vault.Model) error {
|
func (m *Manager) reloadCurrentRemote(merged vault.Model) error {
|
||||||
|
merged = normalizeUnderRoot(merged, m.vaultRoot)
|
||||||
encoded, err := encodeModelWithConfig(merged, m.key, configOrCurrent(m.config, nil))
|
encoded, err := encodeModelWithConfig(merged, m.key, configOrCurrent(m.config, nil))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -455,6 +486,9 @@ func (m *Manager) reloadCurrentRemote(merged vault.Model) error {
|
|||||||
return fmt.Errorf("reopen remote %s after synchronize: %w", m.remotePath, err)
|
return fmt.Errorf("reopen remote %s after synchronize: %w", m.remotePath, err)
|
||||||
}
|
}
|
||||||
m.model = merged
|
m.model = merged
|
||||||
|
if root := detectSingleVaultRoot(merged); root != "" {
|
||||||
|
m.vaultRoot = root
|
||||||
|
}
|
||||||
m.encoded = encoded
|
m.encoded = encoded
|
||||||
m.remoteVersion = version
|
m.remoteVersion = version
|
||||||
m.locked = false
|
m.locked = false
|
||||||
@@ -716,6 +750,52 @@ func mergePeerGroups(primary, secondary [][]string) [][]string {
|
|||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func detectSingleVaultRoot(model vault.Model) string {
|
||||||
|
if len(model.EntriesInPath(nil)) != 0 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
groups := model.ChildGroups(nil)
|
||||||
|
if len(groups) != 1 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return groups[0]
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizeUnderRoot(model vault.Model, root string) vault.Model {
|
||||||
|
if root == "" {
|
||||||
|
return model
|
||||||
|
}
|
||||||
|
|
||||||
|
out := cloneModel(model)
|
||||||
|
normalizePath := func(path []string) []string {
|
||||||
|
switch {
|
||||||
|
case len(path) == 0:
|
||||||
|
return []string{root}
|
||||||
|
case path[0] == root:
|
||||||
|
return path
|
||||||
|
default:
|
||||||
|
return append([]string{root}, path...)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for i := range out.Entries {
|
||||||
|
out.Entries[i].Path = normalizePath(out.Entries[i].Path)
|
||||||
|
for j := range out.Entries[i].History {
|
||||||
|
out.Entries[i].History[j].Path = normalizePath(out.Entries[i].History[j].Path)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for i := range out.RecycleBin {
|
||||||
|
out.RecycleBin[i].Path = normalizePath(out.RecycleBin[i].Path)
|
||||||
|
for j := range out.RecycleBin[i].History {
|
||||||
|
out.RecycleBin[i].History[j].Path = normalizePath(out.RecycleBin[i].History[j].Path)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for i := range out.Groups {
|
||||||
|
out.Groups[i] = normalizePath(out.Groups[i])
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
func loadLocalSource(path string, key vault.MasterKey) (vault.Model, *vault.KDBXConfig, error) {
|
func loadLocalSource(path string, key vault.MasterKey) (vault.Model, *vault.KDBXConfig, error) {
|
||||||
content, err := os.ReadFile(path)
|
content, err := os.ReadFile(path)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -175,6 +175,80 @@ func TestSavePersistsEditsBackToCurrentPath(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestSaveReparentsMixedPathsUnderSingleVaultRoot(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
key := vault.MasterKey{Password: "correct horse battery staple"}
|
||||||
|
path := filepath.Join(t.TempDir(), "hidden-root.kdbx")
|
||||||
|
|
||||||
|
var initial bytes.Buffer
|
||||||
|
if err := vault.SaveKDBX(&initial, vault.Model{
|
||||||
|
Entries: []vault.Entry{
|
||||||
|
{
|
||||||
|
ID: "entry-1",
|
||||||
|
Title: "Vault Console",
|
||||||
|
Username: "dannyocean",
|
||||||
|
Password: "token-1",
|
||||||
|
URL: "https://vault.crew.example.invalid",
|
||||||
|
Path: []string{"keepass", "Crew", "Internet"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
ID: "entry-2",
|
||||||
|
Title: "Mail",
|
||||||
|
Username: "dannyocean",
|
||||||
|
Password: "token-2",
|
||||||
|
URL: "https://dispatch.crew.example.invalid",
|
||||||
|
Path: []string{"keepass", "Crew", "eMail"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}, key.Password); err != nil {
|
||||||
|
t.Fatalf("SaveKDBX() error = %v", err)
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(path, initial.Bytes(), 0o600); err != nil {
|
||||||
|
t.Fatalf("WriteFile(hidden-root.kdbx) error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var sess Manager
|
||||||
|
if err := sess.Open(path, key); err != nil {
|
||||||
|
t.Fatalf("Open() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
current, err := sess.Current()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Current() error = %v", err)
|
||||||
|
}
|
||||||
|
current.Entries[0].Path = []string{"Crew", "Internet"}
|
||||||
|
current.Groups = append(current.Groups, []string{"Crew"}, []string{"Crew", "Internet"}, []string{"Crew", "eMail"})
|
||||||
|
sess.Replace(current)
|
||||||
|
|
||||||
|
if err := sess.Save(); err != nil {
|
||||||
|
t.Fatalf("Save() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
reopened, err := os.Open(path)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Open(saved path) error = %v", err)
|
||||||
|
}
|
||||||
|
defer reopened.Close()
|
||||||
|
|
||||||
|
db := gokeepasslib.NewDatabase()
|
||||||
|
db.Credentials = gokeepasslib.NewPasswordCredentials(key.Password)
|
||||||
|
if err := gokeepasslib.NewDecoder(reopened).Decode(db); err != nil {
|
||||||
|
t.Fatalf("Decode(saved path) error = %v", err)
|
||||||
|
}
|
||||||
|
if err := db.UnlockProtectedEntries(); err != nil {
|
||||||
|
t.Fatalf("UnlockProtectedEntries() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(db.Content.Root.Groups) != 1 || db.Content.Root.Groups[0].Name != "keepass" {
|
||||||
|
t.Fatalf("top-level groups = %#v, want single keepass root", db.Content.Root.Groups)
|
||||||
|
}
|
||||||
|
rootGroups := db.Content.Root.Groups[0].Groups
|
||||||
|
if len(rootGroups) != 1 || rootGroups[0].Name != "Crew" {
|
||||||
|
t.Fatalf("keepass child groups = %#v, want single Crew group", rootGroups)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestSaveWithoutPathFails(t *testing.T) {
|
func TestSaveWithoutPathFails(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user