Add backend resolver interface with file and env backends plus phase-3 stubs

Introduce the single Backend interface every credential source implements.
v0 ships two working backends: file (0600 KEY=VALUE env files, parsed in
pure Go, never sourced; looser permission masks refused before read) and
env (process-environment indirection). Bitwarden Secrets Manager and
HashiCorp Vault ship as explicit not-implemented stubs behind the same
interface so the phase-3 connectors are drop-ins. All failures are typed
ResolveErrors carrying only the ref, backend, and a fixed reason enum —
never material.

💘 Generated with Crush

Assisted-by: Crush:glm-5.2
This commit is contained in:
2026-08-28 22:30:50 -05:00
parent 325a36cc35
commit b43bc55963
10 changed files with 730 additions and 0 deletions
+107
View File
@@ -0,0 +1,107 @@
// Package backend defines the resolver interface every credential source
// implements. Backends receive opaque refs (mpk-<name>) and return key
// material; material is held in memory only, never persisted, never
// written to logs. Errors identify the ref, the backend, and a
// machine-readable reason — never material.
package backend
import (
"context"
"errors"
"fmt"
"sort"
)
// Ref is one configured mpk-<name> entry from keyproxy.toml. Source and
// Key name WHERE material lives; they are locations, not material.
type Ref struct {
Name string `json:"ref"` // consumer-facing opaque ref, e.g. "mpk-redmine"
Backend string `json:"backend"` // file | env | bitwarden | vault
Source string `json:"-"` // backend-specific source (file path, env var name, vault path)
Key string `json:"-"` // key inside the source (env-file key, secret name)
Mode string `json:"-"` // file backend: allowed permission mask, default "0600"
}
// Reason is a machine-readable failure class. Reason strings are the only
// failure detail that may cross the wire or reach logs: they are fixed
// enums and can never embed source contents.
type Reason string
const (
ReasonUnknownRef Reason = "unknown_ref"
ReasonNotImplemented Reason = "not_implemented"
ReasonUnreadableSource Reason = "unreadable_source"
ReasonInsecureMode Reason = "insecure_source_mode"
ReasonMalformedSource Reason = "malformed_source"
ReasonMissingKey Reason = "missing_key"
ReasonEmptyValue Reason = "empty_value"
)
// ResolveError names the ref, the backend and a reason. It never carries
// material; its Error() output is safe to log.
type ResolveError struct {
Ref string
Backend string
Reason Reason
}
func (e *ResolveError) Error() string {
return fmt.Sprintf("ref %s: backend %s: %s", e.Ref, e.Backend, e.Reason)
}
// Err wraps err as a ResolveError for ref/backend, deduplicating if it
// already is one.
func Err(ref Ref, backend string, reason Reason) error {
return &ResolveError{Ref: ref.Name, Backend: backend, Reason: reason}
}
// AsResolveError extracts a *ResolveError, or wraps an unexpected error
// as a generic unreadable_source failure (the underlying text is dropped:
// it is never safe to assume it is material-free).
func AsResolveError(err error, ref Ref, backend string) *ResolveError {
var re *ResolveError
if err != nil && errors.As(err, &re) {
return re
}
return &ResolveError{Ref: ref.Name, Backend: backend, Reason: ReasonUnreadableSource}
}
// Backend resolves one Ref to its material. Implementations must not
// persist material, cache it to disk, or write it to any log.
type Backend interface {
// Name is the backend's config-facing name (file, env, ...).
Name() string
// Resolve returns the material for ref. Errors must be (or wrap) a
// *ResolveError; returned strings exist in memory only.
Resolve(ctx context.Context, ref Ref) (string, error)
}
// Registry maps backend names to implementations.
type Registry struct {
m map[string]Backend
}
// NewRegistry builds a registry from the given backends.
func NewRegistry(backends ...Backend) *Registry {
r := &Registry{m: make(map[string]Backend, len(backends))}
for _, b := range backends {
r.m[b.Name()] = b
}
return r
}
// Get returns the backend registered under name.
func (r *Registry) Get(name string) (Backend, bool) {
b, ok := r.m[name]
return b, ok
}
// Names lists registered backend names, sorted.
func (r *Registry) Names() []string {
out := make([]string, 0, len(r.m))
for n := range r.m {
out = append(out, n)
}
sort.Strings(out)
return out
}
+22
View File
@@ -0,0 +1,22 @@
package backend
import "context"
// Bitwarden is the phase-3 Bitwarden Secrets Manager backend (machine
// accounts, plain REST via stdlib — the official SDK is source-available
// and AGPL-incompatible). v0 ships the interface slot ONLY: it resolves
// nothing and returns an explicit not-implemented error naming the ref
// and backend, so the connector is a drop-in behind the same interface
// later and misconfigured rollouts fail loudly today.
type Bitwarden struct{}
// NewBitwarden returns the bitwarden stub.
func NewBitwarden() *Bitwarden { return &Bitwarden{} }
// Name implements Backend.
func (b *Bitwarden) Name() string { return "bitwarden" }
// Resolve implements Backend.
func (b *Bitwarden) Resolve(ctx context.Context, ref Ref) (string, error) {
return "", Err(ref, b.Name(), ReasonNotImplemented)
}
+30
View File
@@ -0,0 +1,30 @@
package backend
import (
"context"
"os"
)
// Env resolves refs through process-environment indirection: ref.Source
// is the variable name. This is the crush exposure-minimization pattern —
// configs carry env var NAMES, never literals. The value is read on
// demand and kept in memory only.
type Env struct{}
// NewEnv returns the env backend.
func NewEnv() *Env { return &Env{} }
// Name implements Backend.
func (e *Env) Name() string { return "env" }
// Resolve implements Backend.
func (e *Env) Resolve(ctx context.Context, ref Ref) (string, error) {
v, ok := os.LookupEnv(ref.Source)
if !ok {
return "", Err(ref, e.Name(), ReasonMissingKey)
}
if v == "" {
return "", Err(ref, e.Name(), ReasonEmptyValue)
}
return v, nil
}
+102
View File
@@ -0,0 +1,102 @@
package backend
import (
"context"
"strings"
"testing"
)
func TestEnvResolve(t *testing.T) {
tests := []struct {
name string
set func(t *testing.T)
ref Ref
want string
wantErr string
}{
{
name: "set variable resolves",
set: func(t *testing.T) { t.Setenv("KEYPROXY_TEST_VAR", testMaterial) },
ref: Ref{Name: "mpk-env", Backend: "env", Source: "KEYPROXY_TEST_VAR"},
want: testMaterial,
},
{
name: "unset variable",
set: func(t *testing.T) {},
ref: Ref{Name: "mpk-env", Backend: "env", Source: "KEYPROXY_TEST_UNSET"},
wantErr: "missing_key",
},
{
name: "empty variable",
set: func(t *testing.T) { t.Setenv("KEYPROXY_TEST_EMPTY", "") },
ref: Ref{Name: "mpk-env", Backend: "env", Source: "KEYPROXY_TEST_EMPTY"},
wantErr: "empty_value",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
tt.set(t)
e := NewEnv()
got, err := e.Resolve(context.Background(), tt.ref)
if tt.wantErr != "" {
if err == nil {
t.Fatalf("want error %q, got value", tt.wantErr)
}
msg := err.Error()
if !strings.Contains(msg, tt.wantErr) {
t.Fatalf("error %q does not contain %q", msg, tt.wantErr)
}
assertRedactedError(t, msg, tt.ref.Name, "env", testMaterial)
return
}
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if got != tt.want {
t.Fatalf("got %q, want %q", got, tt.want)
}
})
}
}
func TestStubsNotImplemented(t *testing.T) {
tests := []struct {
name string
backend Backend
bname string
}{
{"bitwarden", NewBitwarden(), "bitwarden"},
{"vault", NewVault(), "vault"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
ref := Ref{Name: "mpk-stub", Backend: tt.bname, Source: "sm://x", Key: "K"}
got, err := tt.backend.Resolve(context.Background(), ref)
if err == nil {
t.Fatalf("stub resolved to %q; want explicit not-implemented error", got)
}
if got != "" {
t.Fatalf("stub returned non-empty value %q", got)
}
msg := err.Error()
for _, want := range []string{"mpk-stub", tt.bname, "not_implemented"} {
if !strings.Contains(msg, want) {
t.Fatalf("stub error %q must contain %q", msg, want)
}
}
})
}
}
func TestRegistry(t *testing.T) {
r := NewRegistry(NewFile(), NewEnv(), NewBitwarden(), NewVault())
if got := r.Names(); strings.Join(got, ",") != "bitwarden,env,file,vault" {
t.Fatalf("names = %v", got)
}
if _, ok := r.Get("nope"); ok {
t.Fatal("unknown backend must not resolve")
}
if b, ok := r.Get("file"); !ok || b.Name() != "file" {
t.Fatal("file backend must resolve")
}
}
+102
View File
@@ -0,0 +1,102 @@
package backend
import (
"fmt"
"strings"
)
// parseEnvFile parses KEY=VALUE lines in pure Go. Env files are NEVER
// sourced or eval'd (house rule: no exec of env files); there is no
// shell expansion, no command substitution, no interpolation of any
// kind. Accepted syntax per line:
//
// # comment skipped (also inline comments after whitespace)
// (blank line) skipped
// export KEY=VALUE optional "export " prefix, skipped
// KEY=VALUE KEY is [A-Za-z_][A-Za-z0-9_]*; VALUE is the rest
// of the line, trimmed; one matched pair of
// surrounding single or double quotes is stripped
// (no escape processing)
//
// A later duplicate KEY overrides an earlier one (same order semantics
// as sourcing the file). Malformed lines fail loudly: the error carries
// the line NUMBER only, never the line contents (contents may be
// material).
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
}
// validEnvKey reports whether key is a legal env-file identifier.
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
}
// unquote strips one matched pair of surrounding quotes.
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
}
+116
View File
@@ -0,0 +1,116 @@
package backend
import (
"strings"
"testing"
)
func TestParseEnvFile(t *testing.T) {
tests := []struct {
name string
in string
want map[string]string
wantErr string
}{
{
name: "simple lines",
in: "A=1\nB=two words\n",
want: map[string]string{"A": "1", "B": "two words"},
},
{
name: "comments and blanks",
in: "# header\n\nA=1\n # indented comment\nB=2\n",
want: map[string]string{"A": "1", "B": "2"},
},
{
name: "export prefix",
in: "export A=1\nexport\tB=2\n",
want: map[string]string{"A": "1", "B": "2"},
},
{
name: "quoted values stripped",
in: "A=\"1 2\"\nB='3 4'\n",
want: map[string]string{"A": "1 2", "B": "3 4"},
},
{
name: "spaces around key and value trimmed",
in: " A = 1 \n",
want: map[string]string{"A": "1"},
},
{
name: "inline comment after value",
in: "A=1 # trailing\nB=\"2 # kept\"\n",
want: map[string]string{"A": "1", "B": "2 # kept"},
},
{
name: "equals inside value",
in: "A=b=c\n",
want: map[string]string{"A": "b=c"},
},
{
name: "crlf line endings",
in: "A=1\r\nB=2\r\n",
want: map[string]string{"A": "1", "B": "2"},
},
{
name: "later duplicate wins",
in: "A=first\nA=second\n",
want: map[string]string{"A": "second"},
},
{
name: "line without equals",
in: "SECRET_MATERIAL_NO_EQUALS\n",
wantErr: "line 1: malformed",
},
{
name: "illegal key character",
in: "A-B=supersecret\n",
wantErr: "line 1: malformed",
},
{
name: "key starting with digit",
in: "1KEY=supersecret\n",
wantErr: "line 1: malformed",
},
{
name: "empty key",
in: "=value\n",
wantErr: "line 1: malformed",
},
{
name: "line number reported",
in: "A=1\nB=2\nbroken line here\n",
wantErr: "line 3: malformed",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, err := parseEnvFile([]byte(tt.in))
if tt.wantErr != "" {
if err == nil {
t.Fatalf("want error %q, got nil (parsed %v)", tt.wantErr, got)
}
if !strings.Contains(err.Error(), tt.wantErr) {
t.Fatalf("error %q does not contain %q", err, tt.wantErr)
}
// Parse errors carry line numbers only — never line
// contents (contents may be material).
if strings.Contains(err.Error(), "supersecret") || strings.Contains(err.Error(), "SECRET_MATERIAL") {
t.Fatalf("parse error leaks line contents: %q", err)
}
return
}
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if len(got) != len(tt.want) {
t.Fatalf("got %v, want %v", got, tt.want)
}
for k, v := range tt.want {
if got[k] != v {
t.Fatalf("key %s: got %q, want %q", k, got[k], v)
}
}
})
}
}
+77
View File
@@ -0,0 +1,77 @@
package backend
import (
"context"
"os"
"strconv"
"strings"
)
// File resolves refs from env files on disk (KEY=VALUE lines, parsed in
// Go, never sourced). House rule: secret files are 0600. A file whose
// permission bits reach beyond the allowed mask (default 0600; stricter
// masks such as 0400 always pass) is refused before it is read. Material
// is read on demand per resolve and kept in memory only.
type File struct{}
// NewFile returns the file backend.
func NewFile() *File { return &File{} }
// Name implements Backend.
func (f *File) Name() string { return "file" }
// Resolve implements Backend.
func (f *File) Resolve(ctx context.Context, ref Ref) (string, error) {
path := expandHome(ref.Source)
info, err := os.Stat(path)
if err != nil || info.IsDir() {
return "", Err(ref, f.Name(), ReasonUnreadableSource)
}
if info.Mode().Perm()&^allowedMode(ref.Mode) != 0 {
return "", Err(ref, f.Name(), ReasonInsecureMode)
}
data, err := os.ReadFile(path)
if err != nil {
return "", Err(ref, f.Name(), ReasonUnreadableSource)
}
kv, err := parseEnvFile(data)
if err != nil {
return "", Err(ref, f.Name(), ReasonMalformedSource)
}
v, ok := kv[ref.Key]
if !ok {
return "", Err(ref, f.Name(), ReasonMissingKey)
}
if v == "" {
return "", Err(ref, f.Name(), ReasonEmptyValue)
}
return v, nil
}
// allowedMode parses ref.Mode ("0600"-style octal, default 0600) into a
// permission mask; unparsable masks fail closed to 0 (nothing allowed).
func allowedMode(mode string) os.FileMode {
if mode == "" {
return 0o600
}
m, err := strconv.ParseUint(strings.TrimPrefix(mode, "0o"), 8, 32)
if err != nil {
return 0
}
return os.FileMode(m) & os.ModePerm
}
// expandHome resolves a leading ~ / ~/ to the user's home directory;
// every other path is returned unchanged.
func expandHome(path string) string {
if path == "~" || strings.HasPrefix(path, "~/") {
home, err := os.UserHomeDir()
if err == nil {
if path == "~" {
return home
}
return home + path[1:]
}
}
return path
}
+149
View File
@@ -0,0 +1,149 @@
package backend
import (
"context"
"os"
"path/filepath"
"strings"
"testing"
)
const testMaterial = "sk-live-supersecret-material-0123456789"
// writeEnvFile writes an env file with the given permission mask.
func writeEnvFile(t *testing.T, dir, name, content string, mode os.FileMode) string {
t.Helper()
path := filepath.Join(dir, name)
if err := os.WriteFile(path, []byte(content), mode); err != nil {
t.Fatal(err)
}
if err := os.Chmod(path, mode); err != nil {
t.Fatal(err)
}
return path
}
func TestFileResolve(t *testing.T) {
dir := t.TempDir()
secretFile := writeEnvFile(t, dir, "creds.env", "API_KEY="+testMaterial+"\nOTHER=x\n", 0o600)
emptyFile := writeEnvFile(t, dir, "empty.env", "API_KEY=\n", 0o600)
looseFile := writeEnvFile(t, dir, "loose.env", "API_KEY="+testMaterial+"\n", 0o644)
strictFile := writeEnvFile(t, dir, "strict.env", "API_KEY="+testMaterial+"\n", 0o400)
groupFile := writeEnvFile(t, dir, "group.env", "API_KEY="+testMaterial+"\n", 0o640)
brokenFile := writeEnvFile(t, dir, "broken.env", "this line has no equals\n", 0o600)
tests := []struct {
name string
ref Ref
wantValue string
wantErr string // substring of the ResolveError message
}{
{
name: "0600 file resolves",
ref: Ref{Name: "mpk-test", Backend: "file", Source: secretFile, Key: "API_KEY"},
wantValue: testMaterial,
},
{
name: "0400 stricter than mask passes",
ref: Ref{Name: "mpk-test", Backend: "file", Source: strictFile, Key: "API_KEY"},
wantValue: testMaterial,
},
{
name: "0644 refused insecure mode",
ref: Ref{Name: "mpk-test", Backend: "file", Source: looseFile, Key: "API_KEY"},
wantErr: "insecure_source_mode",
},
{
name: "0640 refused under default 0600 mask",
ref: Ref{Name: "mpk-test", Backend: "file", Source: groupFile, Key: "API_KEY"},
wantErr: "insecure_source_mode",
},
{
name: "0640 passes when mask says 0640",
ref: Ref{Name: "mpk-test", Backend: "file", Source: groupFile, Key: "API_KEY", Mode: "0640"},
wantValue: testMaterial,
},
{
name: "missing file",
ref: Ref{Name: "mpk-test", Backend: "file", Source: filepath.Join(dir, "nope.env"), Key: "API_KEY"},
wantErr: "unreadable_source",
},
{
name: "directory as source",
ref: Ref{Name: "mpk-test", Backend: "file", Source: dir, Key: "API_KEY"},
wantErr: "unreadable_source",
},
{
name: "missing key",
ref: Ref{Name: "mpk-test", Backend: "file", Source: secretFile, Key: "NOT_THERE"},
wantErr: "missing_key",
},
{
name: "empty value",
ref: Ref{Name: "mpk-test", Backend: "file", Source: emptyFile, Key: "API_KEY"},
wantErr: "empty_value",
},
{
name: "malformed file",
ref: Ref{Name: "mpk-test", Backend: "file", Source: brokenFile, Key: "API_KEY"},
wantErr: "malformed_source",
},
{
name: "mode mask fails closed on junk",
ref: Ref{Name: "mpk-test", Backend: "file", Source: secretFile, Key: "API_KEY", Mode: "junk"},
wantErr: "insecure_source_mode",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
f := NewFile()
got, err := f.Resolve(context.Background(), tt.ref)
if tt.wantErr != "" {
if err == nil {
t.Fatalf("want error %q, got value", tt.wantErr)
}
msg := err.Error()
if !strings.Contains(msg, tt.wantErr) {
t.Fatalf("error %q does not contain %q", msg, tt.wantErr)
}
assertRedactedError(t, msg, tt.ref.Name, "file", testMaterial)
return
}
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if got != tt.wantValue {
t.Fatalf("got %q, want %q", got, tt.wantValue)
}
})
}
}
// TestFileHomeExpansion covers ~/ expansion (generic home indirection,
// not an org-specific path).
func TestFileHomeExpansion(t *testing.T) {
t.Setenv("HOME", t.TempDir())
dir, _ := os.LookupEnv("HOME")
path := writeEnvFile(t, dir, "creds.env", "API_KEY="+testMaterial+"\n", 0o600)
f := NewFile()
got, err := f.Resolve(context.Background(), Ref{Name: "mpk-home", Backend: "file", Source: "~/creds.env", Key: "API_KEY"})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if got != testMaterial {
t.Fatalf("got %q, want the material", got)
}
_ = path
}
// assertRedactedError enforces the house rule: resolution failures name
// the ref and the backend, NEVER material.
func assertRedactedError(t *testing.T, msg, refName, backendName, material string) {
t.Helper()
if !strings.Contains(msg, refName) || !strings.Contains(msg, backendName) {
t.Fatalf("error %q must name ref %q and backend %q", msg, refName, backendName)
}
if strings.Contains(msg, material) {
t.Fatalf("ERROR LEAKS MATERIAL: %q", msg)
}
}
+22
View File
@@ -0,0 +1,22 @@
package backend
import "context"
// Vault is the phase-3 HashiCorp Vault backend (KV v2 + AppRole via the
// official Go api package, MPL-2.0, vendored). v0 ships the interface
// slot ONLY: it resolves nothing and returns an explicit
// not-implemented error naming the ref and backend, so the connector is
// a drop-in behind the same interface later and misconfigured rollouts
// fail loudly today.
type Vault struct{}
// NewVault returns the vault stub.
func NewVault() *Vault { return &Vault{} }
// Name implements Backend.
func (v *Vault) Name() string { return "vault" }
// Resolve implements Backend.
func (v *Vault) Resolve(ctx context.Context, ref Ref) (string, error) {
return "", Err(ref, v.Name(), ReasonNotImplemented)
}