Add browser save and update workflow
This commit is contained in:
@@ -2,12 +2,15 @@ package browserbridge
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"encoding/binary"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
@@ -28,11 +31,15 @@ const (
|
||||
)
|
||||
|
||||
type Request struct {
|
||||
Action string `json:"action"`
|
||||
BearerToken string `json:"bearerToken,omitempty"`
|
||||
URL string `json:"url,omitempty"`
|
||||
EntryID string `json:"entryId,omitempty"`
|
||||
Query string `json:"query,omitempty"`
|
||||
Action string `json:"action"`
|
||||
BearerToken string `json:"bearerToken,omitempty"`
|
||||
URL string `json:"url,omitempty"`
|
||||
EntryID string `json:"entryId,omitempty"`
|
||||
Query string `json:"query,omitempty"`
|
||||
Title string `json:"title,omitempty"`
|
||||
Username string `json:"username,omitempty"`
|
||||
Password string `json:"password,omitempty"`
|
||||
Path []string `json:"path,omitempty"`
|
||||
}
|
||||
|
||||
type Response struct {
|
||||
@@ -81,10 +88,13 @@ type Client interface {
|
||||
FindBrowserLogins(context.Context, string) ([]*keepassgov1.BrowserLoginMatch, error)
|
||||
ListEntries(context.Context, []string, string) ([]*keepassgov1.Entry, error)
|
||||
GetBrowserCredential(context.Context, string, string) (*keepassgov1.GetBrowserCredentialResponse, error)
|
||||
UpsertEntry(context.Context, *keepassgov1.Entry) (*keepassgov1.Entry, error)
|
||||
}
|
||||
|
||||
type Browser string
|
||||
|
||||
type actionHandler func(context.Context, Client, Request, string) Response
|
||||
|
||||
const (
|
||||
BrowserFirefox Browser = "firefox"
|
||||
BrowserChrome Browser = "chrome"
|
||||
@@ -166,43 +176,70 @@ func HandleRequest(ctx context.Context, req Request, grpcAddr string, client Cli
|
||||
return Response{Success: false, Error: err.Error()}
|
||||
}
|
||||
action := strings.TrimSpace(req.Action)
|
||||
switch action {
|
||||
case "status":
|
||||
status, err := statusResponse(ctx, client, conn.GRPCAddress)
|
||||
if err != nil {
|
||||
return Response{Success: false, Error: err.Error(), Status: disconnectedStatus(conn.GRPCAddress)}
|
||||
}
|
||||
return Response{Success: true, Status: status, Version: responseVersion}
|
||||
case "find-logins":
|
||||
matches, err := findMatches(ctx, client, req.URL)
|
||||
if err != nil {
|
||||
if status := inferredActionStatus(conn.GRPCAddress, err); status != nil {
|
||||
return Response{Success: true, Status: status, Matches: nil, Version: responseVersion}
|
||||
}
|
||||
return Response{Success: false, Error: err.Error(), Status: disconnectedStatus(conn.GRPCAddress)}
|
||||
}
|
||||
return Response{Success: true, Status: availableStatus(conn.GRPCAddress), Matches: matches, Version: responseVersion}
|
||||
case "search-logins":
|
||||
results, err := searchEntries(ctx, client, req.Query)
|
||||
if err != nil {
|
||||
if status := inferredActionStatus(conn.GRPCAddress, err); status != nil {
|
||||
return Response{Success: true, Status: status, SearchResults: nil, Version: responseVersion}
|
||||
}
|
||||
return Response{Success: false, Error: err.Error(), Status: disconnectedStatus(conn.GRPCAddress)}
|
||||
}
|
||||
return Response{Success: true, Status: availableStatus(conn.GRPCAddress), SearchResults: results, Version: responseVersion}
|
||||
case "get-login":
|
||||
credential, err := loadCredential(ctx, client, req.EntryID, req.URL)
|
||||
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: disconnectedStatus(conn.GRPCAddress)}
|
||||
}
|
||||
return Response{Success: true, Status: availableStatus(conn.GRPCAddress), Credential: credential, Version: responseVersion}
|
||||
default:
|
||||
handler, ok := actionHandlers[action]
|
||||
if !ok {
|
||||
return Response{Success: false, Error: fmt.Sprintf("unsupported action %q", action)}
|
||||
}
|
||||
return handler(ctx, client, req, conn.GRPCAddress)
|
||||
}
|
||||
|
||||
var actionHandlers = map[string]actionHandler{
|
||||
"status": handleStatusAction,
|
||||
"find-logins": handleFindLoginsAction,
|
||||
"search-logins": handleSearchLoginsAction,
|
||||
"get-login": handleGetLoginAction,
|
||||
"save-login": handleSaveLoginAction,
|
||||
}
|
||||
|
||||
func handleStatusAction(ctx context.Context, client Client, _ Request, grpcAddress string) Response {
|
||||
status, err := statusResponse(ctx, client, grpcAddress)
|
||||
if err != nil {
|
||||
return Response{Success: false, Error: err.Error(), Status: disconnectedStatus(grpcAddress)}
|
||||
}
|
||||
return Response{Success: true, Status: status, Version: responseVersion}
|
||||
}
|
||||
|
||||
func handleFindLoginsAction(ctx context.Context, client Client, req Request, grpcAddress string) Response {
|
||||
matches, err := findMatches(ctx, client, req.URL)
|
||||
if err != nil {
|
||||
if status := inferredActionStatus(grpcAddress, err); status != nil {
|
||||
return Response{Success: true, Status: status, Matches: nil, Version: responseVersion}
|
||||
}
|
||||
return Response{Success: false, Error: err.Error(), Status: disconnectedStatus(grpcAddress)}
|
||||
}
|
||||
return Response{Success: true, Status: availableStatus(grpcAddress), Matches: matches, Version: responseVersion}
|
||||
}
|
||||
|
||||
func handleSearchLoginsAction(ctx context.Context, client Client, req Request, grpcAddress string) Response {
|
||||
results, err := searchEntries(ctx, client, req.Query)
|
||||
if err != nil {
|
||||
if status := inferredActionStatus(grpcAddress, err); status != nil {
|
||||
return Response{Success: true, Status: status, SearchResults: nil, Version: responseVersion}
|
||||
}
|
||||
return Response{Success: false, Error: err.Error(), Status: disconnectedStatus(grpcAddress)}
|
||||
}
|
||||
return Response{Success: true, Status: availableStatus(grpcAddress), SearchResults: results, Version: responseVersion}
|
||||
}
|
||||
|
||||
func handleGetLoginAction(ctx context.Context, client Client, req Request, grpcAddress string) Response {
|
||||
credential, err := loadCredential(ctx, client, req.EntryID, req.URL)
|
||||
if err != nil {
|
||||
if status := inferredActionStatus(grpcAddress, err); status != nil {
|
||||
return Response{Success: false, Error: err.Error(), Status: status}
|
||||
}
|
||||
return Response{Success: false, Error: err.Error(), Status: disconnectedStatus(grpcAddress)}
|
||||
}
|
||||
return Response{Success: true, Status: availableStatus(grpcAddress), Credential: credential, Version: responseVersion}
|
||||
}
|
||||
|
||||
func handleSaveLoginAction(ctx context.Context, client Client, req Request, grpcAddress string) Response {
|
||||
if err := saveLogin(ctx, client, req); err != nil {
|
||||
if status := inferredActionStatus(grpcAddress, err); status != nil {
|
||||
return Response{Success: false, Error: err.Error(), Status: status}
|
||||
}
|
||||
return Response{Success: false, Error: err.Error(), Status: disconnectedStatus(grpcAddress)}
|
||||
}
|
||||
return Response{Success: true, Status: availableStatus(grpcAddress), Version: responseVersion}
|
||||
}
|
||||
|
||||
func disconnectedStatus(addr string) *Status {
|
||||
@@ -276,6 +313,95 @@ func loadCredential(ctx context.Context, client Client, entryID, rawURL string)
|
||||
}, nil
|
||||
}
|
||||
|
||||
func saveLogin(ctx context.Context, client Client, req Request) error {
|
||||
if strings.TrimSpace(req.Password) == "" {
|
||||
return fmt.Errorf("browser save requires a password")
|
||||
}
|
||||
if strings.TrimSpace(req.EntryID) != "" {
|
||||
entries, err := client.ListEntries(ctx, nil, "")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
existing := findEntry(entries, req.EntryID)
|
||||
if existing == nil {
|
||||
return fmt.Errorf("entry %q was not found", strings.TrimSpace(req.EntryID))
|
||||
}
|
||||
entry := cloneEntry(existing)
|
||||
entry.Title = coalesceTitle(req.Title, existing.Title, req.URL)
|
||||
entry.Username = strings.TrimSpace(req.Username)
|
||||
entry.Password = strings.TrimSpace(req.Password)
|
||||
entry.Url = strings.TrimSpace(req.URL)
|
||||
_, err = client.UpsertEntry(ctx, entry)
|
||||
return err
|
||||
}
|
||||
path := append([]string(nil), req.Path...)
|
||||
if len(path) == 0 {
|
||||
return fmt.Errorf("browser save requires a target group path")
|
||||
}
|
||||
entry := &keepassgov1.Entry{
|
||||
Id: newBrowserEntryID(),
|
||||
Title: coalesceTitle(req.Title, "", req.URL),
|
||||
Username: strings.TrimSpace(req.Username),
|
||||
Password: strings.TrimSpace(req.Password),
|
||||
Url: strings.TrimSpace(req.URL),
|
||||
Path: path,
|
||||
Fields: map[string]string{},
|
||||
}
|
||||
_, err := client.UpsertEntry(ctx, entry)
|
||||
return err
|
||||
}
|
||||
|
||||
func findEntry(entries []*keepassgov1.Entry, id string) *keepassgov1.Entry {
|
||||
for _, entry := range entries {
|
||||
if entry.GetId() == strings.TrimSpace(id) {
|
||||
return entry
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func cloneEntry(entry *keepassgov1.Entry) *keepassgov1.Entry {
|
||||
if entry == nil {
|
||||
return &keepassgov1.Entry{Fields: map[string]string{}}
|
||||
}
|
||||
fields := make(map[string]string, len(entry.GetFields()))
|
||||
for key, value := range entry.GetFields() {
|
||||
fields[key] = value
|
||||
}
|
||||
return &keepassgov1.Entry{
|
||||
Id: entry.GetId(),
|
||||
Title: entry.GetTitle(),
|
||||
Username: entry.GetUsername(),
|
||||
Password: entry.GetPassword(),
|
||||
Url: entry.GetUrl(),
|
||||
Notes: entry.GetNotes(),
|
||||
Tags: append([]string(nil), entry.GetTags()...),
|
||||
Path: append([]string(nil), entry.GetPath()...),
|
||||
Fields: fields,
|
||||
}
|
||||
}
|
||||
|
||||
func coalesceTitle(title, fallback, rawURL string) string {
|
||||
if trimmed := strings.TrimSpace(title); trimmed != "" {
|
||||
return trimmed
|
||||
}
|
||||
if trimmed := strings.TrimSpace(fallback); trimmed != "" {
|
||||
return trimmed
|
||||
}
|
||||
if parsed, err := url.Parse(strings.TrimSpace(rawURL)); err == nil && strings.TrimSpace(parsed.Hostname()) != "" {
|
||||
return strings.ToLower(strings.TrimSpace(parsed.Hostname()))
|
||||
}
|
||||
return "Browser Login"
|
||||
}
|
||||
|
||||
func newBrowserEntryID() string {
|
||||
var buf [16]byte
|
||||
if _, err := rand.Read(buf[:]); err != nil {
|
||||
return fmt.Sprintf("browser-%d", os.Getpid())
|
||||
}
|
||||
return hex.EncodeToString(buf[:])
|
||||
}
|
||||
|
||||
func searchEntries(ctx context.Context, client Client, query string) ([]Match, error) {
|
||||
resp, err := client.ListEntries(ctx, nil, strings.TrimSpace(query))
|
||||
if err != nil {
|
||||
|
||||
@@ -170,6 +170,89 @@ func TestHandleRequestSearchLogins(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleRequestSaveLoginUpdatesExistingEntry(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
client := &fakeClient{
|
||||
entries: []*keepassgov1.Entry{
|
||||
{
|
||||
Id: "vault-console",
|
||||
Title: "Vault Console",
|
||||
Username: "dannyocean",
|
||||
Password: "old-password",
|
||||
Url: "https://vault.example.invalid/login",
|
||||
Path: []string{"Crew", "Internet"},
|
||||
Fields: map[string]string{
|
||||
"URL1": "vault.example.invalid",
|
||||
"X-Role": "inside-man",
|
||||
},
|
||||
Tags: []string{"vault"},
|
||||
Notes: "Original notes stay intact.",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
resp := HandleRequest(context.Background(), Request{
|
||||
Action: "save-login",
|
||||
BearerToken: "secret",
|
||||
EntryID: "vault-console",
|
||||
Username: "dannyocean",
|
||||
Password: "new-password",
|
||||
URL: "https://vault.example.invalid/login",
|
||||
}, "", client)
|
||||
if !resp.Success {
|
||||
t.Fatalf("HandleRequest(save-login update) success = false, error = %q", resp.Error)
|
||||
}
|
||||
if client.upserted == nil {
|
||||
t.Fatal("HandleRequest(save-login update) did not upsert an entry")
|
||||
}
|
||||
if got := client.upserted.Id; got != "vault-console" {
|
||||
t.Fatalf("upserted.Id = %q, want vault-console", got)
|
||||
}
|
||||
if got := client.upserted.Password; got != "new-password" {
|
||||
t.Fatalf("upserted.Password = %q, want new-password", got)
|
||||
}
|
||||
if got := client.upserted.Fields["X-Role"]; got != "inside-man" {
|
||||
t.Fatalf("upserted.Fields[X-Role] = %q, want inside-man", got)
|
||||
}
|
||||
if got := client.upserted.Notes; got != "Original notes stay intact." {
|
||||
t.Fatalf("upserted.Notes = %q, want original notes", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleRequestSaveLoginCreatesNewEntryInChosenPath(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
client := &fakeClient{}
|
||||
resp := HandleRequest(context.Background(), Request{
|
||||
Action: "save-login",
|
||||
BearerToken: "secret",
|
||||
Title: "Bellagio Login",
|
||||
Username: "linuscaldwell",
|
||||
Password: "yellow-chip",
|
||||
URL: "https://bellagio.example.invalid/login",
|
||||
Path: []string{"Crew", "Internet"},
|
||||
}, "", client)
|
||||
if !resp.Success {
|
||||
t.Fatalf("HandleRequest(save-login create) success = false, error = %q", resp.Error)
|
||||
}
|
||||
if client.upserted == nil {
|
||||
t.Fatal("HandleRequest(save-login create) did not upsert an entry")
|
||||
}
|
||||
if got := client.upserted.Title; got != "Bellagio Login" {
|
||||
t.Fatalf("upserted.Title = %q, want Bellagio Login", got)
|
||||
}
|
||||
if got := client.upserted.Username; got != "linuscaldwell" {
|
||||
t.Fatalf("upserted.Username = %q, want linuscaldwell", got)
|
||||
}
|
||||
if got := client.upserted.Path; !slices.Equal(got, []string{"Crew", "Internet"}) {
|
||||
t.Fatalf("upserted.Path = %v, want [Crew Internet]", got)
|
||||
}
|
||||
if got := client.upserted.Id; got == "" {
|
||||
t.Fatal("upserted.Id = empty, want generated id")
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleRequestFindLoginsInfersLockedStatusFromRPC(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
@@ -332,10 +415,12 @@ type fakeClient struct {
|
||||
matches []*keepassgov1.BrowserLoginMatch
|
||||
entries []*keepassgov1.Entry
|
||||
credential *keepassgov1.GetBrowserCredentialResponse
|
||||
upserted *keepassgov1.Entry
|
||||
err error
|
||||
matchesErr error
|
||||
entriesErr error
|
||||
credentialErr error
|
||||
upsertErr error
|
||||
statusCalls int
|
||||
}
|
||||
|
||||
@@ -427,3 +512,11 @@ func (f *fakeClient) GetBrowserCredential(context.Context, string, string) (*kee
|
||||
}
|
||||
return f.credential, nil
|
||||
}
|
||||
|
||||
func (f *fakeClient) UpsertEntry(_ context.Context, entry *keepassgov1.Entry) (*keepassgov1.Entry, error) {
|
||||
if f.upsertErr != nil {
|
||||
return nil, f.upsertErr
|
||||
}
|
||||
f.upserted = entry
|
||||
return entry, nil
|
||||
}
|
||||
|
||||
@@ -82,3 +82,11 @@ func (c *GRPCClient) GetBrowserCredential(ctx context.Context, entryID, pageURL
|
||||
PageUrl: strings.TrimSpace(pageURL),
|
||||
})
|
||||
}
|
||||
|
||||
func (c *GRPCClient) UpsertEntry(ctx context.Context, entry *keepassgov1.Entry) (*keepassgov1.Entry, error) {
|
||||
resp, err := c.client.UpsertEntry(ctx, &keepassgov1.UpsertEntryRequest{Entry: entry})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return resp.GetEntry(), nil
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user