Add browser extension gRPC bridge
This commit is contained in:
@@ -3,7 +3,9 @@ package api
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"maps"
|
||||
"net/url"
|
||||
"os"
|
||||
"slices"
|
||||
"strings"
|
||||
@@ -225,6 +227,133 @@ func (s *Server) UnlockVault(ctx context.Context, req *keepassgov1.UnlockVaultRe
|
||||
return &keepassgov1.UnlockVaultResponse{}, nil
|
||||
}
|
||||
|
||||
func (s *Server) FindBrowserLogins(ctx context.Context, req *keepassgov1.FindBrowserLoginsRequest) (*keepassgov1.FindBrowserLoginsResponse, error) {
|
||||
model, locked := s.snapshotModel()
|
||||
if locked {
|
||||
return nil, status.Error(codes.FailedPrecondition, "vault is locked")
|
||||
}
|
||||
token, err := s.authorizeVaultRequest(ctx, apitokens.OperationListEntries)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
pageHost, err := normalizedBrowserHost(req.GetPageUrl())
|
||||
if err != nil {
|
||||
return nil, status.Error(codes.InvalidArgument, err.Error())
|
||||
}
|
||||
|
||||
type rankedMatch struct {
|
||||
match *keepassgov1.BrowserLoginMatch
|
||||
score int
|
||||
}
|
||||
var matches []rankedMatch
|
||||
for _, entry := range visibleModel(model).Entries {
|
||||
quality, score := classifyBrowserEntryMatch(pageHost, entry.URL)
|
||||
if score == 0 {
|
||||
continue
|
||||
}
|
||||
matches = append(matches, rankedMatch{
|
||||
match: &keepassgov1.BrowserLoginMatch{
|
||||
Id: entry.ID,
|
||||
Title: entry.Title,
|
||||
Username: entry.Username,
|
||||
Url: entry.URL,
|
||||
Path: append([]string(nil), entry.Path...),
|
||||
Quality: quality,
|
||||
},
|
||||
score: score,
|
||||
})
|
||||
}
|
||||
slices.SortFunc(matches, func(a, b rankedMatch) int {
|
||||
switch {
|
||||
case a.score != b.score:
|
||||
return b.score - a.score
|
||||
case a.match.GetTitle() != b.match.GetTitle():
|
||||
return strings.Compare(a.match.GetTitle(), b.match.GetTitle())
|
||||
case a.match.GetUsername() != b.match.GetUsername():
|
||||
return strings.Compare(a.match.GetUsername(), b.match.GetUsername())
|
||||
default:
|
||||
return strings.Compare(a.match.GetId(), b.match.GetId())
|
||||
}
|
||||
})
|
||||
out := make([]*keepassgov1.BrowserLoginMatch, 0, len(matches))
|
||||
for _, match := range matches {
|
||||
out = append(out, match.match)
|
||||
}
|
||||
switch len(out) {
|
||||
case 1:
|
||||
s.audit.Record(apiaudit.Event{
|
||||
Type: apiaudit.EventAutofillFound,
|
||||
TokenID: token.ID,
|
||||
TokenName: token.Name,
|
||||
ClientName: token.ClientName,
|
||||
Operation: apitokens.OperationListEntries,
|
||||
Message: "browser login match found for " + pageHost,
|
||||
})
|
||||
case 2, 3, 4, 5:
|
||||
s.audit.Record(apiaudit.Event{
|
||||
Type: apiaudit.EventAutofillAmbiguous,
|
||||
TokenID: token.ID,
|
||||
TokenName: token.Name,
|
||||
ClientName: token.ClientName,
|
||||
Operation: apitokens.OperationListEntries,
|
||||
Message: "browser login search returned multiple matches for " + pageHost,
|
||||
})
|
||||
}
|
||||
return &keepassgov1.FindBrowserLoginsResponse{Matches: out}, nil
|
||||
}
|
||||
|
||||
func (s *Server) GetBrowserCredential(ctx context.Context, req *keepassgov1.GetBrowserCredentialRequest) (*keepassgov1.GetBrowserCredentialResponse, error) {
|
||||
model, locked := s.snapshotModel()
|
||||
if locked {
|
||||
return nil, status.Error(codes.FailedPrecondition, "vault is locked")
|
||||
}
|
||||
token, err := s.authenticateRequest(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
entry, err := findEntryByID(model, req.GetId())
|
||||
if err != nil {
|
||||
return nil, status.Error(codes.NotFound, err.Error())
|
||||
}
|
||||
if pageURL := strings.TrimSpace(req.GetPageUrl()); pageURL != "" {
|
||||
pageHost, err := normalizedBrowserHost(pageURL)
|
||||
if err != nil {
|
||||
return nil, status.Error(codes.InvalidArgument, err.Error())
|
||||
}
|
||||
if _, score := classifyBrowserEntryMatch(pageHost, entry.URL); score == 0 {
|
||||
return nil, status.Error(codes.InvalidArgument, "entry url does not match requested page")
|
||||
}
|
||||
}
|
||||
if strings.TrimSpace(entry.Username) != "" {
|
||||
if _, err := s.authorizeResourceRequest(ctx, token, apitokens.OperationCopyUsername, apitokens.Resource{Kind: apitokens.ResourceEntry, EntryID: entry.ID, Path: entry.Path}); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
if _, err := s.authorizeResourceRequest(ctx, token, apitokens.OperationCopyPassword, apitokens.Resource{Kind: apitokens.ResourceEntry, EntryID: entry.ID, Path: entry.Path}); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if strings.TrimSpace(entry.URL) != "" {
|
||||
if _, err := s.authorizeResourceRequest(ctx, token, apitokens.OperationCopyURL, apitokens.Resource{Kind: apitokens.ResourceEntry, EntryID: entry.ID, Path: entry.Path}); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
s.audit.Record(apiaudit.Event{
|
||||
Type: apiaudit.EventAutofillFound,
|
||||
TokenID: token.ID,
|
||||
TokenName: token.Name,
|
||||
ClientName: token.ClientName,
|
||||
Operation: apitokens.OperationCopyPassword,
|
||||
Resource: apitokens.Resource{Kind: apitokens.ResourceEntry, EntryID: entry.ID, Path: entry.Path},
|
||||
Message: "browser credential returned for " + entry.ID,
|
||||
})
|
||||
return &keepassgov1.GetBrowserCredentialResponse{
|
||||
Id: entry.ID,
|
||||
Username: entry.Username,
|
||||
Password: entry.Password,
|
||||
Url: entry.URL,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func mapLifecycleError(operation string, err error) error {
|
||||
switch {
|
||||
case errors.Is(err, os.ErrNotExist):
|
||||
@@ -787,6 +916,39 @@ func findMutableEntryByID(model *vault.Model, id string) (vault.Entry, int, erro
|
||||
return vault.Entry{}, -1, vault.ErrEntryNotFound
|
||||
}
|
||||
|
||||
func normalizedBrowserHost(raw string) (string, error) {
|
||||
parsed, err := url.Parse(strings.TrimSpace(raw))
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("parse page url: %w", err)
|
||||
}
|
||||
host := strings.ToLower(parsed.Hostname())
|
||||
if host == "" {
|
||||
return "", fmt.Errorf("page url must include a hostname")
|
||||
}
|
||||
return host, nil
|
||||
}
|
||||
|
||||
func classifyBrowserEntryMatch(pageHost, rawEntryURL string) (string, int) {
|
||||
parsed, err := url.Parse(strings.TrimSpace(rawEntryURL))
|
||||
if err != nil {
|
||||
return "", 0
|
||||
}
|
||||
entryHost := strings.ToLower(parsed.Hostname())
|
||||
if entryHost == "" {
|
||||
return "", 0
|
||||
}
|
||||
switch {
|
||||
case pageHost == entryHost:
|
||||
return "exact-host", 3
|
||||
case strings.HasSuffix(pageHost, "."+entryHost):
|
||||
return "subdomain", 2
|
||||
case strings.HasSuffix(entryHost, "."+pageHost):
|
||||
return "parent-domain", 1
|
||||
default:
|
||||
return "", 0
|
||||
}
|
||||
}
|
||||
|
||||
func visibleModel(model vault.Model) vault.Model {
|
||||
out := model
|
||||
out.Entries = nil
|
||||
|
||||
@@ -159,6 +159,84 @@ func TestVaultServiceRejectsUnauthorizedPasswordGeneration(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestVaultServiceFindsBrowserLoginsForAuthorizedClients(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
client, _, cleanup := newTestClient(t)
|
||||
defer cleanup()
|
||||
|
||||
ctx := tokenContext(defaultTestTokenSecret)
|
||||
resp, err := client.FindBrowserLogins(ctx, &keepassgov1.FindBrowserLoginsRequest{
|
||||
PageUrl: "https://vault.crew.example.invalid/login",
|
||||
})
|
||||
if 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 resp.Matches[0].Id != "vault-console" {
|
||||
t.Fatalf("FindBrowserLogins().Matches[0].Id = %q, want vault-console", resp.Matches[0].Id)
|
||||
}
|
||||
if resp.Matches[0].Quality != "exact-host" {
|
||||
t.Fatalf("FindBrowserLogins().Matches[0].Quality = %q, want exact-host", resp.Matches[0].Quality)
|
||||
}
|
||||
}
|
||||
|
||||
func TestVaultServiceGetsBrowserCredentialForAuthorizedClients(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
client, _, cleanup := newTestClient(t)
|
||||
defer cleanup()
|
||||
|
||||
ctx := tokenContext(defaultTestTokenSecret)
|
||||
resp, err := client.GetBrowserCredential(ctx, &keepassgov1.GetBrowserCredentialRequest{
|
||||
Id: "vault-console",
|
||||
PageUrl: "https://vault.crew.example.invalid/login",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("GetBrowserCredential() error = %v", err)
|
||||
}
|
||||
if resp.Id != "vault-console" {
|
||||
t.Fatalf("GetBrowserCredential().Id = %q, want vault-console", resp.Id)
|
||||
}
|
||||
if resp.Password != "token-1" {
|
||||
t.Fatalf("GetBrowserCredential().Password = %q, want token-1", resp.Password)
|
||||
}
|
||||
}
|
||||
|
||||
func TestVaultServiceRejectsUnauthorizedBrowserCredentialAccess(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
client, _, cleanup := newTestClientForModel(t, vault.Model{
|
||||
Entries: []vault.Entry{
|
||||
{
|
||||
ID: "vault-console",
|
||||
Title: "Vault Console",
|
||||
Username: "dannyocean",
|
||||
Password: "token-1",
|
||||
URL: "https://vault.crew.example.invalid",
|
||||
Path: []string{"Root", "Internet"},
|
||||
},
|
||||
testAPITokenEntry(t,
|
||||
apitokens.PolicyRule{Effect: apitokens.EffectAllow, Operation: apitokens.OperationListEntries, Resource: apitokens.Resource{Kind: apitokens.ResourceGroup, Path: []string{"Root"}}},
|
||||
apitokens.PolicyRule{Effect: apitokens.EffectAllow, Operation: apitokens.OperationCopyUsername, Resource: apitokens.Resource{Kind: apitokens.ResourceEntry, EntryID: "vault-console", Path: []string{"Root", "Internet"}}},
|
||||
apitokens.PolicyRule{Effect: apitokens.EffectAllow, Operation: apitokens.OperationCopyURL, Resource: apitokens.Resource{Kind: apitokens.ResourceEntry, EntryID: "vault-console", Path: []string{"Root", "Internet"}}},
|
||||
apitokens.PolicyRule{Effect: apitokens.EffectDeny, Operation: apitokens.OperationCopyPassword, Resource: apitokens.Resource{Kind: apitokens.ResourceEntry, EntryID: "vault-console", Path: []string{"Root", "Internet"}}},
|
||||
),
|
||||
},
|
||||
})
|
||||
defer cleanup()
|
||||
|
||||
_, err := client.GetBrowserCredential(tokenContext(defaultTestTokenSecret), &keepassgov1.GetBrowserCredentialRequest{
|
||||
Id: "vault-console",
|
||||
PageUrl: "https://vault.crew.example.invalid/login",
|
||||
})
|
||||
if status.Code(err) != codes.PermissionDenied {
|
||||
t.Fatalf("GetBrowserCredential() code = %v, want %v", status.Code(err), codes.PermissionDenied)
|
||||
}
|
||||
}
|
||||
|
||||
func TestVaultServicePromptsAndResumesWhenApproved(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
|
||||
@@ -0,0 +1,321 @@
|
||||
package browserbridge
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/binary"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
keepassgov1 "git.julianfamily.org/keepassgo/proto/keepassgo/v1"
|
||||
)
|
||||
|
||||
const (
|
||||
NativeHostName = "org.keepassgo.browser"
|
||||
DefaultGRPCAddress = "127.0.0.1:47777"
|
||||
defaultFirefoxID = "browser@keepassgo.invalid"
|
||||
maxNativeMessageSize = 1024 * 1024
|
||||
)
|
||||
|
||||
type Request struct {
|
||||
Action string `json:"action"`
|
||||
GRPCAddress string `json:"grpcAddress,omitempty"`
|
||||
BearerToken string `json:"bearerToken,omitempty"`
|
||||
URL string `json:"url,omitempty"`
|
||||
EntryID string `json:"entryId,omitempty"`
|
||||
}
|
||||
|
||||
type Response struct {
|
||||
Success bool `json:"success"`
|
||||
Error string `json:"error,omitempty"`
|
||||
Status *Status `json:"status,omitempty"`
|
||||
Matches []Match `json:"matches,omitempty"`
|
||||
Credential *Credential `json:"credential,omitempty"`
|
||||
Version string `json:"version,omitempty"`
|
||||
}
|
||||
|
||||
type Status struct {
|
||||
Connected bool `json:"connected"`
|
||||
Locked bool `json:"locked"`
|
||||
Dirty bool `json:"dirty,omitempty"`
|
||||
EntryCount uint32 `json:"entryCount,omitempty"`
|
||||
GRPCAddress string `json:"grpcAddress,omitempty"`
|
||||
}
|
||||
|
||||
type Match struct {
|
||||
ID string `json:"id"`
|
||||
Title string `json:"title"`
|
||||
Username string `json:"username,omitempty"`
|
||||
URL string `json:"url,omitempty"`
|
||||
Path []string `json:"path,omitempty"`
|
||||
Quality string `json:"quality,omitempty"`
|
||||
}
|
||||
|
||||
type Credential struct {
|
||||
ID string `json:"id"`
|
||||
Username string `json:"username,omitempty"`
|
||||
Password string `json:"password,omitempty"`
|
||||
URL string `json:"url,omitempty"`
|
||||
}
|
||||
|
||||
type Connection struct {
|
||||
GRPCAddress string
|
||||
BearerToken string
|
||||
}
|
||||
|
||||
type Client interface {
|
||||
Status(context.Context) (*keepassgov1.GetSessionStatusResponse, error)
|
||||
FindBrowserLogins(context.Context, string) ([]*keepassgov1.BrowserLoginMatch, error)
|
||||
GetBrowserCredential(context.Context, string, string) (*keepassgov1.GetBrowserCredentialResponse, error)
|
||||
}
|
||||
|
||||
type Browser string
|
||||
|
||||
const (
|
||||
BrowserFirefox Browser = "firefox"
|
||||
BrowserChrome Browser = "chrome"
|
||||
BrowserChromium Browser = "chromium"
|
||||
)
|
||||
|
||||
type NativeHostManifest struct {
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description"`
|
||||
Path string `json:"path"`
|
||||
Type string `json:"type"`
|
||||
AllowedExtensions []string `json:"allowed_extensions,omitempty"`
|
||||
AllowedOrigins []string `json:"allowed_origins,omitempty"`
|
||||
}
|
||||
|
||||
func DefaultFirefoxExtensionID() string {
|
||||
return defaultFirefoxID
|
||||
}
|
||||
|
||||
func ReadRequest(r io.Reader) (Request, error) {
|
||||
var sizeBuf [4]byte
|
||||
if _, err := io.ReadFull(r, sizeBuf[:]); err != nil {
|
||||
return Request{}, err
|
||||
}
|
||||
size := binary.LittleEndian.Uint32(sizeBuf[:])
|
||||
if size == 0 || size > maxNativeMessageSize {
|
||||
return Request{}, fmt.Errorf("invalid native message size %d", size)
|
||||
}
|
||||
body := make([]byte, size)
|
||||
if _, err := io.ReadFull(r, body); err != nil {
|
||||
return Request{}, err
|
||||
}
|
||||
var req Request
|
||||
if err := json.Unmarshal(body, &req); err != nil {
|
||||
return Request{}, fmt.Errorf("decode native request: %w", err)
|
||||
}
|
||||
return req, nil
|
||||
}
|
||||
|
||||
func WriteResponse(w io.Writer, resp Response) error {
|
||||
data, err := json.Marshal(resp)
|
||||
if err != nil {
|
||||
return fmt.Errorf("encode native response: %w", err)
|
||||
}
|
||||
if len(data) > maxNativeMessageSize {
|
||||
return fmt.Errorf("native response too large: %d", len(data))
|
||||
}
|
||||
var sizeBuf [4]byte
|
||||
binary.LittleEndian.PutUint32(sizeBuf[:], uint32(len(data)))
|
||||
if _, err := w.Write(sizeBuf[:]); err != nil {
|
||||
return err
|
||||
}
|
||||
_, err = w.Write(data)
|
||||
return err
|
||||
}
|
||||
|
||||
func (r Request) Connection() (Connection, error) {
|
||||
conn := Connection{
|
||||
GRPCAddress: strings.TrimSpace(r.GRPCAddress),
|
||||
BearerToken: strings.TrimSpace(r.BearerToken),
|
||||
}
|
||||
if conn.GRPCAddress == "" {
|
||||
conn.GRPCAddress = DefaultGRPCAddress
|
||||
}
|
||||
if conn.BearerToken == "" {
|
||||
return Connection{}, fmt.Errorf("browser bridge bearer token is required")
|
||||
}
|
||||
return conn, nil
|
||||
}
|
||||
|
||||
func HandleRequest(ctx context.Context, req Request, client Client) Response {
|
||||
conn, err := req.Connection()
|
||||
if err != nil {
|
||||
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: "1"}
|
||||
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)
|
||||
if err != nil {
|
||||
return Response{Success: false, Error: err.Error(), Status: status}
|
||||
}
|
||||
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)}
|
||||
}
|
||||
if status.Locked {
|
||||
return Response{Success: false, Error: "vault is locked", Status: status}
|
||||
}
|
||||
credential, err := loadCredential(ctx, client, req.EntryID, req.URL)
|
||||
if err != nil {
|
||||
return Response{Success: false, Error: err.Error(), Status: status}
|
||||
}
|
||||
return Response{Success: true, Status: status, Credential: credential, Version: "1"}
|
||||
default:
|
||||
return Response{Success: false, Error: fmt.Sprintf("unsupported action %q", action)}
|
||||
}
|
||||
}
|
||||
|
||||
func disconnectedStatus(addr string) *Status {
|
||||
return &Status{Connected: false, GRPCAddress: strings.TrimSpace(addr)}
|
||||
}
|
||||
|
||||
func statusResponse(ctx context.Context, client Client, addr string) (*Status, error) {
|
||||
resp, err := client.Status(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &Status{
|
||||
Connected: true,
|
||||
Locked: resp.GetLocked(),
|
||||
Dirty: resp.GetDirty(),
|
||||
EntryCount: resp.GetEntryCount(),
|
||||
GRPCAddress: strings.TrimSpace(addr),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func findMatches(ctx context.Context, client Client, rawURL string) ([]Match, error) {
|
||||
resp, err := client.FindBrowserLogins(ctx, strings.TrimSpace(rawURL))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out := make([]Match, 0, len(resp))
|
||||
for _, match := range resp {
|
||||
out = append(out, Match{
|
||||
ID: match.GetId(),
|
||||
Title: match.GetTitle(),
|
||||
Username: match.GetUsername(),
|
||||
URL: match.GetUrl(),
|
||||
Path: append([]string(nil), match.GetPath()...),
|
||||
Quality: match.GetQuality(),
|
||||
})
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func loadCredential(ctx context.Context, client Client, entryID, rawURL string) (*Credential, error) {
|
||||
id := strings.TrimSpace(entryID)
|
||||
if id == "" {
|
||||
return nil, fmt.Errorf("entry id is required")
|
||||
}
|
||||
resp, err := client.GetBrowserCredential(ctx, id, strings.TrimSpace(rawURL))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &Credential{
|
||||
ID: resp.GetId(),
|
||||
Username: resp.GetUsername(),
|
||||
Password: resp.GetPassword(),
|
||||
URL: resp.GetUrl(),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func Manifest(browser Browser, binaryPath, extensionID string) (NativeHostManifest, error) {
|
||||
path := strings.TrimSpace(binaryPath)
|
||||
if path == "" {
|
||||
return NativeHostManifest{}, fmt.Errorf("native host binary path is required")
|
||||
}
|
||||
switch browser {
|
||||
case BrowserFirefox:
|
||||
id := strings.TrimSpace(extensionID)
|
||||
if id == "" {
|
||||
id = defaultFirefoxID
|
||||
}
|
||||
return NativeHostManifest{
|
||||
Name: NativeHostName,
|
||||
Description: "KeePassGO browser bridge",
|
||||
Path: path,
|
||||
Type: "stdio",
|
||||
AllowedExtensions: []string{id},
|
||||
}, nil
|
||||
case BrowserChrome, BrowserChromium:
|
||||
id := strings.TrimSpace(extensionID)
|
||||
if id == "" {
|
||||
return NativeHostManifest{}, fmt.Errorf("%s extension id is required", browser)
|
||||
}
|
||||
return NativeHostManifest{
|
||||
Name: NativeHostName,
|
||||
Description: "KeePassGO browser bridge",
|
||||
Path: path,
|
||||
Type: "stdio",
|
||||
AllowedOrigins: []string{"chrome-extension://" + id + "/"},
|
||||
}, nil
|
||||
default:
|
||||
return NativeHostManifest{}, fmt.Errorf("unsupported browser %q", browser)
|
||||
}
|
||||
}
|
||||
|
||||
func DefaultManifestPath(browser Browser) (string, error) {
|
||||
home, err := os.UserHomeDir()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
switch browser {
|
||||
case BrowserFirefox:
|
||||
return filepath.Join(home, ".mozilla", "native-messaging-hosts", NativeHostName+".json"), nil
|
||||
case BrowserChrome:
|
||||
return filepath.Join(home, ".config", "google-chrome", "NativeMessagingHosts", NativeHostName+".json"), nil
|
||||
case BrowserChromium:
|
||||
return filepath.Join(home, ".config", "chromium", "NativeMessagingHosts", NativeHostName+".json"), nil
|
||||
default:
|
||||
return "", fmt.Errorf("unsupported browser %q", browser)
|
||||
}
|
||||
}
|
||||
|
||||
func InstallManifest(browser Browser, binaryPath, extensionID, outputPath string) (string, error) {
|
||||
manifest, err := Manifest(browser, binaryPath, extensionID)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
path := strings.TrimSpace(outputPath)
|
||||
if path == "" {
|
||||
path, err = DefaultManifestPath(browser)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
|
||||
return "", fmt.Errorf("create native host manifest dir: %w", err)
|
||||
}
|
||||
data, err := json.MarshalIndent(manifest, "", " ")
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("encode native host manifest: %w", err)
|
||||
}
|
||||
data = append(data, '\n')
|
||||
if err := os.WriteFile(path, data, 0o644); err != nil {
|
||||
return "", fmt.Errorf("write native host manifest: %w", err)
|
||||
}
|
||||
return path, nil
|
||||
}
|
||||
@@ -0,0 +1,182 @@
|
||||
package browserbridge
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/binary"
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
keepassgov1 "git.julianfamily.org/keepassgo/proto/keepassgo/v1"
|
||||
)
|
||||
|
||||
func TestReadRequestAndWriteResponse(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
var input bytes.Buffer
|
||||
body, err := json.Marshal(Request{
|
||||
Action: "find-logins",
|
||||
GRPCAddress: "127.0.0.1:47777",
|
||||
BearerToken: "secret",
|
||||
URL: "https://example.invalid/login",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Marshal() error = %v", err)
|
||||
}
|
||||
if err := binary.Write(&input, binary.LittleEndian, uint32(len(body))); err != nil {
|
||||
t.Fatalf("binary.Write() error = %v", err)
|
||||
}
|
||||
if _, err := input.Write(body); err != nil {
|
||||
t.Fatalf("Write() error = %v", err)
|
||||
}
|
||||
|
||||
req, err := ReadRequest(&input)
|
||||
if err != nil {
|
||||
t.Fatalf("ReadRequest() error = %v", err)
|
||||
}
|
||||
if req.Action != "find-logins" || req.BearerToken != "secret" {
|
||||
t.Fatalf("ReadRequest() = %#v, want action and token preserved", req)
|
||||
}
|
||||
|
||||
var output bytes.Buffer
|
||||
if err := WriteResponse(&output, Response{Success: true, Version: "1"}); err != nil {
|
||||
t.Fatalf("WriteResponse() error = %v", err)
|
||||
}
|
||||
var size uint32
|
||||
if err := binary.Read(&output, binary.LittleEndian, &size); err != nil {
|
||||
t.Fatalf("binary.Read() error = %v", err)
|
||||
}
|
||||
payload := make([]byte, size)
|
||||
if _, err := output.Read(payload); err != nil {
|
||||
t.Fatalf("Read() payload error = %v", err)
|
||||
}
|
||||
var resp Response
|
||||
if err := json.Unmarshal(payload, &resp); err != nil {
|
||||
t.Fatalf("Unmarshal() error = %v", err)
|
||||
}
|
||||
if !resp.Success || resp.Version != "1" {
|
||||
t.Fatalf("response = %#v, want success version 1", resp)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleRequestFindLogins(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
client := fakeClient{
|
||||
status: &keepassgov1.GetSessionStatusResponse{Locked: false, EntryCount: 2},
|
||||
matches: []*keepassgov1.BrowserLoginMatch{
|
||||
{Id: "vault-console", Title: "Vault Console", Username: "dannyocean", Url: "https://vault.example.invalid", Quality: "exact-host"},
|
||||
},
|
||||
}
|
||||
resp := HandleRequest(context.Background(), Request{
|
||||
Action: "find-logins",
|
||||
BearerToken: "secret",
|
||||
URL: "https://vault.example.invalid/login",
|
||||
}, client)
|
||||
if !resp.Success {
|
||||
t.Fatalf("HandleRequest() success = false, error = %q", resp.Error)
|
||||
}
|
||||
if len(resp.Matches) != 1 || resp.Matches[0].ID != "vault-console" {
|
||||
t.Fatalf("HandleRequest().Matches = %#v, want vault-console", resp.Matches)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleRequestGetLogin(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
client := fakeClient{
|
||||
status: &keepassgov1.GetSessionStatusResponse{Locked: false, EntryCount: 1},
|
||||
credential: &keepassgov1.GetBrowserCredentialResponse{
|
||||
Id: "vault-console",
|
||||
Username: "dannyocean",
|
||||
Password: "token-1",
|
||||
Url: "https://vault.example.invalid",
|
||||
},
|
||||
}
|
||||
resp := HandleRequest(context.Background(), Request{
|
||||
Action: "get-login",
|
||||
BearerToken: "secret",
|
||||
EntryID: "vault-console",
|
||||
URL: "https://vault.example.invalid/login",
|
||||
}, client)
|
||||
if !resp.Success {
|
||||
t.Fatalf("HandleRequest() success = false, error = %q", resp.Error)
|
||||
}
|
||||
if resp.Credential == nil || resp.Credential.ID != "vault-console" {
|
||||
t.Fatalf("HandleRequest().Credential = %#v, want vault-console", resp.Credential)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleRequestRequiresBearerToken(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
resp := HandleRequest(context.Background(), Request{Action: "status"}, fakeClient{})
|
||||
if resp.Success {
|
||||
t.Fatal("HandleRequest().Success = true, want false without token")
|
||||
}
|
||||
}
|
||||
|
||||
func TestInstallManifest(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tmp := t.TempDir()
|
||||
binaryPath := filepath.Join(tmp, "keepassgo-browser-bridge")
|
||||
if err := os.WriteFile(binaryPath, []byte("#!/bin/sh\n"), 0o755); err != nil {
|
||||
t.Fatalf("WriteFile(binary) error = %v", err)
|
||||
}
|
||||
|
||||
path, err := InstallManifest(BrowserFirefox, binaryPath, "", filepath.Join(tmp, "firefox-host.json"))
|
||||
if err != nil {
|
||||
t.Fatalf("InstallManifest() error = %v", err)
|
||||
}
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
t.Fatalf("ReadFile() error = %v", err)
|
||||
}
|
||||
var manifest NativeHostManifest
|
||||
if err := json.Unmarshal(data, &manifest); err != nil {
|
||||
t.Fatalf("Unmarshal() error = %v", err)
|
||||
}
|
||||
if manifest.Path != binaryPath {
|
||||
t.Fatalf("manifest.Path = %q, want %q", manifest.Path, binaryPath)
|
||||
}
|
||||
if len(manifest.AllowedExtensions) != 1 || manifest.AllowedExtensions[0] != DefaultFirefoxExtensionID() {
|
||||
t.Fatalf("manifest.AllowedExtensions = %#v, want default firefox extension id", manifest.AllowedExtensions)
|
||||
}
|
||||
}
|
||||
|
||||
type fakeClient struct {
|
||||
status *keepassgov1.GetSessionStatusResponse
|
||||
matches []*keepassgov1.BrowserLoginMatch
|
||||
credential *keepassgov1.GetBrowserCredentialResponse
|
||||
err error
|
||||
}
|
||||
|
||||
func (f fakeClient) Status(context.Context) (*keepassgov1.GetSessionStatusResponse, error) {
|
||||
if f.err != nil {
|
||||
return nil, f.err
|
||||
}
|
||||
if f.status == nil {
|
||||
return &keepassgov1.GetSessionStatusResponse{}, nil
|
||||
}
|
||||
return f.status, nil
|
||||
}
|
||||
|
||||
func (f fakeClient) FindBrowserLogins(context.Context, string) ([]*keepassgov1.BrowserLoginMatch, error) {
|
||||
if f.err != nil {
|
||||
return nil, f.err
|
||||
}
|
||||
return f.matches, nil
|
||||
}
|
||||
|
||||
func (f fakeClient) GetBrowserCredential(context.Context, string, string) (*keepassgov1.GetBrowserCredentialResponse, error) {
|
||||
if f.err != nil {
|
||||
return nil, f.err
|
||||
}
|
||||
if f.credential == nil {
|
||||
return &keepassgov1.GetBrowserCredentialResponse{}, nil
|
||||
}
|
||||
return f.credential, nil
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
package browserbridge
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net"
|
||||
"strings"
|
||||
|
||||
keepassgov1 "git.julianfamily.org/keepassgo/proto/keepassgo/v1"
|
||||
"google.golang.org/grpc"
|
||||
"google.golang.org/grpc/credentials/insecure"
|
||||
"google.golang.org/grpc/metadata"
|
||||
)
|
||||
|
||||
type GRPCClient struct {
|
||||
client keepassgov1.VaultServiceClient
|
||||
}
|
||||
|
||||
func Dial(ctx context.Context, conn Connection) (*grpc.ClientConn, *GRPCClient, context.Context, error) {
|
||||
if strings.TrimSpace(conn.GRPCAddress) == "" {
|
||||
conn.GRPCAddress = DefaultGRPCAddress
|
||||
}
|
||||
if strings.TrimSpace(conn.BearerToken) == "" {
|
||||
return nil, nil, nil, fmt.Errorf("browser bridge bearer token is required")
|
||||
}
|
||||
address := strings.TrimSpace(conn.GRPCAddress)
|
||||
grpcConn, err := grpc.NewClient("passthrough:///"+address,
|
||||
grpc.WithTransportCredentials(insecure.NewCredentials()),
|
||||
grpc.WithContextDialer(func(context.Context, string) (net.Conn, error) {
|
||||
return net.Dial("tcp", address)
|
||||
}),
|
||||
)
|
||||
if err != nil {
|
||||
return nil, nil, nil, fmt.Errorf("dial gRPC host %s: %w", address, err)
|
||||
}
|
||||
ctx = metadata.AppendToOutgoingContext(ctx, "authorization", "Bearer "+strings.TrimSpace(conn.BearerToken))
|
||||
return grpcConn, &GRPCClient{client: keepassgov1.NewVaultServiceClient(grpcConn)}, ctx, nil
|
||||
}
|
||||
|
||||
func (c *GRPCClient) Status(ctx context.Context) (*keepassgov1.GetSessionStatusResponse, error) {
|
||||
return c.client.GetSessionStatus(ctx, &keepassgov1.GetSessionStatusRequest{})
|
||||
}
|
||||
|
||||
func (c *GRPCClient) FindBrowserLogins(ctx context.Context, pageURL string) ([]*keepassgov1.BrowserLoginMatch, error) {
|
||||
resp, err := c.client.FindBrowserLogins(ctx, &keepassgov1.FindBrowserLoginsRequest{
|
||||
PageUrl: strings.TrimSpace(pageURL),
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return resp.GetMatches(), nil
|
||||
}
|
||||
|
||||
func (c *GRPCClient) GetBrowserCredential(ctx context.Context, entryID, pageURL string) (*keepassgov1.GetBrowserCredentialResponse, error) {
|
||||
return c.client.GetBrowserCredential(ctx, &keepassgov1.GetBrowserCredentialRequest{
|
||||
Id: strings.TrimSpace(entryID),
|
||||
PageUrl: strings.TrimSpace(pageURL),
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user