package client import ( "crypto/tls" "crypto/x509" "encoding/json" "errors" "fmt" "net" "net/http" "net/url" "os" "path/filepath" "strings" "time" ) // Config is what a client remembers between runs, and every setting a user // can change. The session token lives here; the KDBX master passwords never // do, because the client never opens a database, it only moves bytes. // // The file is written with every field present, so editing it is the whole // of the client's settings interface: the tray's "Open Config" opens it, and // the tray picks up a saved change without restarting. type Config struct { // ServerURL is the server's base URL, as a browser sees it. ServerURL string `json:"server_url"` // Username and Token are written by signing in, not by hand. Username string `json:"username"` Token string `json:"token"` // DeviceLabel names this device in the server's session list. DeviceLabel string `json:"device_label"` // MirrorDir is where the databases appear as .kdbx files. MirrorDir string `json:"mirror_dir"` // PollSeconds is how often the mirror is rescanned for local saves. PollSeconds int `json:"poll_seconds"` // RequestTimeoutSeconds bounds one API call. The event stream is exempt. RequestTimeoutSeconds int `json:"request_timeout_seconds"` // CAFile is a PEM bundle trusted in addition to the system roots, for a // server behind a private certificate authority. CAFile string `json:"ca_file"` // LogFile is where the agent logs. Empty means standard error for // `keeporg run`, and keeporg.log beside this file for the tray. LogFile string `json:"log_file"` // Notifications turns the tray's desktop notifications on or off. Notifications bool `json:"notifications"` // AllowInsecureHTTP permits an http:// server_url that is not this // machine. Without TLS the password, the session token and every vault // cross the network readable and alterable by anyone on the path. AllowInsecureHTTP bool `json:"allow_insecure_http"` } // ErrNoConfig means the client has not been logged in yet. var ErrNoConfig = errors.New("not logged in: run `keeporg login` first") // DefaultConfigPath is the per-user configuration path on this platform. func DefaultConfigPath() string { dir, err := os.UserConfigDir() if err != nil { return filepath.Join(".", ".keeporg", "config.json") } return filepath.Join(dir, "keeporg", "config.json") } // DefaultMirrorDir is where databases are mirrored unless told otherwise. func DefaultMirrorDir() string { home, err := os.UserHomeDir() if err != nil { return "keeporg" } return filepath.Join(home, "KeepOrg") } // DefaultConfig is a configuration with every setting at its default and no // server. func DefaultConfig() *Config { label, err := os.Hostname() if err != nil || label == "" { label = "keeporg client" } return &Config{ DeviceLabel: label, MirrorDir: DefaultMirrorDir(), PollSeconds: 2, RequestTimeoutSeconds: 60, Notifications: true, } } // LoadConfig reads a configuration file. A setting the file leaves out keeps // its default, so an older file reads the same as it always did. func LoadConfig(path string) (*Config, error) { b, err := os.ReadFile(path) if os.IsNotExist(err) { return nil, ErrNoConfig } if err != nil { return nil, err } c := DefaultConfig() if err := json.Unmarshal(b, c); err != nil { return nil, fmt.Errorf("%s: %w", path, err) } def := DefaultConfig() if c.MirrorDir == "" { c.MirrorDir = def.MirrorDir } if c.DeviceLabel == "" { c.DeviceLabel = def.DeviceLabel } if c.PollSeconds <= 0 { c.PollSeconds = def.PollSeconds } if c.RequestTimeoutSeconds <= 0 { c.RequestTimeoutSeconds = def.RequestTimeoutSeconds } c.ServerURL = strings.TrimRight(c.ServerURL, "/") c.MirrorDir = expandHome(c.MirrorDir) c.CAFile = expandHome(c.CAFile) c.LogFile = expandHome(c.LogFile) return c, c.Validate() } // EnsureConfig returns the configuration at path, writing a default one // first if there is none, so a user always has a complete file to edit. func EnsureConfig(path string) (*Config, error) { c, err := LoadConfig(path) if !errors.Is(err, ErrNoConfig) { return c, err } c = DefaultConfig() return c, c.Save(path) } // Validate reports the first setting that cannot work. func (c *Config) Validate() error { if c.ServerURL != "" { u, err := url.Parse(c.ServerURL) if err != nil || (u.Scheme != "http" && u.Scheme != "https") || u.Host == "" { return fmt.Errorf("server_url %q must be an http:// or https:// URL", c.ServerURL) } if u.Scheme == "http" && !isLoopback(u.Hostname()) && !c.AllowInsecureHTTP { return fmt.Errorf("server_url %q is not encrypted; use https://, "+ "or set allow_insecure_http if you really mean it", c.ServerURL) } } if c.CAFile != "" { if _, err := os.Stat(c.CAFile); err != nil { return fmt.Errorf("ca_file: %w", err) } } return nil } func isLoopback(host string) bool { if host == "localhost" { return true } ip := net.ParseIP(host) return ip != nil && ip.IsLoopback() } // CheckVerificationURL makes sure a page the server asks the client to open // in a browser is on that server. A compromised server must not be able to // make the desktop open whatever it likes — a file: URL, a network share // that runs a program, another application's URL scheme. func CheckVerificationURL(serverURL, raw string) error { srv, err := url.Parse(serverURL) if err != nil { return err } u, err := url.Parse(raw) if err != nil || u.Scheme != srv.Scheme || !strings.EqualFold(u.Host, srv.Host) || u.User != nil || u.Path != strings.TrimRight(srv.Path, "/")+"/device" { return fmt.Errorf("the server asked to open %q, which is not its own approval page", raw) } return nil } // API returns a client for the configured server, signed in with the stored // token when there is one. func (c *Config) API() (*API, error) { api := NewAPI(c.ServerURL, c.Token) api.HTTP.Timeout = time.Duration(c.RequestTimeoutSeconds) * time.Second if c.CAFile == "" { return api, nil } pem, err := os.ReadFile(c.CAFile) if err != nil { return nil, fmt.Errorf("ca_file: %w", err) } pool, err := x509.SystemCertPool() if err != nil { pool = x509.NewCertPool() } if !pool.AppendCertsFromPEM(pem) { return nil, fmt.Errorf("ca_file %s holds no PEM certificates", c.CAFile) } tr := http.DefaultTransport.(*http.Transport).Clone() tr.TLSClientConfig = &tls.Config{RootCAs: pool, MinVersion: tls.VersionTLS12} api.HTTP.Transport = tr return api, nil } // Poll is PollSeconds as a duration. func (c *Config) Poll() time.Duration { return time.Duration(c.PollSeconds) * time.Second } // Save writes the configuration with owner-only permissions, since it holds // a bearer token. func (c *Config) Save(path string) error { if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil { return err } b, err := json.MarshalIndent(c, "", " ") if err != nil { return err } tmp := path + ".tmp" if err := os.WriteFile(tmp, append(b, '\n'), 0o600); err != nil { return err } return os.Rename(tmp, path) } // expandHome turns a leading ~ into the home directory, since the file is // meant to be edited by hand. func expandHome(p string) string { if p != "~" && !strings.HasPrefix(p, "~/") && !strings.HasPrefix(p, `~\`) { return p } home, err := os.UserHomeDir() if err != nil { return p } return filepath.Join(home, p[1:]) }