Reconstruct KeePassGO repository
This commit is contained in:
@@ -0,0 +1 @@
|
|||||||
|
gio-keepass-mock
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
linters:
|
||||||
|
enable:
|
||||||
|
- errcheck
|
||||||
|
- gosimple
|
||||||
|
- govet
|
||||||
|
- ineffassign
|
||||||
|
- staticcheck
|
||||||
|
- unused
|
||||||
@@ -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.
|
||||||
@@ -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).
|
||||||
@@ -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
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
+530
@@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
@@ -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
@@ -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
|
||||||
|
}
|
||||||
@@ -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
@@ -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
@@ -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)
|
||||||
|
})
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
@@ -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
|
||||||
|
}
|
||||||
@@ -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
@@ -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
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user