Compare commits

..

17 Commits

Author SHA1 Message Date
Joe Julian a88b8a824b Add explicit templates vault view 2026-04-13 08:50:33 -07:00
Joe Julian eccfb886ee Normalize vault storage root on open and create 2026-04-13 07:29:51 -07:00
Joe Julian 6790399e24 Hide physical keepass paths in token and approval UX 2026-04-13 07:18:33 -07:00
Joe Julian 9882d3fc04 Authorize logical root API paths against vault storage 2026-04-13 07:15:16 -07:00
Joe Julian 59cd01f8e7 Use vault views for entry and recycle-bin state 2026-04-13 07:12:32 -07:00
Joe Julian ea30775eb7 Add explicit vault view factories 2026-04-13 07:02:44 -07:00
Joe Julian 0ce25a9712 Add failing vault view behavior tests 2026-04-13 07:00:51 -07:00
Joe Julian 32e6fc6c90 Break vault root bug into commit-sized todo items 2026-04-13 06:59:55 -07:00
Joe Julian e8a48fb7aa Track vault root view bugfix 2026-04-13 06:58:11 -07:00
joejulian 3b323ea4fd Merge pull request 'Complete browser extension gRPC flow' (#4) from feature/browser-extension-grpc into main
ci / lint-test (push) Successful in 3m49s
ci / build (push) Successful in 6m7s
Reviewed-on: #4
2026-04-12 18:58:28 +00:00
Joe Julian 8117e3e8c1 Move browser bridge gRPC address to flag 2026-04-12 07:38:23 -07:00
Joe Julian 77e92a2368 Simplify browser extension manifest descriptions 2026-04-12 07:03:06 -07:00
Joe Julian 4b8c1de1a6 Remove extension gRPC address setting 2026-04-12 06:59:59 -07:00
Joe Julian af2ce66b78 Remove completed browser extension todo items 2026-04-12 06:52:23 -07:00
Joe Julian a02d4a3b1c Unify browser bridge request normalization 2026-04-12 06:48:12 -07:00
Joe Julian 57870ca4f1 Clean up browser bridge and mutation helpers 2026-04-12 00:02:50 -07:00
Joe Julian dc7dd19543 Fix browser authorization edge cases 2026-04-11 23:56:48 -07:00
34 changed files with 1852 additions and 423 deletions
+1
View File
@@ -135,6 +135,7 @@ These features are product requirements, not “nice to have” ideas.
## Delivery Discipline ## Delivery Discipline
- Treat bug fixes as the highest-priority items in `TODO.md`.
- Do not treat this product as complete until the stated requirements in this file are actually satisfied. - 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. - Do not stop at a “good checkpoint” or “meaningful tranche” when required product capabilities are still missing.
- Continue iterating in test-first slices: - Continue iterating in test-first slices:
-9
View File
@@ -46,15 +46,6 @@ feeling like the same application rather than three related UIs.
These should remain in the main user flow rather than being hidden behind a settings gear. These should remain in the main user flow rather than being hidden behind a settings gear.
- Browser extension: - Browser extension:
make browser autofill page-driven rather than popup-driven so the user does not need to manually open the extension to discover or use matches.
- Browser extension:
add per-page or per-tab match detection and visible state so the browser can indicate when KeePassGO has a candidate for the current login form.
- Browser extension:
persist page match state so opening the extension popup is not required multiple times for the same page and does not repeat the whole discovery workflow unnecessarily.
- Browser extension:
show inline field affordances on detected username and password inputs, with a candidate chooser anchored to the field, so browser autofill is selectable from the form itself like Android autofill.
- Browser extension:
treat missing visibility of a pending authorization request during browser autofill as a workflow failure and fix approval surfacing before expanding extension scope further.
- Local open flow: - Local open flow:
make the start screen primarily about opening a vault, not configuring one. make the start screen primarily about opening a vault, not configuring one.
- Local open flow: - Local open flow:
+1 -1
View File
@@ -8,7 +8,7 @@ The Arch package installs this directory under `/usr/share/keepassgo/browser-ext
- `manifest.chromium.json` is the Chromium/Chrome manifest template - `manifest.chromium.json` is the Chromium/Chrome manifest template
- `background.js` caches per-tab match state, updates the toolbar badge, keeps token-scoped approval state visible, and talks to the native messaging host `com.keepassgo.browser` - `background.js` caches per-tab match state, updates the toolbar badge, keeps token-scoped approval state visible, and talks to the native messaging host `com.keepassgo.browser`
- `content.js` fills username and password fields on the current page, keeps fills tied to the focused form when possible, and shows inline KeePassGO field affordances when matches exist - `content.js` fills username and password fields on the current page, keeps fills tied to the focused form when possible, and shows inline KeePassGO field affordances when matches exist
- `options.html` stores the local gRPC address and API token in browser extension storage - `options.html` stores the API token in browser extension storage
The extension sends the API token to the native host on each request. The bridge does not store the token on disk. The extension sends the API token to the native host on each request. The bridge does not store the token on disk.
+1 -7
View File
@@ -3,7 +3,6 @@ const nativeHost = "com.keepassgo.browser";
const isNodeTestEnv = typeof module !== "undefined" && module.exports; const isNodeTestEnv = typeof module !== "undefined" && module.exports;
const usePromiseAPI = typeof globalThis.browser !== "undefined"; const usePromiseAPI = typeof globalThis.browser !== "undefined";
const defaultSettings = { const defaultSettings = {
grpcAddress: "",
bearerToken: "" bearerToken: ""
}; };
const pageStatePrefix = "keepassgo-page-state:"; const pageStatePrefix = "keepassgo-page-state:";
@@ -174,9 +173,8 @@ function connectNative(message) {
} }
async function loadSettings() { async function loadSettings() {
const stored = await storageGet(["grpcAddress", "bearerToken"]); const stored = await storageGet(["bearerToken"]);
return { return {
grpcAddress: (stored.grpcAddress || defaultSettings.grpcAddress).trim(),
bearerToken: (stored.bearerToken || defaultSettings.bearerToken).trim() bearerToken: (stored.bearerToken || defaultSettings.bearerToken).trim()
}; };
} }
@@ -418,7 +416,6 @@ async function fetchStatus(settings) {
} }
const status = await connectNative({ const status = await connectNative({
action: "status", action: "status",
grpcAddress: settings.grpcAddress,
bearerToken: settings.bearerToken bearerToken: settings.bearerToken
}); });
return { return {
@@ -515,7 +512,6 @@ async function refreshPageState(tabId, pageUrl, options = {}) {
const matches = await connectNative({ const matches = await connectNative({
action: "find-logins", action: "find-logins",
grpcAddress: settings.grpcAddress,
bearerToken: settings.bearerToken, bearerToken: settings.bearerToken,
url: resolvedURL url: resolvedURL
}); });
@@ -601,7 +597,6 @@ async function fillLogin(tabId, entryId) {
const response = await connectNative({ const response = await connectNative({
action: "get-login", action: "get-login",
grpcAddress: settings.grpcAddress,
bearerToken: settings.bearerToken, bearerToken: settings.bearerToken,
entryId, entryId,
url: pageUrl url: pageUrl
@@ -697,7 +692,6 @@ if (isNodeTestEnv) {
return; return;
case "keepassgo-save-settings": case "keepassgo-save-settings":
await storageSet({ await storageSet({
grpcAddress: String(message.settings?.grpcAddress || defaultSettings.grpcAddress).trim(),
bearerToken: String(message.settings?.bearerToken || "").trim() bearerToken: String(message.settings?.bearerToken || "").trim()
}); });
await refreshActivePage({ force: true }).catch(() => null); await refreshActivePage({ force: true }).catch(() => null);
+1 -1
View File
@@ -2,7 +2,7 @@
"manifest_version": 3, "manifest_version": 3,
"name": "KeePassGO Browser", "name": "KeePassGO Browser",
"version": "0.1.0", "version": "0.1.0",
"description": "Fill credentials from KeePassGO over the local gRPC API.", "description": "Fill credentials from KeePassGO on sign-in pages.",
"permissions": ["activeTab", "nativeMessaging", "storage", "tabs"], "permissions": ["activeTab", "nativeMessaging", "storage", "tabs"],
"host_permissions": ["http://*/*", "https://*/*"], "host_permissions": ["http://*/*", "https://*/*"],
"background": { "background": {
+1 -1
View File
@@ -2,7 +2,7 @@
"manifest_version": 2, "manifest_version": 2,
"name": "KeePassGO Browser", "name": "KeePassGO Browser",
"version": "0.1.0", "version": "0.1.0",
"description": "Fill credentials from KeePassGO over the local gRPC API.", "description": "Fill credentials from KeePassGO on sign-in pages.",
"permissions": [ "permissions": [
"activeTab", "activeTab",
"nativeMessaging", "nativeMessaging",
+1 -5
View File
@@ -11,14 +11,10 @@
<header class="topbar"> <header class="topbar">
<div> <div>
<h1>Browser Settings</h1> <h1>Browser Settings</h1>
<p class="subtle">Configure how the extension reaches KeePassGO.</p> <p class="subtle">Connect the extension to KeePassGO.</p>
</div> </div>
</header> </header>
<form id="settings-form" class="settings-form"> <form id="settings-form" class="settings-form">
<label>
<span>gRPC address</span>
<input id="grpc-address" name="grpc-address" type="text" value="" placeholder="Leave blank for the local default socket" autocomplete="off">
</label>
<label> <label>
<span>API token</span> <span>API token</span>
<textarea id="bearer-token" name="bearer-token" rows="6" spellcheck="false"></textarea> <textarea id="bearer-token" name="bearer-token" rows="6" spellcheck="false"></textarea>
-2
View File
@@ -22,7 +22,6 @@ async function loadSettings() {
if (!response?.success) { if (!response?.success) {
throw new Error(response?.error || "Could not load settings."); throw new Error(response?.error || "Could not load settings.");
} }
document.getElementById("grpc-address").value = response.settings.grpcAddress || "";
document.getElementById("bearer-token").value = response.settings.bearerToken || ""; document.getElementById("bearer-token").value = response.settings.bearerToken || "";
} }
@@ -34,7 +33,6 @@ async function saveSettings(event) {
const response = await runtimeSend({ const response = await runtimeSend({
type: "keepassgo-save-settings", type: "keepassgo-save-settings",
settings: { settings: {
grpcAddress: document.getElementById("grpc-address").value,
bearerToken: document.getElementById("bearer-token").value bearerToken: document.getElementById("bearer-token").value
} }
}); });
+51 -18
View File
@@ -11,9 +11,17 @@ import (
"git.julianfamily.org/keepassgo/internal/browserbridge" "git.julianfamily.org/keepassgo/internal/browserbridge"
"git.julianfamily.org/keepassgo/internal/grpcaddr" "git.julianfamily.org/keepassgo/internal/grpcaddr"
"google.golang.org/grpc"
) )
type bridgeConfig struct {
grpcAddr string
}
func main() { func main() {
cfg := bridgeConfig{
grpcAddr: resolveGlobalGRPCAddr(os.Args[1:]),
}
if len(os.Args) > 1 { if len(os.Args) > 1 {
switch strings.TrimSpace(os.Args[1]) { switch strings.TrimSpace(os.Args[1]) {
case "install-native-host": case "install-native-host":
@@ -22,13 +30,13 @@ func main() {
} }
return return
case "status": case "status":
if err := runStatus(os.Args[2:]); err != nil { if err := runStatus(cfg, stripGlobalGRPCAddrFlags(os.Args[2:])); err != nil {
fail(err) fail(err)
} }
return return
} }
} }
if err := runNativeMessage(); err != nil { if err := runNativeMessage(cfg); err != nil {
_ = browserbridge.WriteResponse(os.Stdout, browserbridge.Response{Success: false, Error: err.Error()}) _ = browserbridge.WriteResponse(os.Stdout, browserbridge.Response{Success: false, Error: err.Error()})
os.Exit(1) os.Exit(1)
} }
@@ -79,54 +87,79 @@ func runInstallNativeHost(args []string) error {
return nil return nil
} }
func runStatus(args []string) error { func runStatus(cfg bridgeConfig, args []string) error {
fs := flag.NewFlagSet("status", flag.ContinueOnError) fs := flag.NewFlagSet("status", flag.ContinueOnError)
grpcAddr := fs.String("grpc-addr", grpcaddr.Default(runtime.GOOS), "KeePassGO local gRPC address")
token := fs.String("token", "", "KeePassGO API bearer token") token := fs.String("token", "", "KeePassGO API bearer token")
if err := fs.Parse(args); err != nil { if err := fs.Parse(args); err != nil {
return err return err
} }
req := browserbridge.Request{ req := browserbridge.Request{
Action: "status", Action: "status",
GRPCAddress: strings.TrimSpace(*grpcAddr),
BearerToken: strings.TrimSpace(*token), BearerToken: strings.TrimSpace(*token),
} }
connCfg, err := req.Connection() conn, client, ctx, err := dialBridge(context.Background(), cfg, req)
if err != nil {
return err
}
conn, client, ctx, err := browserbridge.Dial(context.Background(), connCfg)
if err != nil { if err != nil {
return err return err
} }
defer func() { _ = conn.Close() }() defer func() { _ = conn.Close() }()
resp := browserbridge.HandleRequest(ctx, req, client) resp := browserbridge.HandleRequest(ctx, req, cfg.grpcAddr, client)
enc := json.NewEncoder(os.Stdout) enc := json.NewEncoder(os.Stdout)
enc.SetIndent("", " ") enc.SetIndent("", " ")
return enc.Encode(resp) return enc.Encode(resp)
} }
func runNativeMessage() error { func runNativeMessage(cfg bridgeConfig) error {
req, err := browserbridge.ReadRequest(os.Stdin) req, err := browserbridge.ReadRequest(os.Stdin)
if err != nil { if err != nil {
return err return err
} }
connCfg, err := req.Connection() conn, client, ctx, err := dialBridge(context.Background(), cfg, req)
if err != nil {
return browserbridge.WriteResponse(os.Stdout, browserbridge.Response{Success: false, Error: err.Error()})
}
conn, client, ctx, err := browserbridge.Dial(context.Background(), connCfg)
if err != nil { if err != nil {
return browserbridge.WriteResponse(os.Stdout, browserbridge.Response{Success: false, Error: err.Error()}) return browserbridge.WriteResponse(os.Stdout, browserbridge.Response{Success: false, Error: err.Error()})
} }
defer func() { _ = conn.Close() }() defer func() { _ = conn.Close() }()
return browserbridge.WriteResponse(os.Stdout, browserbridge.HandleRequest(ctx, req, client)) return browserbridge.WriteResponse(os.Stdout, browserbridge.HandleRequest(ctx, req, cfg.grpcAddr, client))
}
func dialBridge(ctx context.Context, cfg bridgeConfig, req browserbridge.Request) (*grpc.ClientConn, *browserbridge.GRPCClient, context.Context, error) {
return browserbridge.DialRequest(ctx, req, cfg.grpcAddr)
} }
func defaultBinaryPath() (string, error) { func defaultBinaryPath() (string, error) {
return browserbridge.ResolveBridgeBinaryPath("") return browserbridge.ResolveBridgeBinaryPath("")
} }
func resolveGlobalGRPCAddr(args []string) string {
addr := grpcaddr.Default(runtime.GOOS)
for i := 0; i < len(args); i++ {
arg := strings.TrimSpace(args[i])
switch {
case arg == "--grpc-addr" && i+1 < len(args):
return strings.TrimSpace(args[i+1])
case strings.HasPrefix(arg, "--grpc-addr="):
return strings.TrimSpace(strings.TrimPrefix(arg, "--grpc-addr="))
}
}
return addr
}
func stripGlobalGRPCAddrFlags(args []string) []string {
out := make([]string, 0, len(args))
for i := 0; i < len(args); i++ {
arg := strings.TrimSpace(args[i])
switch {
case arg == "--grpc-addr" && i+1 < len(args):
i++
continue
case strings.HasPrefix(arg, "--grpc-addr="):
continue
default:
out = append(out, args[i])
}
}
return out
}
func fail(err error) { func fail(err error) {
fmt.Fprintln(os.Stderr, err) fmt.Fprintln(os.Stderr, err)
os.Exit(1) os.Exit(1)
+3 -4
View File
@@ -11,7 +11,7 @@ The browser extension does **not** talk to vault files directly.
## Security Model ## Security Model
- KeePassGO remains the source of truth for authentication, authorization, approvals, and audit events. - KeePassGO remains the source of truth for authentication, authorization, approvals, and audit events.
- The browser extension stores the gRPC address and API token in browser extension storage. - The browser extension stores the API token in browser extension storage.
- The native messaging host receives the token on each request from the extension. - The native messaging host receives the token on each request from the extension.
- The native messaging host uses the token only to attach `authorization: Bearer ...` metadata to the local gRPC request. - The native messaging host uses the token only to attach `authorization: Bearer ...` metadata to the local gRPC request.
- The native messaging host does not persist the token to disk. - The native messaging host does not persist the token to disk.
@@ -78,14 +78,13 @@ Firefox:
1. Load `browser/extension/manifest.firefox.json` as a temporary add-on or package it as an extension. 1. Load `browser/extension/manifest.firefox.json` as a temporary add-on or package it as an extension.
2. Open the extension settings page. 2. Open the extension settings page.
3. Leave the gRPC address blank to use the local default Unix socket, or set an explicit address if you overrode the listener. 3. Paste an API token scoped for browser login lookup and credential copy.
4. Paste an API token scoped for browser login lookup and credential copy.
Chromium / Chrome: Chromium / Chrome:
1. Load a Chromium manifest based on `browser/extension/manifest.chromium.json`, or install the published extension when that distribution exists. 1. Load a Chromium manifest based on `browser/extension/manifest.chromium.json`, or install the published extension when that distribution exists.
2. Start KeePassGO once so it can refresh the native host manifest for the discovered extension id. 2. Start KeePassGO once so it can refresh the native host manifest for the discovered extension id.
3. Configure the gRPC address and API token in the extension settings page. 3. Configure the API token in the extension settings page.
## Current Browser Flow ## Current Browser Flow
+3
View File
@@ -101,6 +101,9 @@ func (h *Host) Stop() error {
h.started = false h.started = false
h.grpcServer.Stop() h.grpcServer.Stop()
err := h.listener.Close() err := h.listener.Close()
if errors.Is(err, net.ErrClosed) {
err = nil
}
if h.socketPath != "" { if h.socketPath != "" {
if removeErr := os.Remove(h.socketPath); removeErr != nil && !errors.Is(removeErr, os.ErrNotExist) && err == nil { if removeErr := os.Remove(h.socketPath); removeErr != nil && !errors.Is(removeErr, os.ErrNotExist) && err == nil {
err = removeErr err = removeErr
+130 -71
View File
@@ -113,9 +113,6 @@ func (s *Server) GetSessionStatus(ctx context.Context, _ *keepassgov1.GetSession
if err != nil { if err != nil {
return nil, err return nil, err
} }
s.mu.RLock()
defer s.mu.RUnlock()
pendingApprovals := s.approvals.Pending() pendingApprovals := s.approvals.Pending()
var tokenPending uint32 var tokenPending uint32
for _, pending := range pendingApprovals { for _, pending := range pendingApprovals {
@@ -123,11 +120,16 @@ func (s *Server) GetSessionStatus(ctx context.Context, _ *keepassgov1.GetSession
tokenPending++ tokenPending++
} }
} }
s.mu.RLock()
locked := s.locked
dirty := s.dirty
entryCount := uint32(len(s.model.Entries))
s.mu.RUnlock()
return &keepassgov1.GetSessionStatusResponse{ return &keepassgov1.GetSessionStatusResponse{
Locked: s.locked, Locked: locked,
Dirty: s.dirty, Dirty: dirty,
EntryCount: uint32(len(s.model.Entries)), EntryCount: entryCount,
PendingApprovalCount: uint32(len(pendingApprovals)), PendingApprovalCount: uint32(len(pendingApprovals)),
TokenPendingApprovalCount: tokenPending, TokenPendingApprovalCount: tokenPending,
}, nil }, nil
@@ -289,7 +291,7 @@ func (s *Server) FindBrowserLogins(ctx context.Context, req *keepassgov1.FindBro
}, },
score: score, score: score,
resource: resource, resource: resource,
decision: apitokens.Evaluate(token, apitokens.OperationListEntries, resource), decision: evaluateAuthorization(model, token, apitokens.OperationListEntries, resource),
}) })
} }
slices.SortFunc(matches, func(a, b rankedBrowserMatch) int { slices.SortFunc(matches, func(a, b rankedBrowserMatch) int {
@@ -348,22 +350,26 @@ func (s *Server) authorizedBrowserMatches(ctx context.Context, token apitokens.T
if _, err := s.authorizeResourceRequest(ctx, token, apitokens.OperationListEntries, match.resource); err != nil { if _, err := s.authorizeResourceRequest(ctx, token, apitokens.OperationListEntries, match.resource); err != nil {
return nil, err return nil, err
} }
return browserMatchesWithinPath(matches, match.resource.Path), nil return s.authorizedBrowserMatchesWithinPath(ctx, token, matches, match.resource.Path)
} }
return out, nil return out, nil
} }
func browserMatchesWithinPath(matches []rankedBrowserMatch, path []string) []*keepassgov1.BrowserLoginMatch { func (s *Server) authorizedBrowserMatchesWithinPath(_ context.Context, _ apitokens.Token, matches []rankedBrowserMatch, path []string) ([]*keepassgov1.BrowserLoginMatch, error) {
out := make([]*keepassgov1.BrowserLoginMatch, 0, len(matches)) out := make([]*keepassgov1.BrowserLoginMatch, 0, len(matches))
for _, match := range matches { for _, match := range matches {
if len(path) > len(match.resource.Path) { if len(path) > len(match.resource.Path) {
continue continue
} }
if slices.Equal(path, match.resource.Path[:len(path)]) { if !slices.Equal(path, match.resource.Path[:len(path)]) {
continue
}
if match.decision == apitokens.DecisionDeny {
continue
}
out = append(out, match.match) out = append(out, match.match)
} }
} return out, nil
return out
} }
func (s *Server) GetBrowserCredential(ctx context.Context, req *keepassgov1.GetBrowserCredentialRequest) (*keepassgov1.GetBrowserCredentialResponse, error) { func (s *Server) GetBrowserCredential(ctx context.Context, req *keepassgov1.GetBrowserCredentialRequest) (*keepassgov1.GetBrowserCredentialResponse, error) {
@@ -482,76 +488,75 @@ func (s *Server) ListGroups(ctx context.Context, req *keepassgov1.ListGroupsRequ
}, nil }, nil
} }
func (s *Server) CreateGroup(ctx context.Context, req *keepassgov1.CreateGroupRequest) (*keepassgov1.CreateGroupResponse, error) { func (s *Server) mutateAuthorizedVisiblePath(ctx context.Context, clientPath []string, op apitokens.Operation, mutate func(*vault.Model, []string) error) error {
model, locked := s.snapshotModel() model, locked := s.snapshotModel()
if locked { if locked {
return nil, status.Error(codes.FailedPrecondition, "vault is locked") return status.Error(codes.FailedPrecondition, "vault is locked")
} }
parentPath := expandClientPath(visibleModel(model), req.GetParentPath()) internalPath := expandClientPath(visibleModel(model), clientPath)
if _, err := s.authorizePathRequest(ctx, apitokens.OperationMutateGroup, parentPath); err != nil { if _, err := s.authorizePathRequest(ctx, op, internalPath); err != nil {
return nil, err return err
} }
return s.mutateAuthorizedModel(func() error { return nil }, func(model *vault.Model) error {
return mutate(model, internalPath)
})
}
func (s *Server) mutateAuthorizedModel(authorize func() error, mutate func(*vault.Model) error) error {
if err := authorize(); err != nil {
return err
}
s.mu.Lock() s.mu.Lock()
defer s.mu.Unlock() defer s.mu.Unlock()
if err := mutate(&s.model); err != nil {
s.model.CreateGroup(parentPath, req.GetName()) return err
}
s.dirty = true s.dirty = true
s.syncMutationLocked() s.syncMutationLocked()
return nil
}
func (s *Server) CreateGroup(ctx context.Context, req *keepassgov1.CreateGroupRequest) (*keepassgov1.CreateGroupResponse, error) {
if err := s.mutateAuthorizedVisiblePath(ctx, req.GetParentPath(), apitokens.OperationMutateGroup, func(model *vault.Model, parentPath []string) error {
model.CreateGroup(parentPath, req.GetName())
return nil
}); err != nil {
return nil, err
}
return &keepassgov1.CreateGroupResponse{}, nil return &keepassgov1.CreateGroupResponse{}, nil
} }
func (s *Server) RenameGroup(ctx context.Context, req *keepassgov1.RenameGroupRequest) (*keepassgov1.RenameGroupResponse, error) { func (s *Server) RenameGroup(ctx context.Context, req *keepassgov1.RenameGroupRequest) (*keepassgov1.RenameGroupResponse, error) {
model, locked := s.snapshotModel() if err := s.mutateAuthorizedVisiblePath(ctx, req.GetPath(), apitokens.OperationMutateGroup, func(model *vault.Model, groupPath []string) error {
if locked { if err := model.RenameGroup(groupPath, req.GetNewName()); err != nil {
return nil, status.Error(codes.FailedPrecondition, "vault is locked") if errors.Is(err, vault.ErrEntryNotFound) {
return status.Error(codes.NotFound, err.Error())
} }
groupPath := expandClientPath(visibleModel(model), req.GetPath()) return status.Errorf(codes.Internal, "rename group: %v", err)
if _, err := s.authorizePathRequest(ctx, apitokens.OperationMutateGroup, groupPath); err != nil { }
return nil
}); err != nil {
return nil, err return nil, err
} }
s.mu.Lock()
defer s.mu.Unlock()
if err := s.model.RenameGroup(groupPath, 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
s.syncMutationLocked()
return &keepassgov1.RenameGroupResponse{}, nil return &keepassgov1.RenameGroupResponse{}, nil
} }
func (s *Server) DeleteGroup(ctx context.Context, req *keepassgov1.DeleteGroupRequest) (*keepassgov1.DeleteGroupResponse, error) { func (s *Server) DeleteGroup(ctx context.Context, req *keepassgov1.DeleteGroupRequest) (*keepassgov1.DeleteGroupResponse, error) {
model, locked := s.snapshotModel() if err := s.mutateAuthorizedVisiblePath(ctx, req.GetPath(), apitokens.OperationMutateGroup, func(model *vault.Model, groupPath []string) error {
if locked { if err := model.DeleteGroup(groupPath); err != nil {
return nil, status.Error(codes.FailedPrecondition, "vault is locked")
}
groupPath := expandClientPath(visibleModel(model), req.GetPath())
if _, err := s.authorizePathRequest(ctx, apitokens.OperationMutateGroup, groupPath); err != nil {
return nil, err
}
s.mu.Lock()
defer s.mu.Unlock()
if err := s.model.DeleteGroup(groupPath); err != nil {
switch { switch {
case errors.Is(err, vault.ErrEntryNotFound): case errors.Is(err, vault.ErrEntryNotFound):
return nil, status.Error(codes.NotFound, err.Error()) return status.Error(codes.NotFound, err.Error())
case errors.Is(err, vault.ErrGroupNotEmpty): case errors.Is(err, vault.ErrGroupNotEmpty):
return nil, status.Error(codes.FailedPrecondition, err.Error()) return status.Error(codes.FailedPrecondition, err.Error())
default: default:
return nil, status.Errorf(codes.Internal, "delete group: %v", err) return status.Errorf(codes.Internal, "delete group: %v", err)
} }
} }
return nil
s.dirty = true }); err != nil {
s.syncMutationLocked() return nil, err
}
return &keepassgov1.DeleteGroupResponse{}, nil return &keepassgov1.DeleteGroupResponse{}, nil
} }
@@ -568,12 +573,12 @@ func (s *Server) UpsertEntry(ctx context.Context, req *keepassgov1.UpsertEntryRe
if _, err := s.authorizeUpsertEntryRequest(ctx, entry); err != nil { if _, err := s.authorizeUpsertEntryRequest(ctx, entry); err != nil {
return nil, err return nil, err
} }
if err := s.mutateAuthorizedModel(func() error { return nil }, func(model *vault.Model) error {
s.mu.Lock() model.UpsertEntry(entry)
s.model.UpsertEntry(entry) return nil
s.dirty = true }); err != nil {
s.syncMutationLocked() return nil, err
s.mu.Unlock() }
return &keepassgov1.UpsertEntryResponse{Entry: entryToProtoWithModel(visibleModel(model), entry)}, nil return &keepassgov1.UpsertEntryResponse{Entry: entryToProtoWithModel(visibleModel(model), entry)}, nil
} }
@@ -1068,8 +1073,6 @@ func classifyBrowserEntryMatch(pageHost, rawEntryURL string) (string, int) {
return "exact-host", 3 return "exact-host", 3
case strings.HasSuffix(pageHost, "."+entryHost): case strings.HasSuffix(pageHost, "."+entryHost):
return "subdomain", 2 return "subdomain", 2
case strings.HasSuffix(entryHost, "."+pageHost):
return "parent-domain", 1
default: default:
return "", 0 return "", 0
} }
@@ -1217,7 +1220,9 @@ func (s *Server) authorizeTemplateRequest(ctx context.Context, op apitokens.Oper
} }
func (s *Server) authorizeResourceRequest(ctx context.Context, token apitokens.Token, op apitokens.Operation, resource apitokens.Resource) (apitokens.Token, error) { func (s *Server) authorizeResourceRequest(ctx context.Context, token apitokens.Token, op apitokens.Operation, resource apitokens.Resource) (apitokens.Token, error) {
switch apitokens.Evaluate(token, op, resource) { model, _ := s.snapshotModel()
displayResource := displayAuthorizationResource(resource)
switch evaluateAuthorization(model, token, op, resource) {
case apitokens.DecisionAllow: case apitokens.DecisionAllow:
return token, nil return token, nil
case apitokens.DecisionDeny: case apitokens.DecisionDeny:
@@ -1229,9 +1234,9 @@ func (s *Server) authorizeResourceRequest(ctx context.Context, token apitokens.T
TokenName: token.Name, TokenName: token.Name,
ClientName: token.ClientName, ClientName: token.ClientName,
Operation: op, Operation: op,
Resource: resource, Resource: displayResource,
}) })
result, err := s.approvals.Request(ctx, token, op, resource) result, err := s.approvals.Request(ctx, token, op, displayResource)
if result.Rule != nil { if result.Rule != nil {
if persistErr := s.persistApprovalRule(token.ID, *result.Rule); persistErr != nil { if persistErr := s.persistApprovalRule(token.ID, *result.Rule); persistErr != nil {
return apitokens.Token{}, status.Errorf(codes.Internal, "persist approval decision: %v", persistErr) return apitokens.Token{}, status.Errorf(codes.Internal, "persist approval decision: %v", persistErr)
@@ -1245,7 +1250,7 @@ func (s *Server) authorizeResourceRequest(ctx context.Context, token apitokens.T
TokenName: token.Name, TokenName: token.Name,
ClientName: token.ClientName, ClientName: token.ClientName,
Operation: op, Operation: op,
Resource: resource, Resource: displayResource,
}) })
return token, nil return token, nil
case errors.Is(err, apiapproval.ErrRequestDenied): case errors.Is(err, apiapproval.ErrRequestDenied):
@@ -1255,7 +1260,7 @@ func (s *Server) authorizeResourceRequest(ctx context.Context, token apitokens.T
TokenName: token.Name, TokenName: token.Name,
ClientName: token.ClientName, ClientName: token.ClientName,
Operation: op, Operation: op,
Resource: resource, Resource: displayResource,
}) })
return apitokens.Token{}, status.Error(codes.PermissionDenied, "access denied by user approval") return apitokens.Token{}, status.Error(codes.PermissionDenied, "access denied by user approval")
case errors.Is(err, apiapproval.ErrRequestCanceled): case errors.Is(err, apiapproval.ErrRequestCanceled):
@@ -1265,7 +1270,7 @@ func (s *Server) authorizeResourceRequest(ctx context.Context, token apitokens.T
TokenName: token.Name, TokenName: token.Name,
ClientName: token.ClientName, ClientName: token.ClientName,
Operation: op, Operation: op,
Resource: resource, Resource: displayResource,
}) })
return apitokens.Token{}, status.Error(codes.Unauthenticated, "authorization request canceled") return apitokens.Token{}, status.Error(codes.Unauthenticated, "authorization request canceled")
case errors.Is(err, apiapproval.ErrRequestTimedOut): case errors.Is(err, apiapproval.ErrRequestTimedOut):
@@ -1275,7 +1280,7 @@ func (s *Server) authorizeResourceRequest(ctx context.Context, token apitokens.T
TokenName: token.Name, TokenName: token.Name,
ClientName: token.ClientName, ClientName: token.ClientName,
Operation: op, Operation: op,
Resource: resource, Resource: displayResource,
}) })
return apitokens.Token{}, status.Error(codes.DeadlineExceeded, "authorization request timed out") return apitokens.Token{}, status.Error(codes.DeadlineExceeded, "authorization request timed out")
case errors.Is(err, context.Canceled): case errors.Is(err, context.Canceled):
@@ -1334,6 +1339,60 @@ func hasPolicyRule(rules []apitokens.PolicyRule, target apitokens.PolicyRule) bo
return false return false
} }
func evaluateAuthorization(model vault.Model, token apitokens.Token, op apitokens.Operation, resource apitokens.Resource) apitokens.Decision {
return apitokens.Evaluate(canonicalizeTokenForAuthorization(model, token), op, canonicalizeAuthorizationResource(model, resource))
}
func canonicalizeTokenForAuthorization(model vault.Model, token apitokens.Token) apitokens.Token {
token.Policies = append([]apitokens.PolicyRule(nil), token.Policies...)
for i := range token.Policies {
token.Policies[i].Resource = canonicalizeAuthorizationResource(model, token.Policies[i].Resource)
}
return token
}
func canonicalizeAuthorizationResource(model vault.Model, resource apitokens.Resource) apitokens.Resource {
resource.Path = canonicalAuthorizationPath(model, resource.Path)
return resource
}
func displayAuthorizationResource(resource apitokens.Resource) apitokens.Resource {
resource.Path = displayAuthorizationPath(resource.Path)
return resource
}
func canonicalAuthorizationPath(model vault.Model, path []string) []string {
if len(path) == 0 {
return nil
}
if path[0] == vaultview.KeepassRoot {
return append([]string(nil), path...)
}
if path[0] == "Root" {
if len(path) > 1 && (path[1] == "Templates" || path[1] == "API Tokens") {
return append([]string(nil), path...)
}
return vaultview.VaultRoot(model).ToPhysicalPath(path[1:])
}
if path[0] == "Templates" || path[0] == "API Tokens" {
return append([]string(nil), path...)
}
return vaultview.VaultRoot(model).ToPhysicalPath(path)
}
func displayAuthorizationPath(path []string) []string {
if len(path) == 0 {
return nil
}
if path[0] == vaultview.KeepassRoot {
return append([]string{"Root"}, append([]string(nil), path[1:]...)...)
}
if path[0] == "Root" {
return append([]string(nil), path...)
}
return append([]string(nil), path...)
}
func copyOperation(target string) apitokens.Operation { func copyOperation(target string) apitokens.Operation {
switch clipboard.Target(target) { switch clipboard.Target(target) {
case clipboard.TargetUsername: case clipboard.TargetUsername:
+161 -7
View File
@@ -316,7 +316,7 @@ func TestVaultServiceFindsBrowserLoginsWithinAuthorizedGroupScope(t *testing.T)
Path: []string{"keepass", "Joe", "Internet"}, Path: []string{"keepass", "Joe", "Internet"},
}, },
testAPITokenEntry(t, testAPITokenEntry(t,
apitokens.PolicyRule{Effect: apitokens.EffectAllow, Operation: apitokens.OperationListEntries, Resource: apitokens.Resource{Kind: apitokens.ResourceGroup, Path: []string{"keepass", "Joe", "codex"}}}, apitokens.PolicyRule{Effect: apitokens.EffectAllow, Operation: apitokens.OperationListEntries, Resource: apitokens.Resource{Kind: apitokens.ResourceGroup, Path: []string{"Root", "Joe", "codex"}}},
), ),
}, },
}) })
@@ -336,6 +336,160 @@ func TestVaultServiceFindsBrowserLoginsWithinAuthorizedGroupScope(t *testing.T)
} }
} }
func TestVaultServiceFindsBrowserLoginsRechecksChildPoliciesAfterPrompt(t *testing.T) {
t.Parallel()
model := vault.Model{
Entries: []vault.Entry{
{
ID: "rusty-casino",
Title: "Rusty Casino",
Username: "rustyryan",
Password: "bellagio-1",
URL: "https://vault.heist.example.invalid",
Path: []string{"Crews", "Bellagio"},
},
{
ID: "benedict-vault",
Title: "Benedict Vault",
Username: "terrybenedict",
Password: "bellagio-2",
URL: "https://vault.heist.example.invalid",
Path: []string{"Crews", "Bellagio", "Denied"},
},
testAPITokenEntry(t,
apitokens.PolicyRule{Effect: apitokens.EffectDeny, Operation: apitokens.OperationListEntries, Resource: apitokens.Resource{Kind: apitokens.ResourceGroup, Path: []string{"Crews", "Bellagio", "Denied"}}},
),
},
}
client, _, service, cleanup := newTestHarnessForModel(t, model)
defer cleanup()
service.approvals = apiapproval.NewBroker(time.Minute)
respCh := make(chan *keepassgov1.FindBrowserLoginsResponse, 1)
errCh := make(chan error, 1)
go func() {
resp, err := client.FindBrowserLogins(tokenContext(defaultTestTokenSecret), &keepassgov1.FindBrowserLoginsRequest{
PageUrl: "https://vault.heist.example.invalid/login",
})
respCh <- resp
errCh <- err
}()
pending := waitForServerPendingApproval(t, service, 1)[0]
if got := pending.Resource.Path; !slices.Equal(got, []string{"Crews", "Bellagio"}) {
t.Fatalf("pending.Resource.Path = %v, want [Crews Bellagio]", got)
}
if _, _, err := service.ResolveApproval(pending.ID, apiapproval.OutcomeAllowOnce); err != nil {
t.Fatalf("ResolveApproval(allow once) error = %v", err)
}
resp := <-respCh
if err := <-errCh; err != nil {
t.Fatalf("FindBrowserLogins() error = %v", err)
}
if len(resp.Matches) != 1 {
t.Fatalf("len(FindBrowserLogins().Matches) = %d, want 1", len(resp.Matches))
}
if got := resp.Matches[0].Id; got != "rusty-casino" {
t.Fatalf("FindBrowserLogins().Matches[0].Id = %q, want rusty-casino", got)
}
}
func TestVaultServiceApprovalRequestsUseLogicalRootPathForPhysicalVault(t *testing.T) {
t.Parallel()
model := vault.Model{
Entries: []vault.Entry{
{
ID: "codex-nextcloud",
Title: "Nextcloud (codex)",
Username: "jjulian",
Password: "secret-1",
URL: "https://nextcloud.example.invalid",
Path: []string{"keepass", "Joe", "codex"},
},
testAPITokenEntry(t),
},
Groups: [][]string{
{"keepass"},
{"keepass", "Joe"},
{"keepass", "Joe", "codex"},
},
}
client, _, service, cleanup := newTestHarnessForModel(t, model)
defer cleanup()
service.approvals = apiapproval.NewBroker(time.Minute)
respCh := make(chan *keepassgov1.ListEntriesResponse, 1)
errCh := make(chan error, 1)
go func() {
resp, err := client.ListEntries(tokenContext(defaultTestTokenSecret), &keepassgov1.ListEntriesRequest{
Path: []string{"Joe", "codex"},
})
respCh <- resp
errCh <- err
}()
pending := waitForServerPendingApproval(t, service, 1)[0]
if got := pending.Resource.Path; !slices.Equal(got, []string{"Root", "Joe", "codex"}) {
t.Fatalf("pending.Resource.Path = %v, want [Root Joe codex]", got)
}
if _, _, err := service.ResolveApproval(pending.ID, apiapproval.OutcomeAllowOnce); err != nil {
t.Fatalf("ResolveApproval(allow once) error = %v", err)
}
if err := <-errCh; err != nil {
t.Fatalf("ListEntries() error = %v", err)
}
resp := <-respCh
if len(resp.GetEntries()) != 1 || resp.GetEntries()[0].GetId() != "codex-nextcloud" {
t.Fatalf("ListEntries().Entries = %#v, want codex-nextcloud after approval", resp.GetEntries())
}
}
func TestVaultServiceDoesNotMatchSpecificBrowserEntryToParentDomain(t *testing.T) {
t.Parallel()
client, _, cleanup := newTestClientForModel(t, vault.Model{
Entries: []vault.Entry{
{
ID: "inside-man-accounts",
Title: "Inside Man Accounts",
Username: "daltonrussell",
Password: "diamond-1",
URL: "https://accounts.heist.example.invalid",
Path: []string{"Crews", "Bank"},
},
testAPITokenEntry(t,
apitokens.PolicyRule{Effect: apitokens.EffectAllow, Operation: apitokens.OperationListEntries, Resource: apitokens.Resource{Kind: apitokens.ResourceGroup, Path: []string{"Crews"}}},
apitokens.PolicyRule{Effect: apitokens.EffectAllow, Operation: apitokens.OperationCopyUsername, Resource: apitokens.Resource{Kind: apitokens.ResourceEntry, EntryID: "inside-man-accounts", Path: []string{"Crews", "Bank"}}},
apitokens.PolicyRule{Effect: apitokens.EffectAllow, Operation: apitokens.OperationCopyPassword, Resource: apitokens.Resource{Kind: apitokens.ResourceEntry, EntryID: "inside-man-accounts", Path: []string{"Crews", "Bank"}}},
apitokens.PolicyRule{Effect: apitokens.EffectAllow, Operation: apitokens.OperationCopyURL, Resource: apitokens.Resource{Kind: apitokens.ResourceEntry, EntryID: "inside-man-accounts", Path: []string{"Crews", "Bank"}}},
),
},
})
defer cleanup()
resp, err := client.FindBrowserLogins(tokenContext(defaultTestTokenSecret), &keepassgov1.FindBrowserLoginsRequest{
PageUrl: "https://heist.example.invalid/login",
})
if err != nil {
t.Fatalf("FindBrowserLogins() error = %v", err)
}
if len(resp.Matches) != 0 {
t.Fatalf("len(FindBrowserLogins().Matches) = %d, want 0", len(resp.Matches))
}
_, err = client.GetBrowserCredential(tokenContext(defaultTestTokenSecret), &keepassgov1.GetBrowserCredentialRequest{
Id: "inside-man-accounts",
PageUrl: "https://heist.example.invalid/login",
})
if status.Code(err) != codes.InvalidArgument {
t.Fatalf("GetBrowserCredential() code = %v, want %v", status.Code(err), codes.InvalidArgument)
}
}
func TestVaultServiceListEntriesHidesSingleInternalVaultRoot(t *testing.T) { func TestVaultServiceListEntriesHidesSingleInternalVaultRoot(t *testing.T) {
t.Parallel() t.Parallel()
@@ -350,7 +504,7 @@ func TestVaultServiceListEntriesHidesSingleInternalVaultRoot(t *testing.T) {
Path: []string{"keepass", "Joe", "codex"}, Path: []string{"keepass", "Joe", "codex"},
}, },
testAPITokenEntry(t, testAPITokenEntry(t,
apitokens.PolicyRule{Effect: apitokens.EffectAllow, Operation: apitokens.OperationListEntries, Resource: apitokens.Resource{Kind: apitokens.ResourceGroup, Path: []string{"keepass", "Joe", "codex"}}}, apitokens.PolicyRule{Effect: apitokens.EffectAllow, Operation: apitokens.OperationListEntries, Resource: apitokens.Resource{Kind: apitokens.ResourceGroup, Path: []string{"Root", "Joe", "codex"}}},
), ),
}, },
Groups: [][]string{ Groups: [][]string{
@@ -389,7 +543,7 @@ func TestVaultServiceListEntriesHidesSingleInternalVaultRootWhenRecycleBinExists
Path: []string{"keepass", "Joe", "codex"}, Path: []string{"keepass", "Joe", "codex"},
}, },
testAPITokenEntry(t, testAPITokenEntry(t,
apitokens.PolicyRule{Effect: apitokens.EffectAllow, Operation: apitokens.OperationListEntries, Resource: apitokens.Resource{Kind: apitokens.ResourceGroup, Path: []string{"keepass", "Joe", "codex"}}}, apitokens.PolicyRule{Effect: apitokens.EffectAllow, Operation: apitokens.OperationListEntries, Resource: apitokens.Resource{Kind: apitokens.ResourceGroup, Path: []string{"Root", "Joe", "codex"}}},
), ),
}, },
Groups: [][]string{ Groups: [][]string{
@@ -421,7 +575,7 @@ func TestVaultServiceListGroupsHidesSingleInternalVaultRoot(t *testing.T) {
client, _, cleanup := newTestClientForModel(t, vault.Model{ client, _, cleanup := newTestClientForModel(t, vault.Model{
Entries: []vault.Entry{ Entries: []vault.Entry{
testAPITokenEntry(t, testAPITokenEntry(t,
apitokens.PolicyRule{Effect: apitokens.EffectAllow, Operation: apitokens.OperationListGroups, Resource: apitokens.Resource{Kind: apitokens.ResourceGroup, Path: []string{"keepass"}}}, apitokens.PolicyRule{Effect: apitokens.EffectAllow, Operation: apitokens.OperationListGroups, Resource: apitokens.Resource{Kind: apitokens.ResourceGroup, Path: []string{"Root"}}},
), ),
}, },
Groups: [][]string{ Groups: [][]string{
@@ -447,7 +601,7 @@ func TestVaultServiceListGroupsHidesSingleInternalVaultRootWhenRecycleBinExists(
client, _, cleanup := newTestClientForModel(t, vault.Model{ client, _, cleanup := newTestClientForModel(t, vault.Model{
Entries: []vault.Entry{ Entries: []vault.Entry{
testAPITokenEntry(t, testAPITokenEntry(t,
apitokens.PolicyRule{Effect: apitokens.EffectAllow, Operation: apitokens.OperationListGroups, Resource: apitokens.Resource{Kind: apitokens.ResourceGroup, Path: []string{"keepass"}}}, apitokens.PolicyRule{Effect: apitokens.EffectAllow, Operation: apitokens.OperationListGroups, Resource: apitokens.Resource{Kind: apitokens.ResourceGroup, Path: []string{"Root"}}},
), ),
}, },
Groups: [][]string{ Groups: [][]string{
@@ -1285,8 +1439,8 @@ func TestVaultServiceUpsertsNewEntryWithinAuthorizedGroupScope(t *testing.T) {
client, _, cleanup := newTestClientForModel(t, vault.Model{ client, _, cleanup := newTestClientForModel(t, vault.Model{
Entries: []vault.Entry{ Entries: []vault.Entry{
testAPITokenEntry(t, testAPITokenEntry(t,
apitokens.PolicyRule{Effect: apitokens.EffectAllow, Operation: apitokens.OperationMutateEntry, Resource: apitokens.Resource{Kind: apitokens.ResourceGroup, Path: []string{"keepass", "Joe", "codex"}}}, apitokens.PolicyRule{Effect: apitokens.EffectAllow, Operation: apitokens.OperationMutateEntry, Resource: apitokens.Resource{Kind: apitokens.ResourceGroup, Path: []string{"Root", "Joe", "codex"}}},
apitokens.PolicyRule{Effect: apitokens.EffectAllow, Operation: apitokens.OperationListEntries, Resource: apitokens.Resource{Kind: apitokens.ResourceGroup, Path: []string{"keepass", "Joe", "codex"}}}, apitokens.PolicyRule{Effect: apitokens.EffectAllow, Operation: apitokens.OperationListEntries, Resource: apitokens.Resource{Kind: apitokens.ResourceGroup, Path: []string{"Root", "Joe", "codex"}}},
), ),
}, },
Groups: [][]string{ Groups: [][]string{
+2 -1
View File
@@ -17,6 +17,7 @@ var (
ErrRequestCanceled = errors.New("authorization request canceled") ErrRequestCanceled = errors.New("authorization request canceled")
ErrRequestTimedOut = errors.New("authorization request timed out") ErrRequestTimedOut = errors.New("authorization request timed out")
ErrRequestNotFound = errors.New("authorization request not found") ErrRequestNotFound = errors.New("authorization request not found")
ErrBrokerNotConfigured = errors.New("authorization broker is not configured")
) )
type Outcome string type Outcome string
@@ -120,7 +121,7 @@ func (b *Broker) SetChangeNotifier(notify func()) {
func (b *Broker) Request(ctx context.Context, token apitokens.Token, op apitokens.Operation, resource apitokens.Resource) (Result, error) { func (b *Broker) Request(ctx context.Context, token apitokens.Token, op apitokens.Operation, resource apitokens.Resource) (Result, error) {
if b == nil { if b == nil {
return Result{}, ErrRequestTimedOut return Result{}, ErrBrokerNotConfigured
} }
pending := &pendingRequest{ pending := &pendingRequest{
+10
View File
@@ -121,6 +121,16 @@ func TestBrokerTimesOutPendingRequests(t *testing.T) {
} }
} }
func TestNilBrokerReturnsConfigurationError(t *testing.T) {
t.Parallel()
var broker *Broker
_, err := broker.Request(context.Background(), apitokens.Token{ID: "token-1", Name: "CLI"}, apitokens.OperationListGroups, apitokens.Resource{Kind: apitokens.ResourceGroup, Path: []string{"Root"}})
if !errors.Is(err, ErrBrokerNotConfigured) {
t.Fatalf("Request(nil broker) error = %v, want %v", err, ErrBrokerNotConfigured)
}
}
func TestBrokerNotifiesWhenPendingRequestsChange(t *testing.T) { func TestBrokerNotifiesWhenPendingRequestsChange(t *testing.T) {
t.Parallel() t.Parallel()
+294 -112
View File
@@ -11,6 +11,7 @@ import (
"git.julianfamily.org/keepassgo/internal/apiaudit" "git.julianfamily.org/keepassgo/internal/apiaudit"
"git.julianfamily.org/keepassgo/internal/apitokens" "git.julianfamily.org/keepassgo/internal/apitokens"
"git.julianfamily.org/keepassgo/internal/vault" "git.julianfamily.org/keepassgo/internal/vault"
"git.julianfamily.org/keepassgo/internal/vaultview"
"git.julianfamily.org/keepassgo/internal/webdav" "git.julianfamily.org/keepassgo/internal/webdav"
) )
@@ -30,6 +31,9 @@ const (
SectionAbout Section = "about" SectionAbout Section = "about"
) )
const entriesRootLabel = "Root"
const templatesRootLabel = "Templates"
type CurrentSession interface { type CurrentSession interface {
Current() (vault.Model, error) Current() (vault.Model, error)
} }
@@ -98,6 +102,10 @@ type RemoteOpenableSession interface {
OpenRemote(webdav.Client, string, vault.MasterKey) error OpenRemote(webdav.Client, string, vault.MasterKey) error
} }
type WarningSession interface {
ConsumeWarning() string
}
type SecurityConfigurableSession interface { type SecurityConfigurableSession interface {
ConfigureSecurity(vault.SecuritySettings) error ConfigureSecurity(vault.SecuritySettings) error
SecuritySettings() vault.SecuritySettings SecuritySettings() vault.SecuritySettings
@@ -192,139 +200,111 @@ func (s *State) RemoteCredentialEntries() ([]vault.Entry, error) {
} }
func (s *State) IssueAPIToken(name, clientName string, expiresAt *time.Time, now time.Time) (apitokens.Token, string, error) { func (s *State) IssueAPIToken(name, clientName string, expiresAt *time.Time, now time.Time) (apitokens.Token, string, error) {
session, ok := s.Session.(MutableSession) result, err := s.mutateAPITokens(apiaudit.EventTokenIssued, "issued API token", func(model *vault.Model) (tokenMutationResult, error) {
if !ok {
return apitokens.Token{}, "", fmt.Errorf("session is not mutable")
}
model, err := session.Current()
if err != nil {
return apitokens.Token{}, "", err
}
token, secret, err := apitokens.Issue(name, clientName, expiresAt, now) token, secret, err := apitokens.Issue(name, clientName, expiresAt, now)
if err != nil {
return tokenMutationResult{}, err
}
apitokens.Upsert(model, token)
return tokenMutationResult{token: token, secret: secret}, nil
})
if err != nil { if err != nil {
return apitokens.Token{}, "", err return apitokens.Token{}, "", err
} }
apitokens.Upsert(&model, token) return result.token, result.secret, nil
session.Replace(model)
if err := s.markDirtyAndAutoSave(); err != nil {
return apitokens.Token{}, "", err
}
s.recordTokenAudit(apiaudit.EventTokenIssued, token, "issued API token")
return token, secret, nil
} }
func (s *State) RotateAPIToken(id string, now time.Time) (apitokens.Token, string, error) { func (s *State) RotateAPIToken(id string, now time.Time) (apitokens.Token, string, error) {
session, ok := s.Session.(MutableSession) result, err := s.mutateAPITokens(apiaudit.EventTokenRotated, "rotated API token", func(model *vault.Model) (tokenMutationResult, error) {
if !ok { token, err := apitokens.Find(*model, id)
return apitokens.Token{}, "", fmt.Errorf("session is not mutable")
}
model, err := session.Current()
if err != nil { if err != nil {
return apitokens.Token{}, "", err return tokenMutationResult{}, err
}
token, err := apitokens.Find(model, id)
if err != nil {
return apitokens.Token{}, "", err
} }
token, secret, err := apitokens.Rotate(token, now) token, secret, err := apitokens.Rotate(token, now)
if err != nil {
return tokenMutationResult{}, err
}
apitokens.Upsert(model, token)
return tokenMutationResult{token: token, secret: secret}, nil
})
if err != nil { if err != nil {
return apitokens.Token{}, "", err return apitokens.Token{}, "", err
} }
apitokens.Upsert(&model, token) return result.token, result.secret, nil
session.Replace(model)
if err := s.markDirtyAndAutoSave(); err != nil {
return apitokens.Token{}, "", err
}
s.recordTokenAudit(apiaudit.EventTokenRotated, token, "rotated API token")
return token, secret, nil
} }
func (s *State) UpsertAPIToken(token apitokens.Token) error { func (s *State) UpsertAPIToken(token apitokens.Token) error {
session, ok := s.Session.(MutableSession) _, err := s.mutateAPITokens(apiaudit.EventTokenUpdated, "updated API token", func(model *vault.Model) (tokenMutationResult, error) {
if !ok { apitokens.Upsert(model, token)
return fmt.Errorf("session is not mutable") return tokenMutationResult{token: token}, nil
} })
model, err := session.Current()
if err != nil {
return err return err
}
apitokens.Upsert(&model, token)
session.Replace(model)
if err := s.markDirtyAndAutoSave(); err != nil {
return err
}
s.recordTokenAudit(apiaudit.EventTokenUpdated, token, "updated API token")
return nil
} }
func (s *State) DisableAPIToken(id string) error { func (s *State) DisableAPIToken(id string) error {
session, ok := s.Session.(MutableSession) _, err := s.mutateAPITokens(apiaudit.EventTokenDisabled, "disabled API token", func(model *vault.Model) (tokenMutationResult, error) {
if !ok { token, err := apitokens.Find(*model, id)
return fmt.Errorf("session is not mutable")
}
model, err := session.Current()
if err != nil { if err != nil {
return err return tokenMutationResult{}, err
}
token, err := apitokens.Find(model, id)
if err != nil {
return err
} }
token = apitokens.Disable(token) token = apitokens.Disable(token)
apitokens.Upsert(&model, token) apitokens.Upsert(model, token)
session.Replace(model) return tokenMutationResult{token: token}, nil
if err := s.markDirtyAndAutoSave(); err != nil { })
return err return err
}
s.recordTokenAudit(apiaudit.EventTokenDisabled, token, "disabled API token")
return nil
} }
func (s *State) RevokeAPIToken(id string, when time.Time) error { func (s *State) RevokeAPIToken(id string, when time.Time) error {
session, ok := s.Session.(MutableSession) _, err := s.mutateAPITokens(apiaudit.EventTokenRevoked, "revoked API token", func(model *vault.Model) (tokenMutationResult, error) {
if !ok { token, err := apitokens.Find(*model, id)
return fmt.Errorf("session is not mutable")
}
model, err := session.Current()
if err != nil { if err != nil {
return err return tokenMutationResult{}, err
}
token, err := apitokens.Find(model, id)
if err != nil {
return err
} }
token = apitokens.Revoke(token, when) token = apitokens.Revoke(token, when)
apitokens.Upsert(&model, token) apitokens.Upsert(model, token)
session.Replace(model) return tokenMutationResult{token: token}, nil
if err := s.markDirtyAndAutoSave(); err != nil { })
return err return err
}
s.recordTokenAudit(apiaudit.EventTokenRevoked, token, "revoked API token")
return nil
} }
func (s *State) DeleteAPIToken(id string) error { func (s *State) DeleteAPIToken(id string) error {
_, err := s.mutateAPITokens(apiaudit.EventTokenDeleted, "deleted API token", func(model *vault.Model) (tokenMutationResult, error) {
token, err := apitokens.Find(*model, id)
if err != nil {
return tokenMutationResult{}, err
}
if err := apitokens.Delete(model, id); err != nil {
return tokenMutationResult{}, err
}
return tokenMutationResult{token: token}, nil
})
return err
}
type tokenMutationResult struct {
token apitokens.Token
secret string
}
func (s *State) mutateAPITokens(eventType apiaudit.EventType, message string, mutate func(*vault.Model) (tokenMutationResult, error)) (tokenMutationResult, error) {
session, ok := s.Session.(MutableSession) session, ok := s.Session.(MutableSession)
if !ok { if !ok {
return fmt.Errorf("session is not mutable") return tokenMutationResult{}, fmt.Errorf("session is not mutable")
} }
model, err := session.Current() model, err := session.Current()
if err != nil { if err != nil {
return err return tokenMutationResult{}, err
} }
token, err := apitokens.Find(model, id) result, err := mutate(&model)
if err != nil { if err != nil {
return err return tokenMutationResult{}, err
}
if err := apitokens.Delete(&model, id); err != nil {
return err
} }
session.Replace(model) session.Replace(model)
if err := s.markDirtyAndAutoSave(); err != nil { if err := s.markDirtyAndAutoSave(); err != nil {
return err return tokenMutationResult{}, err
} }
s.recordTokenAudit(apiaudit.EventTokenDeleted, token, "deleted API token") s.recordTokenAudit(eventType, result.token, message)
return nil return result, nil
} }
func (s *State) recordTokenAudit(eventType apiaudit.EventType, token apitokens.Token, message string) { func (s *State) recordTokenAudit(eventType apiaudit.EventType, token apitokens.Token, message string) {
@@ -403,7 +383,7 @@ func (s *State) VisibleEntries() ([]vault.Entry, error) {
} }
if s.Section == SectionEntries { if s.Section == SectionEntries {
return entriesInPath(model.Entries, s.CurrentPath), nil return entriesInPath(entries, logicalEntriesPathForModel(model, s.CurrentPath)), nil
} }
if s.Section == SectionRecycleBin || len(s.CurrentPath) == 0 { if s.Section == SectionRecycleBin || len(s.CurrentPath) == 0 {
return entries, nil return entries, nil
@@ -423,13 +403,13 @@ func (s *State) ChildGroups() ([]string, error) {
} }
if s.Section != SectionEntries { if s.Section != SectionEntries {
if s.Section == SectionTemplates && len(s.CurrentPath) == 0 { if s.Section == SectionTemplates {
return childGroups(s.entriesForSection(model), []string{"Templates"}), nil return vaultview.VaultTemplates(model).ChildGroups(templatesViewPath(s.CurrentPath)), nil
} }
return childGroups(s.entriesForSection(model), s.CurrentPath), nil return childGroups(s.entriesForSection(model), s.CurrentPath), nil
} }
return model.ChildGroups(s.CurrentPath), nil return vaultview.VaultRoot(model).ChildGroups(entriesViewPathForModel(model, s.CurrentPath)), nil
} }
func (s *State) SelectVisibleIndex(index int) error { func (s *State) SelectVisibleIndex(index int) error {
@@ -473,13 +453,13 @@ func (s *State) currentModel() (vault.Model, error) {
func (s *State) entriesForSection(model vault.Model) []vault.Entry { func (s *State) entriesForSection(model vault.Model) []vault.Entry {
switch s.Section { switch s.Section {
case SectionTemplates: case SectionTemplates:
return slices.Clone(model.Templates) return logicalTemplateEntries(vaultview.VaultTemplates(model).EntriesUnderPath(nil))
case SectionRecycleBin: case SectionRecycleBin:
return slices.Clone(model.RecycleBin) return logicalEntries(vaultview.VaultRecycleBin(model).EntriesUnderPath(nil))
case SectionAPITokens, SectionAPIAudit, SectionAbout: case SectionAPITokens, SectionAPIAudit, SectionAbout:
return nil return nil
default: default:
return slices.Clone(model.Entries) return logicalEntries(vaultview.VaultRoot(model).EntriesUnderPath(nil))
} }
} }
@@ -487,11 +467,11 @@ func (s State) SearchPathContext(entry vault.Entry) string {
path := slices.Clone(entry.Path) path := slices.Clone(entry.Path)
switch s.Section { switch s.Section {
case SectionTemplates: case SectionTemplates:
if len(path) == 0 || path[0] != "Templates" { path = logicalTemplatePath(path)
path = append([]string{"Templates"}, path...)
}
case SectionRecycleBin: case SectionRecycleBin:
path = append([]string{"Recycle Bin"}, path...) path = append([]string{"Recycle Bin"}, logicalEntriesPath(path)...)
case SectionEntries:
path = logicalEntriesPath(path)
} }
return strings.Join(path, " / ") return strings.Join(path, " / ")
} }
@@ -548,6 +528,163 @@ func filterEntries(entries []vault.Entry, query string) []vault.Entry {
return out return out
} }
func logicalEntriesPathForModel(model vault.Model, path []string) []string {
if len(path) == 0 {
return []string{entriesRootLabel}
}
if path[0] == entriesRootLabel {
return append([]string(nil), path...)
}
if usesPhysicalEntriesRoot(model) && path[0] == vaultview.KeepassRoot {
path = path[1:]
}
return append([]string{entriesRootLabel}, append([]string(nil), path...)...)
}
func logicalEntriesPath(path []string) []string {
if len(path) == 0 {
return []string{entriesRootLabel}
}
if path[0] == entriesRootLabel {
return append([]string(nil), path...)
}
if path[0] == vaultview.KeepassRoot {
path = path[1:]
}
return append([]string{entriesRootLabel}, append([]string(nil), path...)...)
}
func logicalTemplatePath(path []string) []string {
if len(path) == 0 {
return []string{templatesRootLabel}
}
if path[0] == templatesRootLabel {
return append([]string(nil), path...)
}
return append([]string{templatesRootLabel}, append([]string(nil), path...)...)
}
func templatesViewPath(path []string) []string {
if len(path) == 0 {
return nil
}
if path[0] == templatesRootLabel {
return append([]string(nil), path[1:]...)
}
return append([]string(nil), path...)
}
func entriesViewPathForModel(model vault.Model, path []string) []string {
if len(path) == 0 {
return nil
}
switch {
case usesPhysicalEntriesRoot(model) && path[0] == entriesRootLabel:
return append([]string(nil), path[1:]...)
case usesLogicalEntriesRoot(model):
return append([]string(nil), path...)
case path[0] == entriesRootLabel:
return append([]string(nil), path[1:]...)
default:
return append([]string(nil), path...)
}
}
func logicalEntry(entry vault.Entry) vault.Entry {
entry.Path = logicalEntriesPath(entry.Path)
for i := range entry.History {
entry.History[i] = logicalEntry(entry.History[i])
}
return entry
}
func logicalEntries(entries []vault.Entry) []vault.Entry {
if len(entries) == 0 {
return nil
}
out := make([]vault.Entry, len(entries))
for i := range entries {
out[i] = logicalEntry(entries[i])
}
return out
}
func logicalTemplateEntry(entry vault.Entry) vault.Entry {
entry.Path = logicalTemplatePath(entry.Path)
for i := range entry.History {
entry.History[i] = logicalTemplateEntry(entry.History[i])
}
return entry
}
func logicalTemplateEntries(entries []vault.Entry) []vault.Entry {
if len(entries) == 0 {
return nil
}
out := make([]vault.Entry, len(entries))
for i := range entries {
out[i] = logicalTemplateEntry(entries[i])
}
return out
}
func entryForModel(model vault.Model, entry vault.Entry) vault.Entry {
entry.Path = entriesViewPathForModel(model, entry.Path)
for i := range entry.History {
entry.History[i] = entryForModel(model, entry.History[i])
}
return entry
}
func templateEntryForModel(entry vault.Entry) vault.Entry {
entry.Path = templatesViewPath(entry.Path)
for i := range entry.History {
entry.History[i] = templateEntryForModel(entry.History[i])
}
return entry
}
func usesPhysicalEntriesRoot(model vault.Model) bool {
if len(model.Entries) == 0 && len(model.Groups) == 0 && len(model.RecycleBin) == 0 {
return true
}
for _, group := range model.Groups {
if len(group) > 0 && group[0] == vaultview.KeepassRoot {
return true
}
}
for _, entry := range model.Entries {
if len(entry.Path) > 0 && entry.Path[0] == vaultview.KeepassRoot {
return true
}
}
for _, entry := range model.RecycleBin {
if len(entry.Path) > 0 && entry.Path[0] == vaultview.KeepassRoot {
return true
}
}
return false
}
func usesLogicalEntriesRoot(model vault.Model) bool {
for _, group := range model.Groups {
if len(group) > 0 && group[0] == entriesRootLabel {
return true
}
}
for _, entry := range model.Entries {
if len(entry.Path) > 0 && entry.Path[0] == entriesRootLabel {
return true
}
}
for _, entry := range model.RecycleBin {
if len(entry.Path) > 0 && entry.Path[0] == entriesRootLabel {
return true
}
}
return false
}
func childGroups(entries []vault.Entry, path []string) []string { func childGroups(entries []vault.Entry, path []string) []string {
seen := map[string]bool{} seen := map[string]bool{}
var groups []string var groups []string
@@ -572,6 +709,33 @@ func childGroups(entries []vault.Entry, path []string) []string {
return groups return groups
} }
func sectionGroupView(model vault.Model, section Section) vaultview.View {
switch section {
case SectionTemplates:
return vaultview.VaultTemplates(model)
default:
return vaultview.VaultRoot(model)
}
}
func sectionGroupViewPath(model vault.Model, section Section, path []string) []string {
switch section {
case SectionTemplates:
return templatesViewPath(path)
default:
return entriesViewPathForModel(model, path)
}
}
func sectionGroupLogicalPath(model vault.Model, section Section, path []string) []string {
switch section {
case SectionTemplates:
return logicalTemplatePath(path)
default:
return logicalEntriesPathForModel(model, path)
}
}
func (s *State) DeleteSelectedEntry() error { func (s *State) DeleteSelectedEntry() error {
session, ok := s.Session.(MutableSession) session, ok := s.Session.(MutableSession)
if !ok { if !ok {
@@ -622,7 +786,7 @@ func (s *State) UpsertEntry(entry vault.Entry) error {
return err return err
} }
model.UpsertEntry(entry) model.UpsertEntry(vaultview.VaultRoot(model).ToPhysicalEntry(entryForModel(model, entry)))
session.Replace(model) session.Replace(model)
s.SelectedEntryID = entry.ID s.SelectedEntryID = entry.ID
return s.markDirtyAndAutoSave() return s.markDirtyAndAutoSave()
@@ -639,7 +803,7 @@ func (s *State) UpsertTemplate(entry vault.Entry) error {
return err return err
} }
model.UpsertTemplate(entry) model.UpsertTemplate(vaultview.VaultTemplates(model).ToPhysicalEntry(templateEntryForModel(entry)))
session.Replace(model) session.Replace(model)
s.SelectedEntryID = entry.ID s.SelectedEntryID = entry.ID
return s.markDirtyAndAutoSave() return s.markDirtyAndAutoSave()
@@ -656,7 +820,7 @@ func (s *State) InstantiateTemplate(templateID string, overrides vault.Entry) (v
return vault.Entry{}, err return vault.Entry{}, err
} }
entry, err := model.InstantiateTemplate(templateID, overrides) entry, err := model.InstantiateTemplate(templateID, vaultview.VaultRoot(model).ToPhysicalEntry(entryForModel(model, overrides)))
if err != nil { if err != nil {
return vault.Entry{}, err return vault.Entry{}, err
} }
@@ -666,7 +830,7 @@ func (s *State) InstantiateTemplate(templateID string, overrides vault.Entry) (v
if err := s.markDirtyAndAutoSave(); err != nil { if err := s.markDirtyAndAutoSave(); err != nil {
return vault.Entry{}, err return vault.Entry{}, err
} }
return entry, nil return logicalEntry(entry), nil
} }
func (s *State) DeleteTemplate(id string) error { func (s *State) DeleteTemplate(id string) error {
@@ -754,7 +918,13 @@ func (s *State) Unlock(key vault.MasterKey) error {
return fmt.Errorf("session is not lockable") return fmt.Errorf("session is not lockable")
} }
return session.Unlock(key) if err := session.Unlock(key); err != nil {
return err
}
if warningSession, ok := s.Session.(WarningSession); ok {
s.StatusMessage = warningSession.ConsumeWarning()
}
return nil
} }
func (s *State) ChangeMasterKey(key vault.MasterKey) error { func (s *State) ChangeMasterKey(key vault.MasterKey) error {
@@ -916,6 +1086,9 @@ func (s *State) OpenVault(path string, key vault.MasterKey) error {
s.CurrentPath = nil s.CurrentPath = nil
s.SelectedEntryID = "" s.SelectedEntryID = ""
s.Dirty = false s.Dirty = false
if warningSession, ok := s.Session.(WarningSession); ok {
s.StatusMessage = warningSession.ConsumeWarning()
}
return nil return nil
} }
@@ -946,6 +1119,9 @@ func (s *State) OpenRemoteVault(client webdav.Client, path string, key vault.Mas
s.CurrentPath = nil s.CurrentPath = nil
s.SelectedEntryID = "" s.SelectedEntryID = ""
s.Dirty = false s.Dirty = false
if warningSession, ok := s.Session.(WarningSession); ok {
s.StatusMessage = warningSession.ConsumeWarning()
}
return nil return nil
} }
@@ -1021,7 +1197,8 @@ func (s *State) CreateGroup(name string) error {
return err return err
} }
model.CreateGroup(s.CurrentPath, name) view := sectionGroupView(model, s.Section)
model.CreateGroup(view.ToPhysicalPath(sectionGroupViewPath(model, s.Section, s.CurrentPath)), name)
session.Replace(model) session.Replace(model)
return s.markDirtyAndAutoSave() return s.markDirtyAndAutoSave()
} }
@@ -1035,13 +1212,16 @@ func (s *State) MoveCurrentGroup(parent []string) error {
if err != nil { if err != nil {
return err return err
} }
current := append([]string(nil), s.CurrentPath...) view := sectionGroupView(model, s.Section)
if err := model.MoveGroup(current, parent); err != nil { current := sectionGroupLogicalPath(model, s.Section, s.CurrentPath)
currentViewPath := sectionGroupViewPath(model, s.Section, current)
parentViewPath := sectionGroupViewPath(model, s.Section, parent)
if err := model.MoveGroup(view.ToPhysicalPath(currentViewPath), view.ToPhysicalPath(parentViewPath)); err != nil {
return err return err
} }
session.Replace(model) session.Replace(model)
if len(current) > 0 { if len(currentViewPath) > 0 {
s.CurrentPath = append(append([]string(nil), parent...), current[len(current)-1]) s.CurrentPath = sectionGroupLogicalPath(model, s.Section, append(append([]string(nil), parentViewPath...), currentViewPath[len(currentViewPath)-1]))
} }
return s.markDirtyAndAutoSave() return s.markDirtyAndAutoSave()
} }
@@ -1057,7 +1237,8 @@ func (s *State) RenameCurrentGroup(newName string) error {
return err return err
} }
if err := model.RenameGroup(s.CurrentPath, newName); err != nil { view := sectionGroupView(model, s.Section)
if err := model.RenameGroup(view.ToPhysicalPath(sectionGroupViewPath(model, s.Section, s.CurrentPath)), newName); err != nil {
return err return err
} }
@@ -1079,7 +1260,7 @@ func (s *State) MoveSelectedEntry(path []string) error {
return err return err
} }
if err := model.MoveEntry(s.SelectedEntryID, path); err != nil { if err := model.MoveEntry(s.SelectedEntryID, vaultview.VaultRoot(model).ToPhysicalPath(entriesViewPathForModel(model, path))); err != nil {
return err return err
} }
@@ -1098,7 +1279,8 @@ func (s *State) DeleteCurrentGroup() error {
return err return err
} }
if err := model.DeleteGroup(s.CurrentPath); err != nil { view := sectionGroupView(model, s.Section)
if err := model.DeleteGroup(view.ToPhysicalPath(sectionGroupViewPath(model, s.Section, s.CurrentPath))); err != nil {
return err return err
} }
+74 -5
View File
@@ -27,7 +27,7 @@ func TestVisibleEntriesFollowsCurrentPathWithoutSearch(t *testing.T) {
}, },
}, },
}, },
CurrentPath: []string{"Crew", "Internet"}, CurrentPath: []string{"Root", "Crew", "Internet"},
} }
got, err := state.VisibleEntries() got, err := state.VisibleEntries()
@@ -583,6 +583,75 @@ func TestSearchPathContextIncludesSectionRoots(t *testing.T) {
} }
} }
func TestVisibleEntriesUseLogicalVaultRootForPhysicalKeepassModel(t *testing.T) {
t.Parallel()
state := State{
Session: stubSession{
model: vault.Model{
Entries: []vault.Entry{
{ID: "bellagio", Title: "Bellagio", Path: []string{"keepass", "Crew", "Internet"}},
{ID: "vault-console", Title: "Vault Console", Path: []string{"keepass", "Crew", "Internet"}},
{ID: "surveillance-console", Title: "Surveillance Console", Path: []string{"keepass", "Crew", "Security Office"}},
},
Groups: [][]string{
{"keepass"},
{"keepass", "Crew"},
{"keepass", "Crew", "Internet"},
{"keepass", "Crew", "Security Office"},
},
},
},
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("VisibleEntries() titles = %v, want [Bellagio Vault Console]", titles)
}
if !slices.Equal(got[0].Path, []string{"Root", "Crew", "Internet"}) {
t.Fatalf("VisibleEntries()[0].Path = %v, want [Root Crew Internet]", got[0].Path)
}
}
func TestChildGroupsUseLogicalVaultRootForPhysicalKeepassModel(t *testing.T) {
t.Parallel()
state := State{
Session: stubSession{
model: vault.Model{
Entries: []vault.Entry{
{ID: "bellagio", Title: "Bellagio", Path: []string{"keepass", "Crew", "Internet"}},
{ID: "surveillance-console", Title: "Surveillance Console", Path: []string{"keepass", "Crew", "Security Office"}},
},
Groups: [][]string{
{"keepass"},
{"keepass", "Crew"},
{"keepass", "Crew", "Internet"},
{"keepass", "Crew", "Security Office"},
},
},
},
}
got, err := state.ChildGroups()
if err != nil {
t.Fatalf("ChildGroups() error = %v", err)
}
if !slices.Equal(got, []string{"Crew"}) {
t.Fatalf("ChildGroups() = %v, want [Crew]", got)
}
}
func TestChildGroupsUsesCurrentModelAndCurrentPath(t *testing.T) { func TestChildGroupsUsesCurrentModelAndCurrentPath(t *testing.T) {
t.Parallel() t.Parallel()
@@ -1634,11 +1703,11 @@ func TestCreateGroupSupportsNestedGroupPath(t *testing.T) {
t.Fatalf("CreateGroup() error = %v", err) t.Fatalf("CreateGroup() error = %v", err)
} }
if got := session.model.ChildGroups([]string{"Root"}); !slices.Equal(got, []string{"Infrastructure"}) { if got := session.model.ChildGroups([]string{"keepass"}); !slices.Equal(got, []string{"Infrastructure"}) {
t.Fatalf("ChildGroups(Root) = %v, want [Infrastructure]", got) t.Fatalf("ChildGroups(keepass) = %v, want [Infrastructure]", got)
} }
if got := session.model.ChildGroups([]string{"Root", "Infrastructure"}); !slices.Equal(got, []string{"Prod"}) { if got := session.model.ChildGroups([]string{"keepass", "Infrastructure"}); !slices.Equal(got, []string{"Prod"}) {
t.Fatalf("ChildGroups(Root/Infrastructure) = %v, want [Prod]", got) t.Fatalf("ChildGroups(keepass/Infrastructure) = %v, want [Prod]", got)
} }
} }
+15 -1
View File
@@ -71,7 +71,7 @@ func AuditEventSearchTerms(event apiaudit.Event) string {
event.ClientName, event.ClientName,
string(event.Operation), string(event.Operation),
AuditOperationLabel(event.Operation), AuditOperationLabel(event.Operation),
strings.Join(event.Resource.Path, " / "), FormatResourcePath(event.Resource.Path),
event.Resource.EntryID, event.Resource.EntryID,
event.Message, event.Message,
} }
@@ -91,3 +91,17 @@ func AuditEventSearchTerms(event apiaudit.Event) string {
} }
return strings.ToLower(strings.Join(parts, " ")) return strings.ToLower(strings.Join(parts, " "))
} }
func DisplayResourcePath(path []string) []string {
if len(path) == 0 {
return nil
}
if path[0] == "keepass" {
return append([]string{"Root"}, append([]string(nil), path[1:]...)...)
}
return append([]string(nil), path...)
}
func FormatResourcePath(path []string) string {
return strings.Join(DisplayResourcePath(path), " / ")
}
+3 -3
View File
@@ -336,7 +336,7 @@ func (u *ui) editAPIPolicyRuleAction(index int) error {
} }
u.apiPolicyGroupScope = true u.apiPolicyGroupScope = true
u.apiPolicyGroupScopeW.Value = true u.apiPolicyGroupScopeW.Value = true
u.apiPolicyPath.SetText(strings.Join(rule.Resource.Path, " / ")) u.apiPolicyPath.SetText(apiui.FormatResourcePath(rule.Resource.Path))
u.apiPolicyEntryID.SetText("") u.apiPolicyEntryID.SetText("")
return nil return nil
} }
@@ -476,7 +476,7 @@ func policyRuleParts(rule apitokens.PolicyRule) (string, string, string) {
if rule.Resource.Kind == apitokens.ResourceEntry { if rule.Resource.Kind == apitokens.ResourceEntry {
resource = "Entry: " + rule.Resource.EntryID resource = "Entry: " + rule.Resource.EntryID
} else if len(rule.Resource.Path) > 0 { } else if len(rule.Resource.Path) > 0 {
resource = strings.Join(rule.Resource.Path, " / ") resource = apiui.FormatResourcePath(rule.Resource.Path)
} }
return effect, operation, resource return effect, operation, resource
} }
@@ -1211,5 +1211,5 @@ func formatAuditResource(resource apitokens.Resource) string {
if len(resource.Path) == 0 { if len(resource.Path) == 0 {
return "/" return "/"
} }
return strings.Join(resource.Path, " / ") return apiui.FormatResourcePath(resource.Path)
} }
+2 -1
View File
@@ -27,6 +27,7 @@ import (
"git.julianfamily.org/keepassgo/internal/apiaudit" "git.julianfamily.org/keepassgo/internal/apiaudit"
"git.julianfamily.org/keepassgo/internal/apitokens" "git.julianfamily.org/keepassgo/internal/apitokens"
"git.julianfamily.org/keepassgo/internal/appstate" "git.julianfamily.org/keepassgo/internal/appstate"
apiui "git.julianfamily.org/keepassgo/internal/appui/api"
detailmodel "git.julianfamily.org/keepassgo/internal/appui/detail" detailmodel "git.julianfamily.org/keepassgo/internal/appui/detail"
detaillayout "git.julianfamily.org/keepassgo/internal/appui/detail/layout" detaillayout "git.julianfamily.org/keepassgo/internal/appui/detail/layout"
lifecyclemodel "git.julianfamily.org/keepassgo/internal/appui/lifecycle" lifecyclemodel "git.julianfamily.org/keepassgo/internal/appui/lifecycle"
@@ -1511,7 +1512,7 @@ func approvalResourceText(request apiapproval.Request) string {
} }
case apitokens.ResourceGroup: case apitokens.ResourceGroup:
if len(request.Resource.Path) > 0 { if len(request.Resource.Path) > 0 {
return strings.Join(request.Resource.Path, " / ") return apiui.FormatResourcePath(request.Resource.Path)
} }
} }
return "Vault root" return "Vault root"
+11 -2
View File
@@ -24,6 +24,7 @@ import (
"git.julianfamily.org/keepassgo/internal/clipboard" "git.julianfamily.org/keepassgo/internal/clipboard"
"git.julianfamily.org/keepassgo/internal/session" "git.julianfamily.org/keepassgo/internal/session"
"git.julianfamily.org/keepassgo/internal/vault" "git.julianfamily.org/keepassgo/internal/vault"
"git.julianfamily.org/keepassgo/internal/vaultview"
) )
func (u *ui) bannerSurface() uiBanner { func (u *ui) bannerSurface() uiBanner {
@@ -558,6 +559,11 @@ func copyPath(path []string) []string {
} }
func pathExistsInModel(model vault.Model, path []string) bool { func pathExistsInModel(model vault.Model, path []string) bool {
if len(path) > 0 && path[0] == "Root" {
view := vaultview.VaultRoot(model)
viewPath := entriesViewPathForModel(model, path)
return len(view.EntriesInPath(viewPath)) > 0 || len(view.ChildGroups(viewPath)) > 0 || hasExactGroup(model, view.ToPhysicalPath(viewPath))
}
return len(model.EntriesInPath(path)) > 0 || len(model.ChildGroups(path)) > 0 || hasExactGroup(model, path) return len(model.EntriesInPath(path)) > 0 || len(model.ChildGroups(path)) > 0 || hasExactGroup(model, path)
} }
@@ -569,9 +575,12 @@ func normalizeEntriesPathWithoutModel(path []string, root string) []string {
return []string{root} return []string{root}
} }
if path[0] == "Root" { if path[0] == "Root" {
return copyPath(path)
}
if path[0] == vaultview.KeepassRoot {
return append([]string{root}, path[1:]...) return append([]string{root}, path[1:]...)
} }
return copyPath(path) return append([]string{root}, copyPath(path)...)
} }
func (u *ui) normalizedEntriesPath(path []string) []string { func (u *ui) normalizedEntriesPath(path []string) []string {
@@ -590,7 +599,7 @@ func (u *ui) normalizedEntriesPath(path []string) []string {
return []string{root} return []string{root}
} }
if path[0] == "Root" && root != "" { if path[0] == "Root" && root != "" {
candidate := append([]string{root}, path[1:]...) candidate := copyPath(path)
if pathExistsInModel(model, candidate) { if pathExistsInModel(model, candidate) {
return candidate return candidate
} }
+108 -32
View File
@@ -2441,8 +2441,8 @@ func TestUIOpenRemoteActionBootstrapsFromLocalVaultBinding(t *testing.T) {
if err != nil { if err != nil {
t.Fatalf("Session.Current() error = %v", err) t.Fatalf("Session.Current() error = %v", err)
} }
if got := current.EntriesInPath([]string{"Root", "Internet"}); len(got) != 1 || got[0].Title != "Vault Console" { if got := current.EntriesInPath([]string{"keepass", "Internet"}); len(got) != 1 || got[0].Title != "Vault Console" {
t.Fatalf("EntriesInPath(Root/Internet) = %#v, want Vault Console", got) t.Fatalf("EntriesInPath(keepass/Internet) = %#v, want Vault Console", got)
} }
} }
@@ -2675,8 +2675,8 @@ func TestUIStartOpenRemoteActionBootstrapsFromLocalVaultBinding(t *testing.T) {
if err != nil { if err != nil {
t.Fatalf("Session.Current() error = %v", err) t.Fatalf("Session.Current() error = %v", err)
} }
if got := current.EntriesInPath([]string{"Root", "Internet"}); len(got) != 1 || got[0].Title != "Vault Console" { if got := current.EntriesInPath([]string{"keepass", "Internet"}); len(got) != 1 || got[0].Title != "Vault Console" {
t.Fatalf("EntriesInPath(Root/Internet) = %#v, want Vault Console", got) t.Fatalf("EntriesInPath(keepass/Internet) = %#v, want Vault Console", got)
} }
} }
@@ -3180,8 +3180,8 @@ func TestUIAdvancedSynchronizeFromLocalMergesIntoCurrentVault(t *testing.T) {
if err != nil { if err != nil {
t.Fatalf("reopened Current() error = %v", err) t.Fatalf("reopened Current() error = %v", err)
} }
if got := len(model.EntriesInPath([]string{"Root", "Internet"})); got != 2 { if got := len(model.EntriesInPath([]string{"keepass", "Internet"})); got != 2 {
t.Fatalf("len(EntriesInPath(Root/Internet)) = %d, want 2", got) t.Fatalf("len(EntriesInPath(keepass/Internet)) = %d, want 2", got)
} }
} }
@@ -3241,8 +3241,8 @@ func TestUIAdvancedSynchronizeFromImportedLocalVaultMergesIntoCurrentVault(t *te
if err != nil { if err != nil {
t.Fatalf("reopened Current() error = %v", err) t.Fatalf("reopened Current() error = %v", err)
} }
if got := len(model.EntriesInPath([]string{"Root", "Internet"})); got != 2 { if got := len(model.EntriesInPath([]string{"keepass", "Internet"})); got != 2 {
t.Fatalf("len(EntriesInPath(Root/Internet)) = %d, want 2", got) t.Fatalf("len(EntriesInPath(keepass/Internet)) = %d, want 2", got)
} }
} }
@@ -3406,8 +3406,8 @@ func TestUIAdvancedSynchronizeToRemoteWritesMergedVaultToTarget(t *testing.T) {
if err != nil { if err != nil {
t.Fatalf("reopened Current() error = %v", err) t.Fatalf("reopened Current() error = %v", err)
} }
if got := len(model.EntriesInPath([]string{"Root", "Internet"})); got != 2 { if got := len(model.EntriesInPath([]string{"keepass", "Internet"})); got != 2 {
t.Fatalf("len(EntriesInPath(Root/Internet)) = %d, want 2", got) t.Fatalf("len(EntriesInPath(keepass/Internet)) = %d, want 2", got)
} }
} }
@@ -3606,11 +3606,11 @@ func TestUICreateGroupActionSupportsNestedSubgroups(t *testing.T) {
t.Fatalf("createGroupAction() error = %v", err) t.Fatalf("createGroupAction() error = %v", err)
} }
if got := u.state.Session.(*uiSession).model.ChildGroups([]string{"Root"}); !slices.Equal(got, []string{"Infrastructure"}) { if got := u.state.Session.(*uiSession).model.ChildGroups([]string{"keepass"}); !slices.Equal(got, []string{"Infrastructure"}) {
t.Fatalf("ChildGroups(Root) = %v, want [Infrastructure]", got) t.Fatalf("ChildGroups(keepass) = %v, want [Infrastructure]", got)
} }
if got := u.state.Session.(*uiSession).model.ChildGroups([]string{"Root", "Infrastructure"}); !slices.Equal(got, []string{"Prod"}) { if got := u.state.Session.(*uiSession).model.ChildGroups([]string{"keepass", "Infrastructure"}); !slices.Equal(got, []string{"Prod"}) {
t.Fatalf("ChildGroups(Root/Infrastructure) = %v, want [Prod]", got) t.Fatalf("ChildGroups(keepass/Infrastructure) = %v, want [Prod]", got)
} }
} }
@@ -5125,8 +5125,8 @@ func TestUIAutoEntersSingleVaultRootGroupAndDisplaysSlashRoot(t *testing.T) {
t.Fatalf("openVaultAction() error = %v", err) t.Fatalf("openVaultAction() error = %v", err)
} }
if got := u.currentPath; !slices.Equal(got, []string{"keepass"}) { if got := u.currentPath; !slices.Equal(got, []string{"Root"}) {
t.Fatalf("currentPath = %v, want [keepass]", got) t.Fatalf("currentPath = %v, want [Root]", got)
} }
if got := u.displayPath(); len(got) != 0 { if got := u.displayPath(); len(got) != 0 {
t.Fatalf("displayPath() = %v, want root slash path", got) t.Fatalf("displayPath() = %v, want root slash path", got)
@@ -5136,6 +5136,39 @@ func TestUIAutoEntersSingleVaultRootGroupAndDisplaysSlashRoot(t *testing.T) {
} }
} }
func TestUIOpenVaultShowsLegacyRootNormalizationWarning(t *testing.T) {
t.Parallel()
path := filepath.Join(t.TempDir(), "legacy-root.kdbx")
var encoded bytes.Buffer
if err := vault.SaveKDBX(&encoded, vault.Model{
Entries: []vault.Entry{
{ID: "vault-console", Title: "Vault Console", Path: []string{"Root", "Crew", "Internet"}},
},
Groups: [][]string{
{"Root"},
{"Root", "Crew"},
{"Root", "Crew", "Internet"},
},
}, "correct horse battery staple"); err != nil {
t.Fatalf("SaveKDBX() error = %v", err)
}
if err := os.WriteFile(path, encoded.Bytes(), 0o600); err != nil {
t.Fatalf("WriteFile(legacy-root.kdbx) error = %v", err)
}
u := newUIWithSession("desktop", &session.Manager{})
u.masterPassword.SetText("correct horse battery staple")
u.vaultPath.SetText(path)
if err := u.openVaultAction(); err != nil {
t.Fatalf("openVaultAction() error = %v", err)
}
if got := u.state.StatusMessage; !strings.Contains(got, "legacy vault root") {
t.Fatalf("StatusMessage = %q, want legacy vault root normalization warning", got)
}
}
func TestUIAutoEntersSingleVaultRootWhenRecycleBinAlsoExists(t *testing.T) { func TestUIAutoEntersSingleVaultRootWhenRecycleBinAlsoExists(t *testing.T) {
t.Parallel() t.Parallel()
@@ -5152,8 +5185,8 @@ func TestUIAutoEntersSingleVaultRootWhenRecycleBinAlsoExists(t *testing.T) {
u.showEntriesSection() u.showEntriesSection()
if got := u.currentPath; !slices.Equal(got, []string{"keepass"}) { if got := u.currentPath; !slices.Equal(got, []string{"Root"}) {
t.Fatalf("currentPath = %v, want [keepass]", got) t.Fatalf("currentPath = %v, want [Root]", got)
} }
if got := u.displayPath(); len(got) != 0 { if got := u.displayPath(); len(got) != 0 {
t.Fatalf("displayPath() = %v, want root slash path", got) t.Fatalf("displayPath() = %v, want root slash path", got)
@@ -5174,15 +5207,15 @@ func TestUIShowEntriesSectionRestoresHiddenRootAfterLeavingEntries(t *testing.T)
}) })
u.showEntriesSection() u.showEntriesSection()
if got := u.currentPath; !slices.Equal(got, []string{"keepass"}) { if got := u.currentPath; !slices.Equal(got, []string{"Root"}) {
t.Fatalf("currentPath after initial entries section = %v, want [keepass]", got) t.Fatalf("currentPath after initial entries section = %v, want [Root]", got)
} }
u.showAPITokensSection() u.showAPITokensSection()
u.showEntriesSection() u.showEntriesSection()
if got := u.currentPath; !slices.Equal(got, []string{"keepass"}) { if got := u.currentPath; !slices.Equal(got, []string{"Root"}) {
t.Fatalf("currentPath after returning to entries = %v, want [keepass]", got) t.Fatalf("currentPath after returning to entries = %v, want [Root]", got)
} }
if got := u.displayPath(); len(got) != 0 { if got := u.displayPath(); len(got) != 0 {
t.Fatalf("displayPath() after returning to entries = %v, want root slash path", got) t.Fatalf("displayPath() after returning to entries = %v, want root slash path", got)
@@ -5215,8 +5248,8 @@ func TestUISyncCurrentPathNormalizesHiddenRootAfterSectionSwitch(t *testing.T) {
u.syncCurrentPath() u.syncCurrentPath()
if got := u.currentPath; !slices.Equal(got, []string{"keepass"}) { if got := u.currentPath; !slices.Equal(got, []string{"Root"}) {
t.Fatalf("currentPath after syncCurrentPath() = %v, want [keepass]", got) t.Fatalf("currentPath after syncCurrentPath() = %v, want [Root]", got)
} }
if got := u.displayPath(); len(got) != 0 { if got := u.displayPath(); len(got) != 0 {
t.Fatalf("displayPath() after syncCurrentPath() = %v, want root slash path", got) t.Fatalf("displayPath() after syncCurrentPath() = %v, want root slash path", got)
@@ -5235,7 +5268,7 @@ func TestUIShowEntriesSectionRestoresEntriesViewState(t *testing.T) {
}) })
u.showEntriesSection() u.showEntriesSection()
u.setCurrentPath([]string{"keepass", "Crew", "Internet"}) u.setCurrentPath([]string{"Root", "Crew", "Internet"})
u.search.SetText("amazon") u.search.SetText("amazon")
u.filter() u.filter()
u.state.SelectedEntryID = "amazon" u.state.SelectedEntryID = "amazon"
@@ -5245,8 +5278,8 @@ func TestUIShowEntriesSectionRestoresEntriesViewState(t *testing.T) {
u.showAPITokensSection() u.showAPITokensSection()
u.showEntriesSection() u.showEntriesSection()
if got := u.currentPath; !slices.Equal(got, []string{"keepass", "Crew", "Internet"}) { if got := u.currentPath; !slices.Equal(got, []string{"Root", "Crew", "Internet"}) {
t.Fatalf("currentPath after returning to entries = %v, want [keepass Crew Internet]", got) t.Fatalf("currentPath after returning to entries = %v, want [Root Crew Internet]", got)
} }
if got := u.search.Text(); got != "amazon" { if got := u.search.Text(); got != "amazon" {
t.Fatalf("search text after returning to entries = %q, want amazon", got) t.Fatalf("search text after returning to entries = %q, want amazon", got)
@@ -8073,7 +8106,7 @@ func TestUISelectedRemoteCardUsesLocalCacheSummaryForBoundRemote(t *testing.T) {
wantDetails := []string{ wantDetails := []string{
"/vaults/cache", "/vaults/cache",
"Sync target: home.kdbx · dav.example.invalid", "Sync target: home.kdbx · dav.example.invalid",
"Last group: Root / Internet", "Last group: Internet",
} }
if !slices.Equal(gotDetails, wantDetails) { if !slices.Equal(gotDetails, wantDetails) {
t.Fatalf("selectedRemoteCardDetailLines() = %v, want %v", gotDetails, wantDetails) t.Fatalf("selectedRemoteCardDetailLines() = %v, want %v", gotDetails, wantDetails)
@@ -8105,7 +8138,7 @@ func TestUISelectedRemoteCardUsesConnectionSummaryWithoutLocalCache(t *testing.T
wantDetails := []string{ wantDetails := []string{
"Path: vaults/home.kdbx", "Path: vaults/home.kdbx",
"Server: https://dav.example.invalid", "Server: https://dav.example.invalid",
"Last group: Root / Internet", "Last group: Internet",
} }
if !slices.Equal(gotDetails, wantDetails) { if !slices.Equal(gotDetails, wantDetails) {
t.Fatalf("selectedRemoteCardDetailLines() = %v, want %v", gotDetails, wantDetails) t.Fatalf("selectedRemoteCardDetailLines() = %v, want %v", gotDetails, wantDetails)
@@ -8480,7 +8513,7 @@ func TestUIConsumesPendingSharedVaultImportOnStartup(t *testing.T) {
if err := reopened.openVaultAction(); err != nil { if err := reopened.openVaultAction(); err != nil {
t.Fatalf("openVaultAction(imported) error = %v", err) t.Fatalf("openVaultAction(imported) error = %v", err)
} }
reopened.state.NavigateToPath([]string{"Crew", "Internet"}) reopened.state.NavigateToPath([]string{"Root", "Crew", "Internet"})
reopened.filter() reopened.filter()
if got := reopened.filteredTitles(); !slices.Equal(got, []string{"Bellagio"}) { if got := reopened.filteredTitles(); !slices.Equal(got, []string{"Bellagio"}) {
t.Fatalf("filteredTitles() = %v, want [Bellagio]", got) t.Fatalf("filteredTitles() = %v, want [Bellagio]", got)
@@ -9327,8 +9360,8 @@ func TestUIAPIPolicyTargetActionsUseCurrentContext(t *testing.T) {
if err := u.useCurrentGroupForPolicyAction(); err != nil { if err := u.useCurrentGroupForPolicyAction(); err != nil {
t.Fatalf("useCurrentGroupForPolicyAction() error = %v", err) t.Fatalf("useCurrentGroupForPolicyAction() error = %v", err)
} }
if got := u.apiPolicyPath.Text(); got != "bashertarr" { if got := u.apiPolicyPath.Text(); got != "Crew / bashertarr" {
t.Fatalf("apiPolicyPath.Text() = %q, want %q", got, "bashertarr") t.Fatalf("apiPolicyPath.Text() = %q, want %q", got, "Crew / bashertarr")
} }
if !u.apiPolicyGroupScopeW.Value { if !u.apiPolicyGroupScopeW.Value {
t.Fatal("apiPolicyGroupScopeW.Value = false, want true") t.Fatal("apiPolicyGroupScopeW.Value = false, want true")
@@ -9355,6 +9388,49 @@ func TestUIAPIPolicyTargetActionsUseCurrentContext(t *testing.T) {
} }
} }
func TestUIEditAPIPolicyRuleHidesPhysicalKeepassRoot(t *testing.T) {
t.Parallel()
token := apitokens.Token{
ID: "token-1",
Name: "Crew Browser",
Policies: []apitokens.PolicyRule{{
Effect: apitokens.EffectAllow,
Operation: apitokens.OperationListEntries,
Resource: apitokens.Resource{
Kind: apitokens.ResourceGroup,
Path: []string{"keepass", "Crew", "bashertarr"},
},
}},
}
u := newUIWithModel("desktop", vault.Model{
Entries: []vault.Entry{
token.Entry(apitokens.EntryPath),
},
})
u.showAPITokensSection()
u.state.SelectedEntryID = "token-1"
if err := u.editAPIPolicyRuleAction(0); err != nil {
t.Fatalf("editAPIPolicyRuleAction() error = %v", err)
}
if got := u.apiPolicyPath.Text(); got != "Root / Crew / bashertarr" {
t.Fatalf("apiPolicyPath.Text() = %q, want %q", got, "Root / Crew / bashertarr")
}
}
func TestUIAuditAndApprovalFormattingHidePhysicalKeepassRoot(t *testing.T) {
t.Parallel()
resource := apitokens.Resource{Kind: apitokens.ResourceGroup, Path: []string{"keepass", "Crew", "bashertarr"}}
if got := formatAuditResource(resource); got != "Root / Crew / bashertarr" {
t.Fatalf("formatAuditResource() = %q, want %q", got, "Root / Crew / bashertarr")
}
if got := approvalResourceText(apiapproval.Request{Resource: resource}); got != "Root / Crew / bashertarr" {
t.Fatalf("approvalResourceText() = %q, want %q", got, "Root / Crew / bashertarr")
}
}
func TestUIVisibleBreadcrumbsCompressesAggressivelyOnPhone(t *testing.T) { func TestUIVisibleBreadcrumbsCompressesAggressivelyOnPhone(t *testing.T) {
t.Parallel() t.Parallel()
+67 -12
View File
@@ -1260,14 +1260,10 @@ func (u *ui) recentVaultGroup(path string) []string {
} }
func (u *ui) hiddenVaultRoot() string { func (u *ui) hiddenVaultRoot() string {
if u.state.Section != appstate.SectionEntries { if u.state.Section == appstate.SectionEntries {
return "" return "Root"
} }
model, err := u.state.Session.Current()
if err != nil {
return "" return ""
}
return vaultview.HiddenRoot(model)
} }
func (u *ui) enterHiddenVaultRoot() { func (u *ui) enterHiddenVaultRoot() {
@@ -1294,7 +1290,7 @@ func (u *ui) restoreRecentVaultGroup(path string) {
u.setCurrentPath(saved) u.setCurrentPath(saved)
return return
} }
if len(model.EntriesInPath(saved)) > 0 || len(model.ChildGroups(saved)) > 0 || hasExactGroup(model, saved) { if pathExistsInModel(model, saved) {
u.setCurrentPath(saved) u.setCurrentPath(saved)
return return
} }
@@ -1317,7 +1313,7 @@ func (u *ui) restoreRecentRemoteGroup(baseURL, path string) {
u.setCurrentPath(saved) u.setCurrentPath(saved)
return return
} }
if len(model.EntriesInPath(saved)) > 0 || len(model.ChildGroups(saved)) > 0 || hasExactGroup(model, saved) { if pathExistsInModel(model, saved) {
u.setCurrentPath(saved) u.setCurrentPath(saved)
return return
} }
@@ -1339,7 +1335,7 @@ func (u *ui) restoreEntriesPath(path []string) {
u.setCurrentPath(path) u.setCurrentPath(path)
return return
} }
if len(model.EntriesInPath(path)) > 0 || len(model.ChildGroups(path)) > 0 || hasExactGroup(model, path) { if pathExistsInModel(model, path) {
u.setCurrentPath(path) u.setCurrentPath(path)
return return
} }
@@ -1415,6 +1411,22 @@ func pathHasPrefix(path, prefix []string) bool {
return slices.Equal(path[:len(prefix)], prefix) return slices.Equal(path[:len(prefix)], prefix)
} }
func entriesViewPathForModel(model vault.Model, path []string) []string {
if len(path) == 0 {
return nil
}
switch {
case usesPhysicalEntriesRoot(model) && path[0] == "Root":
return append([]string(nil), path[1:]...)
case usesLogicalEntriesRoot(model):
return append([]string(nil), path...)
case path[0] == "Root":
return append([]string(nil), path[1:]...)
default:
return append([]string(nil), path...)
}
}
func hasExactGroup(model vault.Model, path []string) bool { func hasExactGroup(model vault.Model, path []string) bool {
for _, group := range model.Groups { for _, group := range model.Groups {
if slices.Equal(group, path) { if slices.Equal(group, path) {
@@ -1433,12 +1445,14 @@ func (u *ui) currentGroupDeletionState() (bool, string) {
if err != nil { if err != nil {
return false, "" return false, ""
} }
path := append([]string(nil), u.currentPath...) view := vaultview.VaultRoot(model)
if len(model.ChildGroups(path)) > 0 { path := entriesViewPathForModel(model, u.currentPath)
physicalPath := view.ToPhysicalPath(path)
if len(model.ChildGroups(physicalPath)) > 0 {
return false, "This group contains child groups. Move or delete them before removing the group." return false, "This group contains child groups. Move or delete them before removing the group."
} }
for _, item := range model.Entries { for _, item := range model.Entries {
if slices.Equal(item.Path, path) || pathHasPrefix(item.Path, path) { if slices.Equal(item.Path, physicalPath) || pathHasPrefix(item.Path, physicalPath) {
return false, "This group contains entries. Move or delete them before removing the group." return false, "This group contains entries. Move or delete them before removing the group."
} }
} }
@@ -1450,6 +1464,47 @@ func (u *ui) currentGroupDeletionState() (bool, string) {
return true, "Deleting this empty group will not remove any entries." return true, "Deleting this empty group will not remove any entries."
} }
func usesPhysicalEntriesRoot(model vault.Model) bool {
if len(model.Entries) == 0 && len(model.Groups) == 0 && len(model.RecycleBin) == 0 {
return true
}
for _, group := range model.Groups {
if len(group) > 0 && group[0] == vaultview.KeepassRoot {
return true
}
}
for _, entry := range model.Entries {
if len(entry.Path) > 0 && entry.Path[0] == vaultview.KeepassRoot {
return true
}
}
for _, entry := range model.RecycleBin {
if len(entry.Path) > 0 && entry.Path[0] == vaultview.KeepassRoot {
return true
}
}
return false
}
func usesLogicalEntriesRoot(model vault.Model) bool {
for _, group := range model.Groups {
if len(group) > 0 && group[0] == "Root" {
return true
}
}
for _, entry := range model.Entries {
if len(entry.Path) > 0 && entry.Path[0] == "Root" {
return true
}
}
for _, entry := range model.RecycleBin {
if len(entry.Path) > 0 && entry.Path[0] == "Root" {
return true
}
}
return false
}
func (u *ui) deleteGroupPendingConfirmation() bool { func (u *ui) deleteGroupPendingConfirmation() bool {
return len(u.deleteGroupPath) > 0 && slices.Equal(u.deleteGroupPath, u.currentPath) return len(u.deleteGroupPath) > 0 && slices.Equal(u.deleteGroupPath, u.currentPath)
} }
+3 -1
View File
@@ -109,7 +109,9 @@ func ensureBrowserNativeHosts() {
if err != nil { if err != nil {
return return
} }
_ = browserbridge.EnsureNativeHostManifests(appBinaryPath) if err := browserbridge.EnsureNativeHostManifests(appBinaryPath); err != nil {
platform.LogInfo("KeePassGO", fmt.Sprintf("keepassgo browser native host registration failed: %v", err))
}
} }
type uiApprovalManager struct { type uiApprovalManager struct {
+40 -26
View File
@@ -15,6 +15,8 @@ import (
"git.julianfamily.org/keepassgo/internal/grpcaddr" "git.julianfamily.org/keepassgo/internal/grpcaddr"
keepassgov1 "git.julianfamily.org/keepassgo/proto/keepassgo/v1" keepassgov1 "git.julianfamily.org/keepassgo/proto/keepassgo/v1"
gcodes "google.golang.org/grpc/codes"
gstatus "google.golang.org/grpc/status"
) )
const ( const (
@@ -22,11 +24,11 @@ const (
defaultFirefoxID = "browser@keepassgo.com" defaultFirefoxID = "browser@keepassgo.com"
maxNativeMessageSize = 1024 * 1024 maxNativeMessageSize = 1024 * 1024
chromiumIDBytes = 16 chromiumIDBytes = 16
responseVersion = "1"
) )
type Request struct { type Request struct {
Action string `json:"action"` Action string `json:"action"`
GRPCAddress string `json:"grpcAddress,omitempty"`
BearerToken string `json:"bearerToken,omitempty"` BearerToken string `json:"bearerToken,omitempty"`
URL string `json:"url,omitempty"` URL string `json:"url,omitempty"`
EntryID string `json:"entryId,omitempty"` EntryID string `json:"entryId,omitempty"`
@@ -136,22 +138,27 @@ func WriteResponse(w io.Writer, resp Response) error {
return err return err
} }
func (r Request) Connection() (Connection, error) { func (r Request) Connection(grpcAddr string) (Connection, error) {
conn := Connection{ return normalizeConnection(Connection{
GRPCAddress: strings.TrimSpace(r.GRPCAddress), GRPCAddress: strings.TrimSpace(grpcAddr),
BearerToken: strings.TrimSpace(r.BearerToken), BearerToken: strings.TrimSpace(r.BearerToken),
} })
if conn.GRPCAddress == "" { }
func normalizeConnection(conn Connection) (Connection, error) {
if strings.TrimSpace(conn.GRPCAddress) == "" {
conn.GRPCAddress = grpcaddr.Default(runtime.GOOS) conn.GRPCAddress = grpcaddr.Default(runtime.GOOS)
} }
if conn.BearerToken == "" { if strings.TrimSpace(conn.BearerToken) == "" {
return Connection{}, fmt.Errorf("browser bridge bearer token is required") return Connection{}, fmt.Errorf("browser bridge bearer token is required")
} }
conn.GRPCAddress = strings.TrimSpace(conn.GRPCAddress)
conn.BearerToken = strings.TrimSpace(conn.BearerToken)
return conn, nil return conn, nil
} }
func HandleRequest(ctx context.Context, req Request, client Client) Response { func HandleRequest(ctx context.Context, req Request, grpcAddr string, client Client) Response {
conn, err := req.Connection() conn, err := req.Connection(grpcAddr)
if err != nil { if err != nil {
return Response{Success: false, Error: err.Error()} return Response{Success: false, Error: err.Error()}
} }
@@ -162,33 +169,25 @@ func HandleRequest(ctx context.Context, req Request, client Client) Response {
if err != nil { if err != nil {
return Response{Success: false, Error: err.Error(), Status: disconnectedStatus(conn.GRPCAddress)} return Response{Success: false, Error: err.Error(), Status: disconnectedStatus(conn.GRPCAddress)}
} }
return Response{Success: true, Status: status, Version: "1"} return Response{Success: true, Status: status, Version: responseVersion}
case "find-logins": case "find-logins":
status, err := statusResponse(ctx, client, conn.GRPCAddress)
if err != nil {
return Response{Success: false, Error: err.Error(), Status: disconnectedStatus(conn.GRPCAddress)}
}
if status.Locked {
return Response{Success: true, Status: status, Matches: nil, Version: "1"}
}
matches, err := findMatches(ctx, client, req.URL) matches, err := findMatches(ctx, client, req.URL)
if err != nil { if err != nil {
return Response{Success: false, Error: err.Error(), Status: status} if status := inferredActionStatus(conn.GRPCAddress, err); status != nil {
return Response{Success: true, Status: status, Matches: nil, Version: responseVersion}
} }
return Response{Success: true, Status: status, Matches: matches, Version: "1"}
case "get-login":
status, err := statusResponse(ctx, client, conn.GRPCAddress)
if err != nil {
return Response{Success: false, Error: err.Error(), Status: disconnectedStatus(conn.GRPCAddress)} return Response{Success: false, Error: err.Error(), Status: disconnectedStatus(conn.GRPCAddress)}
} }
if status.Locked { return Response{Success: true, Status: availableStatus(conn.GRPCAddress), Matches: matches, Version: responseVersion}
return Response{Success: false, Error: "vault is locked", Status: status} case "get-login":
}
credential, err := loadCredential(ctx, client, req.EntryID, req.URL) credential, err := loadCredential(ctx, client, req.EntryID, req.URL)
if err != nil { if err != nil {
if status := inferredActionStatus(conn.GRPCAddress, err); status != nil {
return Response{Success: false, Error: err.Error(), Status: status} return Response{Success: false, Error: err.Error(), Status: status}
} }
return Response{Success: true, Status: status, Credential: credential, Version: "1"} return Response{Success: false, Error: err.Error(), Status: disconnectedStatus(conn.GRPCAddress)}
}
return Response{Success: true, Status: availableStatus(conn.GRPCAddress), Credential: credential, Version: responseVersion}
default: default:
return Response{Success: false, Error: fmt.Sprintf("unsupported action %q", action)} return Response{Success: false, Error: fmt.Sprintf("unsupported action %q", action)}
} }
@@ -198,6 +197,21 @@ func disconnectedStatus(addr string) *Status {
return &Status{Connected: false, GRPCAddress: strings.TrimSpace(addr)} return &Status{Connected: false, GRPCAddress: strings.TrimSpace(addr)}
} }
func availableStatus(addr string) *Status {
return &Status{Connected: true, Locked: false, GRPCAddress: strings.TrimSpace(addr)}
}
func inferredActionStatus(addr string, err error) *Status {
switch gstatus.Code(err) {
case gcodes.FailedPrecondition:
return &Status{Connected: true, Locked: true, GRPCAddress: strings.TrimSpace(addr)}
case gcodes.OK:
return availableStatus(addr)
default:
return nil
}
}
func statusResponse(ctx context.Context, client Client, addr string) (*Status, error) { func statusResponse(ctx context.Context, client Client, addr string) (*Status, error) {
resp, err := client.Status(ctx) resp, err := client.Status(ctx)
if err != nil { if err != nil {
+52 -17
View File
@@ -13,6 +13,8 @@ import (
"testing" "testing"
keepassgov1 "git.julianfamily.org/keepassgo/proto/keepassgo/v1" keepassgov1 "git.julianfamily.org/keepassgo/proto/keepassgo/v1"
gcodes "google.golang.org/grpc/codes"
gstatus "google.golang.org/grpc/status"
) )
func TestReadRequestAndWriteResponse(t *testing.T) { func TestReadRequestAndWriteResponse(t *testing.T) {
@@ -21,7 +23,6 @@ func TestReadRequestAndWriteResponse(t *testing.T) {
var input bytes.Buffer var input bytes.Buffer
body, err := json.Marshal(Request{ body, err := json.Marshal(Request{
Action: "find-logins", Action: "find-logins",
GRPCAddress: "127.0.0.1:47777",
BearerToken: "secret", BearerToken: "secret",
URL: "https://example.invalid/login", URL: "https://example.invalid/login",
}) })
@@ -42,8 +43,8 @@ func TestReadRequestAndWriteResponse(t *testing.T) {
if req.Action != "find-logins" || req.BearerToken != "secret" { if req.Action != "find-logins" || req.BearerToken != "secret" {
t.Fatalf("ReadRequest() = %#v, want action and token preserved", req) t.Fatalf("ReadRequest() = %#v, want action and token preserved", req)
} }
if conn, err := req.Connection(); err != nil || conn.GRPCAddress != "127.0.0.1:47777" { if conn, err := req.Connection("127.0.0.1:47777"); err != nil || conn.GRPCAddress != "127.0.0.1:47777" {
t.Fatalf("req.Connection() = (%#v, %v), want explicit tcp address preserved", conn, err) t.Fatalf("req.Connection(127.0.0.1:47777) = (%#v, %v), want explicit tcp address preserved", conn, err)
} }
var output bytes.Buffer var output bytes.Buffer
@@ -70,8 +71,7 @@ func TestReadRequestAndWriteResponse(t *testing.T) {
func TestHandleRequestFindLogins(t *testing.T) { func TestHandleRequestFindLogins(t *testing.T) {
t.Parallel() t.Parallel()
client := fakeClient{ client := &fakeClient{
status: &keepassgov1.GetSessionStatusResponse{Locked: false, EntryCount: 2},
matches: []*keepassgov1.BrowserLoginMatch{ matches: []*keepassgov1.BrowserLoginMatch{
{Id: "vault-console", Title: "Vault Console", Username: "dannyocean", Url: "https://vault.example.invalid", Quality: "exact-host"}, {Id: "vault-console", Title: "Vault Console", Username: "dannyocean", Url: "https://vault.example.invalid", Quality: "exact-host"},
}, },
@@ -80,19 +80,22 @@ func TestHandleRequestFindLogins(t *testing.T) {
Action: "find-logins", Action: "find-logins",
BearerToken: "secret", BearerToken: "secret",
URL: "https://vault.example.invalid/login", URL: "https://vault.example.invalid/login",
}, client) }, "", client)
if !resp.Success { if !resp.Success {
t.Fatalf("HandleRequest() success = false, error = %q", resp.Error) t.Fatalf("HandleRequest() success = false, error = %q", resp.Error)
} }
if len(resp.Matches) != 1 || resp.Matches[0].ID != "vault-console" { if len(resp.Matches) != 1 || resp.Matches[0].ID != "vault-console" {
t.Fatalf("HandleRequest().Matches = %#v, want vault-console", resp.Matches) t.Fatalf("HandleRequest().Matches = %#v, want vault-console", resp.Matches)
} }
if client.statusCalls != 0 {
t.Fatalf("HandleRequest(find-logins) statusCalls = %d, want 0", client.statusCalls)
}
} }
func TestHandleRequestStatusIncludesPendingApprovalCounts(t *testing.T) { func TestHandleRequestStatusIncludesPendingApprovalCounts(t *testing.T) {
t.Parallel() t.Parallel()
client := fakeClient{ client := &fakeClient{
status: &keepassgov1.GetSessionStatusResponse{ status: &keepassgov1.GetSessionStatusResponse{
Locked: false, Locked: false,
EntryCount: 2, EntryCount: 2,
@@ -103,7 +106,7 @@ func TestHandleRequestStatusIncludesPendingApprovalCounts(t *testing.T) {
resp := HandleRequest(context.Background(), Request{ resp := HandleRequest(context.Background(), Request{
Action: "status", Action: "status",
BearerToken: "secret", BearerToken: "secret",
}, client) }, "", client)
if !resp.Success { if !resp.Success {
t.Fatalf("HandleRequest(status) success = false, error = %q", resp.Error) t.Fatalf("HandleRequest(status) success = false, error = %q", resp.Error)
} }
@@ -121,8 +124,7 @@ func TestHandleRequestStatusIncludesPendingApprovalCounts(t *testing.T) {
func TestHandleRequestGetLogin(t *testing.T) { func TestHandleRequestGetLogin(t *testing.T) {
t.Parallel() t.Parallel()
client := fakeClient{ client := &fakeClient{
status: &keepassgov1.GetSessionStatusResponse{Locked: false, EntryCount: 1},
credential: &keepassgov1.GetBrowserCredentialResponse{ credential: &keepassgov1.GetBrowserCredentialResponse{
Id: "vault-console", Id: "vault-console",
Username: "dannyocean", Username: "dannyocean",
@@ -135,19 +137,42 @@ func TestHandleRequestGetLogin(t *testing.T) {
BearerToken: "secret", BearerToken: "secret",
EntryID: "vault-console", EntryID: "vault-console",
URL: "https://vault.example.invalid/login", URL: "https://vault.example.invalid/login",
}, client) }, "", client)
if !resp.Success { if !resp.Success {
t.Fatalf("HandleRequest() success = false, error = %q", resp.Error) t.Fatalf("HandleRequest() success = false, error = %q", resp.Error)
} }
if resp.Credential == nil || resp.Credential.ID != "vault-console" { if resp.Credential == nil || resp.Credential.ID != "vault-console" {
t.Fatalf("HandleRequest().Credential = %#v, want vault-console", resp.Credential) t.Fatalf("HandleRequest().Credential = %#v, want vault-console", resp.Credential)
} }
if client.statusCalls != 0 {
t.Fatalf("HandleRequest(get-login) statusCalls = %d, want 0", client.statusCalls)
}
}
func TestHandleRequestFindLoginsInfersLockedStatusFromRPC(t *testing.T) {
t.Parallel()
client := &fakeClient{matchesErr: gstatus.Error(gcodes.FailedPrecondition, "vault is locked")}
resp := HandleRequest(context.Background(), Request{
Action: "find-logins",
BearerToken: "secret",
URL: "https://vault.example.invalid/login",
}, "", client)
if !resp.Success {
t.Fatalf("HandleRequest(find-logins locked) success = false, error = %q", resp.Error)
}
if resp.Status == nil || !resp.Status.Locked {
t.Fatalf("HandleRequest(find-logins locked).Status = %#v, want locked status", resp.Status)
}
if client.statusCalls != 0 {
t.Fatalf("HandleRequest(find-logins locked) statusCalls = %d, want 0", client.statusCalls)
}
} }
func TestHandleRequestRequiresBearerToken(t *testing.T) { func TestHandleRequestRequiresBearerToken(t *testing.T) {
t.Parallel() t.Parallel()
resp := HandleRequest(context.Background(), Request{Action: "status"}, fakeClient{}) resp := HandleRequest(context.Background(), Request{Action: "status"}, "", &fakeClient{})
if resp.Success { if resp.Success {
t.Fatal("HandleRequest().Success = true, want false without token") t.Fatal("HandleRequest().Success = true, want false without token")
} }
@@ -157,9 +182,9 @@ func TestRequestConnectionDefaultsAddress(t *testing.T) {
t.Parallel() t.Parallel()
req := Request{Action: "status", BearerToken: "secret"} req := Request{Action: "status", BearerToken: "secret"}
conn, err := req.Connection() conn, err := req.Connection("")
if err != nil { if err != nil {
t.Fatalf("Connection() error = %v", err) t.Fatalf("Connection(\"\") error = %v", err)
} }
if conn.GRPCAddress == "" { if conn.GRPCAddress == "" {
t.Fatal("Connection().GRPCAddress = empty, want default address") t.Fatal("Connection().GRPCAddress = empty, want default address")
@@ -286,6 +311,9 @@ type fakeClient struct {
matches []*keepassgov1.BrowserLoginMatch matches []*keepassgov1.BrowserLoginMatch
credential *keepassgov1.GetBrowserCredentialResponse credential *keepassgov1.GetBrowserCredentialResponse
err error err error
matchesErr error
credentialErr error
statusCalls int
} }
func writeExtensionManifest(t *testing.T, path, name string) { func writeExtensionManifest(t *testing.T, path, name string) {
@@ -333,7 +361,8 @@ func assertManifestContainsExtension(t *testing.T, path, field, want string) {
} }
} }
func (f fakeClient) Status(context.Context) (*keepassgov1.GetSessionStatusResponse, error) { func (f *fakeClient) Status(context.Context) (*keepassgov1.GetSessionStatusResponse, error) {
f.statusCalls++
if f.err != nil { if f.err != nil {
return nil, f.err return nil, f.err
} }
@@ -343,14 +372,20 @@ func (f fakeClient) Status(context.Context) (*keepassgov1.GetSessionStatusRespon
return f.status, nil return f.status, nil
} }
func (f fakeClient) FindBrowserLogins(context.Context, string) ([]*keepassgov1.BrowserLoginMatch, error) { func (f *fakeClient) FindBrowserLogins(context.Context, string) ([]*keepassgov1.BrowserLoginMatch, error) {
if f.matchesErr != nil {
return nil, f.matchesErr
}
if f.err != nil { if f.err != nil {
return nil, f.err return nil, f.err
} }
return f.matches, nil return f.matches, nil
} }
func (f fakeClient) GetBrowserCredential(context.Context, string, string) (*keepassgov1.GetBrowserCredentialResponse, error) { func (f *fakeClient) GetBrowserCredential(context.Context, string, string) (*keepassgov1.GetBrowserCredentialResponse, error) {
if f.credentialErr != nil {
return nil, f.credentialErr
}
if f.err != nil { if f.err != nil {
return nil, f.err return nil, f.err
} }
+14 -9
View File
@@ -4,7 +4,6 @@ import (
"context" "context"
"fmt" "fmt"
"net" "net"
"runtime"
"strings" "strings"
"git.julianfamily.org/keepassgo/internal/grpcaddr" "git.julianfamily.org/keepassgo/internal/grpcaddr"
@@ -18,14 +17,20 @@ type GRPCClient struct {
client keepassgov1.VaultServiceClient client keepassgov1.VaultServiceClient
} }
func DialRequest(ctx context.Context, req Request, grpcAddr string) (*grpc.ClientConn, *GRPCClient, context.Context, error) {
conn, err := req.Connection(grpcAddr)
if err != nil {
return nil, nil, nil, err
}
return Dial(ctx, conn)
}
func Dial(ctx context.Context, conn Connection) (*grpc.ClientConn, *GRPCClient, context.Context, error) { func Dial(ctx context.Context, conn Connection) (*grpc.ClientConn, *GRPCClient, context.Context, error) {
if strings.TrimSpace(conn.GRPCAddress) == "" { normalized, err := normalizeConnection(conn)
conn.GRPCAddress = grpcaddr.Default(runtime.GOOS) if err != nil {
return nil, nil, nil, err
} }
if strings.TrimSpace(conn.BearerToken) == "" { network, endpoint, err := grpcaddr.Parse(normalized.GRPCAddress)
return nil, nil, nil, fmt.Errorf("browser bridge bearer token is required")
}
network, endpoint, err := grpcaddr.Parse(conn.GRPCAddress)
if err != nil { if err != nil {
return nil, nil, nil, err return nil, nil, nil, err
} }
@@ -40,9 +45,9 @@ func Dial(ctx context.Context, conn Connection) (*grpc.ClientConn, *GRPCClient,
}), }),
) )
if err != nil { if err != nil {
return nil, nil, nil, fmt.Errorf("dial gRPC host %s: %w", strings.TrimSpace(conn.GRPCAddress), err) return nil, nil, nil, fmt.Errorf("dial gRPC host %s: %w", normalized.GRPCAddress, err)
} }
ctx = metadata.AppendToOutgoingContext(ctx, "authorization", "Bearer "+strings.TrimSpace(conn.BearerToken)) ctx = metadata.AppendToOutgoingContext(ctx, "authorization", "Bearer "+normalized.BearerToken)
return grpcConn, &GRPCClient{client: keepassgov1.NewVaultServiceClient(grpcConn)}, ctx, nil return grpcConn, &GRPCClient{client: keepassgov1.NewVaultServiceClient(grpcConn)}, ctx, nil
} }
+72 -25
View File
@@ -12,6 +12,7 @@ import (
"strings" "strings"
"git.julianfamily.org/keepassgo/internal/vault" "git.julianfamily.org/keepassgo/internal/vault"
"git.julianfamily.org/keepassgo/internal/vaultview"
"git.julianfamily.org/keepassgo/internal/webdav" "git.julianfamily.org/keepassgo/internal/webdav"
) )
@@ -31,6 +32,7 @@ type Manager struct {
remoteClient *webdav.Client remoteClient *webdav.Client
remotePath string remotePath string
remoteVersion webdav.Version remoteVersion webdav.Version
warning string
} }
type PreparedLocalOpen struct { type PreparedLocalOpen struct {
@@ -40,6 +42,7 @@ type PreparedLocalOpen struct {
Key vault.MasterKey Key vault.MasterKey
Encoded []byte Encoded []byte
VaultRoot string VaultRoot string
Warning string
} }
type PreparedRemoteOpen struct { type PreparedRemoteOpen struct {
@@ -51,6 +54,7 @@ type PreparedRemoteOpen struct {
Encoded []byte Encoded []byte
VaultRoot string VaultRoot string
RemoteVersion webdav.Version RemoteVersion webdav.Version
Warning string
} }
type PreparedUnlock struct { type PreparedUnlock struct {
@@ -58,6 +62,7 @@ type PreparedUnlock struct {
Config *vault.KDBXConfig Config *vault.KDBXConfig
Key vault.MasterKey Key vault.MasterKey
VaultRoot string VaultRoot string
Warning string
} }
func (m *Manager) SecuritySettings() vault.SecuritySettings { func (m *Manager) SecuritySettings() vault.SecuritySettings {
@@ -74,7 +79,7 @@ func (m *Manager) ConfigureSecurity(settings vault.SecuritySettings) error {
} }
func (m *Manager) Create(model vault.Model, key vault.MasterKey) error { func (m *Manager) Create(model vault.Model, key vault.MasterKey) error {
root := detectSingleVaultRoot(model) root := vaultview.KeepassRoot
model = normalizeUnderRoot(model, root) model = normalizeUnderRoot(model, root)
var encoded bytes.Buffer var encoded bytes.Buffer
if err := vault.SaveKDBXWithConfigAndKey(&encoded, model, key, m.config); err != nil { if err := vault.SaveKDBXWithConfigAndKey(&encoded, model, key, m.config); err != nil {
@@ -86,6 +91,7 @@ func (m *Manager) Create(model vault.Model, key vault.MasterKey) error {
m.vaultRoot = root m.vaultRoot = root
m.encoded = encoded.Bytes() m.encoded = encoded.Bytes()
m.locked = false m.locked = false
m.warning = ""
return nil return nil
} }
@@ -118,6 +124,12 @@ func (m *Manager) Open(path string, key vault.MasterKey) error {
return nil return nil
} }
func (m *Manager) ConsumeWarning() string {
warning := strings.TrimSpace(m.warning)
m.warning = ""
return warning
}
func (m *Manager) Save() error { func (m *Manager) Save() error {
if m.remoteClient != nil && m.remotePath != "" { if m.remoteClient != nil && m.remotePath != "" {
return m.SaveRemote() return m.SaveRemote()
@@ -254,7 +266,7 @@ func (m *Manager) SaveAs(path string) error {
func (m *Manager) Replace(model vault.Model) { func (m *Manager) Replace(model vault.Model) {
root := m.vaultRoot root := m.vaultRoot
if root == "" { if root == "" {
root = detectSingleVaultRoot(model) root = vaultview.KeepassRoot
} }
m.model = normalizeUnderRoot(model, root) m.model = normalizeUnderRoot(model, root)
m.vaultRoot = root m.vaultRoot = root
@@ -305,12 +317,13 @@ func PrepareLocalOpen(path string, key vault.MasterKey) (PreparedLocalOpen, erro
return PreparedLocalOpen{}, fmt.Errorf("open %s: %w", path, err) return PreparedLocalOpen{}, fmt.Errorf("open %s: %w", path, err)
} }
return PreparedLocalOpen{ return PreparedLocalOpen{
Model: model, Model: normalizeUnderRoot(model, vaultview.KeepassRoot),
Config: config, Config: config,
Path: path, Path: path,
Key: key, Key: key,
Encoded: content, Encoded: content,
VaultRoot: detectSingleVaultRoot(model), VaultRoot: vaultview.KeepassRoot,
Warning: normalizationWarning(model),
}, nil }, nil
} }
@@ -324,14 +337,15 @@ func PrepareRemoteOpen(client webdav.Client, path string, key vault.MasterKey) (
return PreparedRemoteOpen{}, fmt.Errorf("decode remote %s: %w", path, err) return PreparedRemoteOpen{}, fmt.Errorf("decode remote %s: %w", path, err)
} }
return PreparedRemoteOpen{ return PreparedRemoteOpen{
Model: model, Model: normalizeUnderRoot(model, vaultview.KeepassRoot),
Config: config, Config: config,
Client: client, Client: client,
Path: path, Path: path,
Key: key, Key: key,
Encoded: content, Encoded: content,
VaultRoot: detectSingleVaultRoot(model), VaultRoot: vaultview.KeepassRoot,
RemoteVersion: version, RemoteVersion: version,
Warning: normalizationWarning(model),
}, nil }, nil
} }
@@ -341,10 +355,11 @@ func PrepareUnlock(encoded []byte, key vault.MasterKey) (PreparedUnlock, error)
return PreparedUnlock{}, fmt.Errorf("unlock vault: %w", err) return PreparedUnlock{}, fmt.Errorf("unlock vault: %w", err)
} }
return PreparedUnlock{ return PreparedUnlock{
Model: model, Model: normalizeUnderRoot(model, vaultview.KeepassRoot),
Config: config, Config: config,
Key: key, Key: key,
VaultRoot: detectSingleVaultRoot(model), VaultRoot: vaultview.KeepassRoot,
Warning: normalizationWarning(model),
}, nil }, nil
} }
@@ -359,6 +374,7 @@ func (m *Manager) ApplyPreparedLocalOpen(prepared PreparedLocalOpen) {
m.remoteClient = nil m.remoteClient = nil
m.remotePath = "" m.remotePath = ""
m.remoteVersion = webdav.Version{} m.remoteVersion = webdav.Version{}
m.warning = prepared.Warning
} }
func (m *Manager) ApplyPreparedRemoteOpen(prepared PreparedRemoteOpen) { func (m *Manager) ApplyPreparedRemoteOpen(prepared PreparedRemoteOpen) {
@@ -372,6 +388,7 @@ func (m *Manager) ApplyPreparedRemoteOpen(prepared PreparedRemoteOpen) {
m.remotePath = prepared.Path m.remotePath = prepared.Path
m.remoteVersion = prepared.RemoteVersion m.remoteVersion = prepared.RemoteVersion
m.path = "" m.path = ""
m.warning = prepared.Warning
} }
func (m *Manager) ApplyPreparedUnlock(prepared PreparedUnlock) { func (m *Manager) ApplyPreparedUnlock(prepared PreparedUnlock) {
@@ -380,6 +397,7 @@ func (m *Manager) ApplyPreparedUnlock(prepared PreparedUnlock) {
m.key = prepared.Key m.key = prepared.Key
m.vaultRoot = prepared.VaultRoot m.vaultRoot = prepared.VaultRoot
m.locked = false m.locked = false
m.warning = prepared.Warning
} }
func (m *Manager) ChangeMasterKey(key vault.MasterKey) error { func (m *Manager) ChangeMasterKey(key vault.MasterKey) error {
@@ -584,9 +602,7 @@ func (m *Manager) reloadCurrentLocal(merged vault.Model) error {
return err return err
} }
m.model = merged m.model = merged
if root := detectSingleVaultRoot(merged); root != "" { m.vaultRoot = vaultview.KeepassRoot
m.vaultRoot = root
}
m.encoded = encoded m.encoded = encoded
m.locked = false m.locked = false
return nil return nil
@@ -603,9 +619,7 @@ func (m *Manager) reloadCurrentRemote(merged vault.Model) error {
return fmt.Errorf("reopen remote %s after synchronize: %w", m.remotePath, err) return fmt.Errorf("reopen remote %s after synchronize: %w", m.remotePath, err)
} }
m.model = merged m.model = merged
if root := detectSingleVaultRoot(merged); root != "" { m.vaultRoot = vaultview.KeepassRoot
m.vaultRoot = root
}
m.encoded = encoded m.encoded = encoded
m.remoteVersion = version m.remoteVersion = version
m.locked = false m.locked = false
@@ -867,17 +881,6 @@ func mergePeerGroups(primary, secondary [][]string) [][]string {
return out return out
} }
func detectSingleVaultRoot(model vault.Model) string {
if len(model.EntriesInPath(nil)) != 0 {
return ""
}
groups := model.ChildGroups(nil)
if len(groups) != 1 {
return ""
}
return groups[0]
}
func normalizeUnderRoot(model vault.Model, root string) vault.Model { func normalizeUnderRoot(model vault.Model, root string) vault.Model {
if root == "" { if root == "" {
return model return model
@@ -888,8 +891,15 @@ func normalizeUnderRoot(model vault.Model, root string) vault.Model {
switch { switch {
case len(path) == 0: case len(path) == 0:
return []string{root} return []string{root}
case path[0] == "Root":
if len(path) == 1 {
return []string{root}
}
return append([]string{root}, path[1:]...)
case path[0] == root: case path[0] == root:
return path return path
case path[0] == "Templates":
return path
default: default:
return append([]string{root}, path...) return append([]string{root}, path...)
} }
@@ -907,12 +917,49 @@ func normalizeUnderRoot(model vault.Model, root string) vault.Model {
out.RecycleBin[i].History[j].Path = normalizePath(out.RecycleBin[i].History[j].Path) out.RecycleBin[i].History[j].Path = normalizePath(out.RecycleBin[i].History[j].Path)
} }
} }
for i := range out.Templates {
out.Templates[i].Path = normalizePath(out.Templates[i].Path)
for j := range out.Templates[i].History {
out.Templates[i].History[j].Path = normalizePath(out.Templates[i].History[j].Path)
}
}
for i := range out.Groups { for i := range out.Groups {
out.Groups[i] = normalizePath(out.Groups[i]) out.Groups[i] = normalizePath(out.Groups[i])
} }
return out return out
} }
func normalizationWarning(model vault.Model) string {
if len(model.Entries) == 0 && len(model.Groups) == 0 && len(model.RecycleBin) == 0 {
return ""
}
if usesKeepassStorageRoot(model) {
return ""
}
return "Opened legacy vault root layout and normalized it under keepass."
}
func usesKeepassStorageRoot(model vault.Model) bool {
if len(model.Entries) != 0 || len(model.RecycleBin) != 0 {
for _, entry := range model.Entries {
if len(entry.Path) > 0 && entry.Path[0] == vaultview.KeepassRoot {
return true
}
}
for _, entry := range model.RecycleBin {
if len(entry.Path) > 0 && entry.Path[0] == vaultview.KeepassRoot {
return true
}
}
}
for _, group := range model.Groups {
if len(group) > 0 && group[0] == vaultview.KeepassRoot {
return true
}
}
return false
}
func loadLocalSource(path string, key vault.MasterKey) (vault.Model, *vault.KDBXConfig, error) { func loadLocalSource(path string, key vault.MasterKey) (vault.Model, *vault.KDBXConfig, error) {
content, err := os.ReadFile(path) content, err := os.ReadFile(path)
if err != nil { if err != nil {
+63 -12
View File
@@ -64,7 +64,7 @@ func TestCreateSaveAsLockAndUnlockRoundTripsVault(t *testing.T) {
t.Fatalf("Current() after Unlock() error = %v", err) t.Fatalf("Current() after Unlock() error = %v", err)
} }
got := current.EntriesInPath([]string{"Root", "Internet"}) got := current.EntriesInPath([]string{"keepass", "Internet"})
if len(got) != 1 || got[0].Title != "Vault Console" || got[0].Password != "token-1" { 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) t.Fatalf("Current() entries = %#v, want persisted Vault Console entry", got)
} }
@@ -110,12 +110,63 @@ func TestOpenLoadsExistingKDBXFromDisk(t *testing.T) {
t.Fatalf("Current() error = %v", err) t.Fatalf("Current() error = %v", err)
} }
got := current.EntriesInPath([]string{"Root", "Home Assistant"}) got := current.EntriesInPath([]string{"keepass", "Home Assistant"})
if len(got) != 1 || got[0].Password != "token-2" { if len(got) != 1 || got[0].Password != "token-2" {
t.Fatalf("Current() entries = %#v, want Home Assistant entry", got) t.Fatalf("Current() entries = %#v, want Home Assistant entry", got)
} }
} }
func TestOpenNormalizesLegacyVaultRootToKeepassAndReportsWarning(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"},
},
},
Groups: [][]string{
{"Root"},
{"Root", "Home Assistant"},
},
}
path := filepath.Join(t.TempDir(), "legacy-root.kdbx")
file, err := os.Create(path)
if err != nil {
t.Fatalf("Create(legacy 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(legacy 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)
}
if got := current.EntriesInPath([]string{"keepass", "Home Assistant"}); len(got) != 1 || got[0].ID != "entry-1" {
t.Fatalf("Current().EntriesInPath([keepass Home Assistant]) = %#v, want normalized legacy entry", got)
}
if got := sess.ConsumeWarning(); got == "" {
t.Fatal("ConsumeWarning() = empty, want legacy root normalization warning")
}
}
func TestSavePersistsEditsBackToCurrentPath(t *testing.T) { func TestSavePersistsEditsBackToCurrentPath(t *testing.T) {
t.Parallel() t.Parallel()
@@ -169,7 +220,7 @@ func TestSavePersistsEditsBackToCurrentPath(t *testing.T) {
t.Fatalf("LoadKDBXWithKey() error = %v", err) t.Fatalf("LoadKDBXWithKey() error = %v", err)
} }
got := loaded.EntriesInPath([]string{"Root", "Internet"}) got := loaded.EntriesInPath([]string{"keepass", "Internet"})
if len(got) != 1 || got[0].Password != "token-2" { if len(got) != 1 || got[0].Password != "token-2" {
t.Fatalf("loaded entries = %#v, want updated password token-2", got) t.Fatalf("loaded entries = %#v, want updated password token-2", got)
} }
@@ -307,7 +358,7 @@ func TestOpenRemoteLoadsExistingKDBXFromWebDAV(t *testing.T) {
t.Fatalf("Current() error = %v", err) t.Fatalf("Current() error = %v", err)
} }
got := current.EntriesInPath([]string{"Root", "Internet"}) got := current.EntriesInPath([]string{"keepass", "Internet"})
if len(got) != 1 || got[0].Password != "token-1" { if len(got) != 1 || got[0].Password != "token-1" {
t.Fatalf("Current() entries = %#v, want Vault Console entry from remote vault", got) t.Fatalf("Current() entries = %#v, want Vault Console entry from remote vault", got)
} }
@@ -392,7 +443,7 @@ func TestSaveRemotePersistsEditsBackToWebDAV(t *testing.T) {
t.Fatalf("LoadKDBXWithKey(savedBytes) error = %v", err) t.Fatalf("LoadKDBXWithKey(savedBytes) error = %v", err)
} }
got := loaded.EntriesInPath([]string{"Root", "Home Assistant"}) got := loaded.EntriesInPath([]string{"keepass", "Home Assistant"})
if len(got) != 1 || got[0].Password != "token-2" { if len(got) != 1 || got[0].Password != "token-2" {
t.Fatalf("loaded remote entries = %#v, want updated token-2 entry", got) t.Fatalf("loaded remote entries = %#v, want updated token-2 entry", got)
} }
@@ -513,7 +564,7 @@ func TestChangeMasterKeyReencryptsSavedAndLockedVault(t *testing.T) {
if err != nil { if err != nil {
t.Fatalf("Current() error = %v", err) t.Fatalf("Current() error = %v", err)
} }
got := current.EntriesInPath([]string{"Root", "Internet"}) got := current.EntriesInPath([]string{"keepass", "Internet"})
if len(got) != 1 || got[0].Title != "Vault Console" { if len(got) != 1 || got[0].Title != "Vault Console" {
t.Fatalf("Current() entries = %#v, want Vault Console entry after ChangeMasterKey", got) t.Fatalf("Current() entries = %#v, want Vault Console entry after ChangeMasterKey", got)
} }
@@ -720,7 +771,7 @@ func TestRemoteSaveAndReopenPreservesCrossFeatureState(t *testing.T) {
t.Fatalf("Current() after reopen error = %v", err) t.Fatalf("Current() after reopen error = %v", err)
} }
got := current.EntriesInPath([]string{"Root", "Internet"}) got := current.EntriesInPath([]string{"keepass", "Internet"})
if len(got) != 1 { if len(got) != 1 {
t.Fatalf("len(EntriesInPath(Root/Internet)) after reopen = %d, want 1", len(got)) t.Fatalf("len(EntriesInPath(Root/Internet)) after reopen = %d, want 1", len(got))
} }
@@ -879,7 +930,7 @@ func TestSynchronizeRemotePreservesOverwrittenRemoteVariantInHistory(t *testing.
t.Fatalf("reopened Current() error = %v", err) t.Fatalf("reopened Current() error = %v", err)
} }
got := current.EntriesInPath([]string{"Root", "Internet"}) got := current.EntriesInPath([]string{"keepass", "Internet"})
if len(got) != 1 { if len(got) != 1 {
t.Fatalf("len(EntriesInPath(Root/Internet)) = %d, want 1", len(got)) t.Fatalf("len(EntriesInPath(Root/Internet)) = %d, want 1", len(got))
} }
@@ -947,7 +998,7 @@ func TestSynchronizeFromLocalMergesOtherVaultIntoCurrentSource(t *testing.T) {
t.Fatalf("reopened Current() error = %v", err) t.Fatalf("reopened Current() error = %v", err)
} }
got := current.EntriesInPath([]string{"Root", "Internet"}) got := current.EntriesInPath([]string{"keepass", "Internet"})
if len(got) != 2 { if len(got) != 2 {
t.Fatalf("len(EntriesInPath(Root/Internet)) = %d, want 2", len(got)) t.Fatalf("len(EntriesInPath(Root/Internet)) = %d, want 2", len(got))
} }
@@ -1004,7 +1055,7 @@ func TestSynchronizeFromLocalBytesMergesOtherVaultIntoCurrentSource(t *testing.T
t.Fatalf("reopened Current() error = %v", err) t.Fatalf("reopened Current() error = %v", err)
} }
got := current.EntriesInPath([]string{"Root", "Internet"}) got := current.EntriesInPath([]string{"keepass", "Internet"})
if len(got) != 2 { if len(got) != 2 {
t.Fatalf("len(EntriesInPath(Root/Internet)) = %d, want 2", len(got)) t.Fatalf("len(EntriesInPath(Root/Internet)) = %d, want 2", len(got))
} }
@@ -1063,7 +1114,7 @@ func TestSynchronizeToLocalWritesMergedVaultToTarget(t *testing.T) {
t.Fatalf("reopened Current() error = %v", err) t.Fatalf("reopened Current() error = %v", err)
} }
got := current.EntriesInPath([]string{"Root", "Internet"}) got := current.EntriesInPath([]string{"keepass", "Internet"})
if len(got) != 2 { if len(got) != 2 {
t.Fatalf("len(EntriesInPath(Root/Internet)) = %d, want 2", len(got)) t.Fatalf("len(EntriesInPath(Root/Internet)) = %d, want 2", len(got))
} }
@@ -1148,7 +1199,7 @@ func TestSynchronizeToRemoteWritesMergedVaultToTarget(t *testing.T) {
t.Fatalf("reopened Current() error = %v", err) t.Fatalf("reopened Current() error = %v", err)
} }
got := current.EntriesInPath([]string{"Root", "Internet"}) got := current.EntriesInPath([]string{"keepass", "Internet"})
if len(got) != 2 { if len(got) != 2 {
t.Fatalf("len(EntriesInPath(Root/Internet)) = %d, want 2", len(got)) t.Fatalf("len(EntriesInPath(Root/Internet)) = %d, want 2", len(got))
} }
+5
View File
@@ -26,6 +26,7 @@ var ErrInvalidMasterKey = errors.New("invalid master key")
const ( const (
templatesRoot = "Templates" templatesRoot = "Templates"
recycleBinRoot = "Recycle Bin" recycleBinRoot = "Recycle Bin"
keepassRoot = "keepass"
keepassGOIDField = "KeePassGO-ID" keepassGOIDField = "KeePassGO-ID"
remoteProfilesKey = "keepassgo.remoteProfiles" remoteProfilesKey = "keepassgo.remoteProfiles"
) )
@@ -502,6 +503,10 @@ func compareGroupNames(a, b string) int {
return -1 return -1
case b == "Root": case b == "Root":
return 1 return 1
case a == keepassRoot:
return -1
case b == keepassRoot:
return 1
case a == templatesRoot: case a == templatesRoot:
return -1 return -1
case b == templatesRoot: case b == templatesRoot:
+51
View File
@@ -755,6 +755,57 @@ func TestKDBXReopenCyclesPreserveStableIDsAndCrossFeatureState(t *testing.T) {
} }
} }
func TestKDBXKeepassRootEntriesPreserveAttachmentsWithTemplates(t *testing.T) {
t.Parallel()
model := Model{
Entries: []Entry{
{
ID: "entry-1",
Title: "Vault Console",
Username: "dannyocean",
Password: "bellagio-pass-2",
URL: "https://vault.crew.example.invalid",
Path: []string{"keepass", "Internet"},
Attachments: map[string][]byte{
"token.txt": []byte("secret attachment contents"),
},
},
},
Templates: []Entry{
{
ID: "tpl-1",
Title: "Website Login",
Username: "template-user",
Password: "template-password",
Path: []string{"Templates", "Web"},
},
},
Groups: [][]string{
{"keepass", "Internet"},
{"Templates", "Web"},
},
}
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{"keepass", "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 { func mustGroup(name string, children ...any) gokeepasslib.Group {
group := gokeepasslib.NewGroup() group := gokeepasslib.NewGroup()
group.Name = name group.Name = name
+16 -8
View File
@@ -5,19 +5,27 @@ import "git.julianfamily.org/keepassgo/internal/vault"
// HiddenRoot returns the single synthetic top-level vault group that should be // HiddenRoot returns the single synthetic top-level vault group that should be
// treated as an internal storage root rather than as a user-visible group. // treated as an internal storage root rather than as a user-visible group.
func HiddenRoot(model vault.Model) string { func HiddenRoot(model vault.Model) string {
if len(model.EntriesInPath(nil)) != 0 { if !hasGroup(model.Groups, []string{KeepassRoot}) {
return "" return ""
} }
groups := model.ChildGroups(nil) return KeepassRoot
roots := make([]string, 0, len(groups)) }
func hasGroup(groups [][]string, path []string) bool {
for _, group := range groups { for _, group := range groups {
if group == "Recycle Bin" { if len(group) != len(path) {
continue continue
} }
roots = append(roots, group) match := true
for i := range group {
if group[i] != path[i] {
match = false
break
} }
if len(roots) != 1 {
return ""
} }
return roots[0] if match {
return true
}
}
return false
} }
+427
View File
@@ -0,0 +1,427 @@
package vaultview
import (
"slices"
"git.julianfamily.org/keepassgo/internal/vault"
)
const KeepassRoot = "keepass"
const TemplatesRoot = "Templates"
// View projects the physical vault model into a logical tree for a specific
// product surface.
type View interface {
ChildGroups(path []string) []string
EntriesInPath(path []string) []vault.Entry
EntriesUnderPath(path []string) []vault.Entry
ToPhysicalPath(path []string) []string
FromPhysicalPath(path []string) []string
ToPhysicalEntry(entry vault.Entry) vault.Entry
FromPhysicalEntry(entry vault.Entry) vault.Entry
}
// Vault returns the physical datastore view.
func Vault(model vault.Model) View {
return physicalView{model: model}
}
// VaultRoot returns the logical main-vault view rooted at the physical
// keepass storage group.
func VaultRoot(model vault.Model) View {
return prefixedView{model: model, root: KeepassRoot, rooted: usesTopLevelRoot(model, KeepassRoot)}
}
// VaultTemplates returns the logical templates view rooted at the physical
// Templates storage group.
func VaultTemplates(model vault.Model) View {
return templatesView{model: model}
}
// VaultRecycleBin returns the logical recycle-bin view.
func VaultRecycleBin(model vault.Model) View {
return recycleBinView{model: model}
}
type physicalView struct {
model vault.Model
}
func (v physicalView) ChildGroups(path []string) []string {
return v.model.ChildGroups(path)
}
func (v physicalView) EntriesInPath(path []string) []vault.Entry {
return cloneEntries(v.model.EntriesInPath(path))
}
func (v physicalView) EntriesUnderPath(path []string) []vault.Entry {
return cloneEntries(v.model.EntriesUnderPath(path))
}
func (v physicalView) ToPhysicalPath(path []string) []string {
return clonePath(path)
}
func (v physicalView) FromPhysicalPath(path []string) []string {
return clonePath(path)
}
func (v physicalView) ToPhysicalEntry(entry vault.Entry) vault.Entry {
return cloneEntry(entry)
}
func (v physicalView) FromPhysicalEntry(entry vault.Entry) vault.Entry {
return cloneEntry(entry)
}
type prefixedView struct {
model vault.Model
root string
rooted bool
}
func (v prefixedView) ChildGroups(path []string) []string {
return v.model.ChildGroups(v.ToPhysicalPath(path))
}
func (v prefixedView) EntriesInPath(path []string) []vault.Entry {
return v.mapEntries(v.model.EntriesInPath(v.ToPhysicalPath(path)))
}
func (v prefixedView) EntriesUnderPath(path []string) []vault.Entry {
return v.mapEntries(v.model.EntriesUnderPath(v.ToPhysicalPath(path)))
}
func (v prefixedView) ToPhysicalPath(path []string) []string {
if !v.rooted {
return clonePath(path)
}
if len(path) == 0 {
return []string{v.root}
}
return append([]string{v.root}, clonePath(path)...)
}
func (v prefixedView) FromPhysicalPath(path []string) []string {
if !v.rooted {
return clonePath(path)
}
if len(path) == 0 {
return nil
}
if path[0] != v.root {
return clonePath(path)
}
return clonePath(path[1:])
}
func (v prefixedView) ToPhysicalEntry(entry vault.Entry) vault.Entry {
entry = cloneEntry(entry)
entry.Path = v.ToPhysicalPath(entry.Path)
for i := range entry.History {
entry.History[i].Path = v.ToPhysicalPath(entry.History[i].Path)
}
return entry
}
func (v prefixedView) FromPhysicalEntry(entry vault.Entry) vault.Entry {
entry = cloneEntry(entry)
entry.Path = v.FromPhysicalPath(entry.Path)
for i := range entry.History {
entry.History[i].Path = v.FromPhysicalPath(entry.History[i].Path)
}
return entry
}
func (v prefixedView) mapEntries(entries []vault.Entry) []vault.Entry {
out := make([]vault.Entry, 0, len(entries))
for _, entry := range entries {
out = append(out, v.FromPhysicalEntry(entry))
}
return out
}
type recycleBinView struct {
model vault.Model
}
type templatesView struct {
model vault.Model
}
func (v templatesView) ChildGroups(path []string) []string {
return groupChildren(templateGroupPaths(v.model), v.EntriesUnderPath(nil), path)
}
func (v templatesView) EntriesInPath(path []string) []vault.Entry {
return entriesInPath(v.EntriesUnderPath(nil), path)
}
func (v templatesView) EntriesUnderPath(path []string) []vault.Entry {
var out []vault.Entry
for _, entry := range v.model.Templates {
if len(path) > len(entry.Path) {
continue
}
physical := entry.Path
if len(physical) > 0 && physical[0] == TemplatesRoot {
physical = physical[1:]
}
if len(path) > len(physical) {
continue
}
if !slices.Equal(physical[:len(path)], path) {
continue
}
item := cloneEntry(entry)
item.Path = clonePath(physical)
for i := range item.History {
item.History[i].Path = v.FromPhysicalPath(item.History[i].Path)
}
out = append(out, item)
}
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 (v templatesView) ToPhysicalPath(path []string) []string {
if len(path) == 0 {
return []string{TemplatesRoot}
}
return append([]string{TemplatesRoot}, clonePath(path)...)
}
func (v templatesView) FromPhysicalPath(path []string) []string {
if len(path) == 0 {
return nil
}
if path[0] != TemplatesRoot {
return clonePath(path)
}
return clonePath(path[1:])
}
func (v templatesView) ToPhysicalEntry(entry vault.Entry) vault.Entry {
entry = cloneEntry(entry)
entry.Path = v.ToPhysicalPath(entry.Path)
for i := range entry.History {
entry.History[i].Path = v.ToPhysicalPath(entry.History[i].Path)
}
return entry
}
func (v templatesView) FromPhysicalEntry(entry vault.Entry) vault.Entry {
entry = cloneEntry(entry)
entry.Path = v.FromPhysicalPath(entry.Path)
for i := range entry.History {
entry.History[i].Path = v.FromPhysicalPath(entry.History[i].Path)
}
return entry
}
func (v recycleBinView) ChildGroups(path []string) []string {
return childGroups(v.model.RecycleBin, path)
}
func (v recycleBinView) EntriesInPath(path []string) []vault.Entry {
return entriesInPath(v.model.RecycleBin, path)
}
func (v recycleBinView) EntriesUnderPath(path []string) []vault.Entry {
var out []vault.Entry
for _, entry := range v.model.RecycleBin {
if len(path) > len(entry.Path) {
continue
}
if !slices.Equal(entry.Path[:len(path)], path) {
continue
}
out = append(out, cloneEntry(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 (v recycleBinView) ToPhysicalPath(path []string) []string {
return clonePath(path)
}
func (v recycleBinView) FromPhysicalPath(path []string) []string {
return clonePath(path)
}
func (v recycleBinView) ToPhysicalEntry(entry vault.Entry) vault.Entry {
return cloneEntry(entry)
}
func (v recycleBinView) FromPhysicalEntry(entry vault.Entry) vault.Entry {
return cloneEntry(entry)
}
func childGroups(entries []vault.Entry, path []string) []string {
return groupChildren(nil, entries, path)
}
func groupChildren(groupPaths [][]string, 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)
}
for _, groupPath := range groupPaths {
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 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, cloneEntry(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 cloneEntries(entries []vault.Entry) []vault.Entry {
if len(entries) == 0 {
return nil
}
out := make([]vault.Entry, len(entries))
for i := range entries {
out[i] = cloneEntry(entries[i])
}
return out
}
func cloneEntry(entry vault.Entry) vault.Entry {
entry.Path = clonePath(entry.Path)
entry.Tags = slices.Clone(entry.Tags)
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
}
if len(entry.History) != 0 {
history := make([]vault.Entry, len(entry.History))
for i := range entry.History {
history[i] = cloneEntry(entry.History[i])
}
entry.History = history
}
return entry
}
func clonePath(path []string) []string {
if len(path) == 0 {
return nil
}
return slices.Clone(path)
}
func templateGroupPaths(model vault.Model) [][]string {
var out [][]string
for _, group := range model.Groups {
if len(group) == 0 || group[0] != TemplatesRoot {
continue
}
out = append(out, clonePath(group[1:]))
}
return out
}
func usesTopLevelRoot(model vault.Model, root string) bool {
if len(model.Entries) == 0 && len(model.Groups) == 0 && len(model.RecycleBin) == 0 {
return root == KeepassRoot
}
return groupsUseRoot(model.Groups, root) ||
entriesUseRoot(model.Entries, root) ||
entriesUseRoot(model.Templates, root) ||
entriesUseRoot(model.RecycleBin, root)
}
func groupsUseRoot(groups [][]string, root string) bool {
for _, group := range groups {
if len(group) > 0 && group[0] == root {
return true
}
}
return false
}
func entriesUseRoot(entries []vault.Entry, root string) bool {
for _, entry := range entries {
if len(entry.Path) > 0 && entry.Path[0] == root {
return true
}
}
return false
}
+139
View File
@@ -0,0 +1,139 @@
package vaultview
import (
"slices"
"testing"
"git.julianfamily.org/keepassgo/internal/vault"
)
func TestVaultRootProjectsKeepassStorageRoot(t *testing.T) {
t.Parallel()
model := vault.Model{
Entries: []vault.Entry{
{ID: "bellagio-ledger", Title: "Bellagio Ledger", Path: []string{"keepass", "Crew", "Internet"}},
{ID: "fountain-cameras", Title: "Fountain Cameras", Path: []string{"keepass", "Crew", "Security"}},
},
Groups: [][]string{
{"keepass"},
{"keepass", "Crew"},
{"keepass", "Crew", "Internet"},
{"keepass", "Crew", "Security"},
{"Recycle Bin"},
},
}
view := VaultRoot(model)
if got := view.ChildGroups(nil); !slices.Equal(got, []string{"Crew"}) {
t.Fatalf("VaultRoot(model).ChildGroups(nil) = %v, want [Crew]", got)
}
if got := view.ChildGroups([]string{"Crew"}); !slices.Equal(got, []string{"Internet", "Security"}) {
t.Fatalf("VaultRoot(model).ChildGroups([Crew]) = %v, want [Internet Security]", got)
}
gotEntries := view.EntriesInPath([]string{"Crew", "Internet"})
if len(gotEntries) != 1 || !slices.Equal(gotEntries[0].Path, []string{"Crew", "Internet"}) {
t.Fatalf("VaultRoot(model).EntriesInPath([Crew Internet]) = %#v, want logical path [Crew Internet]", gotEntries)
}
if got := view.ToPhysicalPath(nil); !slices.Equal(got, []string{"keepass"}) {
t.Fatalf("VaultRoot(model).ToPhysicalPath(nil) = %v, want [keepass]", got)
}
if got := view.ToPhysicalPath([]string{"Crew", "Internet"}); !slices.Equal(got, []string{"keepass", "Crew", "Internet"}) {
t.Fatalf("VaultRoot(model).ToPhysicalPath([Crew Internet]) = %v, want [keepass Crew Internet]", got)
}
if got := view.FromPhysicalPath([]string{"keepass", "Crew", "Internet"}); !slices.Equal(got, []string{"Crew", "Internet"}) {
t.Fatalf("VaultRoot(model).FromPhysicalPath([keepass Crew Internet]) = %v, want [Crew Internet]", got)
}
}
func TestVaultRecycleBinProjectsRecycleTree(t *testing.T) {
t.Parallel()
model := vault.Model{
RecycleBin: []vault.Entry{
{ID: "bellagio-ledger", Title: "Bellagio Ledger", Path: []string{"Crew", "Internet"}},
{ID: "fountain-cameras", Title: "Fountain Cameras", Path: []string{"Crew", "Security"}},
},
}
view := VaultRecycleBin(model)
if got := view.ChildGroups(nil); !slices.Equal(got, []string{"Crew"}) {
t.Fatalf("VaultRecycleBin(model).ChildGroups(nil) = %v, want [Crew]", got)
}
if got := view.ChildGroups([]string{"Crew"}); !slices.Equal(got, []string{"Internet", "Security"}) {
t.Fatalf("VaultRecycleBin(model).ChildGroups([Crew]) = %v, want [Internet Security]", got)
}
gotEntries := view.EntriesInPath([]string{"Crew", "Internet"})
if len(gotEntries) != 1 || !slices.Equal(gotEntries[0].Path, []string{"Crew", "Internet"}) {
t.Fatalf("VaultRecycleBin(model).EntriesInPath([Crew Internet]) = %#v, want logical recycle-bin path [Crew Internet]", gotEntries)
}
if got := view.ToPhysicalPath([]string{"Crew", "Internet"}); !slices.Equal(got, []string{"Crew", "Internet"}) {
t.Fatalf("VaultRecycleBin(model).ToPhysicalPath([Crew Internet]) = %v, want [Crew Internet]", got)
}
}
func TestVaultTemplatesProjectsTemplatesStorageRoot(t *testing.T) {
t.Parallel()
model := vault.Model{
Templates: []vault.Entry{
{ID: "website-login", Title: "Website Login", Path: []string{"Templates", "Web"}},
{ID: "ssh-login", Title: "SSH Login", Path: []string{"Templates", "Infra"}},
},
Groups: [][]string{
{"Templates"},
{"Templates", "Infra"},
{"Templates", "Web"},
{"keepass"},
},
}
view := VaultTemplates(model)
if got := view.ChildGroups(nil); !slices.Equal(got, []string{"Infra", "Web"}) {
t.Fatalf("VaultTemplates(model).ChildGroups(nil) = %v, want [Infra Web]", got)
}
gotEntries := view.EntriesInPath([]string{"Web"})
if len(gotEntries) != 1 || !slices.Equal(gotEntries[0].Path, []string{"Web"}) {
t.Fatalf("VaultTemplates(model).EntriesInPath([Web]) = %#v, want logical path [Web]", gotEntries)
}
if got := view.ToPhysicalPath(nil); !slices.Equal(got, []string{"Templates"}) {
t.Fatalf("VaultTemplates(model).ToPhysicalPath(nil) = %v, want [Templates]", got)
}
if got := view.ToPhysicalPath([]string{"Web"}); !slices.Equal(got, []string{"Templates", "Web"}) {
t.Fatalf("VaultTemplates(model).ToPhysicalPath([Web]) = %v, want [Templates Web]", got)
}
if got := view.FromPhysicalPath([]string{"Templates", "Web"}); !slices.Equal(got, []string{"Web"}) {
t.Fatalf("VaultTemplates(model).FromPhysicalPath([Templates Web]) = %v, want [Web]", got)
}
}
func TestVaultReturnsPhysicalPathsUnchanged(t *testing.T) {
t.Parallel()
model := vault.Model{
Entries: []vault.Entry{
{ID: "bellagio-ledger", Title: "Bellagio Ledger", Path: []string{"keepass", "Crew", "Internet"}},
},
}
view := Vault(model)
if got := view.ChildGroups(nil); !slices.Equal(got, []string{"keepass"}) {
t.Fatalf("Vault(model).ChildGroups(nil) = %v, want [keepass]", got)
}
if got := view.ToPhysicalPath([]string{"keepass", "Crew"}); !slices.Equal(got, []string{"keepass", "Crew"}) {
t.Fatalf("Vault(model).ToPhysicalPath([keepass Crew]) = %v, want [keepass Crew]", got)
}
if got := view.FromPhysicalEntry(model.Entries[0]); !slices.Equal(got.Path, []string{"keepass", "Crew", "Internet"}) {
t.Fatalf("Vault(model).FromPhysicalEntry(entry).Path = %v, want [keepass Crew Internet]", got.Path)
}
}