Complete browser extension gRPC flow
This commit is contained in:
+15
-4
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user