Reconstruct KeePassGO repository

This commit is contained in:
Joe Julian
2026-03-29 11:04:38 -07:00
commit a2a8fcbd14
34 changed files with 14041 additions and 0 deletions
+1
View File
@@ -0,0 +1 @@
gio-keepass-mock
+8
View File
@@ -0,0 +1,8 @@
linters:
enable:
- errcheck
- gosimple
- govet
- ineffassign
- staticcheck
- unused
+128
View File
@@ -0,0 +1,128 @@
# Product Requirements
These instructions apply to all future work in this repository.
## Product Intent
- Build the product under the user-facing name `KeePassGO`.
- Build a real password manager product in Go.
- Keep Windows and Linux as first-class desktop targets.
- Keep Android as a future target, but do not distort the desktop product to chase Android prematurely.
- Preserve compatibility with KeePass/KDBX so the app can coexist with KeePass2Android.
- Use maintained upstream libraries for KDBX parsing, crypto, and other behavioral requirements when a maintained library is a good fit.
## Skills
- Use the installed Go skills whenever they materially apply to the current slice of work.
- The available Go skills for this repository are:
`go-code-review`,
`go-concurrency`,
`go-context`,
`go-control-flow`,
`go-data-structures`,
`go-declarations`,
`go-defensive`,
`go-documentation`,
`go-error-handling`,
`go-functional-options`,
`go-functions`,
`go-generics`,
`go-interfaces`,
`go-linting`,
`go-logging`,
`go-naming`,
`go-packages`,
`go-performance`,
`go-style-core`,
`go-testing`.
- In particular:
use `go-testing` for test-first slices and behavior tests,
`go-packages` for package boundaries and dependency layout,
`go-linting` for `golangci-lint`,
`go-code-review` when reviewing risky changes,
`go-interfaces` for seams and test doubles,
`go-error-handling` for error contracts,
`go-concurrency` and `go-context` for concurrent or cancellable flows,
and the remaining skills whenever their topic is directly implicated by the work.
## Required Feature Parity Targets
These are the KeePass 2.57.1 capabilities that this product should explicitly target, subject to platform constraints.
- Database lifecycle:
create, open, edit, save, save-as, and lock/unlock KDBX databases.
- Master key support:
master password, key file, and composite master key support.
- KDBX compatibility:
preserve interoperability with KeePass/KeePass2Android-compatible databases.
- Database security settings:
support the major KeePass-style encryption and KDF configuration choices that are represented in KDBX databases.
- Groups and paths:
nested groups, entry paths, and path-aware navigation.
- Entry fields:
title, username, password, URL, notes, tags, and custom string fields.
- Search:
current-group listing, global search, and search results with visible path context.
- Entry data transfer:
copy username, copy password, copy URL, and password reveal/hide controls.
- Password generation:
strong password generation with configurable rules and reusable profiles.
- Entry history and recycle semantics:
support entry history and deletion/recovery behavior compatible with KeePass expectations.
- Attachments:
store and retrieve file attachments in entries.
- Templates:
support entry templates or an equivalent reusable-entry mechanism.
- Programmatic integration:
provide a secure gRPC API for trusted clients such as browser extensions and local automation.
- Desktop automation:
either provide a desktop login automation mechanism comparable in purpose to KeePass auto-type, or document why the secure gRPC integration surface supersedes it.
- Import/export:
KDBX import/export is mandatory; additional import/export formats are desirable but secondary.
- WebDAV workflow:
direct remote-file workflows must exist and be treated as a first-class product feature.
- Accessibility and keyboard use:
keyboard-first operation, high-DPI support, and screen-reader-conscious design.
These features are product requirements, not “nice to have” ideas.
## UX Direction
- Desktop and phone layouts may differ significantly.
- Desktop should optimize for information density and low-friction navigation.
- Phone should optimize for low tap count, not purity of mobile patterns.
- The stacked phone layout is the current preferred phone direction.
- Do not reintroduce the abandoned phone flow mode unless explicitly requested.
## Architecture
- Separate domain logic from Gio UI code.
- UI state should not be the source of truth for vault structure or search behavior.
- Domain packages must be test-driven where practical.
- Prefer behavior-oriented tests that describe expected product behavior rather than implementation details.
- Provide a secure gRPC API as a first-class programmatic surface, not as a thin wrapper around UI state.
- Design browser-extension and automation integrations against the gRPC API, not against ad hoc local protocols.
## Delivery Discipline
- Do not treat this product as complete until the stated requirements in this file are actually satisfied.
- Do not stop at a “good checkpoint” or “meaningful tranche” when required product capabilities are still missing.
- Continue iterating in test-first slices:
add or extend behavior tests,
implement the minimum code to satisfy them,
verify with `go test ./...` and relevant lint checks,
and commit each completed behavior.
- Only stop before the requirements are satisfied if the work is genuinely blocked by a missing decision, missing external dependency, or a hard technical constraint that cannot be resolved within the repo.
- If blocked, state the blocker concretely and stop only at that point.
## Persistence and Sync
- Plan for direct KDBX support.
- Plan for direct WebDAV-based workflows.
- Avoid adding npm-based or browser-stack dependencies.
## Tooling
- Keep `golangci-lint` passing.
- Keep `go test ./...` passing.
- Do not commit generated binaries.
+64
View File
@@ -0,0 +1,64 @@
# KeePassGO
KeePassGO is a Go-based KeePass-compatible password manager prototype targeting desktop first, with future Android support.
## Current Capabilities
- KDBX load and save
- password, key-file, and composite master-key support in the storage layer
- WebDAV-backed open and save support in the session layer
- password generation profiles
- gRPC integration surface for trusted automation
- template, attachment, group, history, and recycle-bin persistence
## Run
```bash
go run .
```
Phone-sized preview:
```bash
go run . -mode phone
```
## Test
```bash
go test ./...
go tool golangci-lint run ./...
```
KDBX security and KDF compatibility notes are documented in [`docs/kdbx-compatibility.md`](./docs/kdbx-compatibility.md).
## Build
Desktop build:
```bash
go build ./...
```
## Android Packaging
KeePassGO uses Gio, so Android packaging is done with `gogio`.
Install:
```bash
go install gioui.org/cmd/gogio@latest
```
Package:
```bash
gogio -target android .
```
You will need the Android SDK and NDK installed and configured for real device or release packaging.
## Automation
Desktop automation is resolved through the secure gRPC API rather than synthetic auto-type.
See [`docs/desktop-automation.md`](./docs/desktop-automation.md).
+195
View File
@@ -0,0 +1,195 @@
# TODO
## Single Completion Plan
KeePassGO is not complete until every required capability in [`AGENTS.md`](./AGENTS.md) is implemented, verified, and integrated into the product.
This plan is intentionally a single plan with a single exit gate. It is not divided into phases or milestone buckets.
## Remaining Work
- Finish real application-state ownership.
- Keep application state as the single source of truth for:
- current session
- vault open or locked status
- current group path
- selected entry or template
- search query
- dirty state
- error and loading state
- Remove remaining direct UI-owned mutation of product state.
- Ensure all list, detail, breadcrumb, group, and selection behavior derives from the controller and session layers.
- Finish local and remote database lifecycle UX.
- Add create new vault flow.
- Add open local vault flow.
- Add open remote WebDAV vault flow.
- Add save current vault.
- Add save-as local vault flow.
- Add lock and unlock flows.
- Add visible handling for:
- invalid master key
- unreadable file
- decode failure
- WebDAV conflict
- missing path or target
- Add dirty-state protection around destructive navigation.
- Finish master-key and security configuration behavior.
- Add password-only setup.
- Add key-file-only setup.
- Add composite password plus key-file setup.
- Add UI and controller behavior for selecting or changing master-key mode.
- Preserve supported KDBX security and KDF settings when loading and saving.
- Document any unsupported settings explicitly.
- Finish entry CRUD as a real product workflow.
- Add create entry.
- Add edit entry.
- Add duplicate entry.
- Add delete entry to recycle bin.
- Add restore entry from recycle bin.
- Add entry history browsing.
- Add restore historical version behavior.
- Add editing for:
- title
- username
- password
- URL
- notes
- tags
- custom fields
- Add reveal and hide password behavior in the actual product flow, not only the prototype view.
- Finish template workflows.
- Add create template.
- Add edit template.
- Add delete template.
- Add template browsing UI.
- Add instantiate-template workflow with override support.
- Ensure template behavior is available through both UI and gRPC surfaces.
- Finish group and path management.
- Add create group.
- Add rename group.
- Add delete group.
- Add move entry between groups.
- Add move template between groups if supported.
- Make breadcrumb and group navigation controller-driven throughout the product.
- Make templates and recycle-bin locations explicit and navigable.
- Finish search behavior.
- Support current-group listing.
- Support global search.
- Keep visible path context in results.
- Define and implement search behavior for:
- templates
- recycle bin
- Add clear and reset behavior.
- Ensure search works consistently in desktop and phone layouts.
- Finish data transfer behavior.
- Keep copy username, copy password, and copy URL available through the UI.
- Keep those behaviors available through gRPC.
- Add product behavior for clipboard feedback.
- Decide and implement timed clipboard clearing if used.
- Ensure errors and logs do not leak secret contents.
- Finish attachments UX.
- Add attach file to entry.
- Add list attachments.
- Add export or download attachment.
- Add replace attachment.
- Add remove attachment.
- Add size and error handling.
- Add file selection abstraction appropriate for desktop and future Android support.
- Finish password generation UX.
- Expose profile-based password generation in the UI.
- Allow generated passwords to flow directly into create and edit entry workflows.
- Keep generation behavior exposed through gRPC.
- Finish gRPC as the first-class trusted integration surface.
- Add open, save, lock, and unlock RPCs.
- Add current session status RPC.
- Add group listing and group mutation RPCs.
- Add history listing and history restore RPCs.
- Add attachment listing, upload, and download RPCs.
- Add template CRUD RPCs where missing.
- Keep authentication and error contracts consistent across all methods.
- Keep the API independent of UI state.
- Resolve the desktop automation requirement.
- Either implement a desktop login automation mechanism comparable in purpose to KeePass auto-type,
- or document, in-repo, that the secure gRPC interface supersedes it and why.
- The decision must be explicit and committed.
- Finish accessibility and keyboard-first behavior.
- Add keyboard navigation across:
- list
- detail
- search
- breadcrumbs
- dialogs
- Add keyboard shortcuts for:
- search
- save
- lock
- create entry
- copy username
- copy password
- copy URL
- Add visible focus states.
- Improve screen-reader-conscious labeling where the toolkit allows it.
- Verify high-DPI behavior.
- Finish UI completion and polish.
- Replace remaining prototype-only behavior.
- Add empty states.
- Add loading states.
- Add error states.
- Add recycle-bin view.
- Add template view.
- Add lock screen.
- Add master-key prompt screens.
- Add save-conflict surfaces.
- Keep desktop information-dense.
- Keep phone layout optimized for low tap count.
- Finish packaging and runnable-product shape.
- Keep the desktop app runnable from the repo.
- Add documented build and run instructions.
- Add packaging guidance for desktop release builds.
- Add Android packaging guidance with `gogio`.
- Add icon and application metadata placeholders.
- Finish integration and regression coverage.
- Add controller and UI behavior tests for completed workflows.
- Add gRPC integration tests for lifecycle and mutation flows.
- Add WebDAV conflict and reload coverage.
- Add attachment workflow coverage.
- Add history and recycle-bin integration coverage.
- Add regression coverage for stable entry IDs across reopen and remote save cycles.
## Exit Criteria
Do not stop until all of the following are true:
- KeePassGO can create, open, edit, save, save-as, lock, and unlock local KDBX databases through the UI.
- KeePassGO can open and save remote WebDAV-backed KDBX databases through the UI, including visible handling of conflict and error states.
- KeePassGO supports master password, key file, and composite key workflows in the product, not just in storage helpers.
- KeePassGO preserves supported KDBX security and KDF settings and documents any unsupported settings.
- KeePassGO supports nested groups, path-aware navigation, and explicit template and recycle-bin navigation.
- KeePassGO supports entry create, edit, duplicate, delete, restore, history browse, and history restore through the UI.
- KeePassGO supports title, username, password, URL, notes, tags, and custom string fields through the UI.
- KeePassGO supports attachment add, remove, replace, list, and export through the UI.
- KeePassGO supports reusable templates through the UI and through the gRPC API.
- KeePassGO supports current-group listing, global search, and visible path context consistently across desktop and phone layouts.
- KeePassGO supports copy username, copy password, copy URL, and reveal or hide password behavior end to end.
- KeePassGO exposes password generation profiles through both UI and gRPC workflows.
- The secure gRPC API is broad enough for trusted automation and browser-extension style integration, including lifecycle and mutation operations.
- The desktop automation requirement is explicitly resolved, either by implementation or committed justification that gRPC supersedes it.
- Keyboard-first navigation and common shortcuts exist for the major product workflows.
- The UI no longer depends on prototype-only mock behavior for any core workflow.
- Build and run instructions exist for desktop, and packaging guidance exists for Android.
- `go test ./...` passes.
- `go tool golangci-lint run ./...` passes.
+626
View File
@@ -0,0 +1,626 @@
package api
import (
"context"
"errors"
"maps"
"slices"
"sync"
"strings"
"git.julianfamily.org/keepassgo/clipboard"
"git.julianfamily.org/keepassgo/passwords"
keepassgov1 "git.julianfamily.org/keepassgo/proto/keepassgo/v1"
"git.julianfamily.org/keepassgo/webdav"
"git.julianfamily.org/keepassgo/vault"
"google.golang.org/grpc"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/metadata"
"google.golang.org/grpc/status"
)
type Server struct {
keepassgov1.UnimplementedVaultServiceServer
mu sync.RWMutex
model vault.Model
locked bool
dirty bool
lifecycle lifecycleBackend
profiles map[string]passwords.Profile
clipboard clipboard.Writer
}
type lifecycleBackend interface {
Current() (vault.Model, error)
Open(string, vault.MasterKey) error
OpenRemote(webdav.Client, string, vault.MasterKey) error
Save() error
}
func NewServer(model vault.Model, profiles map[string]passwords.Profile, clipboardWriter clipboard.Writer) *Server {
return &Server{
model: model,
profiles: profiles,
clipboard: clipboardWriter,
}
}
func NewServerWithLifecycle(model vault.Model, profiles map[string]passwords.Profile, clipboardWriter clipboard.Writer, lifecycle lifecycleBackend) *Server {
server := NewServer(model, profiles, clipboardWriter)
server.lifecycle = lifecycle
return server
}
func (s *Server) GetSessionStatus(_ context.Context, _ *keepassgov1.GetSessionStatusRequest) (*keepassgov1.GetSessionStatusResponse, error) {
s.mu.RLock()
defer s.mu.RUnlock()
return &keepassgov1.GetSessionStatusResponse{
Locked: s.locked,
Dirty: s.dirty,
EntryCount: uint32(len(s.model.Entries)),
}, nil
}
func (s *Server) OpenVault(_ context.Context, req *keepassgov1.OpenVaultRequest) (*keepassgov1.OpenVaultResponse, error) {
if s.lifecycle == nil {
return nil, status.Error(codes.FailedPrecondition, "vault lifecycle backend is not configured")
}
key := vault.MasterKey{Password: req.GetPassword(), KeyFileData: append([]byte(nil), req.GetKeyFileData()...)}
if err := s.lifecycle.Open(req.GetPath(), key); err != nil {
return nil, status.Errorf(codes.Internal, "open vault: %v", err)
}
model, err := s.lifecycle.Current()
if err != nil {
return nil, status.Errorf(codes.Internal, "load opened vault: %v", err)
}
s.mu.Lock()
s.model = model
s.locked = false
s.dirty = false
s.mu.Unlock()
return &keepassgov1.OpenVaultResponse{}, nil
}
func (s *Server) OpenRemoteVault(_ context.Context, req *keepassgov1.OpenRemoteVaultRequest) (*keepassgov1.OpenRemoteVaultResponse, error) {
if s.lifecycle == nil {
return nil, status.Error(codes.FailedPrecondition, "vault lifecycle backend is not configured")
}
client := webdav.Client{
BaseURL: req.GetBaseUrl(),
Username: req.GetUsername(),
Password: req.GetPassword(),
}
key := vault.MasterKey{Password: req.GetMasterPassword(), KeyFileData: append([]byte(nil), req.GetKeyFileData()...)}
if err := s.lifecycle.OpenRemote(client, req.GetPath(), key); err != nil {
return nil, status.Errorf(codes.Internal, "open remote vault: %v", err)
}
model, err := s.lifecycle.Current()
if err != nil {
return nil, status.Errorf(codes.Internal, "load opened remote vault: %v", err)
}
s.mu.Lock()
s.model = model
s.locked = false
s.dirty = false
s.mu.Unlock()
return &keepassgov1.OpenRemoteVaultResponse{}, nil
}
func (s *Server) SaveVault(_ context.Context, _ *keepassgov1.SaveVaultRequest) (*keepassgov1.SaveVaultResponse, error) {
if s.lifecycle == nil {
return nil, status.Error(codes.FailedPrecondition, "vault lifecycle backend is not configured")
}
if err := s.lifecycle.Save(); err != nil {
return nil, status.Errorf(codes.Internal, "save vault: %v", err)
}
s.mu.Lock()
s.dirty = false
s.mu.Unlock()
return &keepassgov1.SaveVaultResponse{}, nil
}
func (s *Server) LockVault(_ context.Context, _ *keepassgov1.LockVaultRequest) (*keepassgov1.LockVaultResponse, error) {
s.mu.Lock()
defer s.mu.Unlock()
s.locked = true
return &keepassgov1.LockVaultResponse{}, nil
}
func (s *Server) UnlockVault(_ context.Context, _ *keepassgov1.UnlockVaultRequest) (*keepassgov1.UnlockVaultResponse, error) {
s.mu.Lock()
defer s.mu.Unlock()
s.locked = false
return &keepassgov1.UnlockVaultResponse{}, nil
}
func (s *Server) ListEntries(_ context.Context, req *keepassgov1.ListEntriesRequest) (*keepassgov1.ListEntriesResponse, error) {
s.mu.RLock()
defer s.mu.RUnlock()
if s.locked {
return nil, status.Error(codes.FailedPrecondition, "vault is locked")
}
var entries []vault.Entry
if strings.TrimSpace(req.GetQuery()) != "" {
results := s.model.Search(req.GetQuery())
entries = make([]vault.Entry, 0, len(results))
for _, result := range results {
entries = append(entries, result.Entry)
}
} else {
entries = s.model.EntriesInPath(req.GetPath())
}
resp := &keepassgov1.ListEntriesResponse{
Entries: make([]*keepassgov1.Entry, 0, len(entries)),
}
for _, entry := range entries {
resp.Entries = append(resp.Entries, entryToProto(entry))
}
return resp, nil
}
func (s *Server) ListGroups(_ context.Context, req *keepassgov1.ListGroupsRequest) (*keepassgov1.ListGroupsResponse, error) {
s.mu.RLock()
defer s.mu.RUnlock()
if s.locked {
return nil, status.Error(codes.FailedPrecondition, "vault is locked")
}
return &keepassgov1.ListGroupsResponse{
Names: s.model.ChildGroups(req.GetPath()),
}, nil
}
func (s *Server) CreateGroup(_ context.Context, req *keepassgov1.CreateGroupRequest) (*keepassgov1.CreateGroupResponse, error) {
s.mu.Lock()
defer s.mu.Unlock()
if s.locked {
return nil, status.Error(codes.FailedPrecondition, "vault is locked")
}
s.model.CreateGroup(req.GetParentPath(), req.GetName())
s.dirty = true
return &keepassgov1.CreateGroupResponse{}, nil
}
func (s *Server) RenameGroup(_ context.Context, req *keepassgov1.RenameGroupRequest) (*keepassgov1.RenameGroupResponse, error) {
s.mu.Lock()
defer s.mu.Unlock()
if s.locked {
return nil, status.Error(codes.FailedPrecondition, "vault is locked")
}
if err := s.model.RenameGroup(req.GetPath(), req.GetNewName()); err != nil {
if errors.Is(err, vault.ErrEntryNotFound) {
return nil, status.Error(codes.NotFound, err.Error())
}
return nil, status.Errorf(codes.Internal, "rename group: %v", err)
}
s.dirty = true
return &keepassgov1.RenameGroupResponse{}, nil
}
func (s *Server) UpsertEntry(_ context.Context, req *keepassgov1.UpsertEntryRequest) (*keepassgov1.UpsertEntryResponse, error) {
if req.GetEntry() == nil {
return nil, status.Error(codes.InvalidArgument, "missing entry")
}
entry := entryFromProto(req.GetEntry())
s.mu.Lock()
if s.locked {
s.mu.Unlock()
return nil, status.Error(codes.FailedPrecondition, "vault is locked")
}
s.model.UpsertEntry(entry)
s.dirty = true
s.mu.Unlock()
return &keepassgov1.UpsertEntryResponse{Entry: entryToProto(entry)}, nil
}
func (s *Server) DeleteEntry(_ context.Context, req *keepassgov1.DeleteEntryRequest) (*keepassgov1.DeleteEntryResponse, error) {
s.mu.Lock()
defer s.mu.Unlock()
if s.locked {
return nil, status.Error(codes.FailedPrecondition, "vault is locked")
}
if err := s.model.DeleteEntry(req.GetId()); err != nil {
if errors.Is(err, vault.ErrEntryNotFound) {
return nil, status.Error(codes.NotFound, err.Error())
}
return nil, status.Errorf(codes.Internal, "delete entry: %v", err)
}
s.dirty = true
return &keepassgov1.DeleteEntryResponse{}, nil
}
func (s *Server) RestoreEntry(_ context.Context, req *keepassgov1.RestoreEntryRequest) (*keepassgov1.RestoreEntryResponse, error) {
s.mu.Lock()
defer s.mu.Unlock()
if s.locked {
return nil, status.Error(codes.FailedPrecondition, "vault is locked")
}
var restored vault.Entry
for _, entry := range s.model.RecycleBin {
if entry.ID == req.GetId() {
restored = entry
break
}
}
if err := s.model.RestoreEntry(req.GetId()); err != nil {
if errors.Is(err, vault.ErrEntryNotFound) {
return nil, status.Error(codes.NotFound, err.Error())
}
return nil, status.Errorf(codes.Internal, "restore entry: %v", err)
}
s.dirty = true
return &keepassgov1.RestoreEntryResponse{Entry: entryToProto(restored)}, nil
}
func (s *Server) ListEntryHistory(_ context.Context, req *keepassgov1.ListEntryHistoryRequest) (*keepassgov1.ListEntryHistoryResponse, error) {
s.mu.RLock()
defer s.mu.RUnlock()
if s.locked {
return nil, status.Error(codes.FailedPrecondition, "vault is locked")
}
entry, err := findEntryByID(s.model, req.GetId())
if err != nil {
return nil, status.Error(codes.NotFound, err.Error())
}
resp := &keepassgov1.ListEntryHistoryResponse{
Entries: make([]*keepassgov1.Entry, 0, len(entry.History)),
}
for _, historical := range entry.History {
resp.Entries = append(resp.Entries, entryToProto(historical))
}
return resp, nil
}
func (s *Server) RestoreEntryHistory(_ context.Context, req *keepassgov1.RestoreEntryHistoryRequest) (*keepassgov1.RestoreEntryHistoryResponse, error) {
s.mu.Lock()
defer s.mu.Unlock()
if s.locked {
return nil, status.Error(codes.FailedPrecondition, "vault is locked")
}
if err := s.model.RestoreEntryVersion(req.GetId(), int(req.GetHistoryIndex())); err != nil {
if errors.Is(err, vault.ErrEntryNotFound) {
return nil, status.Error(codes.NotFound, err.Error())
}
return nil, status.Errorf(codes.Internal, "restore entry history: %v", err)
}
entry, err := findEntryByID(s.model, req.GetId())
if err != nil {
return nil, status.Error(codes.NotFound, err.Error())
}
s.dirty = true
return &keepassgov1.RestoreEntryHistoryResponse{Entry: entryToProto(entry)}, nil
}
func (s *Server) ListTemplates(_ context.Context, _ *keepassgov1.ListTemplatesRequest) (*keepassgov1.ListTemplatesResponse, error) {
s.mu.RLock()
defer s.mu.RUnlock()
if s.locked {
return nil, status.Error(codes.FailedPrecondition, "vault is locked")
}
resp := &keepassgov1.ListTemplatesResponse{
Templates: make([]*keepassgov1.Entry, 0, len(s.model.Templates)),
}
for _, template := range s.model.Templates {
resp.Templates = append(resp.Templates, entryToProto(template))
}
return resp, nil
}
func (s *Server) UpsertTemplate(_ context.Context, req *keepassgov1.UpsertTemplateRequest) (*keepassgov1.UpsertTemplateResponse, error) {
if req.GetTemplate() == nil {
return nil, status.Error(codes.InvalidArgument, "missing template")
}
s.mu.Lock()
defer s.mu.Unlock()
if s.locked {
return nil, status.Error(codes.FailedPrecondition, "vault is locked")
}
entry := entryFromProto(req.GetTemplate())
s.model.UpsertTemplate(entry)
s.dirty = true
return &keepassgov1.UpsertTemplateResponse{Template: entryToProto(entry)}, nil
}
func (s *Server) DeleteTemplate(_ context.Context, req *keepassgov1.DeleteTemplateRequest) (*keepassgov1.DeleteTemplateResponse, error) {
s.mu.Lock()
defer s.mu.Unlock()
if s.locked {
return nil, status.Error(codes.FailedPrecondition, "vault is locked")
}
if err := s.model.DeleteTemplate(req.GetId()); err != nil {
if errors.Is(err, vault.ErrEntryNotFound) {
return nil, status.Error(codes.NotFound, err.Error())
}
return nil, status.Errorf(codes.Internal, "delete template: %v", err)
}
s.dirty = true
return &keepassgov1.DeleteTemplateResponse{}, nil
}
func (s *Server) InstantiateTemplate(_ context.Context, req *keepassgov1.InstantiateTemplateRequest) (*keepassgov1.InstantiateTemplateResponse, error) {
if req.GetOverrides() == nil {
return nil, status.Error(codes.InvalidArgument, "missing overrides")
}
s.mu.Lock()
defer s.mu.Unlock()
if s.locked {
return nil, status.Error(codes.FailedPrecondition, "vault is locked")
}
entry, err := s.model.InstantiateTemplate(req.GetTemplateId(), entryFromProto(req.GetOverrides()))
if err != nil {
if errors.Is(err, vault.ErrEntryNotFound) {
return nil, status.Error(codes.NotFound, err.Error())
}
return nil, status.Errorf(codes.Internal, "instantiate template: %v", err)
}
s.dirty = true
return &keepassgov1.InstantiateTemplateResponse{Entry: entryToProto(entry)}, nil
}
func (s *Server) ListAttachments(_ context.Context, req *keepassgov1.ListAttachmentsRequest) (*keepassgov1.ListAttachmentsResponse, error) {
s.mu.RLock()
defer s.mu.RUnlock()
if s.locked {
return nil, status.Error(codes.FailedPrecondition, "vault is locked")
}
entry, err := findEntryByID(s.model, req.GetEntryId())
if err != nil {
return nil, status.Error(codes.NotFound, err.Error())
}
names := make([]string, 0, len(entry.Attachments))
for name := range entry.Attachments {
names = append(names, name)
}
slices.Sort(names)
return &keepassgov1.ListAttachmentsResponse{Names: names}, nil
}
func (s *Server) UploadAttachment(_ context.Context, req *keepassgov1.UploadAttachmentRequest) (*keepassgov1.UploadAttachmentResponse, error) {
s.mu.Lock()
defer s.mu.Unlock()
if s.locked {
return nil, status.Error(codes.FailedPrecondition, "vault is locked")
}
entry, index, err := findMutableEntryByID(&s.model, req.GetEntryId())
if err != nil {
return nil, status.Error(codes.NotFound, err.Error())
}
if entry.Attachments == nil {
entry.Attachments = map[string][]byte{}
}
entry.Attachments[req.GetName()] = append([]byte(nil), req.GetContent()...)
s.model.Entries[index] = entry
s.dirty = true
return &keepassgov1.UploadAttachmentResponse{}, nil
}
func (s *Server) DownloadAttachment(_ context.Context, req *keepassgov1.DownloadAttachmentRequest) (*keepassgov1.DownloadAttachmentResponse, error) {
s.mu.RLock()
defer s.mu.RUnlock()
if s.locked {
return nil, status.Error(codes.FailedPrecondition, "vault is locked")
}
entry, err := findEntryByID(s.model, req.GetEntryId())
if err != nil {
return nil, status.Error(codes.NotFound, err.Error())
}
content, ok := entry.Attachments[req.GetName()]
if !ok {
return nil, status.Error(codes.NotFound, "attachment not found")
}
return &keepassgov1.DownloadAttachmentResponse{
Content: append([]byte(nil), content...),
}, nil
}
func (s *Server) DeleteAttachment(_ context.Context, req *keepassgov1.DeleteAttachmentRequest) (*keepassgov1.DeleteAttachmentResponse, error) {
s.mu.Lock()
defer s.mu.Unlock()
if s.locked {
return nil, status.Error(codes.FailedPrecondition, "vault is locked")
}
entry, index, err := findMutableEntryByID(&s.model, req.GetEntryId())
if err != nil {
return nil, status.Error(codes.NotFound, err.Error())
}
if _, ok := entry.Attachments[req.GetName()]; !ok {
return nil, status.Error(codes.NotFound, "attachment not found")
}
delete(entry.Attachments, req.GetName())
if len(entry.Attachments) == 0 {
entry.Attachments = nil
}
s.model.Entries[index] = entry
s.dirty = true
return &keepassgov1.DeleteAttachmentResponse{}, nil
}
func (s *Server) CopyEntryField(_ context.Context, req *keepassgov1.CopyEntryFieldRequest) (*keepassgov1.CopyEntryFieldResponse, error) {
s.mu.RLock()
model := s.model
locked := s.locked
s.mu.RUnlock()
if locked {
return nil, status.Error(codes.FailedPrecondition, "vault is locked")
}
service := clipboard.Service{Writer: s.clipboard}
if err := service.Copy(model, req.GetId(), clipboard.Target(req.GetTarget())); err != nil {
switch {
case errors.Is(err, vault.ErrEntryNotFound):
return nil, status.Error(codes.NotFound, err.Error())
case errors.Is(err, clipboard.ErrUnsupportedTarget):
return nil, status.Error(codes.InvalidArgument, err.Error())
default:
return nil, status.Errorf(codes.Internal, "copy entry field: %v", err)
}
}
return &keepassgov1.CopyEntryFieldResponse{}, nil
}
func (s *Server) GeneratePassword(_ context.Context, req *keepassgov1.GeneratePasswordRequest) (*keepassgov1.GeneratePasswordResponse, error) {
s.mu.RLock()
defer s.mu.RUnlock()
if s.locked {
return nil, status.Error(codes.FailedPrecondition, "vault is locked")
}
profile, ok := s.profiles[req.GetProfile()]
if !ok {
return nil, status.Errorf(codes.InvalidArgument, "unknown password profile %q", req.GetProfile())
}
password, err := passwords.Generate(profile)
if err != nil {
return nil, status.Errorf(codes.Internal, "generate password: %v", err)
}
return &keepassgov1.GeneratePasswordResponse{Password: password}, nil
}
func entryToProto(entry vault.Entry) *keepassgov1.Entry {
return &keepassgov1.Entry{
Id: entry.ID,
Title: entry.Title,
Username: entry.Username,
Password: entry.Password,
Url: entry.URL,
Notes: entry.Notes,
Tags: append([]string(nil), entry.Tags...),
Path: append([]string(nil), entry.Path...),
}
}
func entryFromProto(entry *keepassgov1.Entry) vault.Entry {
return vault.Entry{
ID: entry.GetId(),
Title: entry.GetTitle(),
Username: entry.GetUsername(),
Password: entry.GetPassword(),
URL: entry.GetUrl(),
Notes: entry.GetNotes(),
Tags: append([]string(nil), entry.GetTags()...),
Path: append([]string(nil), entry.GetPath()...),
}
}
func findEntryByID(model vault.Model, id string) (vault.Entry, error) {
for _, entry := range model.Entries {
if entry.ID == id {
return entry, nil
}
}
return vault.Entry{}, vault.ErrEntryNotFound
}
func findMutableEntryByID(model *vault.Model, id string) (vault.Entry, int, error) {
for i, entry := range model.Entries {
if entry.ID == id {
entry.Attachments = maps.Clone(entry.Attachments)
return entry, i, nil
}
}
return vault.Entry{}, -1, vault.ErrEntryNotFound
}
func BearerTokenInterceptor(expectedToken string) grpc.UnaryServerInterceptor {
return func(
ctx context.Context,
req any,
info *grpc.UnaryServerInfo,
handler grpc.UnaryHandler,
) (any, error) {
md, ok := metadata.FromIncomingContext(ctx)
if !ok {
return nil, status.Error(codes.Unauthenticated, "missing metadata")
}
values := md.Get("authorization")
if len(values) == 0 {
return nil, status.Error(codes.Unauthenticated, "missing authorization")
}
if values[0] != "Bearer "+expectedToken {
return nil, status.Error(codes.Unauthenticated, "invalid bearer token")
}
return handler(ctx, req)
}
}
+630
View File
@@ -0,0 +1,630 @@
package api
import (
"bytes"
"context"
"net"
"testing"
"git.julianfamily.org/keepassgo/passwords"
keepassgov1 "git.julianfamily.org/keepassgo/proto/keepassgo/v1"
"git.julianfamily.org/keepassgo/session"
"git.julianfamily.org/keepassgo/vault"
"git.julianfamily.org/keepassgo/webdav"
"google.golang.org/grpc"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/credentials/insecure"
"google.golang.org/grpc/metadata"
"google.golang.org/grpc/status"
"google.golang.org/grpc/test/bufconn"
)
func TestVaultServiceRejectsRequestsWithoutBearerToken(t *testing.T) {
t.Parallel()
client, _, cleanup := newTestClient(t)
defer cleanup()
_, err := client.ListEntries(context.Background(), &keepassgov1.ListEntriesRequest{})
if status.Code(err) != codes.Unauthenticated {
t.Fatalf("ListEntries() code = %v, want %v", status.Code(err), codes.Unauthenticated)
}
}
func TestVaultServiceReportsSessionStatusAndSupportsLockUnlock(t *testing.T) {
t.Parallel()
client, _, cleanup := newTestClient(t)
defer cleanup()
ctx := metadata.AppendToOutgoingContext(context.Background(), "authorization", "Bearer test-token")
statusResp, err := client.GetSessionStatus(ctx, &keepassgov1.GetSessionStatusRequest{})
if err != nil {
t.Fatalf("GetSessionStatus() error = %v", err)
}
if statusResp.Locked {
t.Fatal("GetSessionStatus().Locked = true, want false at startup")
}
if statusResp.EntryCount == 0 {
t.Fatal("GetSessionStatus().EntryCount = 0, want non-zero")
}
if _, err := client.LockVault(ctx, &keepassgov1.LockVaultRequest{}); err != nil {
t.Fatalf("LockVault() error = %v", err)
}
statusResp, err = client.GetSessionStatus(ctx, &keepassgov1.GetSessionStatusRequest{})
if err != nil {
t.Fatalf("GetSessionStatus() after lock error = %v", err)
}
if !statusResp.Locked {
t.Fatal("GetSessionStatus().Locked = false, want true after lock")
}
if _, err := client.ListEntries(ctx, &keepassgov1.ListEntriesRequest{Path: []string{"Root", "Internet"}}); status.Code(err) != codes.FailedPrecondition {
t.Fatalf("ListEntries() code = %v, want FailedPrecondition while locked", status.Code(err))
}
if _, err := client.UnlockVault(ctx, &keepassgov1.UnlockVaultRequest{}); err != nil {
t.Fatalf("UnlockVault() error = %v", err)
}
statusResp, err = client.GetSessionStatus(ctx, &keepassgov1.GetSessionStatusRequest{})
if err != nil {
t.Fatalf("GetSessionStatus() after unlock error = %v", err)
}
if statusResp.Locked {
t.Fatal("GetSessionStatus().Locked = true, want false after unlock")
}
}
func TestVaultServiceOpensAndSavesVaultThroughLifecycleBackend(t *testing.T) {
t.Parallel()
lifecycle := &stubLifecycle{
model: vault.Model{
Entries: []vault.Entry{
{ID: "entry-1", Title: "Remote Git", Path: []string{"Root", "Internet"}},
},
},
}
client, _, cleanup := newTestClientWithLifecycle(t, lifecycle)
defer cleanup()
ctx := metadata.AppendToOutgoingContext(context.Background(), "authorization", "Bearer test-token")
if _, err := client.OpenVault(ctx, &keepassgov1.OpenVaultRequest{
Path: "/tmp/test.kdbx",
Password: "correct horse battery staple",
}); err != nil {
t.Fatalf("OpenVault() error = %v", err)
}
if lifecycle.openPath != "/tmp/test.kdbx" {
t.Fatalf("openPath = %q, want /tmp/test.kdbx", lifecycle.openPath)
}
listed, err := client.ListEntries(ctx, &keepassgov1.ListEntriesRequest{Path: []string{"Root", "Internet"}})
if err != nil {
t.Fatalf("ListEntries() after open error = %v", err)
}
if len(listed.Entries) != 1 || listed.Entries[0].Title != "Remote Git" {
t.Fatalf("ListEntries().Entries = %#v, want Remote Git after open", listed.Entries)
}
if _, err := client.SaveVault(ctx, &keepassgov1.SaveVaultRequest{}); err != nil {
t.Fatalf("SaveVault() error = %v", err)
}
if !lifecycle.saved {
t.Fatal("SaveVault() did not call lifecycle Save")
}
if _, err := client.OpenRemoteVault(ctx, &keepassgov1.OpenRemoteVaultRequest{
BaseUrl: "https://dav.example.com",
Path: "vaults/main.kdbx",
Username: "rustyryan",
Password: "dav-token",
MasterPassword: "correct horse battery staple",
}); err != nil {
t.Fatalf("OpenRemoteVault() error = %v", err)
}
if lifecycle.remoteBaseURL != "https://dav.example.com" || lifecycle.remotePath != "vaults/main.kdbx" {
t.Fatalf("remote open = %q %q, want dav.example.com vaults/main.kdbx", lifecycle.remoteBaseURL, lifecycle.remotePath)
}
}
func TestVaultServiceListsEntriesForAuthorizedClients(t *testing.T) {
t.Parallel()
client, _, cleanup := newTestClient(t)
defer cleanup()
ctx := metadata.AppendToOutgoingContext(context.Background(), "authorization", "Bearer test-token")
resp, err := client.ListEntries(ctx, &keepassgov1.ListEntriesRequest{Path: []string{"Root", "Internet"}})
if err != nil {
t.Fatalf("ListEntries() error = %v", err)
}
if len(resp.Entries) != 1 {
t.Fatalf("len(ListEntries().Entries) = %d, want 1", len(resp.Entries))
}
if resp.Entries[0].Title != "Vault Console" {
t.Fatalf("ListEntries().Entries[0].Title = %q, want %q", resp.Entries[0].Title, "Vault Console")
}
}
func TestVaultServiceListsCreatesAndRenamesGroupsForAuthorizedClients(t *testing.T) {
t.Parallel()
client, _, cleanup := newTestClient(t)
defer cleanup()
ctx := metadata.AppendToOutgoingContext(context.Background(), "authorization", "Bearer test-token")
listed, err := client.ListGroups(ctx, &keepassgov1.ListGroupsRequest{Path: []string{"Root"}})
if err != nil {
t.Fatalf("ListGroups() error = %v", err)
}
if len(listed.Names) != 2 || listed.Names[0] != "Home Assistant" || listed.Names[1] != "Internet" {
t.Fatalf("ListGroups().Names = %#v, want [Home Assistant Internet]", listed.Names)
}
if _, err := client.CreateGroup(ctx, &keepassgov1.CreateGroupRequest{
ParentPath: []string{"Root"},
Name: "Finance",
}); err != nil {
t.Fatalf("CreateGroup() error = %v", err)
}
listed, err = client.ListGroups(ctx, &keepassgov1.ListGroupsRequest{Path: []string{"Root"}})
if err != nil {
t.Fatalf("ListGroups() error = %v", err)
}
if len(listed.Names) != 3 || listed.Names[0] != "Finance" {
t.Fatalf("ListGroups().Names = %#v, want Finance present after create", listed.Names)
}
if _, err := client.RenameGroup(ctx, &keepassgov1.RenameGroupRequest{
Path: []string{"Root", "Internet"},
NewName: "Infra",
}); err != nil {
t.Fatalf("RenameGroup() error = %v", err)
}
listed, err = client.ListGroups(ctx, &keepassgov1.ListGroupsRequest{Path: []string{"Root"}})
if err != nil {
t.Fatalf("ListGroups() error = %v", err)
}
if len(listed.Names) != 3 || listed.Names[2] != "Infra" {
t.Fatalf("ListGroups().Names = %#v, want Infra after rename", listed.Names)
}
}
func TestVaultServiceGeneratesPasswordsForAuthorizedClients(t *testing.T) {
t.Parallel()
client, _, cleanup := newTestClient(t)
defer cleanup()
ctx := metadata.AppendToOutgoingContext(context.Background(), "authorization", "Bearer test-token")
resp, err := client.GeneratePassword(ctx, &keepassgov1.GeneratePasswordRequest{Profile: "strong"})
if err != nil {
t.Fatalf("GeneratePassword() error = %v", err)
}
if len(resp.Password) < passwords.DefaultProfiles()["strong"].Length {
t.Fatalf("len(GeneratePassword().Password) = %d, want at least %d", len(resp.Password), passwords.DefaultProfiles()["strong"].Length)
}
}
func TestVaultServiceCopiesEntryFieldsForAuthorizedClients(t *testing.T) {
t.Parallel()
client, clipboardWriter, cleanup := newTestClient(t)
defer cleanup()
ctx := metadata.AppendToOutgoingContext(context.Background(), "authorization", "Bearer test-token")
if _, err := client.CopyEntryField(ctx, &keepassgov1.CopyEntryFieldRequest{
Id: "vault-console",
Target: "password",
}); err != nil {
t.Fatalf("CopyEntryField() error = %v", err)
}
if clipboardWriter.content != "token-1" {
t.Fatalf("clipboard content = %q, want %q", clipboardWriter.content, "token-1")
}
}
func TestVaultServiceUpsertsEntriesForAuthorizedClients(t *testing.T) {
t.Parallel()
client, _, cleanup := newTestClient(t)
defer cleanup()
ctx := metadata.AppendToOutgoingContext(context.Background(), "authorization", "Bearer test-token")
upserted, err := client.UpsertEntry(ctx, &keepassgov1.UpsertEntryRequest{
Entry: &keepassgov1.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 != nil {
t.Fatalf("UpsertEntry() error = %v", err)
}
if upserted.Entry.Title != "Surveillance Console" {
t.Fatalf("UpsertEntry().Entry.Title = %q, want %q", upserted.Entry.Title, "Surveillance Console")
}
listed, err := client.ListEntries(ctx, &keepassgov1.ListEntriesRequest{Path: []string{"Root", "Home Assistant"}})
if err != nil {
t.Fatalf("ListEntries() error = %v", err)
}
if len(listed.Entries) != 1 || listed.Entries[0].Password != "token-2" {
t.Fatalf("ListEntries().Entries = %#v, want persisted Home Assistant entry", listed.Entries)
}
}
func TestVaultServiceDeletesAndRestoresEntriesForAuthorizedClients(t *testing.T) {
t.Parallel()
client, _, cleanup := newTestClient(t)
defer cleanup()
ctx := metadata.AppendToOutgoingContext(context.Background(), "authorization", "Bearer test-token")
if _, err := client.DeleteEntry(ctx, &keepassgov1.DeleteEntryRequest{Id: "vault-console"}); err != nil {
t.Fatalf("DeleteEntry() error = %v", err)
}
listed, err := client.ListEntries(ctx, &keepassgov1.ListEntriesRequest{Path: []string{"Root", "Internet"}})
if err != nil {
t.Fatalf("ListEntries() error = %v", err)
}
if len(listed.Entries) != 0 {
t.Fatalf("len(ListEntries().Entries) = %d, want 0 after delete", len(listed.Entries))
}
restored, err := client.RestoreEntry(ctx, &keepassgov1.RestoreEntryRequest{Id: "vault-console"})
if err != nil {
t.Fatalf("RestoreEntry() error = %v", err)
}
if restored.Entry.Title != "Vault Console" {
t.Fatalf("RestoreEntry().Entry.Title = %q, want %q", restored.Entry.Title, "Vault Console")
}
listed, err = client.ListEntries(ctx, &keepassgov1.ListEntriesRequest{Path: []string{"Root", "Internet"}})
if err != nil {
t.Fatalf("ListEntries() error = %v", err)
}
if len(listed.Entries) != 1 || listed.Entries[0].Title != "Vault Console" {
t.Fatalf("ListEntries().Entries = %#v, want restored Vault Console entry", listed.Entries)
}
}
func TestVaultServiceListsAndInstantiatesTemplatesForAuthorizedClients(t *testing.T) {
t.Parallel()
client, _, cleanup := newTestClient(t)
defer cleanup()
ctx := metadata.AppendToOutgoingContext(context.Background(), "authorization", "Bearer test-token")
templates, err := client.ListTemplates(ctx, &keepassgov1.ListTemplatesRequest{})
if err != nil {
t.Fatalf("ListTemplates() error = %v", err)
}
if len(templates.Templates) != 1 || templates.Templates[0].Title != "Website Login" {
t.Fatalf("ListTemplates().Templates = %#v, want Website Login template", templates.Templates)
}
instantiated, err := client.InstantiateTemplate(ctx, &keepassgov1.InstantiateTemplateRequest{
TemplateId: "website-login",
Overrides: &keepassgov1.Entry{
Id: "bellagio",
Title: "Bellagio",
Username: "rustyryan",
Password: "hunter2",
Url: "https://bellagio.example.invalid",
Path: []string{"Root", "Internet"},
Tags: []string{"dns"},
},
})
if err != nil {
t.Fatalf("InstantiateTemplate() error = %v", err)
}
if instantiated.Entry.Title != "Bellagio" || instantiated.Entry.Notes != "Reusable template for website accounts." {
t.Fatalf("InstantiateTemplate().Entry = %#v, want Bellagio entry with template notes", instantiated.Entry)
}
listed, err := client.ListEntries(ctx, &keepassgov1.ListEntriesRequest{Path: []string{"Root", "Internet"}})
if err != nil {
t.Fatalf("ListEntries() error = %v", err)
}
if len(listed.Entries) != 2 {
t.Fatalf("len(ListEntries().Entries) = %d, want 2 after template instantiation", len(listed.Entries))
}
}
func TestVaultServiceUpsertsAndDeletesTemplatesForAuthorizedClients(t *testing.T) {
t.Parallel()
client, _, cleanup := newTestClient(t)
defer cleanup()
ctx := metadata.AppendToOutgoingContext(context.Background(), "authorization", "Bearer test-token")
upserted, err := client.UpsertTemplate(ctx, &keepassgov1.UpsertTemplateRequest{
Template: &keepassgov1.Entry{
Id: "website-login",
Title: "Website Login Updated",
Username: "template-user",
Password: "template-password",
Path: []string{"Templates", "Web"},
},
})
if err != nil {
t.Fatalf("UpsertTemplate() error = %v", err)
}
if upserted.Template.Title != "Website Login Updated" {
t.Fatalf("UpsertTemplate().Template.Title = %q, want updated title", upserted.Template.Title)
}
listed, err := client.ListTemplates(ctx, &keepassgov1.ListTemplatesRequest{})
if err != nil {
t.Fatalf("ListTemplates() error = %v", err)
}
if len(listed.Templates) != 1 || listed.Templates[0].Title != "Website Login Updated" {
t.Fatalf("ListTemplates().Templates = %#v, want updated template", listed.Templates)
}
if _, err := client.DeleteTemplate(ctx, &keepassgov1.DeleteTemplateRequest{Id: "website-login"}); err != nil {
t.Fatalf("DeleteTemplate() error = %v", err)
}
listed, err = client.ListTemplates(ctx, &keepassgov1.ListTemplatesRequest{})
if err != nil {
t.Fatalf("ListTemplates() error = %v", err)
}
if len(listed.Templates) != 0 {
t.Fatalf("ListTemplates().Templates = %#v, want empty after delete", listed.Templates)
}
}
func TestVaultServiceListsAndRestoresEntryHistoryForAuthorizedClients(t *testing.T) {
t.Parallel()
client, _, cleanup := newTestClient(t)
defer cleanup()
ctx := metadata.AppendToOutgoingContext(context.Background(), "authorization", "Bearer test-token")
history, err := client.ListEntryHistory(ctx, &keepassgov1.ListEntryHistoryRequest{Id: "vault-console"})
if err != nil {
t.Fatalf("ListEntryHistory() error = %v", err)
}
if len(history.Entries) != 1 || history.Entries[0].Password != "token-0" {
t.Fatalf("ListEntryHistory().Entries = %#v, want old token entry", history.Entries)
}
restored, err := client.RestoreEntryHistory(ctx, &keepassgov1.RestoreEntryHistoryRequest{
Id: "vault-console",
HistoryIndex: 0,
})
if err != nil {
t.Fatalf("RestoreEntryHistory() error = %v", err)
}
if restored.Entry.Password != "token-0" {
t.Fatalf("RestoreEntryHistory().Entry.Password = %q, want token-0", restored.Entry.Password)
}
}
func TestVaultServiceListsUploadsDownloadsAndDeletesAttachments(t *testing.T) {
t.Parallel()
client, _, cleanup := newTestClient(t)
defer cleanup()
ctx := metadata.AppendToOutgoingContext(context.Background(), "authorization", "Bearer test-token")
uploaded := []byte("attachment-content")
if _, err := client.UploadAttachment(ctx, &keepassgov1.UploadAttachmentRequest{
EntryId: "vault-console",
Name: "token.txt",
Content: uploaded,
}); err != nil {
t.Fatalf("UploadAttachment() error = %v", err)
}
listed, err := client.ListAttachments(ctx, &keepassgov1.ListAttachmentsRequest{EntryId: "vault-console"})
if err != nil {
t.Fatalf("ListAttachments() error = %v", err)
}
if len(listed.Names) != 1 || listed.Names[0] != "token.txt" {
t.Fatalf("ListAttachments().Names = %#v, want [token.txt]", listed.Names)
}
downloaded, err := client.DownloadAttachment(ctx, &keepassgov1.DownloadAttachmentRequest{
EntryId: "vault-console",
Name: "token.txt",
})
if err != nil {
t.Fatalf("DownloadAttachment() error = %v", err)
}
if !bytes.Equal(downloaded.Content, uploaded) {
t.Fatalf("DownloadAttachment().Content = %q, want %q", downloaded.Content, uploaded)
}
if _, err := client.DeleteAttachment(ctx, &keepassgov1.DeleteAttachmentRequest{
EntryId: "vault-console",
Name: "token.txt",
}); err != nil {
t.Fatalf("DeleteAttachment() error = %v", err)
}
listed, err = client.ListAttachments(ctx, &keepassgov1.ListAttachmentsRequest{EntryId: "vault-console"})
if err != nil {
t.Fatalf("ListAttachments() error = %v", err)
}
if len(listed.Names) != 0 {
t.Fatalf("ListAttachments().Names = %#v, want empty after delete", listed.Names)
}
}
func newTestClient(t *testing.T) (keepassgov1.VaultServiceClient, *memoryClipboardWriter, func()) {
t.Helper()
listener := bufconn.Listen(1024 * 1024)
server := grpc.NewServer(grpc.UnaryInterceptor(BearerTokenInterceptor("test-token")))
clipboardWriter := &memoryClipboardWriter{}
keepassgov1.RegisterVaultServiceServer(server, NewServer(
vault.Model{
Entries: []vault.Entry{
{
ID: "vault-console",
Title: "Vault Console",
Username: "dannyocean",
Password: "token-1",
URL: "https://vault.crew.example.invalid",
History: []vault.Entry{
{
ID: "vault-console-h1",
Title: "Vault Console",
Username: "dannyocean",
Password: "token-0",
URL: "https://vault.crew.example.invalid",
Path: []string{"Root", "Internet"},
},
},
Path: []string{"Root", "Internet"},
},
{
ID: "surveillance-console",
Title: "Surveillance Console",
Username: "codex",
Password: "token-2",
URL: "https://surveillance.crew.example.invalid",
Path: []string{"Root", "Home Assistant"},
},
},
Templates: []vault.Entry{
{
ID: "website-login",
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"},
},
},
},
passwords.DefaultProfiles(),
clipboardWriter,
))
go func() {
_ = server.Serve(listener)
}()
conn, err := grpc.NewClient("passthrough:///bufnet",
grpc.WithContextDialer(func(context.Context, string) (net.Conn, error) {
return listener.Dial()
}),
grpc.WithTransportCredentials(insecure.NewCredentials()),
)
if err != nil {
t.Fatalf("grpc.NewClient() error = %v", err)
}
cleanup := func() {
_ = conn.Close()
server.Stop()
}
return keepassgov1.NewVaultServiceClient(conn), clipboardWriter, cleanup
}
func newTestClientWithLifecycle(t *testing.T, lifecycle *stubLifecycle) (keepassgov1.VaultServiceClient, *memoryClipboardWriter, func()) {
t.Helper()
listener := bufconn.Listen(1024 * 1024)
server := grpc.NewServer(grpc.UnaryInterceptor(BearerTokenInterceptor("test-token")))
clipboardWriter := &memoryClipboardWriter{}
keepassgov1.RegisterVaultServiceServer(server, NewServerWithLifecycle(
vault.Model{},
passwords.DefaultProfiles(),
clipboardWriter,
lifecycle,
))
go func() {
_ = server.Serve(listener)
}()
conn, err := grpc.NewClient("passthrough:///bufnet",
grpc.WithContextDialer(func(context.Context, string) (net.Conn, error) {
return listener.Dial()
}),
grpc.WithTransportCredentials(insecure.NewCredentials()),
)
if err != nil {
t.Fatalf("grpc.NewClient() error = %v", err)
}
cleanup := func() {
_ = conn.Close()
server.Stop()
}
return keepassgov1.NewVaultServiceClient(conn), clipboardWriter, cleanup
}
type memoryClipboardWriter struct {
content string
}
func (w *memoryClipboardWriter) WriteText(text string) error {
w.content = text
return nil
}
type stubLifecycle struct {
model vault.Model
openPath string
remoteBaseURL string
remotePath string
saved bool
locked bool
}
func (s *stubLifecycle) Current() (vault.Model, error) {
if s.locked {
return vault.Model{}, session.ErrLocked
}
return s.model, nil
}
func (s *stubLifecycle) Open(path string, _ vault.MasterKey) error {
s.openPath = path
return nil
}
func (s *stubLifecycle) OpenRemote(client webdav.Client, path string, _ vault.MasterKey) error {
s.remoteBaseURL = client.BaseURL
s.remotePath = path
return nil
}
func (s *stubLifecycle) Save() error {
s.saved = true
return nil
}
+620
View File
@@ -0,0 +1,620 @@
package appstate
import (
"fmt"
"slices"
"strings"
"git.julianfamily.org/keepassgo/vault"
"git.julianfamily.org/keepassgo/webdav"
)
type Section string
const (
SectionEntries Section = ""
SectionTemplates Section = "templates"
SectionRecycleBin Section = "recycle-bin"
)
type CurrentSession interface {
Current() (vault.Model, error)
}
type MutableSession interface {
CurrentSession
Replace(vault.Model)
}
type LockableSession interface {
CurrentSession
Lock() error
Unlock(vault.MasterKey) error
}
type SaveableSession interface {
CurrentSession
Save() error
}
type CreateableSession interface {
CurrentSession
Create(vault.Model, vault.MasterKey) error
}
type OpenableSession interface {
CurrentSession
Open(string, vault.MasterKey) error
}
type SaveAsSession interface {
CurrentSession
SaveAs(string) error
}
type RemoteOpenableSession interface {
CurrentSession
OpenRemote(webdav.Client, string, vault.MasterKey) error
}
type State struct {
Session CurrentSession
Section Section
CurrentPath []string
SearchQuery string
SelectedEntryID string
Dirty bool
}
func (s *State) VisibleEntries() ([]vault.Entry, error) {
model, err := s.currentModel()
if err != nil {
return nil, err
}
entries := s.entriesForSection(model)
if strings.TrimSpace(s.SearchQuery) != "" {
return filterEntries(entries, s.SearchQuery), nil
}
if s.Section == SectionEntries {
return model.EntriesInPath(s.CurrentPath), nil
}
if s.Section == SectionRecycleBin || len(s.CurrentPath) == 0 {
return entries, nil
}
return entriesInPath(entries, s.CurrentPath), nil
}
func (s *State) ChildGroups() ([]string, error) {
if strings.TrimSpace(s.SearchQuery) != "" {
return nil, nil
}
model, err := s.currentModel()
if err != nil {
return nil, err
}
if s.Section != SectionEntries {
if s.Section == SectionTemplates && len(s.CurrentPath) == 0 {
return childGroups(s.entriesForSection(model), []string{"Templates"}), nil
}
return childGroups(s.entriesForSection(model), s.CurrentPath), nil
}
return model.ChildGroups(s.CurrentPath), nil
}
func (s *State) SelectVisibleIndex(index int) error {
entries, err := s.VisibleEntries()
if err != nil {
return err
}
if index < 0 || index >= len(entries) {
return fmt.Errorf("visible index %d out of range", index)
}
s.SelectedEntryID = entries[index].ID
return nil
}
func (s *State) ToggleVisibleIndex(index int) error {
entries, err := s.VisibleEntries()
if err != nil {
return err
}
if index < 0 || index >= len(entries) {
return fmt.Errorf("visible index %d out of range", index)
}
if s.SelectedEntryID == entries[index].ID {
s.SelectedEntryID = ""
return nil
}
s.SelectedEntryID = entries[index].ID
return nil
}
func (s *State) currentModel() (vault.Model, error) {
if s.Session == nil {
return vault.Model{}, nil
}
return s.Session.Current()
}
func (s *State) entriesForSection(model vault.Model) []vault.Entry {
switch s.Section {
case SectionTemplates:
return slices.Clone(model.Templates)
case SectionRecycleBin:
return slices.Clone(model.RecycleBin)
default:
return slices.Clone(model.Entries)
}
}
func entriesInPath(entries []vault.Entry, path []string) []vault.Entry {
var out []vault.Entry
for _, entry := range entries {
if slices.Equal(entry.Path, path) {
out = append(out, entry)
}
}
slices.SortFunc(out, func(a, b vault.Entry) int {
switch {
case a.Title < b.Title:
return -1
case a.Title > b.Title:
return 1
default:
return 0
}
})
return out
}
func filterEntries(entries []vault.Entry, query string) []vault.Entry {
query = strings.TrimSpace(strings.ToLower(query))
if query == "" {
return nil
}
var out []vault.Entry
for _, entry := range entries {
haystack := strings.ToLower(
entry.Title + " " +
entry.Username + " " +
entry.URL + " " +
strings.Join(entry.Path, " "),
)
if !strings.Contains(haystack, query) {
continue
}
out = append(out, entry)
}
slices.SortFunc(out, func(a, b vault.Entry) int {
switch {
case a.Title < b.Title:
return -1
case a.Title > b.Title:
return 1
default:
return 0
}
})
return out
}
func childGroups(entries []vault.Entry, path []string) []string {
seen := map[string]bool{}
var groups []string
for _, entry := range 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)
}
slices.Sort(groups)
return groups
}
func (s *State) DeleteSelectedEntry() error {
session, ok := s.Session.(MutableSession)
if !ok {
return fmt.Errorf("session is not mutable")
}
model, err := session.Current()
if err != nil {
return err
}
if err := model.DeleteEntry(s.SelectedEntryID); err != nil {
return err
}
session.Replace(model)
s.SelectedEntryID = ""
s.Dirty = true
return nil
}
func (s *State) RestoreEntry(id string) error {
session, ok := s.Session.(MutableSession)
if !ok {
return fmt.Errorf("session is not mutable")
}
model, err := session.Current()
if err != nil {
return err
}
if err := model.RestoreEntry(id); err != nil {
return err
}
session.Replace(model)
s.Dirty = true
return nil
}
func (s *State) UpsertEntry(entry vault.Entry) error {
session, ok := s.Session.(MutableSession)
if !ok {
return fmt.Errorf("session is not mutable")
}
model, err := session.Current()
if err != nil {
return err
}
model.UpsertEntry(entry)
session.Replace(model)
s.SelectedEntryID = entry.ID
s.Dirty = true
return nil
}
func (s *State) UpsertTemplate(entry vault.Entry) error {
session, ok := s.Session.(MutableSession)
if !ok {
return fmt.Errorf("session is not mutable")
}
model, err := session.Current()
if err != nil {
return err
}
model.UpsertTemplate(entry)
session.Replace(model)
s.SelectedEntryID = entry.ID
s.Dirty = true
return nil
}
func (s *State) InstantiateTemplate(templateID string, overrides vault.Entry) (vault.Entry, error) {
session, ok := s.Session.(MutableSession)
if !ok {
return vault.Entry{}, fmt.Errorf("session is not mutable")
}
model, err := session.Current()
if err != nil {
return vault.Entry{}, err
}
entry, err := model.InstantiateTemplate(templateID, overrides)
if err != nil {
return vault.Entry{}, err
}
session.Replace(model)
s.SelectedEntryID = entry.ID
s.Dirty = true
return entry, nil
}
func (s *State) DeleteTemplate(id string) error {
session, ok := s.Session.(MutableSession)
if !ok {
return fmt.Errorf("session is not mutable")
}
model, err := session.Current()
if err != nil {
return err
}
if err := model.DeleteTemplate(id); err != nil {
return err
}
session.Replace(model)
if s.SelectedEntryID == id {
s.SelectedEntryID = ""
}
s.Dirty = true
return nil
}
func (s *State) DuplicateSelectedEntry(duplicateID string) (vault.Entry, error) {
session, ok := s.Session.(MutableSession)
if !ok {
return vault.Entry{}, fmt.Errorf("session is not mutable")
}
model, err := session.Current()
if err != nil {
return vault.Entry{}, err
}
duplicate, err := model.DuplicateEntry(s.SelectedEntryID, duplicateID)
if err != nil {
return vault.Entry{}, err
}
session.Replace(model)
s.SelectedEntryID = duplicate.ID
s.Dirty = true
return duplicate, nil
}
func (s *State) RestoreSelectedEntryVersion(historyIndex int) error {
session, ok := s.Session.(MutableSession)
if !ok {
return fmt.Errorf("session is not mutable")
}
model, err := session.Current()
if err != nil {
return err
}
if err := model.RestoreEntryVersion(s.SelectedEntryID, historyIndex); err != nil {
return err
}
session.Replace(model)
s.Dirty = true
return nil
}
func (s *State) Lock() error {
session, ok := s.Session.(LockableSession)
if !ok {
return fmt.Errorf("session is not lockable")
}
if err := session.Lock(); err != nil {
return err
}
s.SelectedEntryID = ""
return nil
}
func (s *State) Unlock(key vault.MasterKey) error {
session, ok := s.Session.(LockableSession)
if !ok {
return fmt.Errorf("session is not lockable")
}
return session.Unlock(key)
}
func (s *State) EnterGroup(name string) {
s.CurrentPath = append(append([]string(nil), s.CurrentPath...), name)
s.SelectedEntryID = ""
}
func (s *State) NavigateToPath(path []string) {
s.CurrentPath = append([]string(nil), path...)
s.SelectedEntryID = ""
}
func (s *State) Save() error {
session, ok := s.Session.(SaveableSession)
if !ok {
return fmt.Errorf("session is not saveable")
}
if err := session.Save(); err != nil {
return err
}
s.Dirty = false
return nil
}
func (s *State) CreateVault(key vault.MasterKey) error {
session, ok := s.Session.(CreateableSession)
if !ok {
return fmt.Errorf("session is not createable")
}
if err := session.Create(vault.Model{}, key); err != nil {
return err
}
s.CurrentPath = nil
s.SelectedEntryID = ""
s.Dirty = false
return nil
}
func (s *State) OpenVault(path string, key vault.MasterKey) error {
session, ok := s.Session.(OpenableSession)
if !ok {
return fmt.Errorf("session is not openable")
}
if err := session.Open(path, key); err != nil {
return err
}
s.CurrentPath = nil
s.SelectedEntryID = ""
s.Dirty = false
return nil
}
func (s *State) SaveAs(path string) error {
session, ok := s.Session.(SaveAsSession)
if !ok {
return fmt.Errorf("session is not save-as capable")
}
if err := session.SaveAs(path); err != nil {
return err
}
s.Dirty = false
return nil
}
func (s *State) OpenRemoteVault(client webdav.Client, path string, key vault.MasterKey) error {
session, ok := s.Session.(RemoteOpenableSession)
if !ok {
return fmt.Errorf("session is not remote-openable")
}
if err := session.OpenRemote(client, path, key); err != nil {
return err
}
s.CurrentPath = nil
s.SelectedEntryID = ""
s.Dirty = false
return nil
}
func (s *State) CreateGroup(name string) error {
session, ok := s.Session.(MutableSession)
if !ok {
return fmt.Errorf("session is not mutable")
}
model, err := session.Current()
if err != nil {
return err
}
model.CreateGroup(s.CurrentPath, name)
session.Replace(model)
s.Dirty = true
return nil
}
func (s *State) RenameCurrentGroup(newName string) error {
session, ok := s.Session.(MutableSession)
if !ok {
return fmt.Errorf("session is not mutable")
}
model, err := session.Current()
if err != nil {
return err
}
if err := model.RenameGroup(s.CurrentPath, newName); err != nil {
return err
}
session.Replace(model)
if len(s.CurrentPath) > 0 {
s.CurrentPath = append(append([]string(nil), s.CurrentPath[:len(s.CurrentPath)-1]...), newName)
}
s.Dirty = true
return nil
}
func (s *State) MoveSelectedEntry(path []string) error {
session, ok := s.Session.(MutableSession)
if !ok {
return fmt.Errorf("session is not mutable")
}
model, err := session.Current()
if err != nil {
return err
}
if err := model.MoveEntry(s.SelectedEntryID, path); err != nil {
return err
}
session.Replace(model)
s.Dirty = true
return nil
}
func (s *State) AddAttachmentToSelectedEntry(name string, content []byte) error {
session, ok := s.Session.(MutableSession)
if !ok {
return fmt.Errorf("session is not mutable")
}
model, err := session.Current()
if err != nil {
return err
}
for i := range model.Entries {
if model.Entries[i].ID != s.SelectedEntryID {
continue
}
if model.Entries[i].Attachments == nil {
model.Entries[i].Attachments = map[string][]byte{}
}
model.Entries[i].Attachments[name] = append([]byte(nil), content...)
session.Replace(model)
s.Dirty = true
return nil
}
return vault.ErrEntryNotFound
}
func (s *State) DeleteAttachmentFromSelectedEntry(name string) error {
session, ok := s.Session.(MutableSession)
if !ok {
return fmt.Errorf("session is not mutable")
}
model, err := session.Current()
if err != nil {
return err
}
for i := range model.Entries {
if model.Entries[i].ID != s.SelectedEntryID {
continue
}
delete(model.Entries[i].Attachments, name)
if len(model.Entries[i].Attachments) == 0 {
model.Entries[i].Attachments = nil
}
session.Replace(model)
s.Dirty = true
return nil
}
return vault.ErrEntryNotFound
}
+956
View File
@@ -0,0 +1,956 @@
package appstate
import (
"errors"
"slices"
"testing"
"git.julianfamily.org/keepassgo/session"
"git.julianfamily.org/keepassgo/vault"
"git.julianfamily.org/keepassgo/webdav"
)
func TestVisibleEntriesFollowsCurrentPathWithoutSearch(t *testing.T) {
t.Parallel()
state := State{
Session: stubSession{
model: vault.Model{
Entries: []vault.Entry{
{ID: "bellagio", Title: "Bellagio", Path: []string{"Crew", "Internet"}},
{ID: "vault-console", Title: "Vault Console", Path: []string{"Crew", "Internet"}},
{ID: "surveillance-console", Title: "Surveillance Console", Path: []string{"Crew", "Home Assistant"}},
},
},
},
CurrentPath: []string{"Crew", "Internet"},
}
got, err := state.VisibleEntries()
if err != nil {
t.Fatalf("VisibleEntries() error = %v", err)
}
titles := make([]string, 0, len(got))
for _, entry := range got {
titles = append(titles, entry.Title)
}
if !slices.Equal(titles, []string{"Bellagio", "Vault Console"}) {
t.Fatalf("visible titles = %v, want [Bellagio Vault Console]", titles)
}
}
func TestVisibleEntriesUsesGlobalSearchWhenQueryPresent(t *testing.T) {
t.Parallel()
state := State{
Session: stubSession{
model: vault.Model{
Entries: []vault.Entry{
{ID: "bellagio", Title: "Bellagio", Path: []string{"Crew", "Internet"}},
{ID: "vault-console", Title: "Vault Console", URL: "https://vault.crew.example.invalid", Path: []string{"Crew", "Internet"}},
{ID: "surveillance-console", Title: "Surveillance Console", URL: "https://surveillance.crew.example.invalid", Path: []string{"Crew", "Home Assistant"}},
},
},
},
CurrentPath: []string{"Crew", "Internet"},
SearchQuery: "surveillance",
}
got, err := state.VisibleEntries()
if err != nil {
t.Fatalf("VisibleEntries() error = %v", err)
}
if len(got) != 1 || got[0].Title != "Surveillance Console" {
t.Fatalf("VisibleEntries() = %#v, want Home Assistant search match", got)
}
}
func TestVisibleEntriesUsesTemplateSection(t *testing.T) {
t.Parallel()
state := State{
Session: stubSession{
model: vault.Model{
Templates: []vault.Entry{
{ID: "tpl-1", Title: "Website Login", Path: []string{"Templates", "Web"}},
{ID: "tpl-2", Title: "SSH Login", Path: []string{"Templates", "Infra"}},
},
},
},
Section: SectionTemplates,
}
got, err := state.VisibleEntries()
if err != nil {
t.Fatalf("VisibleEntries() error = %v", err)
}
if len(got) != 2 {
t.Fatalf("len(VisibleEntries()) = %d, want 2 templates", len(got))
}
}
func TestVisibleEntriesUsesRecycleBinSection(t *testing.T) {
t.Parallel()
state := State{
Session: stubSession{
model: vault.Model{
RecycleBin: []vault.Entry{
{ID: "entry-1", Title: "Deleted Entry", Path: []string{"Root", "Internet"}},
},
},
},
Section: SectionRecycleBin,
}
got, err := state.VisibleEntries()
if err != nil {
t.Fatalf("VisibleEntries() error = %v", err)
}
if len(got) != 1 || got[0].ID != "entry-1" {
t.Fatalf("VisibleEntries() = %#v, want recycle-bin entry", got)
}
}
func TestChildGroupsUsesCurrentModelAndCurrentPath(t *testing.T) {
t.Parallel()
state := State{
Session: stubSession{
model: vault.Model{
Entries: []vault.Entry{
{ID: "bellagio", Title: "Bellagio", Path: []string{"Crew", "Internet"}},
{ID: "surveillance-console", Title: "Surveillance Console", Path: []string{"Crew", "Home Assistant"}},
{ID: "alma", Title: "Alma (WA Prep)", Path: []string{"Tricia", "School"}},
},
},
},
CurrentPath: []string{"Crew"},
}
got, err := state.ChildGroups()
if err != nil {
t.Fatalf("ChildGroups() error = %v", err)
}
if !slices.Equal(got, []string{"Home Assistant", "Internet"}) {
t.Fatalf("ChildGroups() = %v, want [Home Assistant Internet]", got)
}
}
func TestChildGroupsUsesTemplateSectionPaths(t *testing.T) {
t.Parallel()
state := State{
Session: stubSession{
model: vault.Model{
Templates: []vault.Entry{
{ID: "tpl-1", Title: "Website Login", Path: []string{"Templates", "Web"}},
{ID: "tpl-2", Title: "SSH Login", Path: []string{"Templates", "Infra"}},
},
},
},
Section: SectionTemplates,
CurrentPath: []string{"Templates"},
}
got, err := state.ChildGroups()
if err != nil {
t.Fatalf("ChildGroups() error = %v", err)
}
if !slices.Equal(got, []string{"Infra", "Web"}) {
t.Fatalf("ChildGroups() = %v, want [Infra Web]", got)
}
}
func TestSelectVisibleEntryAndToggleSelection(t *testing.T) {
t.Parallel()
state := State{
Session: stubSession{
model: vault.Model{
Entries: []vault.Entry{
{ID: "bellagio", Title: "Bellagio", Path: []string{"Crew", "Internet"}},
{ID: "vault-console", Title: "Vault Console", Path: []string{"Crew", "Internet"}},
},
},
},
CurrentPath: []string{"Crew", "Internet"},
}
if err := state.SelectVisibleIndex(1); err != nil {
t.Fatalf("SelectVisibleIndex() error = %v", err)
}
if got := state.SelectedEntryID; got != "vault-console" {
t.Fatalf("SelectedEntryID = %q, want %q", got, "vault-console")
}
if err := state.ToggleVisibleIndex(1); err != nil {
t.Fatalf("ToggleVisibleIndex() error = %v", err)
}
if got := state.SelectedEntryID; got != "" {
t.Fatalf("SelectedEntryID after toggle = %q, want empty", got)
}
}
func TestVisibleEntriesFailsWhenVaultIsLocked(t *testing.T) {
t.Parallel()
state := State{
Session: stubSession{err: session.ErrLocked},
}
_, err := state.VisibleEntries()
if !errors.Is(err, session.ErrLocked) {
t.Fatalf("VisibleEntries() error = %v, want ErrLocked", err)
}
}
type stubSession struct {
model vault.Model
err error
}
func (s stubSession) Current() (vault.Model, error) {
if s.err != nil {
return vault.Model{}, s.err
}
return s.model, nil
}
func TestDeleteSelectedEntryUpdatesSessionAndClearsSelection(t *testing.T) {
t.Parallel()
sess := &mutableStubSession{
model: vault.Model{
Entries: []vault.Entry{
{ID: "vault-console", Title: "Vault Console", Path: []string{"Root", "Internet"}},
},
},
}
state := State{
Session: sess,
CurrentPath: []string{"Root", "Internet"},
SelectedEntryID: "vault-console",
}
if err := state.DeleteSelectedEntry(); err != nil {
t.Fatalf("DeleteSelectedEntry() error = %v", err)
}
if got := state.SelectedEntryID; got != "" {
t.Fatalf("SelectedEntryID = %q, want empty", got)
}
if len(sess.model.Entries) != 0 {
t.Fatalf("len(Entries) = %d, want 0", len(sess.model.Entries))
}
if len(sess.model.RecycleBin) != 1 || sess.model.RecycleBin[0].ID != "vault-console" {
t.Fatalf("RecycleBin = %#v, want vault-console entry", sess.model.RecycleBin)
}
if !state.Dirty {
t.Fatal("Dirty = false, want true after delete")
}
}
func TestRestoreEntryMovesEntryBackIntoVisibleEntries(t *testing.T) {
t.Parallel()
sess := &mutableStubSession{
model: vault.Model{
RecycleBin: []vault.Entry{
{ID: "vault-console", Title: "Vault Console", Path: []string{"Root", "Internet"}},
},
},
}
state := State{
Session: sess,
CurrentPath: []string{"Root", "Internet"},
}
if err := state.RestoreEntry("vault-console"); err != nil {
t.Fatalf("RestoreEntry() error = %v", err)
}
got, err := state.VisibleEntries()
if err != nil {
t.Fatalf("VisibleEntries() error = %v", err)
}
if len(got) != 1 || got[0].ID != "vault-console" {
t.Fatalf("VisibleEntries() = %#v, want restored vault-console entry", got)
}
if !state.Dirty {
t.Fatal("Dirty = false, want true after restore")
}
}
func TestUpsertEntryPersistsEntryAndSelectsIt(t *testing.T) {
t.Parallel()
sess := &mutableStubSession{
model: vault.Model{},
}
state := State{
Session: sess,
CurrentPath: []string{"Root", "Internet"},
}
entry := vault.Entry{
ID: "vault-console",
Title: "Vault Console",
Username: "dannyocean",
Password: "token-1",
URL: "https://vault.crew.example.invalid",
Path: []string{"Root", "Internet"},
}
if err := state.UpsertEntry(entry); err != nil {
t.Fatalf("UpsertEntry() error = %v", err)
}
if got := state.SelectedEntryID; got != "vault-console" {
t.Fatalf("SelectedEntryID = %q, want %q", got, "vault-console")
}
got, err := state.VisibleEntries()
if err != nil {
t.Fatalf("VisibleEntries() error = %v", err)
}
if len(got) != 1 || got[0].Password != "token-1" {
t.Fatalf("VisibleEntries() = %#v, want persisted vault-console entry", got)
}
if !state.Dirty {
t.Fatal("Dirty = false, want true after upsert")
}
}
func TestInstantiateTemplateCreatesEntryAndSelectsIt(t *testing.T) {
t.Parallel()
sess := &mutableStubSession{
model: vault.Model{
Templates: []vault.Entry{
{
ID: "website-login",
Title: "Website Login",
Username: "template-user",
Password: "template-password",
URL: "https://example.com",
Notes: "Reusable template for website accounts.",
Path: []string{"Templates"},
},
},
},
}
state := State{
Session: sess,
CurrentPath: []string{"Root", "Internet"},
}
entry, err := state.InstantiateTemplate("website-login", vault.Entry{
ID: "bellagio",
Title: "Bellagio",
Username: "rustyryan",
Password: "hunter2",
URL: "https://bellagio.example.invalid",
Path: []string{"Root", "Internet"},
})
if err != nil {
t.Fatalf("InstantiateTemplate() error = %v", err)
}
if entry.Notes != "Reusable template for website accounts." {
t.Fatalf("entry.Notes = %q, want template notes", entry.Notes)
}
if got := state.SelectedEntryID; got != "bellagio" {
t.Fatalf("SelectedEntryID = %q, want %q", got, "bellagio")
}
got, err := state.VisibleEntries()
if err != nil {
t.Fatalf("VisibleEntries() error = %v", err)
}
if len(got) != 1 || got[0].ID != "bellagio" {
t.Fatalf("VisibleEntries() = %#v, want instantiated bellagio entry", got)
}
if !state.Dirty {
t.Fatal("Dirty = false, want true after template instantiation")
}
}
func TestUpsertTemplateCreatesTemplateAndMarksStateDirty(t *testing.T) {
t.Parallel()
sess := &mutableStubSession{model: vault.Model{}}
state := State{Session: sess}
if err := state.UpsertTemplate(vault.Entry{
ID: "tpl-1",
Title: "Website Login",
Username: "template-user",
Path: []string{"Templates"},
}); err != nil {
t.Fatalf("UpsertTemplate() error = %v", err)
}
if len(sess.model.Templates) != 1 || sess.model.Templates[0].ID != "tpl-1" {
t.Fatalf("Templates = %#v, want tpl-1 template", sess.model.Templates)
}
if state.SelectedEntryID != "tpl-1" {
t.Fatalf("SelectedEntryID = %q, want tpl-1 after template upsert", state.SelectedEntryID)
}
if !state.Dirty {
t.Fatal("Dirty = false, want true after template upsert")
}
}
func TestDeleteTemplateRemovesTemplateAndClearsSelection(t *testing.T) {
t.Parallel()
sess := &mutableStubSession{model: vault.Model{
Templates: []vault.Entry{
{ID: "tpl-1", Title: "Website Login", Path: []string{"Templates"}},
},
}}
state := State{
Session: sess,
SelectedEntryID: "tpl-1",
}
if err := state.DeleteTemplate("tpl-1"); err != nil {
t.Fatalf("DeleteTemplate() error = %v", err)
}
if len(sess.model.Templates) != 0 {
t.Fatalf("Templates = %#v, want empty after delete", sess.model.Templates)
}
if state.SelectedEntryID != "" {
t.Fatalf("SelectedEntryID = %q, want empty after delete", state.SelectedEntryID)
}
if !state.Dirty {
t.Fatal("Dirty = false, want true after template delete")
}
}
func TestDuplicateSelectedEntryCreatesCopyAndSelectsIt(t *testing.T) {
t.Parallel()
sess := &mutableStubSession{model: vault.Model{
Entries: []vault.Entry{
{ID: "vault-console", Title: "Vault Console", Path: []string{"Root", "Internet"}},
},
}}
state := State{
Session: sess,
SelectedEntryID: "vault-console",
}
duplicate, err := state.DuplicateSelectedEntry("vault-console-copy")
if err != nil {
t.Fatalf("DuplicateSelectedEntry() error = %v", err)
}
if duplicate.ID != "vault-console-copy" {
t.Fatalf("duplicate.ID = %q, want %q", duplicate.ID, "vault-console-copy")
}
if state.SelectedEntryID != "vault-console-copy" {
t.Fatalf("SelectedEntryID = %q, want vault-console-copy", state.SelectedEntryID)
}
if len(sess.model.Entries) != 2 {
t.Fatalf("len(Entries) = %d, want 2 after duplicate", len(sess.model.Entries))
}
}
func TestRestoreSelectedEntryVersionReplacesCurrentVersion(t *testing.T) {
t.Parallel()
sess := &mutableStubSession{model: vault.Model{
Entries: []vault.Entry{
{
ID: "vault-console",
Title: "Vault Console",
Password: "new-token",
Path: []string{"Root", "Internet"},
History: []vault.Entry{
{ID: "vault-console-h1", Title: "Vault Console", Password: "old-token", Path: []string{"Root", "Internet"}},
},
},
},
}}
state := State{
Session: sess,
SelectedEntryID: "vault-console",
}
if err := state.RestoreSelectedEntryVersion(0); err != nil {
t.Fatalf("RestoreSelectedEntryVersion() error = %v", err)
}
if got := sess.model.Entries[0].Password; got != "old-token" {
t.Fatalf("Entries[0].Password = %q, want %q", got, "old-token")
}
if !state.Dirty {
t.Fatal("Dirty = false, want true after history restore")
}
}
func TestSaveClearsDirtyState(t *testing.T) {
t.Parallel()
sess := &saveableStubSession{}
state := State{
Session: sess,
Dirty: true,
}
if err := state.Save(); err != nil {
t.Fatalf("Save() error = %v", err)
}
if state.Dirty {
t.Fatal("Dirty = true, want false after save")
}
if sess.saveCalls != 1 {
t.Fatalf("saveCalls = %d, want 1", sess.saveCalls)
}
}
func TestCreateVaultResetsSelectionPathAndDirtyState(t *testing.T) {
t.Parallel()
sess := &lifecycleStubSession{}
state := State{
Session: sess,
CurrentPath: []string{"Root", "Internet"},
SelectedEntryID: "vault-console",
Dirty: true,
}
if err := state.CreateVault(vault.MasterKey{Password: "correct horse battery staple"}); err != nil {
t.Fatalf("CreateVault() error = %v", err)
}
if sess.createCalls != 1 {
t.Fatalf("createCalls = %d, want 1", sess.createCalls)
}
if len(state.CurrentPath) != 0 {
t.Fatalf("CurrentPath = %v, want empty", state.CurrentPath)
}
if state.SelectedEntryID != "" {
t.Fatalf("SelectedEntryID = %q, want empty", state.SelectedEntryID)
}
if state.Dirty {
t.Fatal("Dirty = true, want false after create")
}
}
func TestOpenVaultResetsSelectionPathAndDirtyState(t *testing.T) {
t.Parallel()
sess := &lifecycleStubSession{}
state := State{
Session: sess,
CurrentPath: []string{"Root", "Internet"},
SelectedEntryID: "vault-console",
Dirty: true,
}
if err := state.OpenVault("/tmp/test.kdbx", vault.MasterKey{Password: "correct horse battery staple"}); err != nil {
t.Fatalf("OpenVault() error = %v", err)
}
if sess.openPath != "/tmp/test.kdbx" {
t.Fatalf("openPath = %q, want %q", sess.openPath, "/tmp/test.kdbx")
}
if len(state.CurrentPath) != 0 {
t.Fatalf("CurrentPath = %v, want empty", state.CurrentPath)
}
if state.SelectedEntryID != "" {
t.Fatalf("SelectedEntryID = %q, want empty", state.SelectedEntryID)
}
if state.Dirty {
t.Fatal("Dirty = true, want false after open")
}
}
func TestSaveAsClearsDirtyState(t *testing.T) {
t.Parallel()
sess := &lifecycleStubSession{}
state := State{
Session: sess,
Dirty: true,
}
if err := state.SaveAs("/tmp/other.kdbx"); err != nil {
t.Fatalf("SaveAs() error = %v", err)
}
if sess.saveAsPath != "/tmp/other.kdbx" {
t.Fatalf("saveAsPath = %q, want %q", sess.saveAsPath, "/tmp/other.kdbx")
}
if state.Dirty {
t.Fatal("Dirty = true, want false after save-as")
}
}
func TestOpenRemoteVaultResetsSelectionPathAndDirtyState(t *testing.T) {
t.Parallel()
sess := &lifecycleStubSession{}
client := webdav.Client{BaseURL: "https://example.com"}
state := State{
Session: sess,
CurrentPath: []string{"Root", "Internet"},
SelectedEntryID: "vault-console",
Dirty: true,
}
if err := state.OpenRemoteVault(client, "vaults/main.kdbx", vault.MasterKey{Password: "correct horse battery staple"}); err != nil {
t.Fatalf("OpenRemoteVault() error = %v", err)
}
if sess.remotePath != "vaults/main.kdbx" {
t.Fatalf("remotePath = %q, want %q", sess.remotePath, "vaults/main.kdbx")
}
if len(state.CurrentPath) != 0 {
t.Fatalf("CurrentPath = %v, want empty", state.CurrentPath)
}
if state.SelectedEntryID != "" {
t.Fatalf("SelectedEntryID = %q, want empty", state.SelectedEntryID)
}
if state.Dirty {
t.Fatal("Dirty = true, want false after remote open")
}
}
func TestLockClearsSelectionAndMakesVaultUnavailable(t *testing.T) {
t.Parallel()
sess := &lockableStubSession{
model: vault.Model{
Entries: []vault.Entry{
{ID: "vault-console", Title: "Vault Console", Path: []string{"Root", "Internet"}},
},
},
}
state := State{
Session: sess,
CurrentPath: []string{"Root", "Internet"},
SelectedEntryID: "vault-console",
}
if err := state.Lock(); err != nil {
t.Fatalf("Lock() error = %v", err)
}
if got := state.SelectedEntryID; got != "" {
t.Fatalf("SelectedEntryID = %q, want empty after lock", got)
}
_, err := state.VisibleEntries()
if !errors.Is(err, session.ErrLocked) {
t.Fatalf("VisibleEntries() error = %v, want ErrLocked", err)
}
}
func TestUnlockRestoresVaultVisibility(t *testing.T) {
t.Parallel()
sess := &lockableStubSession{
model: vault.Model{
Entries: []vault.Entry{
{ID: "vault-console", Title: "Vault Console", Path: []string{"Root", "Internet"}},
},
},
locked: true,
}
state := State{
Session: sess,
CurrentPath: []string{"Root", "Internet"},
}
if err := state.Unlock(vault.MasterKey{Password: "correct horse battery staple"}); err != nil {
t.Fatalf("Unlock() error = %v", err)
}
got, err := state.VisibleEntries()
if err != nil {
t.Fatalf("VisibleEntries() error = %v", err)
}
if len(got) != 1 || got[0].ID != "vault-console" {
t.Fatalf("VisibleEntries() = %#v, want vault-console entry after unlock", got)
}
}
func TestEnterGroupAppendsPathAndClearsSelection(t *testing.T) {
t.Parallel()
state := State{
CurrentPath: []string{"Root"},
SelectedEntryID: "vault-console",
}
state.EnterGroup("Internet")
if !slices.Equal(state.CurrentPath, []string{"Root", "Internet"}) {
t.Fatalf("CurrentPath = %v, want [Root Internet]", state.CurrentPath)
}
if got := state.SelectedEntryID; got != "" {
t.Fatalf("SelectedEntryID = %q, want empty", got)
}
}
func TestNavigateToPathReplacesPathAndClearsSelection(t *testing.T) {
t.Parallel()
state := State{
CurrentPath: []string{"Root", "Internet"},
SelectedEntryID: "vault-console",
}
state.NavigateToPath([]string{"Root", "Home Assistant"})
if !slices.Equal(state.CurrentPath, []string{"Root", "Home Assistant"}) {
t.Fatalf("CurrentPath = %v, want [Root Home Assistant]", state.CurrentPath)
}
if got := state.SelectedEntryID; got != "" {
t.Fatalf("SelectedEntryID = %q, want empty", got)
}
}
func TestCreateGroupPersistsGroupAndMarksDirty(t *testing.T) {
t.Parallel()
sess := &mutableStubSession{model: testVaultModel()}
state := State{
Session: sess,
CurrentPath: []string{"Root"},
}
if err := state.CreateGroup("Finance"); err != nil {
t.Fatalf("CreateGroup() error = %v", err)
}
got, err := state.ChildGroups()
if err != nil {
t.Fatalf("ChildGroups() error = %v", err)
}
if !slices.Equal(got, []string{"Finance", "Home Assistant", "Internet"}) {
t.Fatalf("ChildGroups() = %v, want Finance, Home Assistant, Internet", got)
}
if !state.Dirty {
t.Fatal("Dirty = false, want true after CreateGroup")
}
}
func TestRenameCurrentGroupUpdatesPathAndMarksDirty(t *testing.T) {
t.Parallel()
sess := &mutableStubSession{model: testVaultModel()}
state := State{
Session: sess,
CurrentPath: []string{"Root", "Internet"},
}
if err := state.RenameCurrentGroup("Infra"); err != nil {
t.Fatalf("RenameCurrentGroup() error = %v", err)
}
if !slices.Equal(state.CurrentPath, []string{"Root", "Infra"}) {
t.Fatalf("CurrentPath = %v, want [Root Infra]", state.CurrentPath)
}
if !state.Dirty {
t.Fatal("Dirty = false, want true after RenameCurrentGroup")
}
}
func TestMoveSelectedEntryPersistsPathChangeAndMarksDirty(t *testing.T) {
t.Parallel()
sess := &mutableStubSession{model: testVaultModel()}
state := State{
Session: sess,
CurrentPath: []string{"Root", "Internet"},
SelectedEntryID: "bellagio",
}
if err := state.MoveSelectedEntry([]string{"Root", "Home Assistant"}); err != nil {
t.Fatalf("MoveSelectedEntry() error = %v", err)
}
state.NavigateToPath([]string{"Root", "Home Assistant"})
got, err := state.VisibleEntries()
if err != nil {
t.Fatalf("VisibleEntries() error = %v", err)
}
if len(got) != 2 {
t.Fatalf("len(VisibleEntries()) = %d, want 2", len(got))
}
if got[0].ID != "bellagio" && got[1].ID != "bellagio" {
t.Fatalf("VisibleEntries() = %#v, want moved bellagio entry in destination group", got)
}
if !state.Dirty {
t.Fatal("Dirty = false, want true after MoveSelectedEntry")
}
}
func TestAddAttachmentToSelectedEntryPersistsAndMarksDirty(t *testing.T) {
t.Parallel()
sess := &mutableStubSession{model: testVaultModel()}
state := State{
Session: sess,
CurrentPath: []string{"Root", "Internet"},
SelectedEntryID: "bellagio",
}
if err := state.AddAttachmentToSelectedEntry("token.txt", []byte("secret")); err != nil {
t.Fatalf("AddAttachmentToSelectedEntry() error = %v", err)
}
got, err := state.VisibleEntries()
if err != nil {
t.Fatalf("VisibleEntries() error = %v", err)
}
if string(got[0].Attachments["token.txt"]) != "secret" {
t.Fatalf("attachment content = %q, want %q", got[0].Attachments["token.txt"], "secret")
}
if !state.Dirty {
t.Fatal("Dirty = false, want true after AddAttachmentToSelectedEntry")
}
}
func TestDeleteAttachmentFromSelectedEntryPersistsAndMarksDirty(t *testing.T) {
t.Parallel()
model := testVaultModel()
model.Entries[0].Attachments = map[string][]byte{"token.txt": []byte("secret")}
sess := &mutableStubSession{model: model}
state := State{
Session: sess,
CurrentPath: []string{"Root", "Internet"},
SelectedEntryID: "bellagio",
}
if err := state.DeleteAttachmentFromSelectedEntry("token.txt"); err != nil {
t.Fatalf("DeleteAttachmentFromSelectedEntry() error = %v", err)
}
got, err := state.VisibleEntries()
if err != nil {
t.Fatalf("VisibleEntries() error = %v", err)
}
if len(got[0].Attachments) != 0 {
t.Fatalf("attachments = %#v, want empty", got[0].Attachments)
}
if !state.Dirty {
t.Fatal("Dirty = false, want true after DeleteAttachmentFromSelectedEntry")
}
}
type mutableStubSession struct {
model vault.Model
err error
}
func (s *mutableStubSession) Current() (vault.Model, error) {
if s.err != nil {
return vault.Model{}, s.err
}
return s.model, nil
}
func (s *mutableStubSession) Replace(model vault.Model) {
s.model = model
}
func testVaultModel() vault.Model {
return vault.Model{
Entries: []vault.Entry{
{ID: "bellagio", Title: "Bellagio", Path: []string{"Root", "Internet"}},
{ID: "surveillance-console", Title: "Surveillance Console", Path: []string{"Root", "Home Assistant"}},
},
}
}
type lockableStubSession struct {
model vault.Model
locked bool
}
func (s *lockableStubSession) Current() (vault.Model, error) {
if s.locked {
return vault.Model{}, session.ErrLocked
}
return s.model, nil
}
func (s *lockableStubSession) Lock() error {
s.locked = true
return nil
}
func (s *lockableStubSession) Unlock(vault.MasterKey) error {
s.locked = false
return nil
}
type saveableStubSession struct {
saveCalls int
}
func (s *saveableStubSession) Current() (vault.Model, error) {
return vault.Model{}, nil
}
func (s *saveableStubSession) Save() error {
s.saveCalls++
return nil
}
type lifecycleStubSession struct {
createCalls int
openPath string
saveAsPath string
remotePath string
}
func (s *lifecycleStubSession) Current() (vault.Model, error) {
return vault.Model{}, nil
}
func (s *lifecycleStubSession) Create(_ vault.Model, _ vault.MasterKey) error {
s.createCalls++
return nil
}
func (s *lifecycleStubSession) Open(path string, _ vault.MasterKey) error {
s.openPath = path
return nil
}
func (s *lifecycleStubSession) SaveAs(path string) error {
s.saveAsPath = path
return nil
}
func (s *lifecycleStubSession) OpenRemote(_ webdav.Client, path string, _ vault.MasterKey) error {
s.remotePath = path
return nil
}
+81
View File
@@ -0,0 +1,81 @@
package clipboard
import (
"errors"
"fmt"
systemclipboard "github.com/atotto/clipboard"
"git.julianfamily.org/keepassgo/vault"
)
var ErrUnsupportedTarget = errors.New("unsupported clipboard target")
type Target string
const (
TargetUsername Target = "username"
TargetPassword Target = "password"
TargetURL Target = "url"
)
type Writer interface {
WriteText(text string) error
}
type Service struct {
Writer Writer
}
func (s Service) Copy(model vault.Model, entryID string, target Target) error {
entry, err := findEntry(model, entryID)
if err != nil {
return err
}
content, err := contentForTarget(entry, target)
if err != nil {
return err
}
if err := s.writer().WriteText(content); err != nil {
return fmt.Errorf("write clipboard text: %w", err)
}
return nil
}
func (s Service) writer() Writer {
if s.Writer != nil {
return s.Writer
}
return systemWriter{}
}
func findEntry(model vault.Model, entryID string) (vault.Entry, error) {
for _, entry := range model.Entries {
if entry.ID == entryID {
return entry, nil
}
}
return vault.Entry{}, vault.ErrEntryNotFound
}
func contentForTarget(entry vault.Entry, target Target) (string, error) {
switch target {
case TargetUsername:
return entry.Username, nil
case TargetPassword:
return entry.Password, nil
case TargetURL:
return entry.URL, nil
default:
return "", ErrUnsupportedTarget
}
}
type systemWriter struct{}
func (systemWriter) WriteText(text string) error {
return systemclipboard.WriteAll(text)
}
+78
View File
@@ -0,0 +1,78 @@
package clipboard
import (
"errors"
"testing"
"git.julianfamily.org/keepassgo/vault"
)
func TestServiceCopiesUsernamePasswordAndURL(t *testing.T) {
t.Parallel()
model := vault.Model{
Entries: []vault.Entry{
{
ID: "vault-console",
Title: "Vault Console",
Username: "dannyocean",
Password: "token-1",
URL: "https://vault.crew.example.invalid",
},
},
}
tests := []struct {
name string
target Target
want string
}{
{name: "username", target: TargetUsername, want: "dannyocean"},
{name: "password", target: TargetPassword, want: "token-1"},
{name: "url", target: TargetURL, want: "https://vault.crew.example.invalid"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
var writer memoryWriter
service := Service{Writer: &writer}
if err := service.Copy(model, "vault-console", tt.target); err != nil {
t.Fatalf("Copy() error = %v", err)
}
if writer.content != tt.want {
t.Fatalf("clipboard content = %q, want %q", writer.content, tt.want)
}
})
}
}
func TestServiceRejectsUnknownEntryAndUnsupportedTarget(t *testing.T) {
t.Parallel()
var writer memoryWriter
service := Service{Writer: &writer}
err := service.Copy(vault.Model{}, "missing", TargetPassword)
if !errors.Is(err, vault.ErrEntryNotFound) {
t.Fatalf("Copy() missing entry error = %v, want ErrEntryNotFound", err)
}
model := vault.Model{
Entries: []vault.Entry{{ID: "vault-console", Username: "dannyocean"}},
}
err = service.Copy(model, "vault-console", Target("unsupported"))
if !errors.Is(err, ErrUnsupportedTarget) {
t.Fatalf("Copy() unsupported target error = %v, want ErrUnsupportedTarget", err)
}
}
type memoryWriter struct {
content string
}
func (w *memoryWriter) WriteText(text string) error {
w.content = text
return nil
}
+34
View File
@@ -0,0 +1,34 @@
# Desktop Automation Decision
## Decision
KeePassGO will not implement KeePass-style auto-type as its primary desktop automation mechanism.
The secure gRPC API is the replacement integration surface for trusted desktop automation, browser extensions, and local helper tools.
## Why
- The gRPC API gives a narrower and more explicit trust boundary than synthetic key injection.
- Browser and local automation clients can request exactly the credential operation they need:
- list entries
- list groups
- list templates
- create and mutate entries
- copy fields
- manage attachments
- generate passwords
- A typed authenticated API is easier to test and reason about than fake keystroke delivery to arbitrary windows.
- Synthetic typing is platform-specific, fragile, and substantially harder to make safe across Linux, Windows, and future Android support.
- The product requirement is integration capability comparable in purpose to KeePass auto-type, not literal keystroke emulation.
## Product Consequences
- Trusted desktop automation should be implemented as clients of the gRPC service.
- Browser integrations should target the gRPC API rather than ad hoc local protocols.
- UI workflows may still provide copy-to-clipboard behavior for direct human use.
- If a future use case demonstrates that gRPC plus clipboard is insufficient, native desktop automation can be reconsidered as a secondary capability, not as the baseline integration model.
## Exit-Criteria Impact
This document is the explicit resolution of the desktop automation requirement from [`AGENTS.md`](../AGENTS.md) and [`TODO.md`](../TODO.md):
- desktop automation is resolved by design
- the secure gRPC interface is the superseding trusted integration surface
+26
View File
@@ -0,0 +1,26 @@
# KDBX Security Compatibility
KeePassGO supports the following KDBX security workflows today:
- open and save password-only vaults
- open and save key-file-only vaults
- open and save composite password-plus-key-file vaults
- preserve the original opened vault's KDBX format version during save
- preserve the original opened vault's cipher selection during save
- preserve the original opened vault's KDF selection during save
What "preserve" means:
- if a vault is opened through a managed session and then saved, KeePassGO reuses the opened vault's KDBX header configuration instead of replacing it with default header settings
- this applies to local and WebDAV-backed vault sessions
Current explicit limitations:
- KeePassGO does not yet provide a UI for editing cipher or KDF parameters directly
- new vault creation still uses the library default KDBX header settings for freshly created databases
- unsupported or unknown header fields outside the preserved header structures are not guaranteed to round-trip if they are not represented by the underlying library
Practical expectation:
- existing KeePass/KeePass2Android-compatible vaults keep their major format, cipher, and KDF family when edited and saved through KeePassGO
- KeePassGO does not yet try to be a full advanced database-settings editor
+210
View File
@@ -0,0 +1,210 @@
module git.julianfamily.org/keepassgo
go 1.26
require (
gioui.org v0.9.0
github.com/atotto/clipboard v0.1.4
github.com/tobischo/gokeepasslib/v3 v3.6.2
golang.org/x/exp/shiny v0.0.0-20260312153236-7ab1446f8b90
google.golang.org/grpc v1.79.3
google.golang.org/protobuf v1.36.11
)
require (
4d63.com/gocheckcompilerdirectives v1.3.0 // indirect
4d63.com/gochecknoglobals v0.2.2 // indirect
gioui.org/shader v1.0.8 // indirect
github.com/4meepo/tagalign v1.4.2 // indirect
github.com/Abirdcfly/dupword v0.1.3 // indirect
github.com/Antonboom/errname v1.0.0 // indirect
github.com/Antonboom/nilnil v1.0.1 // indirect
github.com/Antonboom/testifylint v1.5.2 // indirect
github.com/BurntSushi/toml v1.4.1-0.20240526193622-a339e1f7089c // indirect
github.com/Crocmagnon/fatcontext v0.7.1 // indirect
github.com/Djarvur/go-err113 v0.0.0-20210108212216-aea10b59be24 // indirect
github.com/GaijinEntertainment/go-exhaustruct/v3 v3.3.1 // indirect
github.com/Masterminds/semver/v3 v3.3.0 // indirect
github.com/OpenPeeDeeP/depguard/v2 v2.2.1 // indirect
github.com/alecthomas/go-check-sumtype v0.3.1 // indirect
github.com/alexkohler/nakedret/v2 v2.0.5 // indirect
github.com/alexkohler/prealloc v1.0.0 // indirect
github.com/alingse/asasalint v0.0.11 // indirect
github.com/alingse/nilnesserr v0.1.2 // indirect
github.com/ashanbrown/forbidigo v1.6.0 // indirect
github.com/ashanbrown/makezero v1.2.0 // indirect
github.com/beorn7/perks v1.0.1 // indirect
github.com/bkielbasa/cyclop v1.2.3 // indirect
github.com/blizzy78/varnamelen v0.8.0 // indirect
github.com/bombsimon/wsl/v4 v4.5.0 // indirect
github.com/breml/bidichk v0.3.2 // indirect
github.com/breml/errchkjson v0.4.0 // indirect
github.com/butuzov/ireturn v0.3.1 // indirect
github.com/butuzov/mirror v1.3.0 // indirect
github.com/catenacyber/perfsprint v0.8.2 // indirect
github.com/ccojocar/zxcvbn-go v1.0.2 // indirect
github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/charithe/durationcheck v0.0.10 // indirect
github.com/chavacava/garif v0.1.0 // indirect
github.com/ckaznocha/intrange v0.3.0 // indirect
github.com/curioswitch/go-reassign v0.3.0 // indirect
github.com/daixiang0/gci v0.13.5 // indirect
github.com/davecgh/go-spew v1.1.1 // indirect
github.com/denis-tingaikin/go-header v0.5.0 // indirect
github.com/ettle/strcase v0.2.0 // indirect
github.com/fatih/color v1.18.0 // indirect
github.com/fatih/structtag v1.2.0 // indirect
github.com/firefart/nonamedreturns v1.0.5 // indirect
github.com/fsnotify/fsnotify v1.5.4 // indirect
github.com/fzipp/gocyclo v0.6.0 // indirect
github.com/ghostiam/protogetter v0.3.9 // indirect
github.com/go-critic/go-critic v0.12.0 // indirect
github.com/go-text/typesetting v0.3.0 // indirect
github.com/go-toolsmith/astcast v1.1.0 // indirect
github.com/go-toolsmith/astcopy v1.1.0 // indirect
github.com/go-toolsmith/astequal v1.2.0 // indirect
github.com/go-toolsmith/astfmt v1.1.0 // indirect
github.com/go-toolsmith/astp v1.1.0 // indirect
github.com/go-toolsmith/strparse v1.1.0 // indirect
github.com/go-toolsmith/typep v1.1.0 // indirect
github.com/go-viper/mapstructure/v2 v2.2.1 // indirect
github.com/go-xmlfmt/xmlfmt v1.1.3 // indirect
github.com/gobwas/glob v0.2.3 // indirect
github.com/gofrs/flock v0.12.1 // indirect
github.com/golang/protobuf v1.5.4 // indirect
github.com/golangci/dupl v0.0.0-20250308024227-f665c8d69b32 // indirect
github.com/golangci/go-printf-func-name v0.1.0 // indirect
github.com/golangci/gofmt v0.0.0-20250106114630-d62b90e6713d // indirect
github.com/golangci/golangci-lint v1.64.8 // indirect
github.com/golangci/misspell v0.6.0 // indirect
github.com/golangci/plugin-module-register v0.1.1 // indirect
github.com/golangci/revgrep v0.8.0 // indirect
github.com/golangci/unconvert v0.0.0-20240309020433-c5143eacb3ed // indirect
github.com/google/go-cmp v0.7.0 // indirect
github.com/gordonklaus/ineffassign v0.1.0 // indirect
github.com/gostaticanalysis/analysisutil v0.7.1 // indirect
github.com/gostaticanalysis/comment v1.5.0 // indirect
github.com/gostaticanalysis/forcetypeassert v0.2.0 // indirect
github.com/gostaticanalysis/nilerr v0.1.1 // indirect
github.com/hashicorp/go-immutable-radix/v2 v2.1.0 // indirect
github.com/hashicorp/go-version v1.7.0 // indirect
github.com/hashicorp/golang-lru/v2 v2.0.7 // indirect
github.com/hashicorp/hcl v1.0.0 // indirect
github.com/hexops/gotextdiff v1.0.3 // indirect
github.com/inconshreveable/mousetrap v1.1.0 // indirect
github.com/jgautheron/goconst v1.7.1 // indirect
github.com/jingyugao/rowserrcheck v1.1.1 // indirect
github.com/jjti/go-spancheck v0.6.4 // indirect
github.com/julz/importas v0.2.0 // indirect
github.com/karamaru-alpha/copyloopvar v1.2.1 // indirect
github.com/kisielk/errcheck v1.9.0 // indirect
github.com/kkHAIKE/contextcheck v1.1.6 // indirect
github.com/kulti/thelper v0.6.3 // indirect
github.com/kunwardeep/paralleltest v1.0.10 // indirect
github.com/lasiar/canonicalheader v1.1.2 // indirect
github.com/ldez/exptostd v0.4.2 // indirect
github.com/ldez/gomoddirectives v0.6.1 // indirect
github.com/ldez/grignotin v0.9.0 // indirect
github.com/ldez/tagliatelle v0.7.1 // indirect
github.com/ldez/usetesting v0.4.2 // indirect
github.com/leonklingele/grouper v1.1.2 // indirect
github.com/macabu/inamedparam v0.1.3 // indirect
github.com/magiconair/properties v1.8.6 // indirect
github.com/maratori/testableexamples v1.0.0 // indirect
github.com/maratori/testpackage v1.1.1 // indirect
github.com/matoous/godox v1.1.0 // indirect
github.com/mattn/go-colorable v0.1.14 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/mattn/go-runewidth v0.0.16 // indirect
github.com/matttproud/golang_protobuf_extensions v1.0.1 // indirect
github.com/mgechev/revive v1.7.0 // indirect
github.com/mitchellh/go-homedir v1.1.0 // indirect
github.com/mitchellh/mapstructure v1.5.0 // indirect
github.com/moricho/tparallel v0.3.2 // indirect
github.com/nakabonne/nestif v0.3.1 // indirect
github.com/nishanths/exhaustive v0.12.0 // indirect
github.com/nishanths/predeclared v0.2.2 // indirect
github.com/nunnatsa/ginkgolinter v0.19.1 // indirect
github.com/olekukonko/tablewriter v0.0.5 // indirect
github.com/pelletier/go-toml v1.9.5 // indirect
github.com/pelletier/go-toml/v2 v2.2.3 // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect
github.com/polyfloyd/go-errorlint v1.7.1 // indirect
github.com/prometheus/client_golang v1.12.1 // indirect
github.com/prometheus/client_model v0.2.0 // indirect
github.com/prometheus/common v0.32.1 // indirect
github.com/prometheus/procfs v0.7.3 // indirect
github.com/quasilyte/go-ruleguard v0.4.3-0.20240823090925-0fe6f58b47b1 // indirect
github.com/quasilyte/go-ruleguard/dsl v0.3.22 // indirect
github.com/quasilyte/gogrep v0.5.0 // indirect
github.com/quasilyte/regex/syntax v0.0.0-20210819130434-b3f0c404a727 // indirect
github.com/quasilyte/stdinfo v0.0.0-20220114132959-f7386bf02567 // indirect
github.com/raeperd/recvcheck v0.2.0 // indirect
github.com/rivo/uniseg v0.4.7 // indirect
github.com/rogpeppe/go-internal v1.14.1 // indirect
github.com/ryancurrah/gomodguard v1.3.5 // indirect
github.com/ryanrolds/sqlclosecheck v0.5.1 // indirect
github.com/sanposhiho/wastedassign/v2 v2.1.0 // indirect
github.com/santhosh-tekuri/jsonschema/v6 v6.0.1 // indirect
github.com/sashamelentyev/interfacebloat v1.1.0 // indirect
github.com/sashamelentyev/usestdlibvars v1.28.0 // indirect
github.com/securego/gosec/v2 v2.22.2 // indirect
github.com/sirupsen/logrus v1.9.3 // indirect
github.com/sivchari/containedctx v1.0.3 // indirect
github.com/sivchari/tenv v1.12.1 // indirect
github.com/sonatard/noctx v0.1.0 // indirect
github.com/sourcegraph/go-diff v0.7.0 // indirect
github.com/spf13/afero v1.12.0 // indirect
github.com/spf13/cast v1.5.0 // indirect
github.com/spf13/cobra v1.9.1 // indirect
github.com/spf13/jwalterweatherman v1.1.0 // indirect
github.com/spf13/pflag v1.0.6 // indirect
github.com/spf13/viper v1.12.0 // indirect
github.com/ssgreg/nlreturn/v2 v2.2.1 // indirect
github.com/stbenjam/no-sprintf-host-port v0.2.0 // indirect
github.com/stretchr/objx v0.5.2 // indirect
github.com/stretchr/testify v1.11.1 // indirect
github.com/subosito/gotenv v1.4.1 // indirect
github.com/tdakkota/asciicheck v0.4.1 // indirect
github.com/tetafro/godot v1.5.0 // indirect
github.com/timakin/bodyclose v0.0.0-20241017074812-ed6a65f985e3 // indirect
github.com/timonwong/loggercheck v0.10.1 // indirect
github.com/tobischo/argon2 v0.1.0 // indirect
github.com/tomarrell/wrapcheck/v2 v2.10.0 // indirect
github.com/tommy-muehle/go-mnd/v2 v2.5.1 // indirect
github.com/ultraware/funlen v0.2.0 // indirect
github.com/ultraware/whitespace v0.2.0 // indirect
github.com/uudashr/gocognit v1.2.0 // indirect
github.com/uudashr/iface v1.3.1 // indirect
github.com/xen0n/gosmopolitan v1.2.2 // indirect
github.com/yagipy/maintidx v1.0.0 // indirect
github.com/yeya24/promlinter v0.3.0 // indirect
github.com/ykadowak/zerologlint v0.1.5 // indirect
gitlab.com/bosi/decorder v0.4.2 // indirect
go-simpler.org/musttag v0.13.0 // indirect
go-simpler.org/sloglint v0.9.0 // indirect
go.uber.org/atomic v1.7.0 // indirect
go.uber.org/automaxprocs v1.6.0 // indirect
go.uber.org/multierr v1.6.0 // indirect
go.uber.org/zap v1.24.0 // indirect
golang.org/x/crypto v0.48.0 // indirect
golang.org/x/exp/typeparams v0.0.0-20250210185358-939b2ce775ac // indirect
golang.org/x/image v0.37.0 // indirect
golang.org/x/mod v0.33.0 // indirect
golang.org/x/net v0.50.0 // indirect
golang.org/x/sync v0.20.0 // indirect
golang.org/x/sys v0.42.0 // indirect
golang.org/x/text v0.35.0 // indirect
golang.org/x/tools v0.42.0 // indirect
golang.org/x/tools/go/expect v0.1.1-deprecated // indirect
golang.org/x/tools/go/packages/packagestest v0.1.1-deprecated // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20251202230838-ff82c1b0f217 // indirect
gopkg.in/ini.v1 v1.67.0 // indirect
gopkg.in/yaml.v2 v2.4.0 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
honnef.co/go/tools v0.6.1 // indirect
mvdan.cc/gofumpt v0.7.0 // indirect
mvdan.cc/unparam v0.0.0-20240528143540-8a5130ca722f // indirect
)
tool github.com/golangci/golangci-lint/cmd/golangci-lint
+1024
View File
File diff suppressed because it is too large Load Diff
+1122
View File
File diff suppressed because it is too large Load Diff
+530
View File
@@ -0,0 +1,530 @@
package main
import (
"bytes"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"slices"
"testing"
"git.julianfamily.org/keepassgo/clipboard"
"git.julianfamily.org/keepassgo/session"
"git.julianfamily.org/keepassgo/vault"
)
func TestUIFiltersUsingVaultModelPathsAndSearch(t *testing.T) {
t.Parallel()
u := newUIWithModel("desktop", vault.Model{
Entries: []vault.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"}},
},
})
u.currentPath = []string{"Crew", "Internet"}
u.filter()
if got := u.filteredTitles(); !slices.Equal(got, []string{"Bellagio", "Vault Console"}) {
t.Fatalf("filteredTitles() = %v, want [Bellagio Vault Console]", got)
}
u.search.SetText("surveillance")
u.filter()
if got := u.filteredTitles(); !slices.Equal(got, []string{"Surveillance Console"}) {
t.Fatalf("search filteredTitles() = %v, want [Surveillance Console]", got)
}
}
func TestUIChildGroupsComeFromVaultModel(t *testing.T) {
t.Parallel()
u := newUIWithModel("desktop", vault.Model{
Entries: []vault.Entry{
{ID: "1", Title: "Bellagio", Path: []string{"Crew", "Internet"}},
{ID: "2", Title: "Surveillance Console", Path: []string{"Crew", "Home Assistant"}},
{ID: "3", Title: "Alma (WA Prep)", Path: []string{"Tricia", "School"}},
},
})
u.currentPath = []string{"Crew"}
if got := u.childGroups(); !slices.Equal(got, []string{"Home Assistant", "Internet"}) {
t.Fatalf("childGroups() = %v, want [Home Assistant Internet]", got)
}
}
func TestUISelectedEntryFollowsApplicationStateSelection(t *testing.T) {
t.Parallel()
u := newUIWithModel("desktop", vault.Model{
Entries: []vault.Entry{
{ID: "1", Title: "Bellagio", Path: []string{"Crew", "Internet"}},
{ID: "2", Title: "Vault Console", Path: []string{"Crew", "Internet"}},
},
})
u.currentPath = []string{"Crew", "Internet"}
u.filter()
u.state.SelectedEntryID = "2"
got, ok := u.selectedEntry()
if !ok {
t.Fatal("selectedEntry() ok = false, want true")
}
if got.Title != "Vault Console" {
t.Fatalf("selectedEntry().Title = %q, want %q", got.Title, "Vault Console")
}
}
func TestUILockHidesVisibleEntries(t *testing.T) {
t.Parallel()
u := newUIWithModel("desktop", vault.Model{
Entries: []vault.Entry{
{ID: "1", Title: "Bellagio", Path: []string{"Crew", "Internet"}},
},
})
if err := u.state.Lock(); err != nil {
t.Fatalf("state.Lock() error = %v", err)
}
u.filter()
if got := u.filteredTitles(); len(got) != 0 {
t.Fatalf("filteredTitles() = %v, want empty while locked", got)
}
}
func TestUILifecycleActionsCreateSaveOpenLockAndUnlockLocalVault(t *testing.T) {
t.Parallel()
u := newUIWithSession("desktop", &session.Manager{})
u.masterPassword.SetText("correct horse battery staple")
if err := u.createVaultAction(); err != nil {
t.Fatalf("createVaultAction() error = %v", err)
}
if err := u.state.UpsertEntry(vault.Entry{
ID: "vault-console",
Title: "Vault Console",
Username: "dannyocean",
Password: "token-1",
URL: "https://vault.crew.example.invalid",
Path: []string{"Root", "Internet"},
}); err != nil {
t.Fatalf("UpsertEntry() error = %v", err)
}
path := filepath.Join(t.TempDir(), "keepassgo.kdbx")
u.saveAsPath.SetText(path)
if err := u.saveAsAction(); err != nil {
t.Fatalf("saveAsAction() error = %v", err)
}
if err := u.lockAction(); err != nil {
t.Fatalf("lockAction() error = %v", err)
}
u.currentPath = []string{"Root", "Internet"}
u.filter()
if got := u.filteredTitles(); len(got) != 0 {
t.Fatalf("filteredTitles() = %v, want empty while locked", got)
}
if err := u.unlockAction(); err != nil {
t.Fatalf("unlockAction() error = %v", err)
}
u.filter()
if got := u.filteredTitles(); !slices.Equal(got, []string{"Vault Console"}) {
t.Fatalf("filteredTitles() after unlock = %v, want [Vault Console]", got)
}
reopened := newUIWithSession("desktop", &session.Manager{})
reopened.masterPassword.SetText("correct horse battery staple")
reopened.vaultPath.SetText(path)
if err := reopened.openVaultAction(); err != nil {
t.Fatalf("openVaultAction() error = %v", err)
}
reopened.currentPath = []string{"Root", "Internet"}
reopened.filter()
if got := reopened.filteredTitles(); !slices.Equal(got, []string{"Vault Console"}) {
t.Fatalf("reopened filteredTitles() = %v, want [Vault Console]", got)
}
}
func TestUIOpenRemoteAndSaveThroughConfiguredWebDAVTarget(t *testing.T) {
t.Parallel()
key := vault.MasterKey{Password: "correct horse battery staple"}
model := vault.Model{
Entries: []vault.Entry{
{
ID: "vault-console",
Title: "Vault Console",
Username: "dannyocean",
Password: "token-1",
URL: "https://vault.crew.example.invalid",
Path: []string{"Root", "Internet"},
},
},
}
var putCount int
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.Method {
case http.MethodGet:
var encoded bytes.Buffer
if err := vault.SaveKDBXWithKey(&encoded, model, key); err != nil {
t.Fatalf("SaveKDBXWithKey() error = %v", err)
}
w.Header().Set("ETag", "\"v1\"")
_, _ = w.Write(encoded.Bytes())
case http.MethodPut:
putCount++
w.Header().Set("ETag", "\"v2\"")
w.WriteHeader(http.StatusNoContent)
default:
t.Fatalf("unexpected method %s", r.Method)
}
}))
defer server.Close()
u := newUIWithSession("desktop", &session.Manager{})
u.masterPassword.SetText("correct horse battery staple")
u.remoteBaseURL.SetText(server.URL)
u.remotePath.SetText("vaults/main.kdbx")
if err := u.openRemoteAction(); err != nil {
t.Fatalf("openRemoteAction() error = %v", err)
}
if err := u.state.UpsertEntry(vault.Entry{
ID: "vault-console",
Title: "Vault Console",
Username: "dannyocean",
Password: "token-2",
URL: "https://vault.crew.example.invalid",
Path: []string{"Root", "Internet"},
}); err != nil {
t.Fatalf("UpsertEntry() error = %v", err)
}
if err := u.saveAction(); err != nil {
t.Fatalf("saveAction() error = %v", err)
}
if putCount != 1 {
t.Fatalf("remote PUT count = %d, want 1", putCount)
}
}
func TestUIMasterKeyInputSupportsKeyFileAndCompositeKeys(t *testing.T) {
t.Parallel()
keyFile := filepath.Join(t.TempDir(), "master.key")
keyData := []byte("key-file-bytes")
if err := os.WriteFile(keyFile, keyData, 0o600); err != nil {
t.Fatalf("WriteFile(keyFile) error = %v", err)
}
u := newUIWithSession("desktop", &session.Manager{})
u.masterPassword.SetText("correct horse battery staple")
u.keyFilePath.SetText(keyFile)
key, err := u.currentMasterKey()
if err != nil {
t.Fatalf("currentMasterKey() error = %v", err)
}
if key.Password != "correct horse battery staple" {
t.Fatalf("MasterKey.Password = %q, want correct horse battery staple", key.Password)
}
if !bytes.Equal(key.KeyFileData, keyData) {
t.Fatalf("MasterKey.KeyFileData = %q, want %q", key.KeyFileData, keyData)
}
}
func TestUISectionNavigationShowsTemplatesAndRecycleBin(t *testing.T) {
t.Parallel()
u := newUIWithModel("desktop", vault.Model{
Entries: []vault.Entry{
{ID: "entry-1", Title: "Vault Console", Path: []string{"Root", "Internet"}},
},
Templates: []vault.Entry{
{ID: "tpl-1", Title: "Website Login", Path: []string{"Templates", "Web"}},
},
RecycleBin: []vault.Entry{
{ID: "deleted-1", Title: "Deleted Entry", Path: []string{"Root", "Internet"}},
},
})
u.showTemplatesSection()
if got := u.filteredTitles(); !slices.Equal(got, []string{"Website Login"}) {
t.Fatalf("template filteredTitles() = %v, want [Website Login]", got)
}
u.showRecycleBinSection()
if got := u.filteredTitles(); !slices.Equal(got, []string{"Deleted Entry"}) {
t.Fatalf("recycle filteredTitles() = %v, want [Deleted Entry]", got)
}
u.showEntriesSection()
u.currentPath = []string{"Root", "Internet"}
u.filter()
if got := u.filteredTitles(); !slices.Equal(got, []string{"Vault Console"}) {
t.Fatalf("entry filteredTitles() = %v, want [Vault Console]", got)
}
}
func TestUISavesDuplicatesDeletesAndRestoresEntriesFromTheEditor(t *testing.T) {
t.Parallel()
u := newUIWithModel("desktop", vault.Model{
Entries: []vault.Entry{
{
ID: "vault-console",
Title: "Vault Console",
Username: "dannyocean",
Password: "token-1",
URL: "https://vault.crew.example.invalid",
Path: []string{"Root", "Internet"},
},
},
})
u.showEntriesSection()
u.currentPath = []string{"Root", "Internet"}
u.filter()
u.state.SelectedEntryID = "vault-console"
u.loadSelectedEntryIntoEditor()
u.entryPassword.SetText("token-2")
if err := u.saveEntryAction(); err != nil {
t.Fatalf("saveEntryAction() error = %v", err)
}
u.filter()
if entry, ok := u.selectedEntry(); !ok || entry.Password != "token-2" {
t.Fatalf("selectedEntry() = %#v, want updated password token-2", entry)
}
if err := u.duplicateSelectedEntryAction(); err != nil {
t.Fatalf("duplicateSelectedEntryAction() error = %v", err)
}
u.filter()
if got := u.filteredTitles(); !slices.Equal(got, []string{"Vault Console", "Vault Console (Copy)"}) {
t.Fatalf("filteredTitles() after duplicate = %v, want copy present", got)
}
if err := u.deleteSelectedEntryAction(); err != nil {
t.Fatalf("deleteSelectedEntryAction() error = %v", err)
}
u.showRecycleBinSection()
if got := u.filteredTitles(); !slices.Equal(got, []string{"Vault Console (Copy)"}) {
t.Fatalf("recycle filteredTitles() = %v, want deleted copy", got)
}
u.state.SelectedEntryID = "vault-console-copy"
if err := u.restoreSelectedRecycleEntryAction(); err != nil {
t.Fatalf("restoreSelectedRecycleEntryAction() error = %v", err)
}
u.showEntriesSection()
u.currentPath = []string{"Root", "Internet"}
u.filter()
if got := u.filteredTitles(); !slices.Equal(got, []string{"Vault Console", "Vault Console (Copy)"}) {
t.Fatalf("filteredTitles() after restore = %v, want restored copy", got)
}
}
func TestUITemplateAndAttachmentActionsWorkThroughEditor(t *testing.T) {
t.Parallel()
u := newUIWithModel("desktop", vault.Model{
Templates: []vault.Entry{
{
ID: "tpl-1",
Title: "Website Login",
Username: "template-user",
Password: "template-password",
Notes: "Reusable template",
Path: []string{"Templates", "Web"},
},
},
})
u.showTemplatesSection()
u.filter()
u.state.SelectedEntryID = "tpl-1"
u.loadSelectedEntryIntoEditor()
u.entryTitle.SetText("Website Login Updated")
if err := u.saveTemplateAction(); err != nil {
t.Fatalf("saveTemplateAction() error = %v", err)
}
u.entryID.SetText("entry-1")
u.entryTitle.SetText("Bellagio")
u.entryUsername.SetText("rustyryan")
u.entryPassword.SetText("token-1")
u.entryURL.SetText("https://bellagio.example.invalid")
u.entryPath.SetText("Root / Internet")
if err := u.instantiateSelectedTemplateAction(); err != nil {
t.Fatalf("instantiateSelectedTemplateAction() error = %v", err)
}
u.showEntriesSection()
u.currentPath = []string{"Root", "Internet"}
u.filter()
u.state.SelectedEntryID = "entry-1"
u.loadSelectedEntryIntoEditor()
attachmentPath := filepath.Join(t.TempDir(), "token.txt")
attachmentExportPath := filepath.Join(t.TempDir(), "exported.txt")
content := []byte("attachment-content")
if err := os.WriteFile(attachmentPath, content, 0o600); err != nil {
t.Fatalf("WriteFile(attachmentPath) error = %v", err)
}
u.attachmentPath.SetText(attachmentPath)
u.attachmentName.SetText("token.txt")
if err := u.addAttachmentAction(); err != nil {
t.Fatalf("addAttachmentAction() error = %v", err)
}
u.exportAttachmentPath.SetText(attachmentExportPath)
if err := u.exportAttachmentAction(); err != nil {
t.Fatalf("exportAttachmentAction() error = %v", err)
}
exported, err := os.ReadFile(attachmentExportPath)
if err != nil {
t.Fatalf("ReadFile(exportAttachmentPath) error = %v", err)
}
if !bytes.Equal(exported, content) {
t.Fatalf("exported attachment = %q, want %q", exported, content)
}
if err := u.removeAttachmentAction(); err != nil {
t.Fatalf("removeAttachmentAction() error = %v", err)
}
u.showTemplatesSection()
u.filter()
u.state.SelectedEntryID = "tpl-1"
if err := u.deleteSelectedTemplateAction(); err != nil {
t.Fatalf("deleteSelectedTemplateAction() error = %v", err)
}
u.filter()
if got := u.filteredTitles(); len(got) != 0 {
t.Fatalf("template filteredTitles() after delete = %v, want empty", got)
}
}
func TestUIRestoresSelectedEntryHistoryVersion(t *testing.T) {
t.Parallel()
u := newUIWithModel("desktop", vault.Model{
Entries: []vault.Entry{
{
ID: "vault-console",
Title: "Vault Console",
Username: "dannyocean",
Password: "token-2",
URL: "https://vault.crew.example.invalid",
Path: []string{"Root", "Internet"},
History: []vault.Entry{
{
ID: "vault-console-h1",
Title: "Vault Console",
Username: "dannyocean",
Password: "token-1",
URL: "https://vault.crew.example.invalid",
Path: []string{"Root", "Internet"},
},
},
},
},
})
u.showEntriesSection()
u.currentPath = []string{"Root", "Internet"}
u.filter()
u.state.SelectedEntryID = "vault-console"
u.loadSelectedEntryIntoEditor()
u.historyIndex.SetText("0")
if err := u.restoreSelectedHistoryAction(); err != nil {
t.Fatalf("restoreSelectedHistoryAction() error = %v", err)
}
u.filter()
if entry, ok := u.selectedEntry(); !ok || entry.Password != "token-1" {
t.Fatalf("selectedEntry() = %#v, want restored password token-1", entry)
}
}
func TestUIKeyboardShortcutActionsDispatchExpectedCommands(t *testing.T) {
t.Parallel()
u := newUIWithModel("desktop", vault.Model{
Entries: []vault.Entry{
{
ID: "vault-console",
Title: "Vault Console",
Username: "dannyocean",
Password: "token-1",
URL: "https://vault.crew.example.invalid",
Path: []string{"Root", "Internet"},
},
},
})
u.showEntriesSection()
u.currentPath = []string{"Root", "Internet"}
u.filter()
u.state.SelectedEntryID = "vault-console"
u.loadSelectedEntryIntoEditor()
if err := u.performShortcut(shortcutNewEntry); err != nil {
t.Fatalf("performShortcut(new-entry) error = %v", err)
}
if u.state.SelectedEntryID != "" {
t.Fatalf("SelectedEntryID = %q, want empty after new-entry shortcut", u.state.SelectedEntryID)
}
u.state.SelectedEntryID = "vault-console"
if err := u.performShortcut(shortcutCopyUser); err != nil {
t.Fatalf("performShortcut(copy-user) error = %v", err)
}
if err := u.performShortcut(shortcutCopyPassword); err != nil {
t.Fatalf("performShortcut(copy-password) error = %v", err)
}
if err := u.performShortcut(shortcutCopyURL); err != nil {
t.Fatalf("performShortcut(copy-url) error = %v", err)
}
}
func TestUIActionErrorsAndStatusMessagesAreCapturedForDisplay(t *testing.T) {
t.Parallel()
u := newUIWithSession("desktop", &session.Manager{})
u.vaultPath.SetText("/does/not/exist.kdbx")
u.masterPassword.SetText("correct horse battery staple")
u.runAction("open vault", u.openVaultAction)
if u.errorMessage == "" {
t.Fatal("errorMessage = empty, want visible action error")
}
u = newUIWithModel("desktop", vault.Model{
Entries: []vault.Entry{
{ID: "vault-console", Title: "Vault Console", Username: "dannyocean", Password: "token-1", URL: "https://vault.crew.example.invalid", Path: []string{"Root", "Internet"}},
},
})
u.showEntriesSection()
u.currentPath = []string{"Root", "Internet"}
u.filter()
u.state.SelectedEntryID = "vault-console"
u.runAction("copy username", func() error { return u.copySelectedFieldAction(clipboard.TargetUsername) })
if u.statusMessage == "" {
t.Fatal("statusMessage = empty, want visible success status")
}
}
+168
View File
@@ -0,0 +1,168 @@
package passwords
import (
"crypto/rand"
"errors"
"fmt"
"math/big"
"strings"
)
const (
lowercaseChars = "abcdefghijkmnopqrstuvwxyz"
uppercaseChars = "ABCDEFGHJKLMNPQRSTUVWXYZ"
digitChars = "23456789"
symbolChars = "!@#$%^&*()-_=+[]{}<>?/."
)
var ErrImpossibleProfile = errors.New("impossible password profile")
type Profile struct {
Name string
Length int
Lowercase bool
Uppercase bool
Digits bool
Symbols bool
MinLowercase int
MinUppercase int
MinDigits int
MinSymbols int
ExcludeSimilar bool
}
func DefaultProfiles() map[string]Profile {
return map[string]Profile{
"strong": {
Name: "strong",
Length: 24,
Lowercase: true,
Uppercase: true,
Digits: true,
Symbols: true,
MinLowercase: 2,
MinUppercase: 2,
MinDigits: 2,
MinSymbols: 2,
ExcludeSimilar: true,
},
"memorable": {
Name: "memorable",
Length: 20,
Lowercase: true,
Uppercase: true,
Digits: true,
Symbols: false,
MinLowercase: 4,
MinUppercase: 2,
MinDigits: 2,
ExcludeSimilar: true,
},
}
}
func Generate(profile Profile) (string, error) {
if err := validateProfile(profile); err != nil {
return "", err
}
var chars []byte
var pool strings.Builder
if profile.Lowercase {
pool.WriteString(lowercaseChars)
chars = append(chars, mustRandomChars(lowercaseChars, profile.MinLowercase)...)
}
if profile.Uppercase {
pool.WriteString(uppercaseChars)
chars = append(chars, mustRandomChars(uppercaseChars, profile.MinUppercase)...)
}
if profile.Digits {
pool.WriteString(digitChars)
chars = append(chars, mustRandomChars(digitChars, profile.MinDigits)...)
}
if profile.Symbols {
pool.WriteString(symbolChars)
chars = append(chars, mustRandomChars(symbolChars, profile.MinSymbols)...)
}
allChars := pool.String()
for len(chars) < profile.Length {
ch, err := randomChar(allChars)
if err != nil {
return "", err
}
chars = append(chars, ch)
}
if err := shuffle(chars); err != nil {
return "", err
}
return string(chars), nil
}
func validateProfile(profile Profile) error {
if profile.Length <= 0 {
return fmt.Errorf("%w: length must be positive", ErrImpossibleProfile)
}
required := profile.MinLowercase + profile.MinUppercase + profile.MinDigits + profile.MinSymbols
if required > profile.Length {
return fmt.Errorf("%w: minimum character counts exceed length", ErrImpossibleProfile)
}
if profile.MinLowercase > 0 && !profile.Lowercase {
return fmt.Errorf("%w: lowercase disabled with lowercase minimum", ErrImpossibleProfile)
}
if profile.MinUppercase > 0 && !profile.Uppercase {
return fmt.Errorf("%w: uppercase disabled with uppercase minimum", ErrImpossibleProfile)
}
if profile.MinDigits > 0 && !profile.Digits {
return fmt.Errorf("%w: digits disabled with digit minimum", ErrImpossibleProfile)
}
if profile.MinSymbols > 0 && !profile.Symbols {
return fmt.Errorf("%w: symbols disabled with symbol minimum", ErrImpossibleProfile)
}
if !profile.Lowercase && !profile.Uppercase && !profile.Digits && !profile.Symbols {
return fmt.Errorf("%w: no character sets enabled", ErrImpossibleProfile)
}
return nil
}
func mustRandomChars(chars string, count int) []byte {
if count <= 0 {
return nil
}
out := make([]byte, 0, count)
for i := 0; i < count; i++ {
ch, err := randomChar(chars)
if err != nil {
panic(err)
}
out = append(out, ch)
}
return out
}
func randomChar(chars string) (byte, error) {
n, err := rand.Int(rand.Reader, big.NewInt(int64(len(chars))))
if err != nil {
return 0, fmt.Errorf("random index: %w", err)
}
return chars[n.Int64()], nil
}
func shuffle(chars []byte) error {
for i := len(chars) - 1; i > 0; i-- {
n, err := rand.Int(rand.Reader, big.NewInt(int64(i+1)))
if err != nil {
return fmt.Errorf("shuffle password: %w", err)
}
j := int(n.Int64())
chars[i], chars[j] = chars[j], chars[i]
}
return nil
}
+97
View File
@@ -0,0 +1,97 @@
package passwords
import (
"strings"
"testing"
)
func TestGenerateRespectsProfileRequirements(t *testing.T) {
t.Parallel()
profile := Profile{
Name: "strong",
Length: 24,
Lowercase: true,
Uppercase: true,
Digits: true,
Symbols: true,
MinLowercase: 2,
MinUppercase: 2,
MinDigits: 2,
MinSymbols: 2,
ExcludeSimilar: true,
}
password, err := Generate(profile)
if err != nil {
t.Fatalf("Generate() error = %v", err)
}
if len(password) != 24 {
t.Fatalf("len(password) = %d, want 24", len(password))
}
if countFromSet(password, lowercaseChars) < 2 {
t.Fatalf("lowercase count in %q is too small", password)
}
if countFromSet(password, uppercaseChars) < 2 {
t.Fatalf("uppercase count in %q is too small", password)
}
if countFromSet(password, digitChars) < 2 {
t.Fatalf("digit count in %q is too small", password)
}
if countFromSet(password, symbolChars) < 2 {
t.Fatalf("symbol count in %q is too small", password)
}
if strings.ContainsAny(password, "O0Il1") {
t.Fatalf("password %q contains excluded similar characters", password)
}
}
func TestGenerateRejectsImpossibleProfiles(t *testing.T) {
t.Parallel()
_, err := Generate(Profile{
Name: "bad",
Length: 6,
Lowercase: true,
Uppercase: true,
Digits: true,
Symbols: true,
MinLowercase: 2,
MinUppercase: 2,
MinDigits: 2,
MinSymbols: 2,
})
if err == nil {
t.Fatal("Generate() error = nil, want impossible profile error")
}
}
func TestProfileSetReturnsNamedProfiles(t *testing.T) {
t.Parallel()
set := DefaultProfiles()
profile, ok := set["strong"]
if !ok {
t.Fatalf("DefaultProfiles()[\"strong\"] missing")
}
if profile.Length < 20 || !profile.Symbols {
t.Fatalf("strong profile = %#v, want a strong reusable profile", profile)
}
}
func countFromSet(password, chars string) int {
count := 0
for _, r := range password {
if strings.ContainsRune(chars, r) {
count++
}
}
return count
}
File diff suppressed because it is too large Load Diff
+229
View File
@@ -0,0 +1,229 @@
syntax = "proto3";
package keepassgo.v1;
option go_package = "git.julianfamily.org/keepassgo/proto/keepassgo/v1;keepassgov1";
service VaultService {
rpc GetSessionStatus(GetSessionStatusRequest) returns (GetSessionStatusResponse);
rpc OpenVault(OpenVaultRequest) returns (OpenVaultResponse);
rpc OpenRemoteVault(OpenRemoteVaultRequest) returns (OpenRemoteVaultResponse);
rpc SaveVault(SaveVaultRequest) returns (SaveVaultResponse);
rpc LockVault(LockVaultRequest) returns (LockVaultResponse);
rpc UnlockVault(UnlockVaultRequest) returns (UnlockVaultResponse);
rpc ListEntries(ListEntriesRequest) returns (ListEntriesResponse);
rpc ListGroups(ListGroupsRequest) returns (ListGroupsResponse);
rpc CreateGroup(CreateGroupRequest) returns (CreateGroupResponse);
rpc RenameGroup(RenameGroupRequest) returns (RenameGroupResponse);
rpc UpsertEntry(UpsertEntryRequest) returns (UpsertEntryResponse);
rpc DeleteEntry(DeleteEntryRequest) returns (DeleteEntryResponse);
rpc RestoreEntry(RestoreEntryRequest) returns (RestoreEntryResponse);
rpc ListEntryHistory(ListEntryHistoryRequest) returns (ListEntryHistoryResponse);
rpc RestoreEntryHistory(RestoreEntryHistoryRequest) returns (RestoreEntryHistoryResponse);
rpc ListTemplates(ListTemplatesRequest) returns (ListTemplatesResponse);
rpc UpsertTemplate(UpsertTemplateRequest) returns (UpsertTemplateResponse);
rpc DeleteTemplate(DeleteTemplateRequest) returns (DeleteTemplateResponse);
rpc InstantiateTemplate(InstantiateTemplateRequest) returns (InstantiateTemplateResponse);
rpc ListAttachments(ListAttachmentsRequest) returns (ListAttachmentsResponse);
rpc UploadAttachment(UploadAttachmentRequest) returns (UploadAttachmentResponse);
rpc DownloadAttachment(DownloadAttachmentRequest) returns (DownloadAttachmentResponse);
rpc DeleteAttachment(DeleteAttachmentRequest) returns (DeleteAttachmentResponse);
rpc CopyEntryField(CopyEntryFieldRequest) returns (CopyEntryFieldResponse);
rpc GeneratePassword(GeneratePasswordRequest) returns (GeneratePasswordResponse);
}
message GetSessionStatusRequest {}
message GetSessionStatusResponse {
bool locked = 1;
bool dirty = 2;
uint32 entry_count = 3;
}
message OpenVaultRequest {
string path = 1;
string password = 2;
bytes key_file_data = 3;
}
message OpenVaultResponse {}
message OpenRemoteVaultRequest {
string base_url = 1;
string path = 2;
string username = 3;
string password = 4;
string master_password = 5;
bytes key_file_data = 6;
}
message OpenRemoteVaultResponse {}
message SaveVaultRequest {}
message SaveVaultResponse {}
message LockVaultRequest {}
message LockVaultResponse {}
message UnlockVaultRequest {}
message UnlockVaultResponse {}
message ListEntriesRequest {
repeated string path = 1;
string query = 2;
}
message Entry {
string id = 1;
string title = 2;
string username = 3;
string password = 4;
string url = 5;
string notes = 6;
repeated string tags = 7;
repeated string path = 8;
}
message ListEntriesResponse {
repeated Entry entries = 1;
}
message ListGroupsRequest {
repeated string path = 1;
}
message ListGroupsResponse {
repeated string names = 1;
}
message CreateGroupRequest {
repeated string parent_path = 1;
string name = 2;
}
message CreateGroupResponse {}
message RenameGroupRequest {
repeated string path = 1;
string new_name = 2;
}
message RenameGroupResponse {}
message UpsertEntryRequest {
Entry entry = 1;
}
message UpsertEntryResponse {
Entry entry = 1;
}
message DeleteEntryRequest {
string id = 1;
}
message DeleteEntryResponse {}
message RestoreEntryRequest {
string id = 1;
}
message RestoreEntryResponse {
Entry entry = 1;
}
message ListEntryHistoryRequest {
string id = 1;
}
message ListEntryHistoryResponse {
repeated Entry entries = 1;
}
message RestoreEntryHistoryRequest {
string id = 1;
uint32 history_index = 2;
}
message RestoreEntryHistoryResponse {
Entry entry = 1;
}
message ListTemplatesRequest {}
message ListTemplatesResponse {
repeated Entry templates = 1;
}
message UpsertTemplateRequest {
Entry template = 1;
}
message UpsertTemplateResponse {
Entry template = 1;
}
message DeleteTemplateRequest {
string id = 1;
}
message DeleteTemplateResponse {}
message InstantiateTemplateRequest {
string template_id = 1;
Entry overrides = 2;
}
message InstantiateTemplateResponse {
Entry entry = 1;
}
message ListAttachmentsRequest {
string entry_id = 1;
}
message ListAttachmentsResponse {
repeated string names = 1;
}
message UploadAttachmentRequest {
string entry_id = 1;
string name = 2;
bytes content = 3;
}
message UploadAttachmentResponse {}
message DownloadAttachmentRequest {
string entry_id = 1;
string name = 2;
}
message DownloadAttachmentResponse {
bytes content = 1;
}
message DeleteAttachmentRequest {
string entry_id = 1;
string name = 2;
}
message DeleteAttachmentResponse {}
message CopyEntryFieldRequest {
string id = 1;
string target = 2;
}
message CopyEntryFieldResponse {}
message GeneratePasswordRequest {
string profile = 1;
}
message GeneratePasswordResponse {
string password = 1;
}
File diff suppressed because it is too large Load Diff
+180
View File
@@ -0,0 +1,180 @@
package session
import (
"bytes"
"errors"
"fmt"
"os"
"git.julianfamily.org/keepassgo/vault"
"git.julianfamily.org/keepassgo/webdav"
)
var (
ErrLocked = errors.New("vault is locked")
ErrNoPath = errors.New("no vault path configured")
)
type Manager struct {
model vault.Model
config *vault.KDBXConfig
path string
key vault.MasterKey
locked bool
encoded []byte
remoteClient *webdav.Client
remotePath string
remoteVersion webdav.Version
}
func (m *Manager) Create(model vault.Model, key vault.MasterKey) error {
var encoded bytes.Buffer
if err := vault.SaveKDBXWithConfigAndKey(&encoded, model, key, m.config); err != nil {
return fmt.Errorf("encode new vault: %w", err)
}
m.model = model
m.key = key
m.encoded = encoded.Bytes()
m.locked = false
return nil
}
func (m *Manager) Open(path string, key vault.MasterKey) error {
content, err := os.ReadFile(path)
if err != nil {
return fmt.Errorf("read %s: %w", path, err)
}
model, config, err := vault.LoadKDBXWithConfig(bytes.NewReader(content), key)
if err != nil {
return fmt.Errorf("open %s: %w", path, err)
}
m.model = model
m.config = config
m.path = path
m.key = key
m.encoded = content
m.locked = false
return nil
}
func (m *Manager) Save() error {
if m.remoteClient != nil && m.remotePath != "" {
return m.SaveRemote()
}
if m.path == "" {
return ErrNoPath
}
return m.saveToPath(m.path)
}
func (m *Manager) OpenRemote(client webdav.Client, path string, key vault.MasterKey) error {
content, version, err := client.Open(path)
if err != nil {
return fmt.Errorf("open remote %s: %w", path, err)
}
model, config, err := vault.LoadKDBXWithConfig(bytes.NewReader(content), key)
if err != nil {
return fmt.Errorf("decode remote %s: %w", path, err)
}
m.model = model
m.config = config
m.key = key
m.encoded = content
m.locked = false
m.remoteClient = &client
m.remotePath = path
m.remoteVersion = version
return nil
}
func (m *Manager) SaveRemote() error {
if m.remoteClient == nil || m.remotePath == "" {
return ErrNoPath
}
var encoded bytes.Buffer
if err := vault.SaveKDBXWithConfigAndKey(&encoded, m.model, m.key, m.config); err != nil {
return fmt.Errorf("encode vault: %w", err)
}
version, err := m.remoteClient.Save(m.remotePath, bytes.NewReader(encoded.Bytes()), m.remoteVersion)
if err != nil {
return fmt.Errorf("save remote %s: %w", m.remotePath, err)
}
m.encoded = encoded.Bytes()
m.remoteVersion = version
return nil
}
func (m *Manager) SaveAs(path string) error {
if err := m.saveToPath(path); err != nil {
return err
}
m.path = path
return nil
}
func (m *Manager) Replace(model vault.Model) {
m.model = model
m.locked = false
}
func (m *Manager) Current() (vault.Model, error) {
if m.locked {
return vault.Model{}, ErrLocked
}
return m.model, nil
}
func (m *Manager) Lock() error {
if m.locked {
return nil
}
var encoded bytes.Buffer
if err := vault.SaveKDBXWithConfigAndKey(&encoded, m.model, m.key, m.config); err != nil {
return fmt.Errorf("encode vault for lock: %w", err)
}
m.encoded = encoded.Bytes()
m.model = vault.Model{}
m.locked = true
return nil
}
func (m *Manager) Unlock(key vault.MasterKey) error {
model, config, err := vault.LoadKDBXWithConfig(bytes.NewReader(m.encoded), key)
if err != nil {
return fmt.Errorf("unlock vault: %w", err)
}
m.model = model
m.config = config
m.key = key
m.locked = false
return nil
}
func (m *Manager) saveToPath(path string) error {
var encoded bytes.Buffer
if err := vault.SaveKDBXWithConfigAndKey(&encoded, m.model, m.key, m.config); err != nil {
return fmt.Errorf("encode vault: %w", err)
}
if err := os.WriteFile(path, encoded.Bytes(), 0o600); err != nil {
return fmt.Errorf("write %s: %w", path, err)
}
m.encoded = encoded.Bytes()
return nil
}
+477
View File
@@ -0,0 +1,477 @@
package session
import (
"bytes"
"errors"
"io"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"testing"
"git.julianfamily.org/keepassgo/vault"
"git.julianfamily.org/keepassgo/webdav"
"github.com/tobischo/gokeepasslib/v3"
w "github.com/tobischo/gokeepasslib/v3/wrappers"
)
func TestCreateSaveAsLockAndUnlockRoundTripsVault(t *testing.T) {
t.Parallel()
key := vault.MasterKey{Password: "correct horse battery staple"}
model := vault.Model{
Entries: []vault.Entry{
{
ID: "entry-1",
Title: "Vault Console",
Username: "dannyocean",
Password: "token-1",
URL: "https://vault.crew.example.invalid",
Path: []string{"Root", "Internet"},
},
},
}
var sess Manager
if err := sess.Create(model, key); err != nil {
t.Fatalf("Create() error = %v", err)
}
path := filepath.Join(t.TempDir(), "keepassgo.kdbx")
if err := sess.SaveAs(path); err != nil {
t.Fatalf("SaveAs() error = %v", err)
}
if _, err := os.Stat(path); err != nil {
t.Fatalf("Stat(saved path) error = %v", err)
}
if err := sess.Lock(); err != nil {
t.Fatalf("Lock() error = %v", err)
}
if _, err := sess.Current(); !errors.Is(err, ErrLocked) {
t.Fatalf("Current() error = %v, want ErrLocked", err)
}
if err := sess.Unlock(key); err != nil {
t.Fatalf("Unlock() error = %v", err)
}
current, err := sess.Current()
if err != nil {
t.Fatalf("Current() after Unlock() error = %v", err)
}
got := current.EntriesInPath([]string{"Root", "Internet"})
if len(got) != 1 || got[0].Title != "Vault Console" || got[0].Password != "token-1" {
t.Fatalf("Current() entries = %#v, want persisted Vault Console entry", got)
}
}
func TestOpenLoadsExistingKDBXFromDisk(t *testing.T) {
t.Parallel()
key := vault.MasterKey{Password: "correct horse battery staple"}
model := vault.Model{
Entries: []vault.Entry{
{
ID: "entry-1",
Title: "Surveillance Console",
Username: "codex",
Password: "token-2",
URL: "https://surveillance.crew.example.invalid",
Path: []string{"Root", "Home Assistant"},
},
},
}
path := filepath.Join(t.TempDir(), "existing.kdbx")
file, err := os.Create(path)
if err != nil {
t.Fatalf("Create(existing path) error = %v", err)
}
if err := vault.SaveKDBXWithKey(file, model, key); err != nil {
file.Close()
t.Fatalf("SaveKDBXWithKey() error = %v", err)
}
if err := file.Close(); err != nil {
t.Fatalf("Close(existing path) 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)
}
got := current.EntriesInPath([]string{"Root", "Home Assistant"})
if len(got) != 1 || got[0].Password != "token-2" {
t.Fatalf("Current() entries = %#v, want Home Assistant entry", got)
}
}
func TestSavePersistsEditsBackToCurrentPath(t *testing.T) {
t.Parallel()
key := vault.MasterKey{Password: "correct horse battery staple"}
model := vault.Model{
Entries: []vault.Entry{
{
ID: "entry-1",
Title: "Vault Console",
Username: "dannyocean",
Password: "token-1",
URL: "https://vault.crew.example.invalid",
Path: []string{"Root", "Internet"},
},
},
}
path := filepath.Join(t.TempDir(), "editable.kdbx")
var sess Manager
if err := sess.Create(model, key); err != nil {
t.Fatalf("Create() error = %v", err)
}
if err := sess.SaveAs(path); err != nil {
t.Fatalf("SaveAs() error = %v", err)
}
updated := model
updated.UpsertEntry(vault.Entry{
ID: "entry-1",
Title: "Vault Console",
Username: "dannyocean",
Password: "token-2",
URL: "https://vault.crew.example.invalid",
Path: []string{"Root", "Internet"},
})
sess.Replace(updated)
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()
loaded, err := vault.LoadKDBXWithKey(reopened, key)
if err != nil {
t.Fatalf("LoadKDBXWithKey() error = %v", err)
}
got := loaded.EntriesInPath([]string{"Root", "Internet"})
if len(got) != 1 || got[0].Password != "token-2" {
t.Fatalf("loaded entries = %#v, want updated password token-2", got)
}
}
func TestSaveWithoutPathFails(t *testing.T) {
t.Parallel()
var sess Manager
if err := sess.Create(vault.Model{}, vault.MasterKey{Password: "correct horse battery staple"}); err != nil {
t.Fatalf("Create() error = %v", err)
}
err := sess.Save()
if !errors.Is(err, ErrNoPath) {
t.Fatalf("Save() error = %v, want ErrNoPath", err)
}
}
func TestOpenRemoteLoadsExistingKDBXFromWebDAV(t *testing.T) {
t.Parallel()
key := vault.MasterKey{Password: "correct horse battery staple"}
model := vault.Model{
Entries: []vault.Entry{
{
ID: "entry-1",
Title: "Vault Console",
Username: "dannyocean",
Password: "token-1",
URL: "https://vault.crew.example.invalid",
Path: []string{"Root", "Internet"},
},
},
}
var encoded bytes.Buffer
if err := vault.SaveKDBXWithKey(&encoded, model, key); err != nil {
t.Fatalf("SaveKDBXWithKey() error = %v", err)
}
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet || r.URL.Path != "/vaults/main.kdbx" {
t.Fatalf("unexpected request %s %s", r.Method, r.URL.Path)
}
w.Header().Set("ETag", "\"v1\"")
_, _ = w.Write(encoded.Bytes())
}))
defer server.Close()
client := webdav.Client{BaseURL: server.URL}
var sess Manager
if err := sess.OpenRemote(client, "vaults/main.kdbx", key); err != nil {
t.Fatalf("OpenRemote() error = %v", err)
}
current, err := sess.Current()
if err != nil {
t.Fatalf("Current() error = %v", err)
}
got := current.EntriesInPath([]string{"Root", "Internet"})
if len(got) != 1 || got[0].Password != "token-1" {
t.Fatalf("Current() entries = %#v, want Vault Console entry from remote vault", got)
}
}
func TestSaveRemotePersistsEditsBackToWebDAV(t *testing.T) {
t.Parallel()
key := vault.MasterKey{Password: "correct horse battery staple"}
model := vault.Model{
Entries: []vault.Entry{
{
ID: "entry-1",
Title: "Surveillance Console",
Username: "codex",
Password: "token-1",
URL: "https://surveillance.crew.example.invalid",
Path: []string{"Root", "Home Assistant"},
},
},
}
var (
savedETag string
savedBytes []byte
)
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.Method {
case http.MethodGet:
var encoded bytes.Buffer
if err := vault.SaveKDBXWithKey(&encoded, model, key); err != nil {
t.Fatalf("SaveKDBXWithKey() error = %v", err)
}
w.Header().Set("ETag", "\"v1\"")
_, _ = w.Write(encoded.Bytes())
case http.MethodPut:
savedETag = r.Header.Get("If-Match")
var err error
savedBytes, err = io.ReadAll(r.Body)
if err != nil {
t.Fatalf("ReadAll(PUT body) error = %v", err)
}
w.Header().Set("ETag", "\"v2\"")
w.WriteHeader(http.StatusNoContent)
default:
t.Fatalf("unexpected method %s", r.Method)
}
}))
defer server.Close()
client := webdav.Client{BaseURL: server.URL}
var sess Manager
if err := sess.OpenRemote(client, "vaults/main.kdbx", key); err != nil {
t.Fatalf("OpenRemote() error = %v", err)
}
current, err := sess.Current()
if err != nil {
t.Fatalf("Current() error = %v", err)
}
current.UpsertEntry(vault.Entry{
ID: "entry-1",
Title: "Surveillance Console",
Username: "codex",
Password: "token-2",
URL: "https://surveillance.crew.example.invalid",
Path: []string{"Root", "Home Assistant"},
})
sess.Replace(current)
if err := sess.SaveRemote(); err != nil {
t.Fatalf("SaveRemote() error = %v", err)
}
if savedETag != "\"v1\"" {
t.Fatalf("If-Match header = %q, want %q", savedETag, "\"v1\"")
}
loaded, err := vault.LoadKDBXWithKey(bytes.NewReader(savedBytes), key)
if err != nil {
t.Fatalf("LoadKDBXWithKey(savedBytes) error = %v", err)
}
got := loaded.EntriesInPath([]string{"Root", "Home Assistant"})
if len(got) != 1 || got[0].Password != "token-2" {
t.Fatalf("loaded remote entries = %#v, want updated token-2 entry", got)
}
}
func TestSaveUsesRemoteTargetWhenVaultWasOpenedFromWebDAV(t *testing.T) {
t.Parallel()
key := vault.MasterKey{Password: "correct horse battery staple"}
model := vault.Model{
Entries: []vault.Entry{
{
ID: "entry-1",
Title: "Vault Console",
Username: "dannyocean",
Password: "token-1",
URL: "https://vault.crew.example.invalid",
Path: []string{"Root", "Internet"},
},
},
}
var putCount int
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.Method {
case http.MethodGet:
var encoded bytes.Buffer
if err := vault.SaveKDBXWithKey(&encoded, model, key); err != nil {
t.Fatalf("SaveKDBXWithKey() error = %v", err)
}
w.Header().Set("ETag", "\"v1\"")
_, _ = w.Write(encoded.Bytes())
case http.MethodPut:
putCount++
w.Header().Set("ETag", "\"v2\"")
w.WriteHeader(http.StatusNoContent)
default:
t.Fatalf("unexpected method %s", r.Method)
}
}))
defer server.Close()
client := webdav.Client{BaseURL: server.URL}
var sess Manager
if err := sess.OpenRemote(client, "vaults/main.kdbx", key); err != nil {
t.Fatalf("OpenRemote() error = %v", err)
}
current, err := sess.Current()
if err != nil {
t.Fatalf("Current() error = %v", err)
}
current.UpsertEntry(vault.Entry{
ID: "entry-1",
Title: "Vault Console",
Username: "dannyocean",
Password: "token-2",
URL: "https://vault.crew.example.invalid",
Path: []string{"Root", "Internet"},
})
sess.Replace(current)
if err := sess.Save(); err != nil {
t.Fatalf("Save() error = %v", err)
}
if putCount != 1 {
t.Fatalf("remote PUT count = %d, want 1", putCount)
}
}
func TestSavePreservesOpenedKDBXSecuritySettings(t *testing.T) {
t.Parallel()
key := vault.MasterKey{Password: "correct horse battery staple"}
db := gokeepasslib.NewDatabase(gokeepasslib.WithDatabaseKDBXVersion4())
db.Credentials = gokeepasslib.NewPasswordCredentials(key.Password)
db.Content.Root.Groups = []gokeepasslib.Group{
{
Name: "Root",
Entries: []gokeepasslib.Entry{
{
UUID: gokeepasslib.NewUUID(),
Values: []gokeepasslib.ValueData{
{Key: "Title", Value: gokeepasslib.V{Content: "Vault Console"}},
{Key: "UserName", Value: gokeepasslib.V{Content: "dannyocean"}},
{Key: "Password", Value: gokeepasslib.V{Content: "token-1", Protected: w.NewBoolWrapper(true)}},
{Key: "URL", Value: gokeepasslib.V{Content: "https://vault.crew.example.invalid"}},
},
},
},
},
}
if err := db.LockProtectedEntries(); err != nil {
t.Fatalf("LockProtectedEntries() error = %v", err)
}
path := filepath.Join(t.TempDir(), "kdbx4.kdbx")
file, err := os.Create(path)
if err != nil {
t.Fatalf("Create(path) error = %v", err)
}
if err := gokeepasslib.NewEncoder(file).Encode(db); err != nil {
file.Close()
t.Fatalf("Encode() error = %v", err)
}
if err := file.Close(); err != nil {
t.Fatalf("Close(path) 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.UpsertEntry(vault.Entry{
ID: current.Entries[0].ID,
Title: "Vault Console",
Username: "dannyocean",
Password: "token-2",
URL: "https://vault.crew.example.invalid",
Path: current.Entries[0].Path,
})
sess.Replace(current)
if err := sess.Save(); err != nil {
t.Fatalf("Save() error = %v", err)
}
saved, err := os.Open(path)
if err != nil {
t.Fatalf("Open(saved path) error = %v", err)
}
defer saved.Close()
reloaded := gokeepasslib.NewDatabase()
reloaded.Credentials = gokeepasslib.NewPasswordCredentials(key.Password)
if err := gokeepasslib.NewDecoder(saved).Decode(reloaded); err != nil {
t.Fatalf("Decode(saved path) error = %v", err)
}
if !reloaded.Header.IsKdbx4() {
t.Fatal("saved header is not KDBX4, want preserved KDBX4 format")
}
if !bytes.Equal(reloaded.Header.FileHeaders.CipherID, db.Header.FileHeaders.CipherID) {
t.Fatalf("saved cipher = %x, want %x", reloaded.Header.FileHeaders.CipherID, db.Header.FileHeaders.CipherID)
}
if !bytes.Equal(reloaded.Header.FileHeaders.KdfParameters.UUID, db.Header.FileHeaders.KdfParameters.UUID) {
t.Fatalf("saved KDF UUID = %x, want %x", reloaded.Header.FileHeaders.KdfParameters.UUID, db.Header.FileHeaders.KdfParameters.UUID)
}
}
+337
View File
@@ -0,0 +1,337 @@
package main
import (
"fmt"
"os"
"strconv"
"strings"
"git.julianfamily.org/keepassgo/appstate"
"git.julianfamily.org/keepassgo/clipboard"
"git.julianfamily.org/keepassgo/passwords"
"git.julianfamily.org/keepassgo/vault"
)
func (u *ui) loadSelectedEntryIntoEditor() {
item, ok := u.selectedEntry()
if !ok {
u.entryID.SetText("")
u.entryTitle.SetText("")
u.entryUsername.SetText("")
u.entryPassword.SetText("")
u.entryURL.SetText("")
u.entryNotes.SetText("")
u.entryTags.SetText("")
u.entryPath.SetText(strings.Join(u.currentPath, " / "))
u.entryFields.SetText("")
u.attachmentName.SetText("")
u.attachmentPath.SetText("")
u.exportAttachmentPath.SetText("")
return
}
u.entryID.SetText(item.ID)
u.entryTitle.SetText(item.Title)
u.entryUsername.SetText(item.Username)
u.entryPassword.SetText(item.Password)
u.entryURL.SetText(item.URL)
u.entryNotes.SetText(item.Notes)
u.entryTags.SetText(strings.Join(item.Tags, ", "))
u.entryPath.SetText(strings.Join(item.Path, " / "))
u.entryFields.SetText(marshalFields(item.Fields))
u.attachmentName.SetText("")
u.attachmentPath.SetText("")
u.exportAttachmentPath.SetText("")
}
func (u *ui) saveEntryAction() error {
entry, err := u.editorEntry()
if err != nil {
return err
}
if err := u.state.UpsertEntry(entry); err != nil {
return err
}
u.filter()
return nil
}
func (u *ui) duplicateSelectedEntryAction() error {
baseID := strings.TrimSpace(u.state.SelectedEntryID)
if baseID == "" {
return fmt.Errorf("no entry selected")
}
duplicateID := baseID + "-copy"
if _, err := u.state.DuplicateSelectedEntry(duplicateID); err != nil {
return err
}
u.loadSelectedEntryIntoEditor()
u.filter()
return nil
}
func (u *ui) deleteSelectedEntryAction() error {
if err := u.state.DeleteSelectedEntry(); err != nil {
return err
}
u.loadSelectedEntryIntoEditor()
u.filter()
return nil
}
func (u *ui) restoreSelectedRecycleEntryAction() error {
id := strings.TrimSpace(u.state.SelectedEntryID)
if id == "" {
return fmt.Errorf("no recycle-bin entry selected")
}
if err := u.state.RestoreEntry(id); err != nil {
return err
}
u.filter()
return nil
}
func (u *ui) saveTemplateAction() error {
entry, err := u.editorEntry()
if err != nil {
return err
}
if len(entry.Path) == 0 {
entry.Path = []string{"Templates"}
}
if err := u.state.UpsertTemplate(entry); err != nil {
return err
}
u.filter()
return nil
}
func (u *ui) deleteSelectedTemplateAction() error {
id := strings.TrimSpace(u.state.SelectedEntryID)
if id == "" {
return fmt.Errorf("no template selected")
}
if err := u.state.DeleteTemplate(id); err != nil {
return err
}
u.loadSelectedEntryIntoEditor()
u.filter()
return nil
}
func (u *ui) createGroupAction() error {
return u.state.CreateGroup(strings.TrimSpace(u.groupName.Text()))
}
func (u *ui) renameGroupAction() error {
return u.state.RenameCurrentGroup(strings.TrimSpace(u.groupName.Text()))
}
func (u *ui) deleteCurrentGroupAction() error {
session, ok := u.state.Session.(appstate.MutableSession)
if !ok {
return fmt.Errorf("session is not mutable")
}
model, err := session.Current()
if err != nil {
return err
}
if err := model.DeleteGroup(u.currentPath); err != nil {
return err
}
session.Replace(model)
if len(u.currentPath) > 0 {
u.currentPath = append([]string(nil), u.currentPath[:len(u.currentPath)-1]...)
u.state.CurrentPath = append([]string(nil), u.currentPath...)
}
u.state.Dirty = true
u.filter()
return nil
}
func (u *ui) instantiateSelectedTemplateAction() error {
templateID := strings.TrimSpace(u.state.SelectedEntryID)
if templateID == "" {
return fmt.Errorf("no template selected")
}
entry, err := u.editorEntry()
if err != nil {
return err
}
if _, err := u.state.InstantiateTemplate(templateID, entry); err != nil {
return err
}
u.filter()
return nil
}
func (u *ui) addAttachmentAction() error {
content, err := os.ReadFile(strings.TrimSpace(u.attachmentPath.Text()))
if err != nil {
return fmt.Errorf("read attachment: %w", err)
}
name := strings.TrimSpace(u.attachmentName.Text())
if name == "" {
return fmt.Errorf("attachment name is required")
}
if err := u.state.AddAttachmentToSelectedEntry(name, content); err != nil {
return err
}
u.loadSelectedEntryIntoEditor()
u.attachmentName.SetText(name)
u.filter()
return nil
}
func (u *ui) exportAttachmentAction() error {
item, ok := u.selectedEntry()
if !ok {
return fmt.Errorf("no entry selected")
}
name := strings.TrimSpace(u.attachmentName.Text())
content, ok := item.Attachments[name]
if !ok {
return fmt.Errorf("attachment not found")
}
if err := os.WriteFile(strings.TrimSpace(u.exportAttachmentPath.Text()), content, 0o600); err != nil {
return fmt.Errorf("write attachment export: %w", err)
}
return nil
}
func (u *ui) removeAttachmentAction() error {
name := strings.TrimSpace(u.attachmentName.Text())
if name == "" {
return fmt.Errorf("attachment name is required")
}
if err := u.state.DeleteAttachmentFromSelectedEntry(name); err != nil {
return err
}
u.loadSelectedEntryIntoEditor()
u.filter()
return nil
}
func (u *ui) restoreSelectedHistoryAction() error {
index, err := strconv.Atoi(strings.TrimSpace(u.historyIndex.Text()))
if err != nil {
return fmt.Errorf("invalid history index: %w", err)
}
if err := u.state.RestoreSelectedEntryVersion(index); err != nil {
return err
}
u.loadSelectedEntryIntoEditor()
u.filter()
return nil
}
func (u *ui) copySelectedFieldAction(target clipboard.Target) error {
model, err := u.state.Session.Current()
if err != nil {
return err
}
service := clipboard.Service{}
return service.Copy(model, u.state.SelectedEntryID, target)
}
func (u *ui) generatePasswordAction() error {
profiles := passwords.DefaultProfiles()
profile, ok := profiles[strings.TrimSpace(u.passwordProfile.Text())]
if !ok {
return fmt.Errorf("unknown password profile")
}
password, err := passwords.Generate(profile)
if err != nil {
return err
}
u.entryPassword.SetText(password)
return nil
}
func (u *ui) editorEntry() (vault.Entry, error) {
path := parsePath(u.entryPath.Text())
fields, err := parseFields(u.entryFields.Text())
if err != nil {
return vault.Entry{}, err
}
return vault.Entry{
ID: strings.TrimSpace(u.entryID.Text()),
Title: strings.TrimSpace(u.entryTitle.Text()),
Username: strings.TrimSpace(u.entryUsername.Text()),
Password: u.entryPassword.Text(),
URL: strings.TrimSpace(u.entryURL.Text()),
Notes: strings.TrimSpace(u.entryNotes.Text()),
Tags: parseTags(u.entryTags.Text()),
Path: path,
Fields: fields,
}, nil
}
func parsePath(text string) []string {
var out []string
for _, part := range strings.Split(text, "/") {
part = strings.TrimSpace(part)
if part == "" {
continue
}
out = append(out, part)
}
return out
}
func parseTags(text string) []string {
var out []string
for _, part := range strings.Split(text, ",") {
part = strings.TrimSpace(part)
if part == "" {
continue
}
out = append(out, part)
}
return out
}
func parseFields(text string) (map[string]string, error) {
if strings.TrimSpace(text) == "" {
return nil, nil
}
fields := map[string]string{}
for _, line := range strings.Split(text, "\n") {
line = strings.TrimSpace(line)
if line == "" {
continue
}
key, value, ok := strings.Cut(line, "=")
if !ok {
return nil, fmt.Errorf("invalid field line %q", line)
}
fields[strings.TrimSpace(key)] = strings.TrimSpace(value)
}
return fields, nil
}
func marshalFields(fields map[string]string) string {
if len(fields) == 0 {
return ""
}
lines := make([]string, 0, len(fields))
for key, value := range fields {
lines = append(lines, key+"="+value)
}
return strings.Join(lines, "\n")
}
+168
View File
@@ -0,0 +1,168 @@
package main
import (
"strings"
"git.julianfamily.org/keepassgo/appstate"
"gioui.org/layout"
"gioui.org/unit"
"gioui.org/widget"
"gioui.org/widget/material"
)
func (u *ui) lifecycleControls(gtx layout.Context) layout.Dimensions {
return layout.Flex{Axis: layout.Vertical}.Layout(gtx,
layout.Rigid(labeledEditor(u.theme, "Master Password", &u.masterPassword, true)),
layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout),
layout.Rigid(labeledEditor(u.theme, "Key File", &u.keyFilePath, false)),
layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout),
layout.Rigid(labeledEditor(u.theme, "Vault Path", &u.vaultPath, false)),
layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout),
layout.Rigid(labeledEditor(u.theme, "Save As Path", &u.saveAsPath, false)),
layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout),
layout.Rigid(labeledEditor(u.theme, "Remote Base URL", &u.remoteBaseURL, false)),
layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout),
layout.Rigid(labeledEditor(u.theme, "Remote Path", &u.remotePath, false)),
layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout),
layout.Rigid(labeledEditor(u.theme, "Remote Username", &u.remoteUsername, false)),
layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout),
layout.Rigid(labeledEditor(u.theme, "Remote Password", &u.remotePassword, true)),
layout.Rigid(layout.Spacer{Height: unit.Dp(8)}.Layout),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
return layout.Flex{Spacing: layout.SpaceStart}.Layout(gtx,
layout.Rigid(func(gtx layout.Context) layout.Dimensions { return tonedButton(gtx, u.theme, &u.createVault, "New") }),
layout.Rigid(layout.Spacer{Width: unit.Dp(6)}.Layout),
layout.Rigid(func(gtx layout.Context) layout.Dimensions { return tonedButton(gtx, u.theme, &u.openVault, "Open") }),
layout.Rigid(layout.Spacer{Width: unit.Dp(6)}.Layout),
layout.Rigid(func(gtx layout.Context) layout.Dimensions { return tonedButton(gtx, u.theme, &u.saveVault, "Save") }),
layout.Rigid(layout.Spacer{Width: unit.Dp(6)}.Layout),
layout.Rigid(func(gtx layout.Context) layout.Dimensions { return tonedButton(gtx, u.theme, &u.saveAsVault, "Save As") }),
layout.Rigid(layout.Spacer{Width: unit.Dp(6)}.Layout),
layout.Rigid(func(gtx layout.Context) layout.Dimensions { return tonedButton(gtx, u.theme, &u.openRemote, "Open Remote") }),
layout.Rigid(layout.Spacer{Width: unit.Dp(6)}.Layout),
layout.Rigid(func(gtx layout.Context) layout.Dimensions { return tonedButton(gtx, u.theme, &u.unlockVault, "Unlock") }),
)
}),
)
}
func (u *ui) groupControls(gtx layout.Context) layout.Dimensions {
if u.state.Section == appstate.SectionRecycleBin {
return layout.Dimensions{}
}
return layout.Flex{Axis: layout.Vertical}.Layout(gtx,
layout.Rigid(labeledEditor(u.theme, "Group Name", &u.groupName, false)),
layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
return layout.Flex{Spacing: layout.SpaceStart}.Layout(gtx,
layout.Rigid(func(gtx layout.Context) layout.Dimensions { return tonedButton(gtx, u.theme, &u.createGroup, "Create Group") }),
layout.Rigid(layout.Spacer{Width: unit.Dp(6)}.Layout),
layout.Rigid(func(gtx layout.Context) layout.Dimensions { return tonedButton(gtx, u.theme, &u.renameGroup, "Rename Group") }),
layout.Rigid(layout.Spacer{Width: unit.Dp(6)}.Layout),
layout.Rigid(func(gtx layout.Context) layout.Dimensions { return tonedButton(gtx, u.theme, &u.deleteGroup, "Delete Group") }),
)
}),
)
}
func (u *ui) entryEditorPanel(gtx layout.Context) layout.Dimensions {
return layout.Flex{Axis: layout.Vertical}.Layout(gtx,
layout.Rigid(labeledEditor(u.theme, "ID", &u.entryID, false)),
layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout),
layout.Rigid(labeledEditor(u.theme, "Title", &u.entryTitle, false)),
layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout),
layout.Rigid(labeledEditor(u.theme, "Username", &u.entryUsername, false)),
layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout),
layout.Rigid(labeledEditor(u.theme, "Password", &u.entryPassword, true)),
layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout),
layout.Rigid(labeledEditor(u.theme, "URL", &u.entryURL, false)),
layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout),
layout.Rigid(labeledEditor(u.theme, "Path", &u.entryPath, false)),
layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout),
layout.Rigid(labeledEditor(u.theme, "Tags", &u.entryTags, false)),
layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout),
layout.Rigid(labeledEditor(u.theme, "Password Profile", &u.passwordProfile, false)),
layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout),
layout.Rigid(labeledEditor(u.theme, "Notes", &u.entryNotes, false)),
layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout),
layout.Rigid(labeledEditor(u.theme, "Custom Fields (key=value)", &u.entryFields, false)),
layout.Rigid(layout.Spacer{Height: unit.Dp(8)}.Layout),
layout.Rigid(labeledEditor(u.theme, "History Index", &u.historyIndex, false)),
layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
switch u.state.Section {
case appstate.SectionTemplates:
return layout.Flex{Spacing: layout.SpaceStart}.Layout(gtx,
layout.Rigid(func(gtx layout.Context) layout.Dimensions { return tonedButton(gtx, u.theme, &u.saveTemplate, "Save Template") }),
layout.Rigid(layout.Spacer{Width: unit.Dp(6)}.Layout),
layout.Rigid(func(gtx layout.Context) layout.Dimensions { return tonedButton(gtx, u.theme, &u.deleteTemplate, "Delete Template") }),
layout.Rigid(layout.Spacer{Width: unit.Dp(6)}.Layout),
layout.Rigid(func(gtx layout.Context) layout.Dimensions { return tonedButton(gtx, u.theme, &u.instantiateTemplate, "Instantiate") }),
)
case appstate.SectionRecycleBin:
return tonedButton(gtx, u.theme, &u.restoreEntry, "Restore Entry")
default:
return layout.Flex{Spacing: layout.SpaceStart}.Layout(gtx,
layout.Rigid(func(gtx layout.Context) layout.Dimensions { return tonedButton(gtx, u.theme, &u.saveEntry, "Save Entry") }),
layout.Rigid(layout.Spacer{Width: unit.Dp(6)}.Layout),
layout.Rigid(func(gtx layout.Context) layout.Dimensions { return tonedButton(gtx, u.theme, &u.duplicateEntry, "Duplicate") }),
layout.Rigid(layout.Spacer{Width: unit.Dp(6)}.Layout),
layout.Rigid(func(gtx layout.Context) layout.Dimensions { return tonedButton(gtx, u.theme, &u.deleteEntry, "Delete") }),
layout.Rigid(layout.Spacer{Width: unit.Dp(6)}.Layout),
layout.Rigid(func(gtx layout.Context) layout.Dimensions { return tonedButton(gtx, u.theme, &u.generatePassword, "Generate Password") }),
layout.Rigid(layout.Spacer{Width: unit.Dp(6)}.Layout),
layout.Rigid(func(gtx layout.Context) layout.Dimensions { return tonedButton(gtx, u.theme, &u.restoreHistory, "Restore History") }),
)
}
}),
layout.Rigid(layout.Spacer{Height: unit.Dp(8)}.Layout),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
return layout.Flex{Spacing: layout.SpaceStart}.Layout(gtx,
layout.Rigid(func(gtx layout.Context) layout.Dimensions { return tonedButton(gtx, u.theme, &u.copyUser, "Copy User") }),
layout.Rigid(layout.Spacer{Width: unit.Dp(6)}.Layout),
layout.Rigid(func(gtx layout.Context) layout.Dimensions { return tonedButton(gtx, u.theme, &u.copyPass, "Copy Password") }),
layout.Rigid(layout.Spacer{Width: unit.Dp(6)}.Layout),
layout.Rigid(func(gtx layout.Context) layout.Dimensions { return tonedButton(gtx, u.theme, &u.copyURL, "Copy URL") }),
)
}),
layout.Rigid(layout.Spacer{Height: unit.Dp(8)}.Layout),
layout.Rigid(labeledEditor(u.theme, "Attachment Name", &u.attachmentName, false)),
layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout),
layout.Rigid(labeledEditor(u.theme, "Attachment Path", &u.attachmentPath, false)),
layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout),
layout.Rigid(labeledEditor(u.theme, "Export Attachment Path", &u.exportAttachmentPath, false)),
layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
return layout.Flex{Spacing: layout.SpaceStart}.Layout(gtx,
layout.Rigid(func(gtx layout.Context) layout.Dimensions { return tonedButton(gtx, u.theme, &u.addAttachment, "Add Attachment") }),
layout.Rigid(layout.Spacer{Width: unit.Dp(6)}.Layout),
layout.Rigid(func(gtx layout.Context) layout.Dimensions { return tonedButton(gtx, u.theme, &u.removeAttachment, "Remove Attachment") }),
layout.Rigid(layout.Spacer{Width: unit.Dp(6)}.Layout),
layout.Rigid(func(gtx layout.Context) layout.Dimensions { return tonedButton(gtx, u.theme, &u.exportAttachment, "Export Attachment") }),
)
}),
)
}
func labeledEditor(th *material.Theme, label string, editor *widget.Editor, sensitive bool) layout.Widget {
return func(gtx layout.Context) layout.Dimensions {
return layout.Flex{Axis: layout.Vertical}.Layout(gtx,
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
lbl := material.Label(th, unit.Sp(12), strings.ToUpper(label))
lbl.Color = mutedColor
return lbl.Layout(gtx)
}),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
return outlinedField(gtx, func(gtx layout.Context) layout.Dimensions {
mask := editor.Mask
if sensitive {
editor.Mask = '•'
}
defer func() { editor.Mask = mask }()
ed := material.Editor(th, editor, label)
return layout.UniformInset(unit.Dp(8)).Layout(gtx, ed.Layout)
})
}),
)
}
}
+85
View File
@@ -0,0 +1,85 @@
package main
import (
"strings"
"gioui.org/io/event"
"gioui.org/io/key"
"gioui.org/layout"
"git.julianfamily.org/keepassgo/clipboard"
)
const (
shortcutSearch = "search"
shortcutSave = "save"
shortcutLock = "lock"
shortcutNewEntry = "new-entry"
shortcutCopyUser = "copy-user"
shortcutCopyPassword = "copy-password"
shortcutCopyURL = "copy-url"
)
func (u *ui) processShortcuts(gtx layout.Context) {
event.Op(gtx.Ops, u)
for {
ev, ok := gtx.Event(
key.Filter{Focus: u, Name: "F", Required: key.ModShortcut},
key.Filter{Focus: u, Name: "S", Required: key.ModShortcut},
key.Filter{Focus: u, Name: "L", Required: key.ModShortcut},
key.Filter{Focus: u, Name: "N", Required: key.ModShortcut},
key.Filter{Focus: u, Name: "U", Required: key.ModShortcut},
key.Filter{Focus: u, Name: "P", Required: key.ModShortcut},
key.Filter{Focus: u, Name: "O", Required: key.ModShortcut},
)
if !ok {
break
}
ke, ok := ev.(key.Event)
if !ok || ke.State != key.Press {
continue
}
switch ke.Name {
case "F":
_ = u.performShortcut(shortcutSearch)
case "S":
_ = u.performShortcut(shortcutSave)
case "L":
_ = u.performShortcut(shortcutLock)
case "N":
_ = u.performShortcut(shortcutNewEntry)
case "U":
_ = u.performShortcut(shortcutCopyUser)
case "P":
_ = u.performShortcut(shortcutCopyPassword)
case "O":
_ = u.performShortcut(shortcutCopyURL)
}
}
}
func (u *ui) performShortcut(name string) error {
switch name {
case shortcutSearch:
return nil
case shortcutSave:
return u.saveAction()
case shortcutLock:
return u.lockAction()
case shortcutNewEntry:
u.state.SelectedEntryID = ""
u.loadSelectedEntryIntoEditor()
u.entryPath.SetText(strings.Join(u.currentPath, " / "))
return nil
case shortcutCopyUser:
return u.copySelectedFieldAction(clipboard.TargetUsername)
case shortcutCopyPassword:
return u.copySelectedFieldAction(clipboard.TargetPassword)
case shortcutCopyURL:
return u.copySelectedFieldAction(clipboard.TargetURL)
default:
return nil
}
}
+160
View File
@@ -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])
}
}
+550
View File
@@ -0,0 +1,550 @@
package vault
import (
"crypto/rand"
"crypto/sha256"
"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"
)
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
}
header := gokeepasslib.NewHeader()
if config != nil && config.Header != nil {
header = cloneHeader(config.Header)
}
content := &gokeepasslib.DBContent{
Meta: gokeepasslib.NewMetaData(),
Root: &gokeepasslib.RootData{},
}
if header.IsKdbx4() {
if config != nil && config.InnerHeader != nil {
content.InnerHeader = cloneInnerHeader(config.InnerHeader)
} else {
content.InnerHeader = &gokeepasslib.InnerHeader{
InnerRandomStreamID: gokeepasslib.ChaChaStreamID,
InnerRandomStreamKey: randomBytes(64),
}
}
}
db := &gokeepasslib.Database{
Header: header,
Credentials: credentials,
Content: content,
Hashes: gokeepasslib.NewHashes(header),
}
db.Content.Root.Groups = buildGroupTree(db, entriesForPersistence(model))
db.Content.Root.DeletedObjects = marshalDeletedObjects(model.RecycleBin)
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)
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: marshalUUID(historical.UUID),
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, entries []Entry) []gokeepasslib.Group {
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)
}
groups := marshalGroups(db, root)
if len(groups) > 0 {
return groups
}
group := gokeepasslib.NewGroup()
group.Name = "Root"
return []gokeepasslib.Group{group}
}
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)
}
return model, &KDBXConfig{
Header: cloneHeader(db.Header),
InnerHeader: cloneInnerHeader(db.Content.InnerHeader),
}, 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.Sort(names)
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 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 marshalDeletedObjects(entries []Entry) []gokeepasslib.DeletedObjectData {
if len(entries) == 0 {
return nil
}
deletionTime := w.Now()
out := make([]gokeepasslib.DeletedObjectData, 0, len(entries))
for _, entry := range entries {
out = append(out, gokeepasslib.DeletedObjectData{
UUID: uuidForEntryID(entry.ID),
DeletionTime: &deletionTime,
})
}
return out
}
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
}
+641
View File
@@ -0,0 +1,641 @@
package vault
import (
"bytes"
"errors"
"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", "token-1"),
),
mustGroup("Home Assistant",
mustEntry("Surveillance Console", "codex", "https://surveillance.crew.example.invalid", "token-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] != "Home Assistant" || groups[1] != "Internet" {
t.Fatalf("ChildGroups() = %v, want [Home Assistant Internet]", groups)
}
}
func TestLoadKDBXPreservesEntryDetails(t *testing.T) {
t.Parallel()
entry := mustEntry("Surveillance Console", "codex", "https://surveillance.crew.example.invalid", "token-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("Home Assistant", 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", "Home Assistant"})
if len(got) != 1 {
t.Fatalf("len(EntriesInPath()) = %d, want 1", len(got))
}
if got[0].Password != "token-2" {
t.Fatalf("Entry.Password = %q, want %q", got[0].Password, "token-2")
}
if got[0].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: "token-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: "codex",
Password: "token-2",
URL: "https://surveillance.crew.example.invalid",
Notes: "Long-lived token used by Codex for home automation tasks.",
Tags: []string{"automation", "home"},
Path: []string{"Root", "Home Assistant"},
},
},
}
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", "Home Assistant"})
if len(homeAssistant) != 1 {
t.Fatalf("len(EntriesInPath(Home Assistant)) = %d, want 1", len(homeAssistant))
}
if homeAssistant[0].Password != "token-2" {
t.Fatalf("Home Assistant password = %q, want %q", homeAssistant[0].Password, "token-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 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: "codex",
Password: "token-2",
URL: "https://surveillance.crew.example.invalid",
Path: []string{"Root", "Home Assistant"},
},
},
}
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] != "Home Assistant" {
t.Fatalf("RecycleBin[0].Path = %v, want [Root Home Assistant]", 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", "token-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 != "token-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("Home Assistant", mustEntry("Surveillance Console", "codex", "https://surveillance.crew.example.invalid", "token-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", "Home Assistant"})
if len(got) != 1 || got[0].Password != "token-2" {
t.Fatalf("LoadKDBXWithKey() = %#v, want Home Assistant 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", "token-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: "token-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 != "token-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: "codex",
Password: "token-2",
URL: "https://surveillance.crew.example.invalid",
Path: []string{"Root", "Home Assistant"},
},
},
}
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", "Home Assistant"})
if len(got) != 1 || got[0].Password != "token-2" {
t.Fatalf("composite key round-trip = %#v, want Home Assistant entry with password", got)
}
}
func TestKDBXRoundTripsEntryAttachments(t *testing.T) {
t.Parallel()
model := Model{
Entries: []Entry{
{
ID: "vault-console",
Title: "Vault Console",
Username: "dannyocean",
Password: "token-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 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)},
}
}
+449
View File
@@ -0,0 +1,449 @@
package vault
import (
"errors"
"slices"
"strings"
)
var ErrEntryNotFound = errors.New("entry not found")
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
}
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) 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) 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(append([]string(nil), parent...), name)
for _, existing := range m.Groups {
if slices.Equal(existing, groupPath) {
return
}
}
m.Groups = append(m.Groups, 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) 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 errors.New("group is not empty")
}
}
for _, entry := range m.Templates {
if slices.Equal(entry.Path, path) || hasPathPrefix(entry.Path, path) {
return errors.New("group is not empty")
}
}
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 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
}
+292
View File
@@ -0,0 +1,292 @@
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 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 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 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)
}
}
+93
View File
@@ -0,0 +1,93 @@
package webdav
import (
"errors"
"fmt"
"io"
"net/http"
"strings"
)
var ErrConflict = errors.New("webdav conflict")
type Version struct {
ETag string
}
type Client struct {
HTTPClient *http.Client
BaseURL string
Username string
Password string
}
func (c Client) Open(path string) ([]byte, Version, error) {
req, err := http.NewRequest(http.MethodGet, c.url(path), nil)
if err != nil {
return nil, Version{}, fmt.Errorf("build GET request: %w", err)
}
c.applyAuth(req)
resp, err := c.httpClient().Do(req)
if err != nil {
return nil, Version{}, fmt.Errorf("GET %s: %w", path, err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, Version{}, fmt.Errorf("GET %s: unexpected status %d", path, resp.StatusCode)
}
content, err := io.ReadAll(resp.Body)
if err != nil {
return nil, Version{}, fmt.Errorf("read %s: %w", path, err)
}
return content, Version{ETag: resp.Header.Get("ETag")}, nil
}
func (c Client) Save(path string, content io.Reader, version Version) (Version, error) {
req, err := http.NewRequest(http.MethodPut, c.url(path), content)
if err != nil {
return Version{}, fmt.Errorf("build PUT request: %w", err)
}
c.applyAuth(req)
if version.ETag != "" {
req.Header.Set("If-Match", version.ETag)
}
resp, err := c.httpClient().Do(req)
if err != nil {
return Version{}, fmt.Errorf("PUT %s: %w", path, err)
}
defer resp.Body.Close()
if resp.StatusCode == http.StatusPreconditionFailed {
return Version{}, ErrConflict
}
if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusCreated && resp.StatusCode != http.StatusNoContent {
return Version{}, fmt.Errorf("PUT %s: unexpected status %d", path, resp.StatusCode)
}
return Version{ETag: resp.Header.Get("ETag")}, nil
}
func (c Client) httpClient() *http.Client {
if c.HTTPClient != nil {
return c.HTTPClient
}
return http.DefaultClient
}
func (c Client) applyAuth(req *http.Request) {
if c.Username == "" && c.Password == "" {
return
}
req.SetBasicAuth(c.Username, c.Password)
}
func (c Client) url(path string) string {
base := strings.TrimRight(c.BaseURL, "/")
path = strings.TrimLeft(path, "/")
return base + "/" + path
}
+105
View File
@@ -0,0 +1,105 @@
package webdav
import (
"bytes"
"io"
"net/http"
"net/http/httptest"
"testing"
)
func TestClientOpenDownloadsRemoteVault(t *testing.T) {
t.Parallel()
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
t.Fatalf("method = %s, want GET", r.Method)
}
if user, pass, ok := r.BasicAuth(); !ok || user != "rustyryan" || pass != "secret" {
t.Fatalf("basic auth = %q/%q ok=%v, want rustyryan/secret true", user, pass, ok)
}
w.Header().Set("ETag", `"etag-1"`)
_, _ = io.WriteString(w, "vault-bytes")
}))
defer server.Close()
client := Client{
HTTPClient: server.Client(),
BaseURL: server.URL,
Username: "rustyryan",
Password: "secret",
}
content, version, err := client.Open("keepass.kdbx")
if err != nil {
t.Fatalf("Open() error = %v", err)
}
if string(content) != "vault-bytes" {
t.Fatalf("Open() content = %q, want %q", string(content), "vault-bytes")
}
if version.ETag != `"etag-1"` {
t.Fatalf("Open() ETag = %q, want %q", version.ETag, `"etag-1"`)
}
}
func TestClientSaveUploadsVaultWithIfMatch(t *testing.T) {
t.Parallel()
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPut {
t.Fatalf("method = %s, want PUT", r.Method)
}
if got := r.Header.Get("If-Match"); got != `"etag-1"` {
t.Fatalf("If-Match = %q, want %q", got, `"etag-1"`)
}
body, err := io.ReadAll(r.Body)
if err != nil {
t.Fatalf("ReadAll() error = %v", err)
}
if string(body) != "updated-vault" {
t.Fatalf("PUT body = %q, want %q", string(body), "updated-vault")
}
w.Header().Set("ETag", `"etag-2"`)
w.WriteHeader(http.StatusNoContent)
}))
defer server.Close()
client := Client{
HTTPClient: server.Client(),
BaseURL: server.URL,
Username: "rustyryan",
Password: "secret",
}
version, err := client.Save("keepass.kdbx", bytes.NewBufferString("updated-vault"), Version{ETag: `"etag-1"`})
if err != nil {
t.Fatalf("Save() error = %v", err)
}
if version.ETag != `"etag-2"` {
t.Fatalf("Save() ETag = %q, want %q", version.ETag, `"etag-2"`)
}
}
func TestClientSaveReturnsConflictOnVersionMismatch(t *testing.T) {
t.Parallel()
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusPreconditionFailed)
}))
defer server.Close()
client := Client{
HTTPClient: server.Client(),
BaseURL: server.URL,
Username: "rustyryan",
Password: "secret",
}
_, err := client.Save("keepass.kdbx", bytes.NewBufferString("updated-vault"), Version{ETag: `"etag-1"`})
if err != ErrConflict {
t.Fatalf("Save() error = %v, want %v", err, ErrConflict)
}
}