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.
This commit is contained in:
2026-08-29 00:08:31 -05:00
parent c94c0b1de4
commit a9b08b96c4
2 changed files with 339 additions and 0 deletions
+200
View File
@@ -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"
}