Compare commits

...

8 Commits

Author SHA1 Message Date
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
19 changed files with 439 additions and 281 deletions
-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
+66 -63
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
@@ -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
} }
+102
View File
@@ -336,6 +336,108 @@ 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 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()
+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()
+60 -88
View File
@@ -192,139 +192,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) {
+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 {
+39 -25
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
} }