diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..2d6c3ed --- /dev/null +++ b/go.mod @@ -0,0 +1,3 @@ +module git.knownelement.com/ukrrs/mopac-keyproxy + +go 1.26 diff --git a/internal/backend/backend.go b/internal/backend/backend.go new file mode 100644 index 0000000..150db37 --- /dev/null +++ b/internal/backend/backend.go @@ -0,0 +1,107 @@ +// Package backend defines the resolver interface every credential source +// implements. Backends receive opaque refs (mpk-) 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- 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 +} diff --git a/internal/backend/bitwarden.go b/internal/backend/bitwarden.go new file mode 100644 index 0000000..de37f69 --- /dev/null +++ b/internal/backend/bitwarden.go @@ -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) +} diff --git a/internal/backend/env.go b/internal/backend/env.go new file mode 100644 index 0000000..efe3a91 --- /dev/null +++ b/internal/backend/env.go @@ -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 +} diff --git a/internal/backend/env_test.go b/internal/backend/env_test.go new file mode 100644 index 0000000..3a8cd0f --- /dev/null +++ b/internal/backend/env_test.go @@ -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") + } +} diff --git a/internal/backend/envfile.go b/internal/backend/envfile.go new file mode 100644 index 0000000..18979d2 --- /dev/null +++ b/internal/backend/envfile.go @@ -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 +} diff --git a/internal/backend/envfile_test.go b/internal/backend/envfile_test.go new file mode 100644 index 0000000..d460f26 --- /dev/null +++ b/internal/backend/envfile_test.go @@ -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) + } + } + }) + } +} diff --git a/internal/backend/file.go b/internal/backend/file.go new file mode 100644 index 0000000..9b5c6d8 --- /dev/null +++ b/internal/backend/file.go @@ -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 +} diff --git a/internal/backend/file_test.go b/internal/backend/file_test.go new file mode 100644 index 0000000..b48045b --- /dev/null +++ b/internal/backend/file_test.go @@ -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) + } +} diff --git a/internal/backend/vault.go b/internal/backend/vault.go new file mode 100644 index 0000000..b08f3dd --- /dev/null +++ b/internal/backend/vault.go @@ -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) +}