Add browser extension gRPC bridge

This commit is contained in:
Joe Julian
2026-04-11 00:52:01 -07:00
parent 885d599db1
commit c017308aa1
23 changed files with 2437 additions and 280 deletions
+162
View File
@@ -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