377 lines
12 KiB
Go
377 lines
12 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"`
|
|
Query string `json:"query,omitempty"`
|
|
}
|
|
|
|
type Response struct {
|
|
Success bool `json:"success"`
|
|
Error string `json:"error,omitempty"`
|
|
Status *Status `json:"status,omitempty"`
|
|
Matches []Match `json:"matches,omitempty"`
|
|
SearchResults []Match `json:"searchResults,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)
|
|
ListEntries(context.Context, []string, string) ([]*keepassgov1.Entry, 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 "search-logins":
|
|
results, err := searchEntries(ctx, client, req.Query)
|
|
if err != nil {
|
|
if status := inferredActionStatus(conn.GRPCAddress, err); status != nil {
|
|
return Response{Success: true, Status: status, SearchResults: nil, Version: responseVersion}
|
|
}
|
|
return Response{Success: false, Error: err.Error(), Status: disconnectedStatus(conn.GRPCAddress)}
|
|
}
|
|
return Response{Success: true, Status: availableStatus(conn.GRPCAddress), SearchResults: results, 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 searchEntries(ctx context.Context, client Client, query string) ([]Match, error) {
|
|
resp, err := client.ListEntries(ctx, nil, strings.TrimSpace(query))
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
out := make([]Match, 0, len(resp))
|
|
for _, entry := range resp {
|
|
out = append(out, Match{
|
|
ID: entry.GetId(),
|
|
Title: entry.GetTitle(),
|
|
Username: entry.GetUsername(),
|
|
URL: entry.GetUrl(),
|
|
Path: append([]string(nil), entry.GetPath()...),
|
|
})
|
|
}
|
|
return out, 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)
|
|
}
|