From a9b08b96c48b2a92ee7948e1c3314569c6813d76 Mon Sep 17 00:00:00 2001 From: reachableceo Date: Sat, 29 Aug 2026 00:08:31 -0500 Subject: [PATCH] Add credential loading from env vars and 0600 env files Credentials arrive only from BW_* process env or an env file parsed in pure Go (never sourced, never exec'd); files looser than 0600 are refused before a single byte is read, and errors carry line numbers and key names, never values. Env wins over file, per the porting-notes precedence. --- internal/config/config.go | 200 +++++++++++++++++++++++++++++++++ internal/config/config_test.go | 139 +++++++++++++++++++++++ 2 files changed, 339 insertions(+) create mode 100644 internal/config/config.go create mode 100644 internal/config/config_test.go diff --git a/internal/config/config.go b/internal/config/config.go new file mode 100644 index 0000000..e8510f7 --- /dev/null +++ b/internal/config/config.go @@ -0,0 +1,200 @@ +// 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" +} diff --git a/internal/config/config_test.go b/internal/config/config_test.go new file mode 100644 index 0000000..edefbc6 --- /dev/null +++ b/internal/config/config_test.go @@ -0,0 +1,139 @@ +package config + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +func writeFile(t *testing.T, mode os.FileMode, content string) string { + t.Helper() + dir := t.TempDir() + path := filepath.Join(dir, "env") + if err := os.WriteFile(path, []byte(content), mode); err != nil { + t.Fatal(err) + } + if err := os.Chmod(path, mode); err != nil { // umask may have tightened + t.Fatal(err) + } + return path +} + +func clearEnv(t *testing.T) { + t.Helper() + for _, k := range []string{KeyServerURL, KeyAccessToken, KeyClientID, KeyClientSecret} { + t.Setenv(k, "") + os.Unsetenv(k) + } +} + +func TestLoadFromEnvFile(t *testing.T) { + clearEnv(t) + path := writeFile(t, 0o600, "# comment\nBW_SERVER_URL=https://vault.example.com\nBW_ACCESS_TOKEN='0.uuid.secret:key=='\n") + cfg, _, err := Load(path) + if err != nil { + t.Fatalf("load: %v", err) + } + if cfg.ServerURL != "https://vault.example.com" { + t.Fatalf("server url: %s", cfg.ServerURL) + } + if cfg.AccessToken != "0.uuid.secret:key==" { + t.Fatalf("access token: %s", cfg.AccessToken) + } +} + +func TestLoadRefusesLooseMode(t *testing.T) { + clearEnv(t) + path := writeFile(t, 0o644, "BW_ACCESS_TOKEN=x\n") + _, _, err := Load(path) + if err == nil { + t.Fatal("loose file accepted") + } + if !strings.Contains(err.Error(), "insecure mode") { + t.Fatalf("wrong error: %v", err) + } +} + +func TestLoadRefusesEvenLooserBeforeReading(t *testing.T) { + clearEnv(t) + // 0644 file whose contents would break parsing: the mode check must + // fire first (the parse error is never reached, contents never read). + path := writeFile(t, 0o666, "this is not valid at all\n") + _, _, err := Load(path) + if err == nil || !strings.Contains(err.Error(), "insecure mode") { + t.Fatalf("mode check did not fire first: %v", err) + } +} + +func TestLoadStricterThan0600OK(t *testing.T) { + clearEnv(t) + path := writeFile(t, 0o400, "BW_CLIENTID=id\nBW_CLIENTSECRET=sec\n") + cfg, _, err := Load(path) + if err != nil { + t.Fatalf("load: %v", err) + } + if cfg.ClientID != "id" || cfg.ClientSecret != "sec" { + t.Fatal("split credentials not loaded") + } +} + +func TestEnvWinsOverFile(t *testing.T) { + clearEnv(t) + path := writeFile(t, 0o600, "BW_ACCESS_TOKEN=file-token\n") + t.Setenv("BW_ACCESS_TOKEN", "env-token") + cfg, _, err := Load(path) + if err != nil { + t.Fatalf("load: %v", err) + } + if cfg.AccessToken != "env-token" { + t.Fatal("env did not win over file") + } +} + +func TestNoCredentialsIsTypedError(t *testing.T) { + clearEnv(t) + _, _, err := Load("") + if err == nil { + t.Fatal("missing credentials accepted") + } + if !strings.Contains(err.Error(), "no credentials") { + t.Fatalf("wrong error: %v", err) + } +} + +func TestMissingFileOK(t *testing.T) { + clearEnv(t) + // Env credentials plus an explicit-but-missing file path: the file is + // simply not there to consult; env carries the login. + t.Setenv("BW_CLIENTID", "id") + t.Setenv("BW_CLIENTSECRET", "sec") + path := writeFile(t, 0o600, "BW_CLIENTID=id\nBW_CLIENTSECRET=sec\n") + cfg, _, err := Load(path + "-does-not-exist") + if err != nil { + t.Fatalf("explicit missing file: %v", err) + } + if cfg.ServerURL != DefaultServerURL { + t.Fatalf("default server url: %s", cfg.ServerURL) + } +} + +func TestParseEnvFileDiscipline(t *testing.T) { + vals, err := parseEnvFile([]byte("\n# c\nexport BW_A=1\nBW_B = spaced \nBW_C=\"quoted\"\nBW_C=later-wins\nBW_D=val # trailing comment\n")) + if err != nil { + t.Fatalf("parse: %v", err) + } + want := map[string]string{"BW_A": "1", "BW_B": "spaced", "BW_C": "later-wins", "BW_D": "val"} + for k, v := range want { + if vals[k] != v { + t.Fatalf("%s: %q != %q", k, vals[k], v) + } + } + _, err = parseEnvFile([]byte("BAD LINE WITHOUT EQUALS\n")) + if err == nil || !strings.Contains(err.Error(), "line 1") { + t.Fatalf("malformed line error: %v", err) + } + if strings.Contains(err.Error(), "BAD LINE") { + t.Fatalf("error echoes file contents: %v", err) + } +}