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:
@@ -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"
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user