Complete browser extension gRPC flow

This commit is contained in:
Joe Julian
2026-04-11 23:45:48 -07:00
parent 2f2338f6f2
commit d522af7d51
24 changed files with 2744 additions and 191 deletions
+15 -4
View File
@@ -109,16 +109,27 @@ func (s *Server) SetSessionState(model vault.Model, locked, dirty bool) {
}
func (s *Server) GetSessionStatus(ctx context.Context, _ *keepassgov1.GetSessionStatusRequest) (*keepassgov1.GetSessionStatusResponse, error) {
if _, err := s.authenticateRequest(ctx); err != nil {
token, err := s.authenticateRequest(ctx)
if err != nil {
return nil, err
}
s.mu.RLock()
defer s.mu.RUnlock()
pendingApprovals := s.approvals.Pending()
var tokenPending uint32
for _, pending := range pendingApprovals {
if pending.TokenID == token.ID {
tokenPending++
}
}
return &keepassgov1.GetSessionStatusResponse{
Locked: s.locked,
Dirty: s.dirty,
EntryCount: uint32(len(s.model.Entries)),
Locked: s.locked,
Dirty: s.dirty,
EntryCount: uint32(len(s.model.Entries)),
PendingApprovalCount: uint32(len(pendingApprovals)),
TokenPendingApprovalCount: tokenPending,
}, nil
}
+73
View File
@@ -5,6 +5,7 @@ import (
"context"
"crypto/sha256"
"encoding/hex"
"errors"
"net"
"os"
"slices"
@@ -121,6 +122,78 @@ func TestVaultServiceAllowsSessionStatusWithoutManageVault(t *testing.T) {
}
}
func TestVaultServiceSessionStatusIncludesPendingApprovalsForCurrentToken(t *testing.T) {
t.Parallel()
token, secret, err := apitokens.Issue("Browser Token", "browser-extension", nil, time.Date(2026, 4, 11, 12, 0, 0, 0, time.UTC))
if err != nil {
t.Fatalf("Issue() error = %v", err)
}
token.SecretHash = hashSecretForTest(secret)
otherToken, otherSecret, err := apitokens.Issue("Other Token", "automation-client", nil, time.Date(2026, 4, 11, 12, 1, 0, 0, time.UTC))
if err != nil {
t.Fatalf("Issue() other error = %v", err)
}
otherToken.SecretHash = hashSecretForTest(otherSecret)
client, _, service, cleanup := newTestHarnessForModel(t, vault.Model{
Entries: []vault.Entry{
token.Entry([]string{"Root", "API Tokens"}),
otherToken.Entry([]string{"Root", "API Tokens"}),
},
})
defer cleanup()
service.approvals = apiapproval.NewBroker(time.Minute)
ctx, cancel := context.WithCancel(tokenContext(secret))
defer cancel()
waiting := make(chan error, 1)
go func() {
_, err := service.approvals.Request(ctx, token, apitokens.OperationCopyPassword, apitokens.Resource{
Kind: apitokens.ResourceEntry,
EntryID: "vault-console",
Path: []string{"Root", "Internet"},
})
waiting <- err
}()
otherCtx, otherCancel := context.WithCancel(tokenContext(otherSecret))
defer otherCancel()
otherWaiting := make(chan error, 1)
go func() {
_, err := service.approvals.Request(otherCtx, otherToken, apitokens.OperationListEntries, apitokens.Resource{
Kind: apitokens.ResourceGroup,
Path: []string{"Root", "Shared"},
})
otherWaiting <- err
}()
waitForServerPendingApproval(t, service, 2)
resp, err := client.GetSessionStatus(tokenContext(secret), &keepassgov1.GetSessionStatusRequest{})
if err != nil {
t.Fatalf("GetSessionStatus() error = %v", err)
}
if got := resp.GetPendingApprovalCount(); got != 2 {
t.Fatalf("GetSessionStatus().PendingApprovalCount = %d, want 2", got)
}
if got := resp.GetTokenPendingApprovalCount(); got != 1 {
t.Fatalf("GetSessionStatus().TokenPendingApprovalCount = %d, want 1", got)
}
for _, pending := range waitForServerPendingApproval(t, service, 2) {
if _, _, err := service.ResolveApproval(pending.ID, apiapproval.OutcomeCancel); err != nil {
t.Fatalf("ResolveApproval(%q) error = %v", pending.ID, err)
}
}
if err := <-waiting; !errors.Is(err, apiapproval.ErrRequestCanceled) {
t.Fatalf("Request(token) error = %v, want %v", err, apiapproval.ErrRequestCanceled)
}
if err := <-otherWaiting; !errors.Is(err, apiapproval.ErrRequestCanceled) {
t.Fatalf("Request(otherToken) error = %v, want %v", err, apiapproval.ErrRequestCanceled)
}
}
func TestVaultServiceRejectsUnauthorizedTemplateMutation(t *testing.T) {
t.Parallel()
+13
View File
@@ -16,6 +16,7 @@ import (
"git.julianfamily.org/keepassgo/internal/apiapproval"
"git.julianfamily.org/keepassgo/internal/apitokens"
"git.julianfamily.org/keepassgo/internal/appui/platform"
"git.julianfamily.org/keepassgo/internal/browserbridge"
"git.julianfamily.org/keepassgo/internal/grpcaddr"
"git.julianfamily.org/keepassgo/internal/passwords"
"git.julianfamily.org/keepassgo/internal/session"
@@ -61,6 +62,7 @@ func defaultGRPCAddr(goos string) string {
}
func run(w *app.Window, mode string, paths statePaths, grpcAddr string) error {
ensureBrowserNativeHosts()
var ops op.Ops
manager := &session.Manager{}
ui := newUIWithSession(mode, manager, paths)
@@ -99,6 +101,17 @@ func run(w *app.Window, mode string, paths statePaths, grpcAddr string) error {
}
}
func ensureBrowserNativeHosts() {
if runtime.GOOS != "linux" {
return
}
appBinaryPath, err := os.Executable()
if err != nil {
return
}
_ = browserbridge.EnsureNativeHostManifests(appBinaryPath)
}
type uiApprovalManager struct {
server *api.Server
}
+15 -33
View File
@@ -42,11 +42,13 @@ type Response struct {
}
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"`
Connected bool `json:"connected"`
Locked bool `json:"locked"`
Dirty bool `json:"dirty,omitempty"`
EntryCount uint32 `json:"entryCount,omitempty"`
PendingApprovalCount uint32 `json:"pendingApprovalCount,omitempty"`
TokenPendingApprovalCount uint32 `json:"tokenPendingApprovalCount,omitempty"`
GRPCAddress string `json:"grpcAddress,omitempty"`
}
type Match struct {
@@ -202,11 +204,13 @@ func statusResponse(ctx context.Context, client Client, addr string) (*Status, e
return nil, err
}
return &Status{
Connected: true,
Locked: resp.GetLocked(),
Dirty: resp.GetDirty(),
EntryCount: resp.GetEntryCount(),
GRPCAddress: strings.TrimSpace(addr),
Connected: true,
Locked: resp.GetLocked(),
Dirty: resp.GetDirty(),
EntryCount: resp.GetEntryCount(),
PendingApprovalCount: resp.GetPendingApprovalCount(),
TokenPendingApprovalCount: resp.GetTokenPendingApprovalCount(),
GRPCAddress: strings.TrimSpace(addr),
}, nil
}
@@ -324,27 +328,5 @@ func DefaultManifestPath(browser Browser) (string, error) {
}
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
return InstallManifestSet(browser, binaryPath, []string{strings.TrimSpace(extensionID)}, outputPath)
}
+145
View File
@@ -8,6 +8,7 @@ import (
"os"
"path/filepath"
"runtime"
"slices"
"strings"
"testing"
@@ -88,6 +89,35 @@ func TestHandleRequestFindLogins(t *testing.T) {
}
}
func TestHandleRequestStatusIncludesPendingApprovalCounts(t *testing.T) {
t.Parallel()
client := fakeClient{
status: &keepassgov1.GetSessionStatusResponse{
Locked: false,
EntryCount: 2,
PendingApprovalCount: 3,
TokenPendingApprovalCount: 1,
},
}
resp := HandleRequest(context.Background(), Request{
Action: "status",
BearerToken: "secret",
}, client)
if !resp.Success {
t.Fatalf("HandleRequest(status) success = false, error = %q", resp.Error)
}
if resp.Status == nil {
t.Fatal("HandleRequest(status).Status = nil, want status")
}
if got := resp.Status.PendingApprovalCount; got != 3 {
t.Fatalf("HandleRequest(status).PendingApprovalCount = %d, want 3", got)
}
if got := resp.Status.TokenPendingApprovalCount; got != 1 {
t.Fatalf("HandleRequest(status).TokenPendingApprovalCount = %d, want 1", got)
}
}
func TestHandleRequestGetLogin(t *testing.T) {
t.Parallel()
@@ -181,6 +211,76 @@ func TestChromiumExtensionIDFromManifestKey(t *testing.T) {
}
}
func TestManifestSetChromiumIncludesAllOrigins(t *testing.T) {
t.Parallel()
manifest, err := ManifestSet(BrowserChromium, "/tmp/keepassgo-browser-bridge", []string{
"mjlnpdomnblnbblhacolncflebbgafhj",
"ddfbfpcgdjkffmjnialjpookcoedahcn",
"mjlnpdomnblnbblhacolncflebbgafhj",
})
if err != nil {
t.Fatalf("ManifestSet() error = %v", err)
}
want := []string{
"chrome-extension://ddfbfpcgdjkffmjnialjpookcoedahcn/",
"chrome-extension://mjlnpdomnblnbblhacolncflebbgafhj/",
}
if !slices.Equal(manifest.AllowedOrigins, want) {
t.Fatalf("ManifestSet().AllowedOrigins = %#v, want %#v", manifest.AllowedOrigins, want)
}
}
func TestDiscoverInstalledExtensionIDsInRoot(t *testing.T) {
t.Parallel()
root := t.TempDir()
writeExtensionManifest(t, filepath.Join(root, "Default", "Extensions", "mjlnpdomnblnbblhacolncflebbgafhj", "1.0.0", "manifest.json"), browserExtensionName)
writeExtensionManifest(t, filepath.Join(root, "Profile 1", "Extensions", "ddfbfpcgdjkffmjnialjpookcoedahcn", "1.2.0", "manifest.json"), browserExtensionName)
writeExtensionManifest(t, filepath.Join(root, "Profile 2", "Extensions", "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", "3.4.5", "manifest.json"), "Bellagio Notes")
writeExtensionManifest(t, filepath.Join(root, "Profile 3", "Extensions", "mjlnpdomnblnbblhacolncflebbgafhj", "1.1.0", "manifest.json"), browserExtensionName)
got, err := DiscoverInstalledExtensionIDsInRoot(root)
if err != nil {
t.Fatalf("DiscoverInstalledExtensionIDsInRoot() error = %v", err)
}
want := []string{
"ddfbfpcgdjkffmjnialjpookcoedahcn",
"mjlnpdomnblnbblhacolncflebbgafhj",
}
if !slices.Equal(got, want) {
t.Fatalf("DiscoverInstalledExtensionIDsInRoot() = %#v, want %#v", got, want)
}
}
func TestEnsureNativeHostManifestsInstallsFirefoxAndDiscoveredChromium(t *testing.T) {
tmp := t.TempDir()
t.Setenv("HOME", filepath.Join(tmp, "home"))
appDir := filepath.Join(tmp, "app")
if err := os.MkdirAll(appDir, 0o755); err != nil {
t.Fatalf("MkdirAll(appDir) error = %v", err)
}
appBinaryPath := filepath.Join(appDir, "keepassgo")
if err := os.WriteFile(appBinaryPath, []byte("#!/bin/sh\n"), 0o755); err != nil {
t.Fatalf("WriteFile(appBinaryPath) error = %v", err)
}
bridgeBinaryPath := filepath.Join(appDir, "keepassgo-browser-bridge")
if err := os.WriteFile(bridgeBinaryPath, []byte("#!/bin/sh\n"), 0o755); err != nil {
t.Fatalf("WriteFile(bridgeBinaryPath) error = %v", err)
}
home := filepath.Join(tmp, "home")
writeExtensionManifest(t, filepath.Join(home, ".config", "chromium", "Default", "Extensions", "mjlnpdomnblnbblhacolncflebbgafhj", "1.0.0", "manifest.json"), browserExtensionName)
writeExtensionManifest(t, filepath.Join(home, ".config", "google-chrome", "Profile 7", "Extensions", "ddfbfpcgdjkffmjnialjpookcoedahcn", "1.0.0", "manifest.json"), browserExtensionName)
if err := EnsureNativeHostManifests(appBinaryPath); err != nil {
t.Fatalf("EnsureNativeHostManifests() error = %v", err)
}
assertManifestContainsExtension(t, filepath.Join(home, ".mozilla", "native-messaging-hosts", NativeHostName+".json"), "allowed_extensions", DefaultFirefoxExtensionID())
assertManifestContainsExtension(t, filepath.Join(home, ".config", "chromium", "NativeMessagingHosts", NativeHostName+".json"), "allowed_origins", "chrome-extension://mjlnpdomnblnbblhacolncflebbgafhj/")
assertManifestContainsExtension(t, filepath.Join(home, ".config", "google-chrome", "NativeMessagingHosts", NativeHostName+".json"), "allowed_origins", "chrome-extension://ddfbfpcgdjkffmjnialjpookcoedahcn/")
}
type fakeClient struct {
status *keepassgov1.GetSessionStatusResponse
matches []*keepassgov1.BrowserLoginMatch
@@ -188,6 +288,51 @@ type fakeClient struct {
err error
}
func writeExtensionManifest(t *testing.T, path, name string) {
t.Helper()
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
t.Fatalf("MkdirAll(%q) error = %v", filepath.Dir(path), err)
}
data, err := json.Marshal(map[string]string{"name": name})
if err != nil {
t.Fatalf("Marshal(manifest %q) error = %v", path, err)
}
if err := os.WriteFile(path, append(data, '\n'), 0o644); err != nil {
t.Fatalf("WriteFile(%q) error = %v", path, err)
}
}
func assertManifestContainsExtension(t *testing.T, path, field, want string) {
t.Helper()
data, err := os.ReadFile(path)
if err != nil {
t.Fatalf("ReadFile(%q) error = %v", path, err)
}
var manifest map[string]any
if err := json.Unmarshal(data, &manifest); err != nil {
t.Fatalf("Unmarshal(%q) error = %v", path, err)
}
valuesAny, ok := manifest[field]
if !ok {
t.Fatalf("manifest %q missing field %q", path, field)
}
valuesRaw, ok := valuesAny.([]any)
if !ok {
t.Fatalf("manifest %q field %q = %#v, want []any", path, field, valuesAny)
}
values := make([]string, 0, len(valuesRaw))
for _, raw := range valuesRaw {
text, ok := raw.(string)
if !ok {
t.Fatalf("manifest %q field %q value = %#v, want string", path, field, raw)
}
values = append(values, text)
}
if !slices.Contains(values, want) {
t.Fatalf("manifest %q field %q = %#v, want to contain %q", path, field, values, want)
}
}
func (f fakeClient) Status(context.Context) (*keepassgov1.GetSessionStatusResponse, error) {
if f.err != nil {
return nil, f.err
@@ -0,0 +1,210 @@
package browserbridge
import (
"encoding/json"
"errors"
"fmt"
"os"
"os/exec"
"path/filepath"
"slices"
"strings"
)
const browserExtensionName = "KeePassGO Browser"
type extensionManifestMetadata struct {
Name string `json:"name"`
}
func ResolveBridgeBinaryPath(appBinaryPath string) (string, error) {
path := strings.TrimSpace(appBinaryPath)
if path == "" {
var err error
path, err = os.Executable()
if err != nil {
return "", fmt.Errorf("resolve app executable: %w", err)
}
}
if strings.TrimSpace(path) == "" {
return "", fmt.Errorf("app executable path is required")
}
if filepath.Base(path) == "keepassgo-browser-bridge" {
return path, nil
}
candidate := filepath.Join(filepath.Dir(path), "keepassgo-browser-bridge")
if info, err := os.Stat(candidate); err == nil && !info.IsDir() {
return candidate, nil
}
resolved, err := exec.LookPath("keepassgo-browser-bridge")
if err == nil {
return resolved, nil
}
return "", fmt.Errorf("locate keepassgo-browser-bridge next to %q or in PATH: %w", path, err)
}
func EnsureNativeHostManifests(appBinaryPath string) error {
bridgePath, err := ResolveBridgeBinaryPath(appBinaryPath)
if err != nil {
return err
}
var errs []error
if _, err := InstallManifest(BrowserFirefox, bridgePath, "", ""); err != nil {
errs = append(errs, fmt.Errorf("install firefox native host: %w", err))
}
for _, browser := range []Browser{BrowserChrome, BrowserChromium} {
ids, err := DiscoverInstalledExtensionIDs(browser)
if err != nil {
errs = append(errs, fmt.Errorf("discover %s extension ids: %w", browser, err))
continue
}
if len(ids) == 0 {
continue
}
if _, err := InstallManifestSet(browser, bridgePath, ids, ""); err != nil {
errs = append(errs, fmt.Errorf("install %s native host: %w", browser, err))
}
}
return errors.Join(errs...)
}
func DiscoverInstalledExtensionIDs(browser Browser) ([]string, error) {
root, err := defaultBrowserProfileRoot(browser)
if err != nil {
return nil, err
}
return DiscoverInstalledExtensionIDsInRoot(root)
}
func DiscoverInstalledExtensionIDsInRoot(root string) ([]string, error) {
base := strings.TrimSpace(root)
if base == "" {
return nil, fmt.Errorf("browser profile root is required")
}
pattern := filepath.Join(base, "*", "Extensions", "*", "*", "manifest.json")
paths, err := filepath.Glob(pattern)
if err != nil {
return nil, fmt.Errorf("glob browser extensions: %w", err)
}
ids := make(map[string]struct{}, len(paths))
for _, path := range paths {
ok, err := isKeePassGOExtensionManifest(path)
if err != nil {
return nil, err
}
if !ok {
continue
}
id := filepath.Base(filepath.Dir(filepath.Dir(path)))
if strings.TrimSpace(id) == "" {
continue
}
ids[id] = struct{}{}
}
out := make([]string, 0, len(ids))
for id := range ids {
out = append(out, id)
}
slices.Sort(out)
return out, nil
}
func InstallManifestSet(browser Browser, binaryPath string, extensionIDs []string, outputPath string) (string, error) {
manifest, err := ManifestSet(browser, binaryPath, extensionIDs)
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
}
func ManifestSet(browser Browser, binaryPath string, extensionIDs []string) (NativeHostManifest, error) {
path := strings.TrimSpace(binaryPath)
if path == "" {
return NativeHostManifest{}, fmt.Errorf("native host binary path is required")
}
switch browser {
case BrowserFirefox:
return Manifest(browser, path, "")
case BrowserChrome, BrowserChromium:
ids := normalizedExtensionIDs(extensionIDs)
if len(ids) == 0 {
return NativeHostManifest{}, fmt.Errorf("%s extension id is required", browser)
}
origins := make([]string, 0, len(ids))
for _, id := range ids {
origins = append(origins, "chrome-extension://"+id+"/")
}
return NativeHostManifest{
Name: NativeHostName,
Description: "KeePassGO browser bridge",
Path: path,
Type: "stdio",
AllowedOrigins: origins,
}, nil
default:
return NativeHostManifest{}, fmt.Errorf("unsupported browser %q", browser)
}
}
func defaultBrowserProfileRoot(browser Browser) (string, error) {
home, err := os.UserHomeDir()
if err != nil {
return "", err
}
switch browser {
case BrowserChrome:
return filepath.Join(home, ".config", "google-chrome"), nil
case BrowserChromium:
return filepath.Join(home, ".config", "chromium"), nil
default:
return "", fmt.Errorf("installed extension discovery is unsupported for %q", browser)
}
}
func isKeePassGOExtensionManifest(path string) (bool, error) {
data, err := os.ReadFile(path)
if err != nil {
return false, fmt.Errorf("read extension manifest %q: %w", path, err)
}
var metadata extensionManifestMetadata
if err := json.Unmarshal(data, &metadata); err != nil {
return false, fmt.Errorf("decode extension manifest %q: %w", path, err)
}
return strings.TrimSpace(metadata.Name) == browserExtensionName, nil
}
func normalizedExtensionIDs(ids []string) []string {
seen := make(map[string]struct{}, len(ids))
out := make([]string, 0, len(ids))
for _, raw := range ids {
id := strings.TrimSpace(raw)
if id == "" {
continue
}
if _, ok := seen[id]; ok {
continue
}
seen[id] = struct{}{}
out = append(out, id)
}
slices.Sort(out)
return out
}