Files
keepassgo/internal/browserbridge/bridge.go
2026-04-12 07:38:23 -07:00

347 lines
11 KiB
Go

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"
gcodes "google.golang.org/grpc/codes"
gstatus "google.golang.org/grpc/status"
)
const (
NativeHostName = "com.keepassgo.browser"
defaultFirefoxID = "browser@keepassgo.com"
maxNativeMessageSize = 1024 * 1024
chromiumIDBytes = 16
responseVersion = "1"
)
type Request struct {
Action string `json:"action"`
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"`
PendingApprovalCount uint32 `json:"pendingApprovalCount,omitempty"`
TokenPendingApprovalCount uint32 `json:"tokenPendingApprovalCount,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(grpcAddr string) (Connection, error) {
return normalizeConnection(Connection{
GRPCAddress: strings.TrimSpace(grpcAddr),
BearerToken: strings.TrimSpace(r.BearerToken),
})
}
func normalizeConnection(conn Connection) (Connection, error) {
if strings.TrimSpace(conn.GRPCAddress) == "" {
conn.GRPCAddress = grpcaddr.Default(runtime.GOOS)
}
if strings.TrimSpace(conn.BearerToken) == "" {
return Connection{}, fmt.Errorf("browser bridge bearer token is required")
}
conn.GRPCAddress = strings.TrimSpace(conn.GRPCAddress)
conn.BearerToken = strings.TrimSpace(conn.BearerToken)
return conn, nil
}
func HandleRequest(ctx context.Context, req Request, grpcAddr string, client Client) Response {
conn, err := req.Connection(grpcAddr)
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: responseVersion}
case "find-logins":
matches, err := findMatches(ctx, client, req.URL)
if err != nil {
if status := inferredActionStatus(conn.GRPCAddress, err); status != nil {
return Response{Success: true, Status: status, Matches: nil, Version: responseVersion}
}
return Response{Success: false, Error: err.Error(), Status: disconnectedStatus(conn.GRPCAddress)}
}
return Response{Success: true, Status: availableStatus(conn.GRPCAddress), Matches: matches, Version: responseVersion}
case "get-login":
credential, err := loadCredential(ctx, client, req.EntryID, req.URL)
if err != nil {
if status := inferredActionStatus(conn.GRPCAddress, err); status != nil {
return Response{Success: false, Error: err.Error(), Status: status}
}
return Response{Success: false, Error: err.Error(), Status: disconnectedStatus(conn.GRPCAddress)}
}
return Response{Success: true, Status: availableStatus(conn.GRPCAddress), Credential: credential, Version: responseVersion}
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 availableStatus(addr string) *Status {
return &Status{Connected: true, Locked: false, GRPCAddress: strings.TrimSpace(addr)}
}
func inferredActionStatus(addr string, err error) *Status {
switch gstatus.Code(err) {
case gcodes.FailedPrecondition:
return &Status{Connected: true, Locked: true, GRPCAddress: strings.TrimSpace(addr)}
case gcodes.OK:
return availableStatus(addr)
default:
return nil
}
}
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(),
PendingApprovalCount: resp.GetPendingApprovalCount(),
TokenPendingApprovalCount: resp.GetTokenPendingApprovalCount(),
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) {
return InstallManifestSet(browser, binaryPath, []string{strings.TrimSpace(extensionID)}, outputPath)
}