package browserbridge import ( "context" "crypto/sha256" "encoding/base64" "encoding/binary" "encoding/json" "fmt" "io" "os" "path/filepath" "runtime" "strings" "git.julianfamily.org/keepassgo/internal/grpcaddr" keepassgov1 "git.julianfamily.org/keepassgo/proto/keepassgo/v1" ) const ( NativeHostName = "com.keepassgo.browser" defaultFirefoxID = "browser@keepassgo.com" maxNativeMessageSize = 1024 * 1024 chromiumIDBytes = 16 ) 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 = grpcaddr.Default(runtime.GOOS) } 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 ChromiumExtensionIDFromManifestKey(raw string) (string, error) { normalized := strings.TrimSpace(raw) normalized = strings.ReplaceAll(normalized, "-----BEGIN PUBLIC KEY-----", "") normalized = strings.ReplaceAll(normalized, "-----END PUBLIC KEY-----", "") normalized = strings.ReplaceAll(normalized, "\n", "") normalized = strings.ReplaceAll(normalized, "\r", "") normalized = strings.ReplaceAll(normalized, "\t", "") normalized = strings.ReplaceAll(normalized, " ", "") if normalized == "" { return "", fmt.Errorf("chromium extension key is required") } publicKeyDER, err := base64.StdEncoding.DecodeString(normalized) if err != nil { return "", fmt.Errorf("decode chromium extension key: %w", err) } hash := sha256.Sum256(publicKeyDER) var builder strings.Builder builder.Grow(chromiumIDBytes * 2) for _, b := range hash[:chromiumIDBytes] { builder.WriteByte('a' + ((b >> 4) & 0x0f)) builder.WriteByte('a' + (b & 0x0f)) } return builder.String(), nil } 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 }