Move app packages under internal
This commit is contained in:
@@ -0,0 +1,160 @@
|
||||
package vault
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestUpsertEntryPreservesPreviousVersionInHistory(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
model := Model{
|
||||
Entries: []Entry{
|
||||
{
|
||||
ID: "vault-console",
|
||||
Title: "Vault Console",
|
||||
Username: "dannyocean",
|
||||
Password: "old-token",
|
||||
URL: "https://vault.crew.example.invalid",
|
||||
Notes: "Original note",
|
||||
Path: []string{"Root", "Internet"},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
model.UpsertEntry(Entry{
|
||||
ID: "vault-console",
|
||||
Title: "Vault Console",
|
||||
Username: "dannyocean",
|
||||
Password: "new-token",
|
||||
URL: "https://vault.crew.example.invalid",
|
||||
Notes: "Updated note",
|
||||
Path: []string{"Root", "Internet"},
|
||||
})
|
||||
|
||||
got := model.EntriesInPath([]string{"Root", "Internet"})
|
||||
if len(got) != 1 {
|
||||
t.Fatalf("len(EntriesInPath()) = %d, want 1", len(got))
|
||||
}
|
||||
|
||||
if got[0].Password != "new-token" {
|
||||
t.Fatalf("Entry.Password = %q, want %q", got[0].Password, "new-token")
|
||||
}
|
||||
|
||||
if len(got[0].History) != 1 {
|
||||
t.Fatalf("len(Entry.History) = %d, want 1", len(got[0].History))
|
||||
}
|
||||
|
||||
if got[0].History[0].Password != "old-token" || got[0].History[0].Notes != "Original note" {
|
||||
t.Fatalf("Entry.History[0] = %#v, want prior entry version", got[0].History[0])
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeleteEntryMovesItToRecycleBin(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
model := Model{
|
||||
Entries: []Entry{
|
||||
{
|
||||
ID: "surveillance-console",
|
||||
Title: "Surveillance Console",
|
||||
Username: "codex",
|
||||
Password: "token-2",
|
||||
URL: "https://surveillance.crew.example.invalid",
|
||||
Path: []string{"Root", "Home Assistant"},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
if err := model.DeleteEntry("surveillance-console"); err != nil {
|
||||
t.Fatalf("DeleteEntry() error = %v", err)
|
||||
}
|
||||
|
||||
if got := model.EntriesInPath([]string{"Root", "Home Assistant"}); len(got) != 0 {
|
||||
t.Fatalf("EntriesInPath() = %#v, want empty after delete", got)
|
||||
}
|
||||
|
||||
if len(model.RecycleBin) != 1 {
|
||||
t.Fatalf("len(RecycleBin) = %d, want 1", len(model.RecycleBin))
|
||||
}
|
||||
|
||||
if model.RecycleBin[0].Title != "Surveillance Console" {
|
||||
t.Fatalf("RecycleBin[0].Title = %q, want %q", model.RecycleBin[0].Title, "Surveillance Console")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRestoreEntryMovesItBackFromRecycleBin(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
model := Model{
|
||||
RecycleBin: []Entry{
|
||||
{
|
||||
ID: "bellagio",
|
||||
Title: "Bellagio",
|
||||
Username: "rustyryan",
|
||||
Password: "token-3",
|
||||
URL: "https://bellagio.example.invalid",
|
||||
Path: []string{"Root", "Internet"},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
if err := model.RestoreEntry("bellagio"); err != nil {
|
||||
t.Fatalf("RestoreEntry() error = %v", err)
|
||||
}
|
||||
|
||||
got := model.EntriesInPath([]string{"Root", "Internet"})
|
||||
if len(got) != 1 {
|
||||
t.Fatalf("len(EntriesInPath()) = %d, want 1", len(got))
|
||||
}
|
||||
|
||||
if got[0].Title != "Bellagio" {
|
||||
t.Fatalf("EntriesInPath()[0].Title = %q, want %q", got[0].Title, "Bellagio")
|
||||
}
|
||||
|
||||
if len(model.RecycleBin) != 0 {
|
||||
t.Fatalf("len(RecycleBin) = %d, want 0", len(model.RecycleBin))
|
||||
}
|
||||
}
|
||||
|
||||
func TestRestoreEntryVersionPromotesHistoricalVersionAndRetainsCurrentInHistory(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
model := Model{
|
||||
Entries: []Entry{
|
||||
{
|
||||
ID: "vault-console",
|
||||
Title: "Vault Console",
|
||||
Username: "dannyocean",
|
||||
Password: "new-token",
|
||||
Notes: "Current note",
|
||||
Path: []string{"Root", "Internet"},
|
||||
History: []Entry{
|
||||
{
|
||||
ID: "vault-console-history-1",
|
||||
Title: "Vault Console",
|
||||
Username: "dannyocean",
|
||||
Password: "old-token",
|
||||
Notes: "Previous note",
|
||||
Path: []string{"Root", "Internet"},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
if err := model.RestoreEntryVersion("vault-console", 0); err != nil {
|
||||
t.Fatalf("RestoreEntryVersion() error = %v", err)
|
||||
}
|
||||
|
||||
got := model.EntriesInPath([]string{"Root", "Internet"})
|
||||
if len(got) != 1 {
|
||||
t.Fatalf("len(EntriesInPath()) = %d, want 1", len(got))
|
||||
}
|
||||
if got[0].Password != "old-token" || got[0].Notes != "Previous note" {
|
||||
t.Fatalf("restored entry = %#v, want old-token/Previous note current version", got[0])
|
||||
}
|
||||
if len(got[0].History) != 1 {
|
||||
t.Fatalf("len(History) = %d, want 1", len(got[0].History))
|
||||
}
|
||||
if got[0].History[0].Password != "new-token" || got[0].History[0].Notes != "Current note" {
|
||||
t.Fatalf("History[0] = %#v, want prior current version retained", got[0].History[0])
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,628 @@
|
||||
package vault
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"maps"
|
||||
"slices"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/tobischo/gokeepasslib/v3"
|
||||
w "github.com/tobischo/gokeepasslib/v3/wrappers"
|
||||
)
|
||||
|
||||
type KDBXConfig struct {
|
||||
Header *gokeepasslib.DBHeader
|
||||
InnerHeader *gokeepasslib.InnerHeader
|
||||
}
|
||||
|
||||
var ErrInvalidMasterKey = errors.New("invalid master key")
|
||||
|
||||
const (
|
||||
templatesRoot = "Templates"
|
||||
recycleBinRoot = "Recycle Bin"
|
||||
keepassGOIDField = "KeePassGO-ID"
|
||||
remoteProfilesKey = "keepassgo.remoteProfiles"
|
||||
)
|
||||
|
||||
func LoadKDBX(r io.Reader, password string) (Model, error) {
|
||||
return LoadKDBXWithKey(r, MasterKey{Password: password})
|
||||
}
|
||||
|
||||
func SaveKDBX(wr io.Writer, model Model, password string) error {
|
||||
return SaveKDBXWithKey(wr, model, MasterKey{Password: password})
|
||||
}
|
||||
|
||||
func SaveKDBXWithKey(wr io.Writer, model Model, key MasterKey) error {
|
||||
return SaveKDBXWithConfigAndKey(wr, model, key, nil)
|
||||
}
|
||||
|
||||
func SaveKDBXWithConfigAndKey(wr io.Writer, model Model, key MasterKey, config *KDBXConfig) error {
|
||||
credentials, err := newCredentials(key)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
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)
|
||||
db.Hashes = gokeepasslib.NewHashes(db.Header)
|
||||
}
|
||||
if db.Header.IsKdbx4() {
|
||||
if config != nil && config.InnerHeader != nil {
|
||||
db.Content.InnerHeader = cloneInnerHeader(config.InnerHeader)
|
||||
db.Content.InnerHeader.Binaries = nil
|
||||
} else if db.Content.InnerHeader == nil {
|
||||
db.Content.InnerHeader = &gokeepasslib.InnerHeader{
|
||||
InnerRandomStreamID: gokeepasslib.ChaChaStreamID,
|
||||
InnerRandomStreamKey: randomBytes(64),
|
||||
}
|
||||
}
|
||||
} else {
|
||||
db.Content.InnerHeader = nil
|
||||
}
|
||||
db.Content.Root.Groups = buildGroupTree(db, model)
|
||||
db.Content.Root.DeletedObjects = nil
|
||||
|
||||
if err := db.LockProtectedEntries(); err != nil {
|
||||
return fmt.Errorf("lock protected entries: %w", err)
|
||||
}
|
||||
|
||||
if err := gokeepasslib.NewEncoder(wr).Encode(db); err != nil {
|
||||
return fmt.Errorf("encode kdbx: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func appendGroupEntries(model *Model, db *gokeepasslib.Database, group gokeepasslib.Group, path []string) {
|
||||
path = append(clonePath(path), group.Name)
|
||||
model.CreateGroup(path[:len(path)-1], group.Name)
|
||||
|
||||
for _, entry := range group.Entries {
|
||||
appendModelEntry(model, Entry{
|
||||
ID: extractEntryID(entry),
|
||||
Title: entry.GetTitle(),
|
||||
Username: entry.GetContent("UserName"),
|
||||
Password: entry.GetPassword(),
|
||||
URL: entry.GetContent("URL"),
|
||||
Notes: entry.GetContent("Notes"),
|
||||
Tags: splitTags(entry.Tags),
|
||||
Fields: extractCustomFields(entry),
|
||||
Attachments: extractAttachments(db, entry),
|
||||
History: extractHistory(db, entry, path),
|
||||
Path: clonePath(path),
|
||||
})
|
||||
}
|
||||
|
||||
for _, child := range group.Groups {
|
||||
appendGroupEntries(model, db, child, path)
|
||||
}
|
||||
}
|
||||
|
||||
func appendModelEntry(model *Model, entry Entry) {
|
||||
if len(entry.Path) == 0 {
|
||||
model.Entries = append(model.Entries, entry)
|
||||
return
|
||||
}
|
||||
|
||||
switch entry.Path[0] {
|
||||
case templatesRoot:
|
||||
model.Templates = append(model.Templates, entry)
|
||||
return
|
||||
case recycleBinRoot:
|
||||
entry.Path = slices.Clone(entry.Path[1:])
|
||||
model.RecycleBin = append(model.RecycleBin, entry)
|
||||
return
|
||||
}
|
||||
|
||||
model.Entries = append(model.Entries, entry)
|
||||
}
|
||||
|
||||
func entriesForPersistence(model Model) []Entry {
|
||||
entries := append(slices.Clone(model.Entries), model.Templates...)
|
||||
for _, entry := range model.RecycleBin {
|
||||
recycleEntry := cloneEntry(entry)
|
||||
recycleEntry.Path = append([]string{recycleBinRoot}, recycleEntry.Path...)
|
||||
entries = append(entries, recycleEntry)
|
||||
}
|
||||
return entries
|
||||
}
|
||||
|
||||
func marshalUUID(id gokeepasslib.UUID) string {
|
||||
text, err := id.MarshalText()
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
return string(text)
|
||||
}
|
||||
|
||||
func clonePath(path []string) []string {
|
||||
if len(path) == 0 {
|
||||
return nil
|
||||
}
|
||||
out := make([]string, len(path))
|
||||
copy(out, path)
|
||||
return out
|
||||
}
|
||||
|
||||
func splitTags(tags string) []string {
|
||||
if strings.TrimSpace(tags) == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
fields := strings.Split(tags, ";")
|
||||
var out []string
|
||||
for _, field := range fields {
|
||||
field = strings.TrimSpace(field)
|
||||
if field == "" {
|
||||
continue
|
||||
}
|
||||
out = append(out, field)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func extractCustomFields(entry gokeepasslib.Entry) map[string]string {
|
||||
fields := map[string]string{}
|
||||
for _, value := range entry.Values {
|
||||
switch value.Key {
|
||||
case "Title", "UserName", "Password", "URL", "Notes", keepassGOIDField:
|
||||
continue
|
||||
default:
|
||||
fields[value.Key] = value.Value.Content
|
||||
}
|
||||
}
|
||||
|
||||
if len(fields) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
return fields
|
||||
}
|
||||
|
||||
func extractEntryID(entry gokeepasslib.Entry) string {
|
||||
if id := entry.GetContent(keepassGOIDField); id != "" {
|
||||
return id
|
||||
}
|
||||
|
||||
return marshalUUID(entry.UUID)
|
||||
}
|
||||
|
||||
func extractHistory(db *gokeepasslib.Database, entry gokeepasslib.Entry, path []string) []Entry {
|
||||
if len(entry.Histories) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
var history []Entry
|
||||
for _, item := range entry.Histories {
|
||||
for _, historical := range item.Entries {
|
||||
history = append(history, Entry{
|
||||
ID: extractEntryID(historical),
|
||||
Title: historical.GetTitle(),
|
||||
Username: historical.GetContent("UserName"),
|
||||
Password: historical.GetPassword(),
|
||||
URL: historical.GetContent("URL"),
|
||||
Notes: historical.GetContent("Notes"),
|
||||
Tags: splitTags(historical.Tags),
|
||||
Fields: extractCustomFields(historical),
|
||||
Attachments: extractAttachments(db, historical),
|
||||
Path: clonePath(path),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return history
|
||||
}
|
||||
|
||||
type groupNode struct {
|
||||
name string
|
||||
children map[string]*groupNode
|
||||
entries []Entry
|
||||
}
|
||||
|
||||
type MasterKey struct {
|
||||
Password string
|
||||
KeyFileData []byte
|
||||
}
|
||||
|
||||
func buildGroupTree(db *gokeepasslib.Database, model Model) []gokeepasslib.Group {
|
||||
entries := entriesForPersistence(model)
|
||||
root := &groupNode{children: map[string]*groupNode{}}
|
||||
for _, entry := range entries {
|
||||
node := root
|
||||
for _, segment := range entry.Path {
|
||||
if node.children[segment] == nil {
|
||||
node.children[segment] = &groupNode{
|
||||
name: segment,
|
||||
children: map[string]*groupNode{},
|
||||
}
|
||||
}
|
||||
node = node.children[segment]
|
||||
}
|
||||
node.entries = append(node.entries, entry)
|
||||
}
|
||||
for _, path := range groupPathsForPersistence(model, entries) {
|
||||
node := root
|
||||
for _, segment := range path {
|
||||
if node.children[segment] == nil {
|
||||
node.children[segment] = &groupNode{
|
||||
name: segment,
|
||||
children: map[string]*groupNode{},
|
||||
}
|
||||
}
|
||||
node = node.children[segment]
|
||||
}
|
||||
}
|
||||
|
||||
groups := marshalGroups(db, root)
|
||||
if len(groups) > 0 {
|
||||
return groups
|
||||
}
|
||||
|
||||
group := gokeepasslib.NewGroup()
|
||||
group.Name = "Root"
|
||||
return []gokeepasslib.Group{group}
|
||||
}
|
||||
|
||||
func groupPathsForPersistence(model Model, entries []Entry) [][]string {
|
||||
seen := map[string]bool{}
|
||||
var groups [][]string
|
||||
appendPath := func(path []string) {
|
||||
key := strings.Join(path, "\x00")
|
||||
if seen[key] {
|
||||
return
|
||||
}
|
||||
seen[key] = true
|
||||
groups = append(groups, slices.Clone(path))
|
||||
}
|
||||
|
||||
for _, entry := range entries {
|
||||
for i := 1; i <= len(entry.Path); i++ {
|
||||
appendPath(entry.Path[:i])
|
||||
}
|
||||
}
|
||||
for _, path := range model.Groups {
|
||||
for i := 1; i <= len(path); i++ {
|
||||
appendPath(path[:i])
|
||||
}
|
||||
}
|
||||
return groups
|
||||
}
|
||||
|
||||
func LoadKDBXWithKey(r io.Reader, key MasterKey) (Model, error) {
|
||||
model, _, err := LoadKDBXWithConfig(r, key)
|
||||
return model, err
|
||||
}
|
||||
|
||||
func LoadKDBXWithConfig(r io.Reader, key MasterKey) (Model, *KDBXConfig, error) {
|
||||
credentials, err := newCredentials(key)
|
||||
if err != nil {
|
||||
return Model{}, nil, err
|
||||
}
|
||||
|
||||
db := gokeepasslib.NewDatabase()
|
||||
db.Credentials = credentials
|
||||
|
||||
if err := gokeepasslib.NewDecoder(r).Decode(db); err != nil {
|
||||
if isInvalidCredentialError(err) {
|
||||
return Model{}, nil, ErrInvalidMasterKey
|
||||
}
|
||||
return Model{}, nil, fmt.Errorf("decode kdbx: %w", err)
|
||||
}
|
||||
|
||||
if err := db.UnlockProtectedEntries(); err != nil {
|
||||
return Model{}, nil, fmt.Errorf("unlock protected entries: %w", err)
|
||||
}
|
||||
|
||||
var model Model
|
||||
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),
|
||||
InnerHeader: cloneInnerHeader(db.Content.InnerHeader),
|
||||
}, 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:
|
||||
credentials, err := gokeepasslib.NewPasswordAndKeyDataCredentials(key.Password, key.KeyFileData)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("build password+key credentials: %w", err)
|
||||
}
|
||||
return credentials, nil
|
||||
case len(key.KeyFileData) > 0:
|
||||
credentials, err := gokeepasslib.NewKeyDataCredentials(key.KeyFileData)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("build key credentials: %w", err)
|
||||
}
|
||||
return credentials, nil
|
||||
default:
|
||||
return gokeepasslib.NewPasswordCredentials(key.Password), nil
|
||||
}
|
||||
}
|
||||
|
||||
func cloneHeader(header *gokeepasslib.DBHeader) *gokeepasslib.DBHeader {
|
||||
if header == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
out := *header
|
||||
out.RawData = nil
|
||||
if header.Signature != nil {
|
||||
signature := *header.Signature
|
||||
out.Signature = &signature
|
||||
}
|
||||
if header.FileHeaders != nil {
|
||||
fileHeaders := *header.FileHeaders
|
||||
fileHeaders.Comment = slices.Clone(header.FileHeaders.Comment)
|
||||
fileHeaders.CipherID = slices.Clone(header.FileHeaders.CipherID)
|
||||
fileHeaders.MasterSeed = slices.Clone(header.FileHeaders.MasterSeed)
|
||||
fileHeaders.TransformSeed = slices.Clone(header.FileHeaders.TransformSeed)
|
||||
fileHeaders.EncryptionIV = slices.Clone(header.FileHeaders.EncryptionIV)
|
||||
fileHeaders.ProtectedStreamKey = slices.Clone(header.FileHeaders.ProtectedStreamKey)
|
||||
fileHeaders.StreamStartBytes = slices.Clone(header.FileHeaders.StreamStartBytes)
|
||||
if header.FileHeaders.KdfParameters != nil {
|
||||
kdf := *header.FileHeaders.KdfParameters
|
||||
kdf.UUID = slices.Clone(header.FileHeaders.KdfParameters.UUID)
|
||||
kdf.SecretKey = slices.Clone(header.FileHeaders.KdfParameters.SecretKey)
|
||||
kdf.AssocData = slices.Clone(header.FileHeaders.KdfParameters.AssocData)
|
||||
if header.FileHeaders.KdfParameters.RawData != nil {
|
||||
kdf.RawData = cloneVariantDictionary(header.FileHeaders.KdfParameters.RawData)
|
||||
}
|
||||
fileHeaders.KdfParameters = &kdf
|
||||
}
|
||||
if header.FileHeaders.PublicCustomData != nil {
|
||||
fileHeaders.PublicCustomData = cloneVariantDictionary(header.FileHeaders.PublicCustomData)
|
||||
}
|
||||
out.FileHeaders = &fileHeaders
|
||||
}
|
||||
|
||||
return &out
|
||||
}
|
||||
|
||||
func cloneVariantDictionary(dict *gokeepasslib.VariantDictionary) *gokeepasslib.VariantDictionary {
|
||||
if dict == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
out := &gokeepasslib.VariantDictionary{Version: dict.Version}
|
||||
out.Items = make([]*gokeepasslib.VariantDictionaryItem, 0, len(dict.Items))
|
||||
for _, item := range dict.Items {
|
||||
cloned := *item
|
||||
cloned.Name = slices.Clone(item.Name)
|
||||
cloned.Value = slices.Clone(item.Value)
|
||||
out.Items = append(out.Items, &cloned)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func cloneInnerHeader(header *gokeepasslib.InnerHeader) *gokeepasslib.InnerHeader {
|
||||
if header == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
out := &gokeepasslib.InnerHeader{
|
||||
InnerRandomStreamID: header.InnerRandomStreamID,
|
||||
InnerRandomStreamKey: slices.Clone(header.InnerRandomStreamKey),
|
||||
}
|
||||
for _, binary := range header.Binaries {
|
||||
out.Binaries = append(out.Binaries, gokeepasslib.Binary{
|
||||
ID: binary.ID,
|
||||
Compressed: binary.Compressed,
|
||||
MemoryProtection: binary.MemoryProtection,
|
||||
Content: slices.Clone(binary.Content),
|
||||
})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func randomBytes(length int) []byte {
|
||||
buf := make([]byte, length)
|
||||
_, _ = io.ReadFull(rand.Reader, buf)
|
||||
return buf
|
||||
}
|
||||
|
||||
func isInvalidCredentialError(err error) bool {
|
||||
if errors.Is(err, gokeepasslib.ErrInvalidDatabaseOrCredentials) {
|
||||
return true
|
||||
}
|
||||
|
||||
return strings.Contains(err.Error(), "Wrong password?")
|
||||
}
|
||||
|
||||
func marshalGroups(db *gokeepasslib.Database, node *groupNode) []gokeepasslib.Group {
|
||||
names := slices.Collect(maps.Keys(node.children))
|
||||
slices.SortFunc(names, compareGroupNames)
|
||||
|
||||
var groups []gokeepasslib.Group
|
||||
for _, name := range names {
|
||||
child := node.children[name]
|
||||
group := gokeepasslib.NewGroup()
|
||||
group.Name = child.name
|
||||
group.Entries = marshalEntries(db, child.entries)
|
||||
group.Groups = marshalGroups(db, child)
|
||||
groups = append(groups, group)
|
||||
}
|
||||
|
||||
return groups
|
||||
}
|
||||
|
||||
func compareGroupNames(a, b string) int {
|
||||
switch {
|
||||
case a == b:
|
||||
return 0
|
||||
case a == "Root":
|
||||
return -1
|
||||
case b == "Root":
|
||||
return 1
|
||||
case a == templatesRoot:
|
||||
return -1
|
||||
case b == templatesRoot:
|
||||
return 1
|
||||
case a == recycleBinRoot:
|
||||
return 1
|
||||
case b == recycleBinRoot:
|
||||
return -1
|
||||
case a < b:
|
||||
return -1
|
||||
default:
|
||||
return 1
|
||||
}
|
||||
}
|
||||
|
||||
func marshalEntries(db *gokeepasslib.Database, entries []Entry) []gokeepasslib.Entry {
|
||||
slices.SortFunc(entries, func(a, b Entry) int {
|
||||
switch {
|
||||
case a.Title < b.Title:
|
||||
return -1
|
||||
case a.Title > b.Title:
|
||||
return 1
|
||||
default:
|
||||
return 0
|
||||
}
|
||||
})
|
||||
|
||||
var out []gokeepasslib.Entry
|
||||
for _, entry := range entries {
|
||||
out = append(out, marshalEntry(db, entry))
|
||||
}
|
||||
|
||||
return out
|
||||
}
|
||||
|
||||
func marshalEntry(db *gokeepasslib.Database, entry Entry) gokeepasslib.Entry {
|
||||
item := gokeepasslib.NewEntry()
|
||||
item.UUID = uuidForEntryID(entry.ID)
|
||||
item.Tags = strings.Join(entry.Tags, "; ")
|
||||
item.Values = append(item.Values,
|
||||
value("Title", entry.Title),
|
||||
value("UserName", entry.Username),
|
||||
protectedValue("Password", entry.Password),
|
||||
value("URL", entry.URL),
|
||||
value("Notes", entry.Notes),
|
||||
value(keepassGOIDField, entry.ID),
|
||||
)
|
||||
|
||||
keys := slices.Collect(maps.Keys(entry.Fields))
|
||||
slices.Sort(keys)
|
||||
for _, key := range keys {
|
||||
item.Values = append(item.Values, value(key, entry.Fields[key]))
|
||||
}
|
||||
|
||||
attachmentNames := slices.Collect(maps.Keys(entry.Attachments))
|
||||
slices.Sort(attachmentNames)
|
||||
for _, name := range attachmentNames {
|
||||
binary := db.AddBinary(entry.Attachments[name])
|
||||
item.Binaries = append(item.Binaries, binary.CreateReference(name))
|
||||
}
|
||||
|
||||
for _, historical := range entry.History {
|
||||
item.Histories = append(item.Histories, gokeepasslib.History{
|
||||
Entries: []gokeepasslib.Entry{marshalEntry(db, historical)},
|
||||
})
|
||||
}
|
||||
|
||||
return item
|
||||
}
|
||||
|
||||
func uuidForEntryID(id string) gokeepasslib.UUID {
|
||||
if id != "" {
|
||||
var uuid gokeepasslib.UUID
|
||||
if err := uuid.UnmarshalText([]byte(id)); err == nil {
|
||||
return uuid
|
||||
}
|
||||
}
|
||||
|
||||
sum := sha256.Sum256([]byte(id))
|
||||
var uuid gokeepasslib.UUID
|
||||
copy(uuid[:], sum[:len(uuid)])
|
||||
if id == "" {
|
||||
copy(uuid[:], time.Now().UTC().AppendFormat(nil, time.RFC3339Nano))
|
||||
}
|
||||
return uuid
|
||||
}
|
||||
|
||||
func value(key, content string) gokeepasslib.ValueData {
|
||||
return gokeepasslib.ValueData{Key: key, Value: gokeepasslib.V{Content: content}}
|
||||
}
|
||||
|
||||
func protectedValue(key, content string) gokeepasslib.ValueData {
|
||||
return gokeepasslib.ValueData{
|
||||
Key: key,
|
||||
Value: gokeepasslib.V{Content: content, Protected: w.NewBoolWrapper(true)},
|
||||
}
|
||||
}
|
||||
|
||||
func extractAttachments(db *gokeepasslib.Database, entry gokeepasslib.Entry) map[string][]byte {
|
||||
if len(entry.Binaries) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
attachments := map[string][]byte{}
|
||||
for _, ref := range entry.Binaries {
|
||||
binary := db.FindBinary(ref.Value.ID)
|
||||
if binary == nil {
|
||||
continue
|
||||
}
|
||||
|
||||
content, err := binary.GetContentBytes()
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
attachments[ref.Name] = slices.Clone(content)
|
||||
}
|
||||
|
||||
if len(attachments) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
return attachments
|
||||
}
|
||||
@@ -0,0 +1,794 @@
|
||||
package vault
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"errors"
|
||||
"slices"
|
||||
"testing"
|
||||
|
||||
"github.com/tobischo/gokeepasslib/v3"
|
||||
w "github.com/tobischo/gokeepasslib/v3/wrappers"
|
||||
)
|
||||
|
||||
func TestLoadKDBXBuildsModelFromNestedGroups(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
db := &gokeepasslib.Database{
|
||||
Header: gokeepasslib.NewHeader(),
|
||||
Credentials: gokeepasslib.NewPasswordCredentials("correct horse battery staple"),
|
||||
Content: &gokeepasslib.DBContent{
|
||||
Meta: gokeepasslib.NewMetaData(),
|
||||
Root: &gokeepasslib.RootData{
|
||||
Groups: []gokeepasslib.Group{
|
||||
mustGroup("Root",
|
||||
mustGroup("Internet",
|
||||
mustEntry("Bellagio", "rustyryan", "https://bellagio.example.invalid", "hunter2"),
|
||||
mustEntry("Vault Console", "dannyocean", "https://vault.crew.example.invalid", "bellagio-pass-1"),
|
||||
),
|
||||
mustGroup("Security Office",
|
||||
mustEntry("Surveillance Console", "bashertarr", "https://surveillance.crew.example.invalid", "bellagio-pass-2"),
|
||||
),
|
||||
),
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
if err := db.LockProtectedEntries(); err != nil {
|
||||
t.Fatalf("LockProtectedEntries failed: %v", err)
|
||||
}
|
||||
|
||||
var encoded bytes.Buffer
|
||||
if err := gokeepasslib.NewEncoder(&encoded).Encode(db); err != nil {
|
||||
t.Fatalf("Encode failed: %v", err)
|
||||
}
|
||||
|
||||
model, err := LoadKDBX(bytes.NewReader(encoded.Bytes()), "correct horse battery staple")
|
||||
if err != nil {
|
||||
t.Fatalf("LoadKDBX failed: %v", err)
|
||||
}
|
||||
|
||||
got := model.EntriesInPath([]string{"Root", "Internet"})
|
||||
if len(got) != 2 {
|
||||
t.Fatalf("len(EntriesInPath()) = %d, want 2", len(got))
|
||||
}
|
||||
|
||||
if got[0].Title != "Bellagio" || got[0].Username != "rustyryan" || got[0].URL != "https://bellagio.example.invalid" {
|
||||
t.Fatalf("unexpected first entry: %#v", got[0])
|
||||
}
|
||||
|
||||
if got[1].Title != "Vault Console" || got[1].Username != "dannyocean" || got[1].URL != "https://vault.crew.example.invalid" {
|
||||
t.Fatalf("unexpected second entry: %#v", got[1])
|
||||
}
|
||||
|
||||
groups := model.ChildGroups([]string{"Root"})
|
||||
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", "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."),
|
||||
mkValue("X-Role", "automation"),
|
||||
)
|
||||
|
||||
db := &gokeepasslib.Database{
|
||||
Header: gokeepasslib.NewHeader(),
|
||||
Credentials: gokeepasslib.NewPasswordCredentials("correct horse battery staple"),
|
||||
Content: &gokeepasslib.DBContent{
|
||||
Meta: gokeepasslib.NewMetaData(),
|
||||
Root: &gokeepasslib.RootData{
|
||||
Groups: []gokeepasslib.Group{
|
||||
mustGroup("Root", mustGroup("Security Office", entry)),
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
if err := db.LockProtectedEntries(); err != nil {
|
||||
t.Fatalf("LockProtectedEntries failed: %v", err)
|
||||
}
|
||||
|
||||
var encoded bytes.Buffer
|
||||
if err := gokeepasslib.NewEncoder(&encoded).Encode(db); err != nil {
|
||||
t.Fatalf("Encode failed: %v", err)
|
||||
}
|
||||
|
||||
model, err := LoadKDBX(bytes.NewReader(encoded.Bytes()), "correct horse battery staple")
|
||||
if err != nil {
|
||||
t.Fatalf("LoadKDBX failed: %v", err)
|
||||
}
|
||||
|
||||
got := model.EntriesInPath([]string{"Root", "Security Office"})
|
||||
if len(got) != 1 {
|
||||
t.Fatalf("len(EntriesInPath()) = %d, want 1", len(got))
|
||||
}
|
||||
|
||||
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." {
|
||||
t.Fatalf("Entry.Notes = %q, want %q", got[0].Notes, "Long-lived token used by Codex for home automation tasks.")
|
||||
}
|
||||
|
||||
if len(got[0].Tags) != 2 || got[0].Tags[0] != "automation" || got[0].Tags[1] != "home" {
|
||||
t.Fatalf("Entry.Tags = %v, want [automation home]", got[0].Tags)
|
||||
}
|
||||
|
||||
if got[0].Fields["X-Role"] != "automation" {
|
||||
t.Fatalf("Entry.Fields[\"X-Role\"] = %q, want %q", got[0].Fields["X-Role"], "automation")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSaveKDBXRoundTripsModel(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
model := Model{
|
||||
Entries: []Entry{
|
||||
{
|
||||
ID: "entry-1",
|
||||
Title: "Vault Console",
|
||||
Username: "dannyocean",
|
||||
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"},
|
||||
Fields: map[string]string{
|
||||
"X-Role": "automation",
|
||||
},
|
||||
Path: []string{"Root", "Internet"},
|
||||
},
|
||||
{
|
||||
ID: "entry-2",
|
||||
Title: "Surveillance Console",
|
||||
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", "Security Office"},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
got := loaded.Search("vault")
|
||||
if len(got) != 1 {
|
||||
t.Fatalf("len(Search(\"git\")) = %d, want 1", len(got))
|
||||
}
|
||||
|
||||
if got[0].Entry.Notes != "Personal git server token entry used for automation and CLI auth." {
|
||||
t.Fatalf("Search(\"git\") notes = %q, want %q", got[0].Entry.Notes, "Personal git server token entry used for automation and CLI auth.")
|
||||
}
|
||||
|
||||
if got[0].Entry.Fields["X-Role"] != "automation" {
|
||||
t.Fatalf("Search(\"git\") X-Role = %q, want %q", got[0].Entry.Fields["X-Role"], "automation")
|
||||
}
|
||||
|
||||
homeAssistant := loaded.EntriesInPath([]string{"Root", "Security Office"})
|
||||
if len(homeAssistant) != 1 {
|
||||
t.Fatalf("len(EntriesInPath(Security Office)) = %d, want 1", len(homeAssistant))
|
||||
}
|
||||
|
||||
if homeAssistant[0].Password != "bellagio-pass-2" {
|
||||
t.Fatalf("Security Office password = %q, want %q", homeAssistant[0].Password, "bellagio-pass-2")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSaveKDBXRoundTripsTemplates(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
model := Model{
|
||||
Templates: []Entry{
|
||||
{
|
||||
ID: "tpl-1",
|
||||
Title: "Website Login",
|
||||
Username: "template-user",
|
||||
Password: "template-password",
|
||||
URL: "https://example.com",
|
||||
Notes: "Reusable template for website accounts.",
|
||||
Tags: []string{"template", "web"},
|
||||
Fields: map[string]string{
|
||||
"Environment": "prod",
|
||||
},
|
||||
Path: []string{"Templates"},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
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.Templates) != 1 {
|
||||
t.Fatalf("len(Templates) = %d, want 1", len(loaded.Templates))
|
||||
}
|
||||
|
||||
if loaded.Templates[0].Title != "Website Login" {
|
||||
t.Fatalf("Templates[0].Title = %q, want %q", loaded.Templates[0].Title, "Website Login")
|
||||
}
|
||||
|
||||
if loaded.Templates[0].Fields["Environment"] != "prod" {
|
||||
t.Fatalf("Templates[0].Fields[Environment] = %q, want %q", loaded.Templates[0].Fields["Environment"], "prod")
|
||||
}
|
||||
|
||||
if len(loaded.Entries) != 0 {
|
||||
t.Fatalf("len(Entries) = %d, want 0", len(loaded.Entries))
|
||||
}
|
||||
}
|
||||
|
||||
func TestSaveKDBXRoundTripsRemoteProfiles(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
model := Model{
|
||||
RemoteProfiles: []RemoteProfile{
|
||||
{
|
||||
ID: "bellagio-webdav",
|
||||
Name: "Bellagio Vault",
|
||||
Backend: RemoteBackendWebDAV,
|
||||
BaseURL: "https://dav.example.invalid/remote.php/dav",
|
||||
Path: "files/bellagio/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 != "bellagio-webdav" || got.Name != "Bellagio Vault" {
|
||||
t.Fatalf("loaded remote profile = %#v, want bellagio-webdav Bellagio 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/bellagio/keepass.kdbx" {
|
||||
t.Fatalf("remote path = %q, want files/bellagio/keepass.kdbx", got.Path)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSaveKDBXRoundTripsEntryHistory(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
model := Model{
|
||||
Entries: []Entry{
|
||||
{
|
||||
ID: "entry-1",
|
||||
Title: "Vault Console",
|
||||
Username: "dannyocean",
|
||||
Password: "new-token",
|
||||
URL: "https://vault.crew.example.invalid",
|
||||
Path: []string{"Root", "Internet"},
|
||||
History: []Entry{
|
||||
{
|
||||
ID: "entry-1-old",
|
||||
Title: "Vault Console",
|
||||
Username: "dannyocean",
|
||||
Password: "old-token",
|
||||
URL: "https://vault.crew.example.invalid",
|
||||
Path: []string{"Root", "Internet"},
|
||||
Notes: "Original version",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
got := loaded.EntriesInPath([]string{"Root", "Internet"})
|
||||
if len(got) != 1 {
|
||||
t.Fatalf("len(EntriesInPath()) = %d, want 1", len(got))
|
||||
}
|
||||
|
||||
if len(got[0].History) != 1 {
|
||||
t.Fatalf("len(History) = %d, want 1", len(got[0].History))
|
||||
}
|
||||
|
||||
if got[0].History[0].Password != "old-token" || got[0].History[0].Notes != "Original version" {
|
||||
t.Fatalf("History[0] = %#v, want preserved prior version", got[0].History[0])
|
||||
}
|
||||
}
|
||||
|
||||
func TestSaveKDBXRoundTripsRecycleBinEntries(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
model := Model{
|
||||
RecycleBin: []Entry{
|
||||
{
|
||||
ID: "entry-1",
|
||||
Title: "Surveillance Console",
|
||||
Username: "bashertarr",
|
||||
Password: "bellagio-pass-2",
|
||||
URL: "https://surveillance.crew.example.invalid",
|
||||
Path: []string{"Root", "Security Office"},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
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.RecycleBin) != 1 {
|
||||
t.Fatalf("len(RecycleBin) = %d, want 1", len(loaded.RecycleBin))
|
||||
}
|
||||
|
||||
if loaded.RecycleBin[0].Title != "Surveillance Console" {
|
||||
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] != "Security Office" {
|
||||
t.Fatalf("RecycleBin[0].Path = %v, want [Root Security Office]", loaded.RecycleBin[0].Path)
|
||||
}
|
||||
|
||||
if len(loaded.Entries) != 0 {
|
||||
t.Fatalf("len(Entries) = %d, want 0", len(loaded.Entries))
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadKDBXWithKeyFileCredentials(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
keyData := []byte(`<?xml version="1.0" encoding="utf-8"?>
|
||||
<KeyFile>
|
||||
<Meta>
|
||||
<Version>1.0</Version>
|
||||
</Meta>
|
||||
<Key>
|
||||
<Data>PbLBYmgEXFhLWf2gxoBMARXgDZGE7f34tr+anCw52LI=</Data>
|
||||
</Key>
|
||||
</KeyFile>
|
||||
`)
|
||||
|
||||
credentials, err := newCredentials(MasterKey{KeyFileData: keyData})
|
||||
if err != nil {
|
||||
t.Fatalf("newCredentials() error = %v", err)
|
||||
}
|
||||
|
||||
db := &gokeepasslib.Database{
|
||||
Header: gokeepasslib.NewHeader(),
|
||||
Credentials: credentials,
|
||||
Content: &gokeepasslib.DBContent{
|
||||
Meta: gokeepasslib.NewMetaData(),
|
||||
Root: &gokeepasslib.RootData{
|
||||
Groups: []gokeepasslib.Group{
|
||||
mustGroup("Root", mustGroup("Internet", mustEntry("Vault Console", "dannyocean", "https://vault.crew.example.invalid", "bellagio-pass-1"))),
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
if err := db.LockProtectedEntries(); err != nil {
|
||||
t.Fatalf("LockProtectedEntries failed: %v", err)
|
||||
}
|
||||
|
||||
var encoded bytes.Buffer
|
||||
if err := gokeepasslib.NewEncoder(&encoded).Encode(db); err != nil {
|
||||
t.Fatalf("Encode failed: %v", err)
|
||||
}
|
||||
|
||||
model, err := LoadKDBXWithKey(bytes.NewReader(encoded.Bytes()), MasterKey{KeyFileData: keyData})
|
||||
if err != nil {
|
||||
t.Fatalf("LoadKDBXWithKey() error = %v", err)
|
||||
}
|
||||
|
||||
got := model.Search("vault")
|
||||
if len(got) != 1 || got[0].Entry.Password != "bellagio-pass-1" {
|
||||
t.Fatalf("LoadKDBXWithKey() = %#v, want password-preserving vault entry", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadKDBXWithCompositeCredentials(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
keyData := []byte(`<?xml version="1.0" encoding="utf-8"?>
|
||||
<KeyFile>
|
||||
<Meta>
|
||||
<Version>1.0</Version>
|
||||
</Meta>
|
||||
<Key>
|
||||
<Data>PbLBYmgEXFhLWf2gxoBMARXgDZGE7f34tr+anCw52LI=</Data>
|
||||
</Key>
|
||||
</KeyFile>
|
||||
`)
|
||||
|
||||
credentials, err := newCredentials(MasterKey{
|
||||
Password: "correct horse battery staple",
|
||||
KeyFileData: keyData,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("newCredentials() error = %v", err)
|
||||
}
|
||||
|
||||
db := &gokeepasslib.Database{
|
||||
Header: gokeepasslib.NewHeader(),
|
||||
Credentials: credentials,
|
||||
Content: &gokeepasslib.DBContent{
|
||||
Meta: gokeepasslib.NewMetaData(),
|
||||
Root: &gokeepasslib.RootData{
|
||||
Groups: []gokeepasslib.Group{
|
||||
mustGroup("Root", mustGroup("Security Office", mustEntry("Surveillance Console", "bashertarr", "https://surveillance.crew.example.invalid", "bellagio-pass-2"))),
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
if err := db.LockProtectedEntries(); err != nil {
|
||||
t.Fatalf("LockProtectedEntries failed: %v", err)
|
||||
}
|
||||
|
||||
var encoded bytes.Buffer
|
||||
if err := gokeepasslib.NewEncoder(&encoded).Encode(db); err != nil {
|
||||
t.Fatalf("Encode failed: %v", err)
|
||||
}
|
||||
|
||||
model, err := LoadKDBXWithKey(bytes.NewReader(encoded.Bytes()), MasterKey{
|
||||
Password: "correct horse battery staple",
|
||||
KeyFileData: keyData,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("LoadKDBXWithKey() error = %v", err)
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadKDBXReturnsInvalidCredentialsError(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
db := &gokeepasslib.Database{
|
||||
Header: gokeepasslib.NewHeader(),
|
||||
Credentials: gokeepasslib.NewPasswordCredentials("correct horse battery staple"),
|
||||
Content: &gokeepasslib.DBContent{
|
||||
Meta: gokeepasslib.NewMetaData(),
|
||||
Root: &gokeepasslib.RootData{
|
||||
Groups: []gokeepasslib.Group{
|
||||
mustGroup("Root", mustGroup("Internet", mustEntry("Vault Console", "dannyocean", "https://vault.crew.example.invalid", "bellagio-pass-1"))),
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
if err := db.LockProtectedEntries(); err != nil {
|
||||
t.Fatalf("LockProtectedEntries failed: %v", err)
|
||||
}
|
||||
|
||||
var encoded bytes.Buffer
|
||||
if err := gokeepasslib.NewEncoder(&encoded).Encode(db); err != nil {
|
||||
t.Fatalf("Encode failed: %v", err)
|
||||
}
|
||||
|
||||
_, err := LoadKDBX(bytes.NewReader(encoded.Bytes()), "definitely wrong password")
|
||||
if !errors.Is(err, ErrInvalidMasterKey) {
|
||||
t.Fatalf("LoadKDBX() error = %v, want %v", err, ErrInvalidMasterKey)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSaveKDBXWithKeyRoundTripsModel(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
keyData := []byte(`<?xml version="1.0" encoding="utf-8"?>
|
||||
<KeyFile>
|
||||
<Meta>
|
||||
<Version>1.0</Version>
|
||||
</Meta>
|
||||
<Key>
|
||||
<Data>PbLBYmgEXFhLWf2gxoBMARXgDZGE7f34tr+anCw52LI=</Data>
|
||||
</Key>
|
||||
</KeyFile>
|
||||
`)
|
||||
|
||||
model := Model{
|
||||
Entries: []Entry{
|
||||
{
|
||||
ID: "vault-console",
|
||||
Title: "Vault Console",
|
||||
Username: "dannyocean",
|
||||
Password: "bellagio-pass-1",
|
||||
URL: "https://vault.crew.example.invalid",
|
||||
Path: []string{"Root", "Internet"},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
var encoded bytes.Buffer
|
||||
if err := SaveKDBXWithKey(&encoded, model, MasterKey{KeyFileData: keyData}); err != nil {
|
||||
t.Fatalf("SaveKDBXWithKey() error = %v", err)
|
||||
}
|
||||
|
||||
loaded, err := LoadKDBXWithKey(bytes.NewReader(encoded.Bytes()), MasterKey{KeyFileData: keyData})
|
||||
if err != nil {
|
||||
t.Fatalf("LoadKDBXWithKey() error = %v", err)
|
||||
}
|
||||
|
||||
got := loaded.Search("vault")
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSaveKDBXWithCompositeKeyRoundTripsModel(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
keyData := []byte(`<?xml version="1.0" encoding="utf-8"?>
|
||||
<KeyFile>
|
||||
<Meta>
|
||||
<Version>1.0</Version>
|
||||
</Meta>
|
||||
<Key>
|
||||
<Data>PbLBYmgEXFhLWf2gxoBMARXgDZGE7f34tr+anCw52LI=</Data>
|
||||
</Key>
|
||||
</KeyFile>
|
||||
`)
|
||||
|
||||
model := Model{
|
||||
Entries: []Entry{
|
||||
{
|
||||
ID: "surveillance-console",
|
||||
Title: "Surveillance Console",
|
||||
Username: "bashertarr",
|
||||
Password: "bellagio-pass-2",
|
||||
URL: "https://surveillance.crew.example.invalid",
|
||||
Path: []string{"Root", "Security Office"},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
key := MasterKey{
|
||||
Password: "correct horse battery staple",
|
||||
KeyFileData: keyData,
|
||||
}
|
||||
|
||||
var encoded bytes.Buffer
|
||||
if err := SaveKDBXWithKey(&encoded, model, key); err != nil {
|
||||
t.Fatalf("SaveKDBXWithKey() error = %v", err)
|
||||
}
|
||||
|
||||
loaded, err := LoadKDBXWithKey(bytes.NewReader(encoded.Bytes()), key)
|
||||
if err != nil {
|
||||
t.Fatalf("LoadKDBXWithKey() error = %v", err)
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
func TestKDBXRoundTripsEntryAttachments(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
model := Model{
|
||||
Entries: []Entry{
|
||||
{
|
||||
ID: "vault-console",
|
||||
Title: "Vault Console",
|
||||
Username: "dannyocean",
|
||||
Password: "bellagio-pass-1",
|
||||
URL: "https://vault.crew.example.invalid",
|
||||
Path: []string{"Root", "Internet"},
|
||||
Attachments: map[string][]byte{
|
||||
"token.txt": []byte("secret attachment contents"),
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
got := loaded.EntriesInPath([]string{"Root", "Internet"})
|
||||
if len(got) != 1 {
|
||||
t.Fatalf("len(EntriesInPath()) = %d, want 1", len(got))
|
||||
}
|
||||
|
||||
if string(got[0].Attachments["token.txt"]) != "secret attachment contents" {
|
||||
t.Fatalf("attachment contents = %q, want %q", string(got[0].Attachments["token.txt"]), "secret attachment contents")
|
||||
}
|
||||
}
|
||||
|
||||
func TestKDBXReopenCyclesPreserveStableIDsAndCrossFeatureState(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
model := Model{
|
||||
Entries: []Entry{
|
||||
{
|
||||
ID: "entry-1",
|
||||
Title: "Vault Console",
|
||||
Username: "dannyocean",
|
||||
Password: "bellagio-pass-2",
|
||||
URL: "https://vault.crew.example.invalid",
|
||||
Notes: "Current credential",
|
||||
Path: []string{"Root", "Internet"},
|
||||
Attachments: map[string][]byte{
|
||||
"token.txt": []byte("secret attachment contents"),
|
||||
},
|
||||
History: []Entry{
|
||||
{
|
||||
ID: "entry-1-history-1",
|
||||
Title: "Vault Console",
|
||||
Username: "dannyocean",
|
||||
Password: "bellagio-pass-1",
|
||||
URL: "https://vault.crew.example.invalid",
|
||||
Notes: "Original credential",
|
||||
Path: []string{"Root", "Internet"},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
Templates: []Entry{
|
||||
{
|
||||
ID: "tpl-1",
|
||||
Title: "Website Login",
|
||||
Username: "template-user",
|
||||
Password: "template-password",
|
||||
Path: []string{"Templates", "Web"},
|
||||
},
|
||||
},
|
||||
RecycleBin: []Entry{
|
||||
{
|
||||
ID: "deleted-1",
|
||||
Title: "Retired Entry",
|
||||
Username: "archived-user",
|
||||
Password: "retired-token",
|
||||
Path: []string{"Root", "Archive"},
|
||||
},
|
||||
},
|
||||
Groups: [][]string{
|
||||
{"Root", "Archive"},
|
||||
{"Root", "Empty Group"},
|
||||
{"Templates", "Web"},
|
||||
},
|
||||
}
|
||||
|
||||
var encoded bytes.Buffer
|
||||
if err := SaveKDBX(&encoded, model, "correct horse battery staple"); err != nil {
|
||||
t.Fatalf("SaveKDBX(first cycle) error = %v", err)
|
||||
}
|
||||
|
||||
reopened, err := LoadKDBX(bytes.NewReader(encoded.Bytes()), "correct horse battery staple")
|
||||
if err != nil {
|
||||
t.Fatalf("LoadKDBX(first cycle) error = %v", err)
|
||||
}
|
||||
|
||||
encoded.Reset()
|
||||
if err := SaveKDBX(&encoded, reopened, "correct horse battery staple"); err != nil {
|
||||
t.Fatalf("SaveKDBX(second cycle) error = %v", err)
|
||||
}
|
||||
|
||||
reopened, err = LoadKDBX(bytes.NewReader(encoded.Bytes()), "correct horse battery staple")
|
||||
if err != nil {
|
||||
t.Fatalf("LoadKDBX(second cycle) error = %v", err)
|
||||
}
|
||||
|
||||
got := reopened.EntriesInPath([]string{"Root", "Internet"})
|
||||
if len(got) != 1 {
|
||||
t.Fatalf("len(EntriesInPath(Root/Internet)) = %d, want 1", len(got))
|
||||
}
|
||||
if got[0].ID != "entry-1" {
|
||||
t.Fatalf("entry ID after reopen cycles = %q, want %q", got[0].ID, "entry-1")
|
||||
}
|
||||
if len(got[0].History) != 1 {
|
||||
t.Fatalf("len(History) after reopen cycles = %d, want 1", len(got[0].History))
|
||||
}
|
||||
if got[0].History[0].ID != "entry-1-history-1" {
|
||||
t.Fatalf("history ID after reopen cycles = %q, want %q", got[0].History[0].ID, "entry-1-history-1")
|
||||
}
|
||||
if string(got[0].Attachments["token.txt"]) != "secret attachment contents" {
|
||||
t.Fatalf("attachment after reopen cycles = %q, want %q", string(got[0].Attachments["token.txt"]), "secret attachment contents")
|
||||
}
|
||||
|
||||
if len(reopened.Templates) != 1 || reopened.Templates[0].Path[1] != "Web" {
|
||||
t.Fatalf("Templates after reopen cycles = %#v, want Website Login in Templates/Web", reopened.Templates)
|
||||
}
|
||||
if len(reopened.RecycleBin) != 1 || reopened.RecycleBin[0].Path[1] != "Archive" {
|
||||
t.Fatalf("RecycleBin after reopen cycles = %#v, want recycled entry in Root/Archive", reopened.RecycleBin)
|
||||
}
|
||||
|
||||
rootGroups := reopened.ChildGroups([]string{"Root"})
|
||||
if !slices.Equal(rootGroups, []string{"Archive", "Empty Group", "Internet"}) {
|
||||
t.Fatalf("ChildGroups(Root) after reopen cycles = %v, want [Archive Empty Group Internet]", rootGroups)
|
||||
}
|
||||
templateGroups := reopened.ChildGroups([]string{"Templates"})
|
||||
if !slices.Equal(templateGroups, []string{"Web"}) {
|
||||
t.Fatalf("ChildGroups(Templates) after reopen cycles = %v, want [Web]", templateGroups)
|
||||
}
|
||||
}
|
||||
|
||||
func mustGroup(name string, children ...any) gokeepasslib.Group {
|
||||
group := gokeepasslib.NewGroup()
|
||||
group.Name = name
|
||||
for _, child := range children {
|
||||
switch value := child.(type) {
|
||||
case gokeepasslib.Group:
|
||||
group.Groups = append(group.Groups, value)
|
||||
case gokeepasslib.Entry:
|
||||
group.Entries = append(group.Entries, value)
|
||||
default:
|
||||
panic("unsupported child type")
|
||||
}
|
||||
}
|
||||
return group
|
||||
}
|
||||
|
||||
func mustEntry(title, username, url, password string) gokeepasslib.Entry {
|
||||
entry := gokeepasslib.NewEntry()
|
||||
entry.Values = append(entry.Values,
|
||||
mkValue("Title", title),
|
||||
mkValue("UserName", username),
|
||||
mkValue("URL", url),
|
||||
mkProtectedValue("Password", password),
|
||||
)
|
||||
return entry
|
||||
}
|
||||
|
||||
func mkValue(key, value string) gokeepasslib.ValueData {
|
||||
return gokeepasslib.ValueData{Key: key, Value: gokeepasslib.V{Content: value}}
|
||||
}
|
||||
|
||||
func mkProtectedValue(key, value string) gokeepasslib.ValueData {
|
||||
return gokeepasslib.ValueData{
|
||||
Key: key,
|
||||
Value: gokeepasslib.V{Content: value, Protected: w.NewBoolWrapper(true)},
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
package vault
|
||||
|
||||
// MasterKeyMode identifies which key material the user intends to provide.
|
||||
type MasterKeyMode string
|
||||
|
||||
const (
|
||||
// MasterKeyModePasswordOnly requires a master password and no key file.
|
||||
MasterKeyModePasswordOnly MasterKeyMode = "password-only"
|
||||
// MasterKeyModeKeyFileOnly requires a key file and no password.
|
||||
MasterKeyModeKeyFileOnly MasterKeyMode = "key-file-only"
|
||||
// MasterKeyModePasswordAndKeyFile requires both password and key file.
|
||||
MasterKeyModePasswordAndKeyFile MasterKeyMode = "password-and-key-file"
|
||||
)
|
||||
@@ -0,0 +1,601 @@
|
||||
package vault
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"slices"
|
||||
"strings"
|
||||
)
|
||||
|
||||
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
|
||||
Title string
|
||||
Username string
|
||||
Password string
|
||||
URL string
|
||||
Notes string
|
||||
Tags []string
|
||||
Fields map[string]string
|
||||
Attachments map[string][]byte
|
||||
History []Entry
|
||||
Path []string
|
||||
}
|
||||
|
||||
type SearchResult struct {
|
||||
Entry Entry
|
||||
Path string
|
||||
}
|
||||
|
||||
type Model struct {
|
||||
Entries []Entry
|
||||
Templates []Entry
|
||||
RecycleBin []Entry
|
||||
Groups [][]string
|
||||
RemoteProfiles []RemoteProfile
|
||||
}
|
||||
|
||||
func (m Model) ChildGroups(path []string) []string {
|
||||
seen := map[string]bool{}
|
||||
var groups []string
|
||||
for _, entry := range m.Entries {
|
||||
if len(path) > len(entry.Path) {
|
||||
continue
|
||||
}
|
||||
if !slices.Equal(entry.Path[:len(path)], path) {
|
||||
continue
|
||||
}
|
||||
if len(entry.Path) == len(path) {
|
||||
continue
|
||||
}
|
||||
group := entry.Path[len(path)]
|
||||
if seen[group] {
|
||||
continue
|
||||
}
|
||||
seen[group] = true
|
||||
groups = append(groups, group)
|
||||
}
|
||||
for _, groupPath := range m.Groups {
|
||||
if len(path) > len(groupPath) {
|
||||
continue
|
||||
}
|
||||
if !slices.Equal(groupPath[:len(path)], path) {
|
||||
continue
|
||||
}
|
||||
if len(groupPath) == len(path) {
|
||||
continue
|
||||
}
|
||||
group := groupPath[len(path)]
|
||||
if seen[group] {
|
||||
continue
|
||||
}
|
||||
seen[group] = true
|
||||
groups = append(groups, group)
|
||||
}
|
||||
slices.Sort(groups)
|
||||
return groups
|
||||
}
|
||||
|
||||
func (m Model) EntriesInPath(path []string) []Entry {
|
||||
var entries []Entry
|
||||
for _, entry := range m.Entries {
|
||||
if slices.Equal(entry.Path, path) {
|
||||
entries = append(entries, entry)
|
||||
}
|
||||
}
|
||||
slices.SortFunc(entries, func(a, b Entry) int {
|
||||
switch {
|
||||
case a.Title < b.Title:
|
||||
return -1
|
||||
case a.Title > b.Title:
|
||||
return 1
|
||||
default:
|
||||
return 0
|
||||
}
|
||||
})
|
||||
return entries
|
||||
}
|
||||
|
||||
func (m Model) EntriesUnderPath(path []string) []Entry {
|
||||
var entries []Entry
|
||||
for _, entry := range m.Entries {
|
||||
if !hasPathPrefix(entry.Path, path) {
|
||||
continue
|
||||
}
|
||||
entries = append(entries, entry)
|
||||
}
|
||||
slices.SortFunc(entries, func(a, b Entry) int {
|
||||
switch {
|
||||
case a.Title < b.Title:
|
||||
return -1
|
||||
case a.Title > b.Title:
|
||||
return 1
|
||||
default:
|
||||
return 0
|
||||
}
|
||||
})
|
||||
return entries
|
||||
}
|
||||
|
||||
func (m Model) Search(query string) []SearchResult {
|
||||
query = strings.TrimSpace(strings.ToLower(query))
|
||||
if query == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
var results []SearchResult
|
||||
for _, entry := range m.Entries {
|
||||
haystack := strings.ToLower(
|
||||
entry.Title + " " +
|
||||
entry.Username + " " +
|
||||
entry.URL + " " +
|
||||
strings.Join(entry.Path, " "),
|
||||
)
|
||||
if !strings.Contains(haystack, query) {
|
||||
continue
|
||||
}
|
||||
results = append(results, SearchResult{
|
||||
Entry: entry,
|
||||
Path: strings.Join(entry.Path, " / "),
|
||||
})
|
||||
}
|
||||
|
||||
slices.SortFunc(results, func(a, b SearchResult) int {
|
||||
switch {
|
||||
case a.Entry.Title < b.Entry.Title:
|
||||
return -1
|
||||
case a.Entry.Title > b.Entry.Title:
|
||||
return 1
|
||||
default:
|
||||
return 0
|
||||
}
|
||||
})
|
||||
return results
|
||||
}
|
||||
|
||||
func (m *Model) UpsertEntry(entry Entry) {
|
||||
for i := range m.Entries {
|
||||
if m.Entries[i].ID != entry.ID {
|
||||
continue
|
||||
}
|
||||
|
||||
previous := cloneEntry(m.Entries[i])
|
||||
entry.History = append([]Entry{previous}, cloneHistory(m.Entries[i].History)...)
|
||||
m.Entries[i] = cloneEntry(entry)
|
||||
return
|
||||
}
|
||||
|
||||
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 {
|
||||
continue
|
||||
}
|
||||
|
||||
m.Templates[i] = cloneEntry(entry)
|
||||
return
|
||||
}
|
||||
|
||||
m.Templates = append(m.Templates, cloneEntry(entry))
|
||||
}
|
||||
|
||||
func (m *Model) DeleteTemplate(id string) error {
|
||||
for i := range m.Templates {
|
||||
if m.Templates[i].ID != id {
|
||||
continue
|
||||
}
|
||||
|
||||
m.Templates = append(m.Templates[:i], m.Templates[i+1:]...)
|
||||
return nil
|
||||
}
|
||||
|
||||
return ErrEntryNotFound
|
||||
}
|
||||
|
||||
func (m *Model) DeleteEntry(id string) error {
|
||||
for i := range m.Entries {
|
||||
if m.Entries[i].ID != id {
|
||||
continue
|
||||
}
|
||||
|
||||
m.RecycleBin = append(m.RecycleBin, cloneEntry(m.Entries[i]))
|
||||
m.Entries = append(m.Entries[:i], m.Entries[i+1:]...)
|
||||
return nil
|
||||
}
|
||||
|
||||
return ErrEntryNotFound
|
||||
}
|
||||
|
||||
func (m *Model) RestoreEntry(id string) error {
|
||||
for i := range m.RecycleBin {
|
||||
if m.RecycleBin[i].ID != id {
|
||||
continue
|
||||
}
|
||||
|
||||
m.Entries = append(m.Entries, cloneEntry(m.RecycleBin[i]))
|
||||
m.RecycleBin = append(m.RecycleBin[:i], m.RecycleBin[i+1:]...)
|
||||
return nil
|
||||
}
|
||||
|
||||
return ErrEntryNotFound
|
||||
}
|
||||
|
||||
func (m *Model) InstantiateTemplate(templateID string, overrides Entry) (Entry, error) {
|
||||
for i := range m.Templates {
|
||||
if m.Templates[i].ID != templateID {
|
||||
continue
|
||||
}
|
||||
|
||||
entry := mergeEntryTemplate(m.Templates[i], overrides)
|
||||
m.UpsertEntry(entry)
|
||||
return cloneEntry(entry), nil
|
||||
}
|
||||
|
||||
return Entry{}, ErrEntryNotFound
|
||||
}
|
||||
|
||||
func (m *Model) DuplicateEntry(id, duplicateID string) (Entry, error) {
|
||||
for i := range m.Entries {
|
||||
if m.Entries[i].ID != id {
|
||||
continue
|
||||
}
|
||||
|
||||
duplicate := cloneEntry(m.Entries[i])
|
||||
duplicate.ID = duplicateID
|
||||
duplicate.Title = duplicate.Title + " (Copy)"
|
||||
duplicate.History = nil
|
||||
m.Entries = append(m.Entries, duplicate)
|
||||
return cloneEntry(duplicate), nil
|
||||
}
|
||||
|
||||
return Entry{}, ErrEntryNotFound
|
||||
}
|
||||
|
||||
func (m *Model) RestoreEntryVersion(id string, historyIndex int) error {
|
||||
for i := range m.Entries {
|
||||
if m.Entries[i].ID != id {
|
||||
continue
|
||||
}
|
||||
if historyIndex < 0 || historyIndex >= len(m.Entries[i].History) {
|
||||
return ErrEntryNotFound
|
||||
}
|
||||
|
||||
current := cloneEntry(m.Entries[i])
|
||||
restored := cloneEntry(m.Entries[i].History[historyIndex])
|
||||
restored.ID = current.ID
|
||||
restored.History = append([]Entry{current}, append(
|
||||
cloneHistory(m.Entries[i].History[:historyIndex]),
|
||||
cloneHistory(m.Entries[i].History[historyIndex+1:])...,
|
||||
)...)
|
||||
m.Entries[i] = restored
|
||||
return nil
|
||||
}
|
||||
|
||||
return ErrEntryNotFound
|
||||
}
|
||||
|
||||
func (m *Model) CreateGroup(parent []string, name string) {
|
||||
groupPath := append([]string(nil), parent...)
|
||||
for _, part := range splitGroupPath(name) {
|
||||
groupPath = append(groupPath, part)
|
||||
if groupPathExists(m.Groups, groupPath) {
|
||||
continue
|
||||
}
|
||||
m.Groups = append(m.Groups, append([]string(nil), groupPath...))
|
||||
}
|
||||
}
|
||||
|
||||
func (m *Model) RenameGroup(path []string, newName string) error {
|
||||
if len(path) == 0 {
|
||||
return ErrEntryNotFound
|
||||
}
|
||||
|
||||
renamed := false
|
||||
newPath := append(append([]string(nil), path[:len(path)-1]...), newName)
|
||||
for i := range m.Entries {
|
||||
if !hasPathPrefix(m.Entries[i].Path, path) {
|
||||
continue
|
||||
}
|
||||
m.Entries[i].Path = append(append([]string(nil), newPath...), m.Entries[i].Path[len(path):]...)
|
||||
renamed = true
|
||||
}
|
||||
for i := range m.Templates {
|
||||
if !hasPathPrefix(m.Templates[i].Path, path) {
|
||||
continue
|
||||
}
|
||||
m.Templates[i].Path = append(append([]string(nil), newPath...), m.Templates[i].Path[len(path):]...)
|
||||
renamed = true
|
||||
}
|
||||
for i := range m.Groups {
|
||||
if !hasPathPrefix(m.Groups[i], path) {
|
||||
continue
|
||||
}
|
||||
m.Groups[i] = append(append([]string(nil), newPath...), m.Groups[i][len(path):]...)
|
||||
renamed = true
|
||||
}
|
||||
if !renamed {
|
||||
return ErrEntryNotFound
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *Model) MoveEntry(id string, path []string) error {
|
||||
for i := range m.Entries {
|
||||
if m.Entries[i].ID != id {
|
||||
continue
|
||||
}
|
||||
m.Entries[i].Path = append([]string(nil), path...)
|
||||
return nil
|
||||
}
|
||||
return ErrEntryNotFound
|
||||
}
|
||||
|
||||
func (m *Model) MoveGroup(path, parent []string) error {
|
||||
if len(path) == 0 {
|
||||
return ErrEntryNotFound
|
||||
}
|
||||
if hasPathPrefix(parent, path) {
|
||||
return ErrEntryNotFound
|
||||
}
|
||||
|
||||
groupName := path[len(path)-1]
|
||||
newPath := append(append([]string(nil), parent...), groupName)
|
||||
moved := false
|
||||
for i := range m.Entries {
|
||||
if !hasPathPrefix(m.Entries[i].Path, path) {
|
||||
continue
|
||||
}
|
||||
m.Entries[i].Path = append(append([]string(nil), newPath...), m.Entries[i].Path[len(path):]...)
|
||||
moved = true
|
||||
}
|
||||
for i := range m.Templates {
|
||||
if !hasPathPrefix(m.Templates[i].Path, path) {
|
||||
continue
|
||||
}
|
||||
m.Templates[i].Path = append(append([]string(nil), newPath...), m.Templates[i].Path[len(path):]...)
|
||||
moved = true
|
||||
}
|
||||
for i := range m.Groups {
|
||||
if !hasPathPrefix(m.Groups[i], path) {
|
||||
continue
|
||||
}
|
||||
m.Groups[i] = append(append([]string(nil), newPath...), m.Groups[i][len(path):]...)
|
||||
moved = true
|
||||
}
|
||||
if !moved {
|
||||
return ErrEntryNotFound
|
||||
}
|
||||
if !groupPathExists(m.Groups, newPath) {
|
||||
m.Groups = append(m.Groups, append([]string(nil), newPath...))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *Model) MoveTemplate(id string, path []string) error {
|
||||
for i := range m.Templates {
|
||||
if m.Templates[i].ID != id {
|
||||
continue
|
||||
}
|
||||
m.Templates[i].Path = append([]string(nil), path...)
|
||||
return nil
|
||||
}
|
||||
return ErrEntryNotFound
|
||||
}
|
||||
|
||||
func (m *Model) DeleteGroup(path []string) error {
|
||||
for _, entry := range m.Entries {
|
||||
if slices.Equal(entry.Path, path) || hasPathPrefix(entry.Path, path) {
|
||||
return ErrGroupNotEmpty
|
||||
}
|
||||
}
|
||||
for _, entry := range m.Templates {
|
||||
if slices.Equal(entry.Path, path) || hasPathPrefix(entry.Path, path) {
|
||||
return ErrGroupNotEmpty
|
||||
}
|
||||
}
|
||||
|
||||
for i := range m.Groups {
|
||||
if slices.Equal(m.Groups[i], path) {
|
||||
m.Groups = append(m.Groups[:i], m.Groups[i+1:]...)
|
||||
return nil
|
||||
}
|
||||
}
|
||||
return ErrEntryNotFound
|
||||
}
|
||||
|
||||
func hasPathPrefix(path, prefix []string) bool {
|
||||
if len(prefix) > len(path) {
|
||||
return false
|
||||
}
|
||||
return slices.Equal(path[:len(prefix)], prefix)
|
||||
}
|
||||
|
||||
func splitGroupPath(name string) []string {
|
||||
var parts []string
|
||||
for _, part := range strings.Split(name, "/") {
|
||||
part = strings.TrimSpace(part)
|
||||
if part == "" {
|
||||
continue
|
||||
}
|
||||
parts = append(parts, part)
|
||||
}
|
||||
return parts
|
||||
}
|
||||
|
||||
func groupPathExists(groups [][]string, path []string) bool {
|
||||
for _, existing := range groups {
|
||||
if slices.Equal(existing, path) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func mergeEntryTemplate(template, overrides Entry) Entry {
|
||||
entry := cloneEntry(template)
|
||||
|
||||
if overrides.ID != "" {
|
||||
entry.ID = overrides.ID
|
||||
}
|
||||
if overrides.Title != "" {
|
||||
entry.Title = overrides.Title
|
||||
}
|
||||
if overrides.Username != "" {
|
||||
entry.Username = overrides.Username
|
||||
}
|
||||
if overrides.Password != "" {
|
||||
entry.Password = overrides.Password
|
||||
}
|
||||
if overrides.URL != "" {
|
||||
entry.URL = overrides.URL
|
||||
}
|
||||
if overrides.Notes != "" {
|
||||
entry.Notes = overrides.Notes
|
||||
}
|
||||
if len(overrides.Tags) > 0 {
|
||||
entry.Tags = slices.Clone(overrides.Tags)
|
||||
}
|
||||
if len(overrides.Path) > 0 {
|
||||
entry.Path = slices.Clone(overrides.Path)
|
||||
}
|
||||
|
||||
entry.Fields = mergeStringMaps(template.Fields, overrides.Fields)
|
||||
entry.Attachments = mergeBinaryMaps(template.Attachments, overrides.Attachments)
|
||||
entry.History = nil
|
||||
|
||||
return entry
|
||||
}
|
||||
|
||||
func mergeStringMaps(base, overrides map[string]string) map[string]string {
|
||||
if len(base) == 0 && len(overrides) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
out := make(map[string]string, len(base)+len(overrides))
|
||||
for key, value := range base {
|
||||
out[key] = value
|
||||
}
|
||||
for key, value := range overrides {
|
||||
out[key] = value
|
||||
}
|
||||
|
||||
return out
|
||||
}
|
||||
|
||||
func mergeBinaryMaps(base, overrides map[string][]byte) map[string][]byte {
|
||||
if len(base) == 0 && len(overrides) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
out := make(map[string][]byte, len(base)+len(overrides))
|
||||
for key, value := range base {
|
||||
out[key] = slices.Clone(value)
|
||||
}
|
||||
for key, value := range overrides {
|
||||
out[key] = slices.Clone(value)
|
||||
}
|
||||
|
||||
return out
|
||||
}
|
||||
|
||||
func cloneEntry(entry Entry) Entry {
|
||||
entry.Tags = slices.Clone(entry.Tags)
|
||||
entry.Path = slices.Clone(entry.Path)
|
||||
entry.History = cloneHistory(entry.History)
|
||||
if entry.Fields != nil {
|
||||
fields := make(map[string]string, len(entry.Fields))
|
||||
for key, value := range entry.Fields {
|
||||
fields[key] = value
|
||||
}
|
||||
entry.Fields = fields
|
||||
}
|
||||
if entry.Attachments != nil {
|
||||
attachments := make(map[string][]byte, len(entry.Attachments))
|
||||
for key, value := range entry.Attachments {
|
||||
attachments[key] = slices.Clone(value)
|
||||
}
|
||||
entry.Attachments = attachments
|
||||
}
|
||||
return entry
|
||||
}
|
||||
|
||||
func cloneHistory(history []Entry) []Entry {
|
||||
if len(history) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
out := make([]Entry, len(history))
|
||||
for i := range history {
|
||||
out[i] = cloneEntry(history[i])
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -0,0 +1,343 @@
|
||||
package vault
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"slices"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func testModel() Model {
|
||||
return Model{
|
||||
Entries: []Entry{
|
||||
{ID: "1", Title: "Bellagio", Username: "rustyryan", URL: "https://bellagio.example.invalid", Path: []string{"Crew", "Internet"}},
|
||||
{ID: "2", Title: "Vault Console", Username: "dannyocean", URL: "https://vault.crew.example.invalid", Path: []string{"Crew", "Internet"}},
|
||||
{ID: "3", Title: "Surveillance Console", Username: "codex", URL: "https://surveillance.crew.example.invalid", Path: []string{"Crew", "Home Assistant"}},
|
||||
{ID: "4", Title: "Alma (WA Prep)", Username: "christina.julian", URL: "https://waprep.getalma.com", Path: []string{"Tricia", "School"}},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func TestChildGroupsReturnsImmediateGroupsOnly(t *testing.T) {
|
||||
model := testModel()
|
||||
|
||||
got := model.ChildGroups([]string{"Crew"})
|
||||
want := []string{"Home Assistant", "Internet"}
|
||||
|
||||
if !slices.Equal(got, want) {
|
||||
t.Fatalf("ChildGroups() = %v, want %v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEntriesInPathReturnsOnlyDirectEntries(t *testing.T) {
|
||||
model := testModel()
|
||||
|
||||
got := model.EntriesInPath([]string{"Crew", "Internet"})
|
||||
if len(got) != 2 {
|
||||
t.Fatalf("len(EntriesInPath()) = %d, want 2", len(got))
|
||||
}
|
||||
|
||||
if got[0].Title != "Bellagio" || got[1].Title != "Vault Console" {
|
||||
t.Fatalf("EntriesInPath() titles = %q, %q", got[0].Title, got[1].Title)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEntriesUnderPathReturnsDescendantEntries(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
model := testModel()
|
||||
got := model.EntriesUnderPath([]string{"Crew"})
|
||||
if len(got) != 3 {
|
||||
t.Fatalf("len(EntriesUnderPath(Crew)) = %d, want 3", len(got))
|
||||
}
|
||||
if got[0].Title != "Bellagio" || got[1].Title != "Surveillance Console" || got[2].Title != "Vault Console" {
|
||||
t.Fatalf("EntriesUnderPath(Crew) titles = %q, %q, %q", got[0].Title, got[1].Title, got[2].Title)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSearchReturnsMatchesWithFullPathContext(t *testing.T) {
|
||||
model := testModel()
|
||||
|
||||
got := model.Search("vault")
|
||||
if len(got) != 1 {
|
||||
t.Fatalf("len(Search()) = %d, want 1", len(got))
|
||||
}
|
||||
|
||||
if got[0].Entry.Title != "Vault Console" {
|
||||
t.Fatalf("Search() title = %q, want %q", got[0].Entry.Title, "Vault Console")
|
||||
}
|
||||
|
||||
if got[0].Path != "Crew / Internet" {
|
||||
t.Fatalf("Search() path = %q, want %q", got[0].Path, "Crew / Internet")
|
||||
}
|
||||
}
|
||||
|
||||
func TestTemplateEntriesAreStoredSeparatelyFromNormalEntries(t *testing.T) {
|
||||
model := testModel()
|
||||
|
||||
model.UpsertTemplate(Entry{
|
||||
ID: "tpl-1",
|
||||
Title: "Website Login",
|
||||
Username: "template-user",
|
||||
Password: "template-password",
|
||||
URL: "https://example.com",
|
||||
Notes: "Reusable template for website accounts.",
|
||||
Tags: []string{"template", "web"},
|
||||
Path: []string{"Templates"},
|
||||
})
|
||||
|
||||
if len(model.Entries) != 4 {
|
||||
t.Fatalf("len(Entries) = %d, want 4", len(model.Entries))
|
||||
}
|
||||
|
||||
if len(model.Templates) != 1 {
|
||||
t.Fatalf("len(Templates) = %d, want 1", len(model.Templates))
|
||||
}
|
||||
|
||||
if got := model.Templates[0].Title; got != "Website Login" {
|
||||
t.Fatalf("Templates[0].Title = %q, want %q", got, "Website Login")
|
||||
}
|
||||
}
|
||||
|
||||
func TestInstantiateTemplateCreatesNormalEntryWithOverrides(t *testing.T) {
|
||||
model := Model{
|
||||
Templates: []Entry{
|
||||
{
|
||||
ID: "tpl-1",
|
||||
Title: "Website Login",
|
||||
Username: "template-user",
|
||||
Password: "template-password",
|
||||
URL: "https://example.com",
|
||||
Notes: "Reusable template for website accounts.",
|
||||
Tags: []string{"template", "web"},
|
||||
Fields: map[string]string{
|
||||
"Environment": "prod",
|
||||
},
|
||||
Path: []string{"Templates"},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
entry, err := model.InstantiateTemplate("tpl-1", Entry{
|
||||
ID: "entry-1",
|
||||
Title: "Bellagio",
|
||||
Username: "rustyryan",
|
||||
Password: "hunter2",
|
||||
URL: "https://bellagio.example.invalid",
|
||||
Path: []string{"Crew", "Internet"},
|
||||
Tags: []string{"dns"},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("InstantiateTemplate() error = %v", err)
|
||||
}
|
||||
|
||||
if entry.ID != "entry-1" {
|
||||
t.Fatalf("entry.ID = %q, want %q", entry.ID, "entry-1")
|
||||
}
|
||||
|
||||
if entry.Title != "Bellagio" {
|
||||
t.Fatalf("entry.Title = %q, want %q", entry.Title, "Bellagio")
|
||||
}
|
||||
|
||||
if entry.Username != "rustyryan" || entry.Password != "hunter2" || entry.URL != "https://bellagio.example.invalid" {
|
||||
t.Fatalf("entry credentials = %#v, want override values", entry)
|
||||
}
|
||||
|
||||
if entry.Notes != "Reusable template for website accounts." {
|
||||
t.Fatalf("entry.Notes = %q, want %q", entry.Notes, "Reusable template for website accounts.")
|
||||
}
|
||||
|
||||
if !slices.Equal(entry.Tags, []string{"dns"}) {
|
||||
t.Fatalf("entry.Tags = %v, want [dns]", entry.Tags)
|
||||
}
|
||||
|
||||
if entry.Fields["Environment"] != "prod" {
|
||||
t.Fatalf("entry.Fields[Environment] = %q, want %q", entry.Fields["Environment"], "prod")
|
||||
}
|
||||
|
||||
got := model.EntriesInPath([]string{"Crew", "Internet"})
|
||||
if len(got) != 1 || got[0].Title != "Bellagio" {
|
||||
t.Fatalf("EntriesInPath() = %#v, want instantiated Bellagio entry", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInstantiateTemplateFailsForUnknownTemplate(t *testing.T) {
|
||||
model := Model{}
|
||||
|
||||
_, err := model.InstantiateTemplate("missing-template", Entry{ID: "entry-1"})
|
||||
if err == nil {
|
||||
t.Fatal("InstantiateTemplate() error = nil, want ErrEntryNotFound")
|
||||
}
|
||||
|
||||
if !errors.Is(err, ErrEntryNotFound) {
|
||||
t.Fatalf("InstantiateTemplate() error = %v, want ErrEntryNotFound", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeleteTemplateRemovesTemplateWithoutTouchingEntries(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
model := Model{
|
||||
Entries: []Entry{
|
||||
{ID: "entry-1", Title: "Vault Console", Path: []string{"Root", "Internet"}},
|
||||
},
|
||||
Templates: []Entry{
|
||||
{ID: "tpl-1", Title: "Website Login", Path: []string{"Templates"}},
|
||||
},
|
||||
}
|
||||
|
||||
if err := model.DeleteTemplate("tpl-1"); err != nil {
|
||||
t.Fatalf("DeleteTemplate() error = %v", err)
|
||||
}
|
||||
|
||||
if len(model.Templates) != 0 {
|
||||
t.Fatalf("len(Templates) = %d, want 0", len(model.Templates))
|
||||
}
|
||||
if len(model.Entries) != 1 || model.Entries[0].ID != "entry-1" {
|
||||
t.Fatalf("Entries = %#v, want unchanged normal entry", model.Entries)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMoveTemplateChangesItsPath(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
model := Model{
|
||||
Templates: []Entry{
|
||||
{ID: "tpl-1", Title: "Website Login", Path: []string{"Templates", "Web"}},
|
||||
},
|
||||
}
|
||||
|
||||
if err := model.MoveTemplate("tpl-1", []string{"Templates", "Infra"}); err != nil {
|
||||
t.Fatalf("MoveTemplate() error = %v", err)
|
||||
}
|
||||
|
||||
if got := model.Templates[0].Path; !slices.Equal(got, []string{"Templates", "Infra"}) {
|
||||
t.Fatalf("Templates[0].Path = %v, want [Templates Infra]", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDuplicateEntryCopiesEntryWithNewIDAndTitle(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
model := Model{
|
||||
Entries: []Entry{
|
||||
{
|
||||
ID: "entry-1",
|
||||
Title: "Vault Console",
|
||||
Username: "dannyocean",
|
||||
Password: "token-1",
|
||||
Path: []string{"Root", "Internet"},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
duplicate, err := model.DuplicateEntry("entry-1", "entry-2")
|
||||
if err != nil {
|
||||
t.Fatalf("DuplicateEntry() error = %v", err)
|
||||
}
|
||||
|
||||
if duplicate.ID != "entry-2" {
|
||||
t.Fatalf("duplicate.ID = %q, want %q", duplicate.ID, "entry-2")
|
||||
}
|
||||
if duplicate.Title != "Vault Console (Copy)" {
|
||||
t.Fatalf("duplicate.Title = %q, want %q", duplicate.Title, "Vault Console (Copy)")
|
||||
}
|
||||
got := model.EntriesInPath([]string{"Root", "Internet"})
|
||||
if len(got) != 2 {
|
||||
t.Fatalf("len(EntriesInPath()) = %d, want 2", len(got))
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateGroupMakesItVisibleAsChildGroup(t *testing.T) {
|
||||
model := testModel()
|
||||
|
||||
model.CreateGroup([]string{"Crew"}, "Finance")
|
||||
|
||||
got := model.ChildGroups([]string{"Crew"})
|
||||
want := []string{"Finance", "Home Assistant", "Internet"}
|
||||
if !slices.Equal(got, want) {
|
||||
t.Fatalf("ChildGroups() = %v, want %v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateGroupSupportsNestedRelativePath(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
model := testModel()
|
||||
model.CreateGroup([]string{"Crew"}, "Infrastructure / Prod")
|
||||
|
||||
got := model.ChildGroups([]string{"Crew"})
|
||||
if !slices.Equal(got, []string{"Home Assistant", "Infrastructure", "Internet"}) {
|
||||
t.Fatalf("ChildGroups(Crew) = %v, want [Home Assistant Infrastructure Internet]", got)
|
||||
}
|
||||
got = model.ChildGroups([]string{"Crew", "Infrastructure"})
|
||||
if !slices.Equal(got, []string{"Prod"}) {
|
||||
t.Fatalf("ChildGroups(Crew/Infrastructure) = %v, want [Prod]", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenameGroupMovesEntriesAndKeepsHierarchy(t *testing.T) {
|
||||
model := testModel()
|
||||
|
||||
if err := model.RenameGroup([]string{"Crew", "Internet"}, "Infra"); err != nil {
|
||||
t.Fatalf("RenameGroup() error = %v", err)
|
||||
}
|
||||
|
||||
got := model.EntriesInPath([]string{"Crew", "Infra"})
|
||||
if len(got) != 2 {
|
||||
t.Fatalf("len(EntriesInPath(Crew/Infra)) = %d, want 2", len(got))
|
||||
}
|
||||
|
||||
if len(model.EntriesInPath([]string{"Crew", "Internet"})) != 0 {
|
||||
t.Fatal("EntriesInPath(Crew/Internet) should be empty after rename")
|
||||
}
|
||||
}
|
||||
|
||||
func TestMoveEntryChangesItsPath(t *testing.T) {
|
||||
model := testModel()
|
||||
|
||||
if err := model.MoveEntry("1", []string{"Tricia", "School"}); err != nil {
|
||||
t.Fatalf("MoveEntry() error = %v", err)
|
||||
}
|
||||
|
||||
got := model.EntriesInPath([]string{"Tricia", "School"})
|
||||
if len(got) != 2 {
|
||||
t.Fatalf("len(EntriesInPath(Tricia/School)) = %d, want 2", len(got))
|
||||
}
|
||||
}
|
||||
|
||||
func TestMoveGroupMovesEntriesAndNestedGroups(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
model := testModel()
|
||||
model.CreateGroup([]string{"Crew", "Internet"}, "Infrastructure")
|
||||
if err := model.MoveGroup([]string{"Crew", "Internet"}, []string{"Tricia"}); err != nil {
|
||||
t.Fatalf("MoveGroup() error = %v", err)
|
||||
}
|
||||
|
||||
got := model.EntriesInPath([]string{"Tricia", "Internet"})
|
||||
if len(got) != 2 {
|
||||
t.Fatalf("len(EntriesInPath(Tricia/Internet)) = %d, want 2", len(got))
|
||||
}
|
||||
if len(model.EntriesInPath([]string{"Crew", "Internet"})) != 0 {
|
||||
t.Fatal("EntriesInPath(Crew/Internet) should be empty after move")
|
||||
}
|
||||
gotGroups := model.ChildGroups([]string{"Tricia", "Internet"})
|
||||
if !slices.Equal(gotGroups, []string{"Infrastructure"}) {
|
||||
t.Fatalf("ChildGroups(Tricia/Internet) = %v, want [Infrastructure]", gotGroups)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeleteEmptyGroupRemovesItFromNavigation(t *testing.T) {
|
||||
model := testModel()
|
||||
|
||||
model.CreateGroup([]string{"Crew"}, "Finance")
|
||||
if err := model.DeleteGroup([]string{"Crew", "Finance"}); err != nil {
|
||||
t.Fatalf("DeleteGroup() error = %v", err)
|
||||
}
|
||||
|
||||
got := model.ChildGroups([]string{"Crew"})
|
||||
want := []string{"Home Assistant", "Internet"}
|
||||
if !slices.Equal(got, want) {
|
||||
t.Fatalf("ChildGroups() = %v, want %v", got, want)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
package vault
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"slices"
|
||||
|
||||
"github.com/tobischo/gokeepasslib/v3"
|
||||
)
|
||||
|
||||
type SecuritySettings struct {
|
||||
Cipher string
|
||||
KDF string
|
||||
}
|
||||
|
||||
const (
|
||||
CipherAES256 = "aes256"
|
||||
CipherChaCha20 = "chacha20"
|
||||
KDFAES = "aes-kdf"
|
||||
KDFArgon2 = "argon2"
|
||||
)
|
||||
|
||||
func SupportedSecuritySettings() (ciphers []string, kdfs []string) {
|
||||
return []string{CipherAES256, CipherChaCha20}, []string{KDFAES, KDFArgon2}
|
||||
}
|
||||
|
||||
func DetectSecuritySettings(config *KDBXConfig) SecuritySettings {
|
||||
settings := SecuritySettings{
|
||||
Cipher: CipherChaCha20,
|
||||
KDF: KDFArgon2,
|
||||
}
|
||||
if config == nil || config.Header == nil || config.Header.FileHeaders == nil {
|
||||
return settings
|
||||
}
|
||||
if slices.Equal(config.Header.FileHeaders.CipherID, gokeepasslib.CipherAES) {
|
||||
settings.Cipher = CipherAES256
|
||||
}
|
||||
if config.Header.FileHeaders.KdfParameters != nil && slices.Equal(config.Header.FileHeaders.KdfParameters.UUID, gokeepasslib.KdfAES4) {
|
||||
settings.KDF = KDFAES
|
||||
}
|
||||
return settings
|
||||
}
|
||||
|
||||
func NewSecurityConfig(settings SecuritySettings) (*KDBXConfig, error) {
|
||||
db := gokeepasslib.NewDatabase(gokeepasslib.WithDatabaseKDBXVersion4())
|
||||
config := &KDBXConfig{
|
||||
Header: cloneHeader(db.Header),
|
||||
InnerHeader: cloneInnerHeader(db.Content.InnerHeader),
|
||||
}
|
||||
return ApplySecuritySettings(config, settings)
|
||||
}
|
||||
|
||||
func ApplySecuritySettings(config *KDBXConfig, settings SecuritySettings) (*KDBXConfig, error) {
|
||||
if config == nil || config.Header == nil || config.Header.FileHeaders == nil {
|
||||
return NewSecurityConfig(settings)
|
||||
}
|
||||
out := &KDBXConfig{
|
||||
Header: cloneHeader(config.Header),
|
||||
InnerHeader: cloneInnerHeader(config.InnerHeader),
|
||||
}
|
||||
if out.Header.FileHeaders.KdfParameters == nil {
|
||||
defaults := gokeepasslib.NewDatabase(gokeepasslib.WithDatabaseKDBXVersion4())
|
||||
out.Header.FileHeaders.KdfParameters = cloneHeader(defaults.Header).FileHeaders.KdfParameters
|
||||
}
|
||||
switch settings.Cipher {
|
||||
case "", CipherChaCha20:
|
||||
out.Header.FileHeaders.CipherID = slices.Clone(gokeepasslib.CipherChaCha20)
|
||||
out.Header.FileHeaders.EncryptionIV = randomBytes(12)
|
||||
case CipherAES256:
|
||||
out.Header.FileHeaders.CipherID = slices.Clone(gokeepasslib.CipherAES)
|
||||
out.Header.FileHeaders.EncryptionIV = randomBytes(16)
|
||||
default:
|
||||
return nil, fmt.Errorf("unsupported cipher %q", settings.Cipher)
|
||||
}
|
||||
|
||||
var salt [32]byte
|
||||
copy(salt[:], randomBytes(32))
|
||||
switch settings.KDF {
|
||||
case "", KDFArgon2:
|
||||
defaults := gokeepasslib.NewDatabase(gokeepasslib.WithDatabaseKDBXVersion4())
|
||||
kdf := defaults.Header.FileHeaders.KdfParameters
|
||||
out.Header.FileHeaders.KdfParameters = &gokeepasslib.KdfParameters{
|
||||
UUID: slices.Clone(gokeepasslib.KdfArgon2),
|
||||
Rounds: kdf.Rounds,
|
||||
Salt: salt,
|
||||
Parallelism: kdf.Parallelism,
|
||||
Memory: kdf.Memory,
|
||||
Iterations: kdf.Iterations,
|
||||
Version: kdf.Version,
|
||||
}
|
||||
case KDFAES:
|
||||
out.Header.FileHeaders.KdfParameters = &gokeepasslib.KdfParameters{
|
||||
UUID: slices.Clone(gokeepasslib.KdfAES4),
|
||||
Rounds: 6000,
|
||||
Salt: salt,
|
||||
}
|
||||
default:
|
||||
return nil, fmt.Errorf("unsupported KDF %q", settings.KDF)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
package vault
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"slices"
|
||||
"testing"
|
||||
|
||||
"github.com/tobischo/gokeepasslib/v3"
|
||||
)
|
||||
|
||||
func TestNewSecurityConfigCreatesRequestedCipherAndKDF(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
config, err := NewSecurityConfig(SecuritySettings{Cipher: CipherAES256, KDF: KDFAES})
|
||||
if err != nil {
|
||||
t.Fatalf("NewSecurityConfig() error = %v", err)
|
||||
}
|
||||
if !slices.Equal(config.Header.FileHeaders.CipherID, gokeepasslib.CipherAES) {
|
||||
t.Fatalf("CipherID = %x, want %x", config.Header.FileHeaders.CipherID, gokeepasslib.CipherAES)
|
||||
}
|
||||
if !slices.Equal(config.Header.FileHeaders.KdfParameters.UUID, gokeepasslib.KdfAES4) {
|
||||
t.Fatalf("KDF UUID = %x, want %x", config.Header.FileHeaders.KdfParameters.UUID, gokeepasslib.KdfAES4)
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplySecuritySettingsPreservesRequestedChoicesAcrossSave(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
config, err := NewSecurityConfig(SecuritySettings{Cipher: CipherChaCha20, KDF: KDFArgon2})
|
||||
if err != nil {
|
||||
t.Fatalf("NewSecurityConfig() error = %v", err)
|
||||
}
|
||||
config, err = ApplySecuritySettings(config, SecuritySettings{Cipher: CipherAES256, KDF: KDFAES})
|
||||
if err != nil {
|
||||
t.Fatalf("ApplySecuritySettings() error = %v", err)
|
||||
}
|
||||
|
||||
var encoded bytes.Buffer
|
||||
if err := SaveKDBXWithConfigAndKey(&encoded, Model{}, MasterKey{Password: "correct horse battery staple"}, config); err != nil {
|
||||
t.Fatalf("SaveKDBXWithConfigAndKey() error = %v", err)
|
||||
}
|
||||
_, reloadedConfig, err := LoadKDBXWithConfig(bytes.NewReader(encoded.Bytes()), MasterKey{Password: "correct horse battery staple"})
|
||||
if err != nil {
|
||||
t.Fatalf("LoadKDBXWithConfig() error = %v", err)
|
||||
}
|
||||
got := DetectSecuritySettings(reloadedConfig)
|
||||
if got.Cipher != CipherAES256 || got.KDF != KDFAES {
|
||||
t.Fatalf("DetectSecuritySettings() = %#v, want aes256/aes-kdf", got)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user