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()
|
||||
|
||||
|
||||
Reference in New Issue
Block a user