// Package config loads machine credentials for the bitwarden-go CLI. // Credentials arrive ONLY from (a) BW_* environment variables or (b) a // 0600 env file parsed in pure Go — never from flags or command-line // arguments. Files looser than 0600 are refused BEFORE being read. // Error messages carry line numbers and key names, never values. package config import ( "fmt" "os" "path/filepath" "strings" ) // Keys understood in the environment and the env file. const ( KeyServerURL = "BW_SERVER_URL" KeyAccessToken = "BW_ACCESS_TOKEN" KeyClientID = "BW_CLIENTID" KeyClientSecret = "BW_CLIENTSECRET" ) // DefaultServerURL is the Bitwarden public cloud; self-hosted Vaultwarden // instances set BW_SERVER_URL instead. const DefaultServerURL = "https://vault.bitwarden.com" // Config is the resolved credential set. type Config struct { ServerURL string AccessToken string ClientID string ClientSecret string } // Source records where each value came from (for safe diagnostics). type Source struct{ Env, File string } // Load resolves credentials: process env wins over the env file (the // porting-notes precedence), file wins over defaults. path may be empty // (file simply not consulted). A file that exists but is looser than 0600 // is an error before any read. func Load(path string) (*Config, *Source, error) { cfg := &Config{} src := &Source{} fileVals, fileUsed, err := loadFile(path) if err != nil { return nil, nil, err } get := func(key string) (string, bool) { if v, ok := os.LookupEnv(key); ok && v != "" { src.Env = key return v, true } if v, ok := fileVals[key]; ok && v != "" { src.File = key return v, true } return "", false } if v, ok := get(KeyServerURL); ok { cfg.ServerURL = strings.TrimRight(v, "/") } if v, ok := get(KeyAccessToken); ok { cfg.AccessToken = v } if v, ok := get(KeyClientID); ok { cfg.ClientID = v } if v, ok := get(KeyClientSecret); ok { cfg.ClientSecret = v } _ = fileUsed if cfg.AccessToken == "" && (cfg.ClientID == "" || cfg.ClientSecret == "") { return nil, nil, fmt.Errorf("config: no credentials: set BW_ACCESS_TOKEN or BW_CLIENTID+BW_CLIENTSECRET (env or %s)", pathOrDefault(path)) } if cfg.ServerURL == "" { cfg.ServerURL = DefaultServerURL } return cfg, src, nil } // loadFile reads and parses path when given. It enforces the 0600 rule // before reading a single byte. func loadFile(path string) (map[string]string, bool, error) { if path == "" { return nil, false, nil } info, err := os.Stat(path) if os.IsNotExist(err) { return nil, false, nil } if err != nil { return nil, false, fmt.Errorf("config: %s: %w", filepath.Base(path), err) } if info.Mode().Perm()&0o077 != 0 { return nil, false, fmt.Errorf("config: %s: insecure mode %04o (must be 0600 or stricter)", filepath.Base(path), info.Mode().Perm()) } data, err := os.ReadFile(path) if err != nil { return nil, false, fmt.Errorf("config: %s: unreadable", filepath.Base(path)) } vals, err := parseEnvFile(data) if err != nil { return nil, false, fmt.Errorf("config: %s: %w", filepath.Base(path), err) } return vals, true, nil } // parseEnvFile parses KEY=VALUE lines in pure Go (same discipline as // mopac-keyproxy): no sourcing, no shell expansion, no interpolation. // Comments, blank lines, an optional "export " prefix and one matched pair // of surrounding quotes are handled; later duplicate keys win. Malformed // lines fail with the line NUMBER only — never the contents. func parseEnvFile(data []byte) (map[string]string, error) { out := map[string]string{} for i, line := range strings.Split(string(data), "\n") { line = strings.TrimRight(line, "\r") trimmed := strings.TrimSpace(line) if trimmed == "" || strings.HasPrefix(trimmed, "#") { continue } line = trimmed if strings.HasPrefix(line, "export ") || strings.HasPrefix(line, "export\t") { line = strings.TrimSpace(line[len("export"):]) } eq := strings.IndexByte(line, '=') if eq <= 0 { return nil, fmt.Errorf("line %d: malformed KEY=VALUE line", i+1) } key := strings.TrimSpace(line[:eq]) if !validEnvKey(key) { return nil, fmt.Errorf("line %d: malformed KEY=VALUE line", i+1) } value := strings.TrimSpace(line[eq+1:]) if idx := commentIndex(value); idx >= 0 { value = strings.TrimSpace(value[:idx]) } out[key] = unquote(value) } return out, nil } func validEnvKey(key string) bool { if key == "" { return false } for i := 0; i < len(key); i++ { c := key[i] switch { case c >= 'a' && c <= 'z', c >= 'A' && c <= 'Z', c == '_': case c >= '0' && c <= '9': if i == 0 { return false } default: return false } } return true } // commentIndex finds an inline comment start (a # preceded by whitespace) // outside a quoted value; -1 if none. func commentIndex(value string) int { var quote byte for i := 0; i < len(value); i++ { c := value[i] switch { case quote != 0: if c == quote { quote = 0 } case c == '"' || c == '\'': quote = c case c == '#' && (i == 0 || value[i-1] == ' ' || value[i-1] == '\t'): return i } } return -1 } func unquote(v string) string { if len(v) >= 2 { if (v[0] == '"' && v[len(v)-1] == '"') || (v[0] == '\'' && v[len(v)-1] == '\'') { return v[1 : len(v)-1] } } return v } func pathOrDefault(path string) string { if path != "" { return path } return "the default env file" }