Compare commits
8 Commits
d522af7d51
...
v0.4.0
| Author | SHA1 | Date | |
|---|---|---|---|
| 3b323ea4fd | |||
| 8117e3e8c1 | |||
| 77e92a2368 | |||
| 4b8c1de1a6 | |||
| af2ce66b78 | |||
| a02d4a3b1c | |||
| 57870ca4f1 | |||
| dc7dd19543 |
@@ -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:
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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": {
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
+71
-68
@@ -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)]) {
|
||||||
out = append(out, match.match)
|
continue
|
||||||
}
|
}
|
||||||
|
if match.decision == apitokens.DecisionDeny {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
out = append(out, match.match)
|
||||||
}
|
}
|
||||||
return out
|
return out, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
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())
|
}
|
||||||
if _, err := s.authorizePathRequest(ctx, apitokens.OperationMutateGroup, groupPath); err != nil {
|
return status.Errorf(codes.Internal, "rename group: %v", err)
|
||||||
|
}
|
||||||
|
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")
|
switch {
|
||||||
}
|
case errors.Is(err, vault.ErrEntryNotFound):
|
||||||
groupPath := expandClientPath(visibleModel(model), req.GetPath())
|
return status.Error(codes.NotFound, err.Error())
|
||||||
if _, err := s.authorizePathRequest(ctx, apitokens.OperationMutateGroup, groupPath); err != nil {
|
case errors.Is(err, vault.ErrGroupNotEmpty):
|
||||||
|
return status.Error(codes.FailedPrecondition, err.Error())
|
||||||
|
default:
|
||||||
|
return status.Errorf(codes.Internal, "delete group: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
s.mu.Lock()
|
|
||||||
defer s.mu.Unlock()
|
|
||||||
|
|
||||||
if err := s.model.DeleteGroup(groupPath); err != nil {
|
|
||||||
switch {
|
|
||||||
case errors.Is(err, vault.ErrEntryNotFound):
|
|
||||||
return nil, status.Error(codes.NotFound, err.Error())
|
|
||||||
case errors.Is(err, vault.ErrGroupNotEmpty):
|
|
||||||
return nil, status.Error(codes.FailedPrecondition, err.Error())
|
|
||||||
default:
|
|
||||||
return nil, status.Errorf(codes.Internal, "delete group: %v", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
s.dirty = true
|
|
||||||
s.syncMutationLocked()
|
|
||||||
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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|
||||||
|
|||||||
@@ -13,10 +13,11 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
var (
|
var (
|
||||||
ErrRequestDenied = errors.New("authorization request denied")
|
ErrRequestDenied = errors.New("authorization request denied")
|
||||||
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{
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|
||||||
|
|||||||
+73
-101
@@ -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 {
|
token, secret, err := apitokens.Issue(name, clientName, expiresAt, now)
|
||||||
return apitokens.Token{}, "", fmt.Errorf("session is not mutable")
|
if err != nil {
|
||||||
}
|
return tokenMutationResult{}, err
|
||||||
model, err := session.Current()
|
}
|
||||||
|
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
|
||||||
}
|
}
|
||||||
token, secret, err := apitokens.Issue(name, clientName, expiresAt, now)
|
return result.token, result.secret, nil
|
||||||
if err != nil {
|
|
||||||
return apitokens.Token{}, "", err
|
|
||||||
}
|
|
||||||
apitokens.Upsert(&model, token)
|
|
||||||
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")
|
if err != nil {
|
||||||
}
|
return tokenMutationResult{}, err
|
||||||
model, err := session.Current()
|
}
|
||||||
|
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
|
||||||
}
|
}
|
||||||
token, err := apitokens.Find(model, id)
|
return result.token, result.secret, nil
|
||||||
if err != nil {
|
|
||||||
return apitokens.Token{}, "", err
|
|
||||||
}
|
|
||||||
token, secret, err := apitokens.Rotate(token, now)
|
|
||||||
if err != nil {
|
|
||||||
return apitokens.Token{}, "", err
|
|
||||||
}
|
|
||||||
apitokens.Upsert(&model, token)
|
|
||||||
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()
|
return err
|
||||||
if err != nil {
|
|
||||||
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")
|
if err != nil {
|
||||||
}
|
return tokenMutationResult{}, err
|
||||||
model, err := session.Current()
|
}
|
||||||
if err != nil {
|
token = apitokens.Disable(token)
|
||||||
return err
|
apitokens.Upsert(model, token)
|
||||||
}
|
return tokenMutationResult{token: token}, nil
|
||||||
token, err := apitokens.Find(model, id)
|
})
|
||||||
if err != nil {
|
return err
|
||||||
return err
|
|
||||||
}
|
|
||||||
token = apitokens.Disable(token)
|
|
||||||
apitokens.Upsert(&model, token)
|
|
||||||
session.Replace(model)
|
|
||||||
if err := s.markDirtyAndAutoSave(); err != nil {
|
|
||||||
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")
|
if err != nil {
|
||||||
}
|
return tokenMutationResult{}, err
|
||||||
model, err := session.Current()
|
}
|
||||||
if err != nil {
|
token = apitokens.Revoke(token, when)
|
||||||
return err
|
apitokens.Upsert(model, token)
|
||||||
}
|
return tokenMutationResult{token: token}, nil
|
||||||
token, err := apitokens.Find(model, id)
|
})
|
||||||
if err != nil {
|
return err
|
||||||
return err
|
|
||||||
}
|
|
||||||
token = apitokens.Revoke(token, when)
|
|
||||||
apitokens.Upsert(&model, token)
|
|
||||||
session.Replace(model)
|
|
||||||
if err := s.markDirtyAndAutoSave(); err != nil {
|
|
||||||
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) {
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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 {
|
||||||
return Response{Success: false, Error: err.Error(), Status: status}
|
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: disconnectedStatus(conn.GRPCAddress)}
|
||||||
}
|
}
|
||||||
return Response{Success: true, Status: status, Credential: credential, Version: "1"}
|
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 {
|
||||||
|
|||||||
@@ -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")
|
||||||
@@ -282,10 +307,13 @@ func TestEnsureNativeHostManifestsInstallsFirefoxAndDiscoveredChromium(t *testin
|
|||||||
}
|
}
|
||||||
|
|
||||||
type fakeClient struct {
|
type fakeClient struct {
|
||||||
status *keepassgov1.GetSessionStatusResponse
|
status *keepassgov1.GetSessionStatusResponse
|
||||||
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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user