From db9d57b37a3e7b66c827deed343b0824cd0738cc Mon Sep 17 00:00:00 2001 From: Winston Howes Date: Mon, 20 Apr 2026 21:07:08 -0700 Subject: [PATCH] Use non-windows build-tagged wincred unsupported test --- app/secrets/plugins/keychain/plugin.go | 68 ++++++ app/secrets/plugins/keychain/plugin_test.go | 163 ++++++++++++++ app/secrets/plugins/plugins.go | 3 + app/secrets/plugins/secretservice/plugin.go | 63 ++++++ .../plugins/secretservice/plugin_test.go | 103 +++++++++ app/secrets/plugins/wincred/decode.go | 76 +++++++ app/secrets/plugins/wincred/load_other.go | 9 + .../plugins/wincred/load_other_test.go | 11 + app/secrets/plugins/wincred/load_windows.go | 62 ++++++ app/secrets/plugins/wincred/plugin.go | 50 +++++ app/secrets/plugins/wincred/plugin_test.go | 209 ++++++++++++++++++ docs/secret-backends.md | 6 + 12 files changed, 823 insertions(+) create mode 100644 app/secrets/plugins/keychain/plugin.go create mode 100644 app/secrets/plugins/keychain/plugin_test.go create mode 100644 app/secrets/plugins/secretservice/plugin.go create mode 100644 app/secrets/plugins/secretservice/plugin_test.go create mode 100644 app/secrets/plugins/wincred/decode.go create mode 100644 app/secrets/plugins/wincred/load_other.go create mode 100644 app/secrets/plugins/wincred/load_other_test.go create mode 100644 app/secrets/plugins/wincred/load_windows.go create mode 100644 app/secrets/plugins/wincred/plugin.go create mode 100644 app/secrets/plugins/wincred/plugin_test.go diff --git a/app/secrets/plugins/keychain/plugin.go b/app/secrets/plugins/keychain/plugin.go new file mode 100644 index 0000000..29ef216 --- /dev/null +++ b/app/secrets/plugins/keychain/plugin.go @@ -0,0 +1,68 @@ +package plugins + +import ( + "context" + "errors" + "fmt" + "os/exec" + "strings" + + "github.com/winhowes/AuthTranslator/app/secrets" +) + +// keychainPlugin loads secrets from the macOS Keychain via the security CLI. +// +// Expected id formats: +// - "service" +// - "service#account" +type keychainPlugin struct{} + +var execSecurityCommand = func(ctx context.Context, args ...string) ([]byte, error) { + cmd := exec.CommandContext(ctx, "security", args...) + return cmd.Output() +} + +func (keychainPlugin) Prefix() string { return "keychain" } + +func (keychainPlugin) Load(ctx context.Context, id string) (string, error) { + service, account, err := parseKeychainID(id) + if err != nil { + return "", err + } + + args := []string{"find-generic-password", "-w", "-s", service} + if account != "" { + args = append(args, "-a", account) + } + + out, err := execSecurityCommand(ctx, args...) + if err != nil { + var ee *exec.ExitError + if errors.As(err, &ee) { + stderr := strings.TrimSpace(string(ee.Stderr)) + if stderr != "" { + return "", fmt.Errorf("keychain lookup failed: %s", stderr) + } + } + return "", fmt.Errorf("keychain lookup failed: %w", err) + } + + return string(out), nil +} + +func parseKeychainID(id string) (service, account string, err error) { + parts := strings.SplitN(id, "#", 2) + service = strings.TrimSpace(parts[0]) + if service == "" { + return "", "", fmt.Errorf("keychain service is required") + } + if len(parts) == 2 { + account = strings.TrimSpace(parts[1]) + if account == "" { + return "", "", fmt.Errorf("keychain account is required when using service#account format") + } + } + return service, account, nil +} + +func init() { secrets.Register(keychainPlugin{}) } diff --git a/app/secrets/plugins/keychain/plugin_test.go b/app/secrets/plugins/keychain/plugin_test.go new file mode 100644 index 0000000..249ad37 --- /dev/null +++ b/app/secrets/plugins/keychain/plugin_test.go @@ -0,0 +1,163 @@ +package plugins + +import ( + "context" + "errors" + "os/exec" + "reflect" + "testing" +) + +func TestKeychainPluginLoad(t *testing.T) { + old := execSecurityCommand + t.Cleanup(func() { execSecurityCommand = old }) + + var gotArgs []string + execSecurityCommand = func(ctx context.Context, args ...string) ([]byte, error) { + gotArgs = append([]string{}, args...) + return []byte("super-secret\n"), nil + } + + p := keychainPlugin{} + got, err := p.Load(context.Background(), "slack#bot") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if got != "super-secret\n" { + t.Fatalf("expected exact secret bytes, got %q", got) + } + + wantArgs := []string{"find-generic-password", "-w", "-s", "slack", "-a", "bot"} + if !reflect.DeepEqual(gotArgs, wantArgs) { + t.Fatalf("args = %v, want %v", gotArgs, wantArgs) + } +} + +func TestKeychainPluginLoadPreservesWhitespace(t *testing.T) { + old := execSecurityCommand + t.Cleanup(func() { execSecurityCommand = old }) + + execSecurityCommand = func(ctx context.Context, args ...string) ([]byte, error) { + return []byte(" secret with spaces \n"), nil + } + + p := keychainPlugin{} + got, err := p.Load(context.Background(), "svc") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if got != " secret with spaces \n" { + t.Fatalf("expected exact secret bytes, got %q", got) + } +} + +func TestKeychainPluginLoadServiceOnly(t *testing.T) { + old := execSecurityCommand + t.Cleanup(func() { execSecurityCommand = old }) + + var gotArgs []string + execSecurityCommand = func(ctx context.Context, args ...string) ([]byte, error) { + gotArgs = append([]string{}, args...) + return []byte("token"), nil + } + + p := keychainPlugin{} + if _, err := p.Load(context.Background(), "slack"); err != nil { + t.Fatalf("unexpected error: %v", err) + } + + wantArgs := []string{"find-generic-password", "-w", "-s", "slack"} + if !reflect.DeepEqual(gotArgs, wantArgs) { + t.Fatalf("args = %v, want %v", gotArgs, wantArgs) + } +} + +func TestKeychainPluginLoadMissingService(t *testing.T) { + p := keychainPlugin{} + if _, err := p.Load(context.Background(), " "); err == nil { + t.Fatal("expected validation error") + } +} + +func TestKeychainPluginLoadExitError(t *testing.T) { + old := execSecurityCommand + t.Cleanup(func() { execSecurityCommand = old }) + + execSecurityCommand = func(ctx context.Context, args ...string) ([]byte, error) { + return nil, &exec.ExitError{Stderr: []byte("item not found")} + } + + p := keychainPlugin{} + if _, err := p.Load(context.Background(), "missing"); err == nil { + t.Fatal("expected lookup error") + } +} + +func TestKeychainPluginLoadExitErrorNoStderr(t *testing.T) { + old := execSecurityCommand + t.Cleanup(func() { execSecurityCommand = old }) + + execSecurityCommand = func(ctx context.Context, args ...string) ([]byte, error) { + return nil, &exec.ExitError{} + } + + p := keychainPlugin{} + if _, err := p.Load(context.Background(), "missing"); err == nil { + t.Fatal("expected lookup error") + } +} + +func TestKeychainPluginLoadCommandError(t *testing.T) { + old := execSecurityCommand + t.Cleanup(func() { execSecurityCommand = old }) + + execSecurityCommand = func(ctx context.Context, args ...string) ([]byte, error) { + return nil, errors.New("command missing") + } + + p := keychainPlugin{} + if _, err := p.Load(context.Background(), "service"); err == nil { + t.Fatal("expected lookup error") + } +} + +func TestExecSecurityCommandDefault(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + cancel() + + if _, err := execSecurityCommand(ctx, "find-generic-password", "-w", "-s", "unused"); err == nil { + t.Fatal("expected error from canceled context") + } +} + +func TestParseKeychainID(t *testing.T) { + service, account, err := parseKeychainID("svc#acc") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if service != "svc" || account != "acc" { + t.Fatalf("unexpected parse result: %q %q", service, account) + } + + service, account, err = parseKeychainID("svc-only") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if service != "svc-only" || account != "" { + t.Fatalf("unexpected parse result: %q %q", service, account) + } + + service, account, err = parseKeychainID(" svc # acc ") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if service != "svc" || account != "acc" { + t.Fatalf("unexpected trimmed parse result: %q %q", service, account) + } +} + +func TestParseKeychainIDMissingAccount(t *testing.T) { + if _, _, err := parseKeychainID("svc#"); err == nil { + t.Fatal("expected error for empty account") + } +} diff --git a/app/secrets/plugins/plugins.go b/app/secrets/plugins/plugins.go index a0a100e..269c5bf 100644 --- a/app/secrets/plugins/plugins.go +++ b/app/secrets/plugins/plugins.go @@ -8,5 +8,8 @@ import ( _ "github.com/winhowes/AuthTranslator/app/secrets/plugins/file" _ "github.com/winhowes/AuthTranslator/app/secrets/plugins/gcp" _ "github.com/winhowes/AuthTranslator/app/secrets/plugins/k8s" + _ "github.com/winhowes/AuthTranslator/app/secrets/plugins/keychain" + _ "github.com/winhowes/AuthTranslator/app/secrets/plugins/secretservice" _ "github.com/winhowes/AuthTranslator/app/secrets/plugins/vault" + _ "github.com/winhowes/AuthTranslator/app/secrets/plugins/wincred" ) diff --git a/app/secrets/plugins/secretservice/plugin.go b/app/secrets/plugins/secretservice/plugin.go new file mode 100644 index 0000000..8763f77 --- /dev/null +++ b/app/secrets/plugins/secretservice/plugin.go @@ -0,0 +1,63 @@ +package plugins + +import ( + "context" + "fmt" + "os/exec" + "strings" + + "github.com/winhowes/AuthTranslator/app/secrets" +) + +// secretServicePlugin reads secrets from Linux Secret Service using secret-tool. +// id must be comma-separated key/value pairs, e.g. "service=slack,user=bot". +type secretServicePlugin struct{} + +var execSecretTool = func(ctx context.Context, args ...string) ([]byte, error) { + cmd := exec.CommandContext(ctx, "secret-tool", args...) + return cmd.Output() +} + +func (secretServicePlugin) Prefix() string { return "secretservice" } + +func (secretServicePlugin) Load(ctx context.Context, id string) (string, error) { + attrs, err := parseSecretServiceAttrs(id) + if err != nil { + return "", err + } + + args := []string{"lookup"} + for _, attr := range attrs { + args = append(args, attr[0], attr[1]) + } + + out, err := execSecretTool(ctx, args...) + if err != nil { + return "", fmt.Errorf("secretservice lookup failed: %w", err) + } + return string(out), nil +} + +func parseSecretServiceAttrs(id string) ([][2]string, error) { + id = strings.TrimSpace(id) + if id == "" { + return nil, fmt.Errorf("secretservice attributes are required") + } + parts := strings.Split(id, ",") + attrs := make([][2]string, 0, len(parts)) + for _, part := range parts { + kv := strings.SplitN(strings.TrimSpace(part), "=", 2) + if len(kv) != 2 { + return nil, fmt.Errorf("invalid secretservice attribute %q", part) + } + k := strings.TrimSpace(kv[0]) + v := strings.TrimSpace(kv[1]) + if k == "" || v == "" { + return nil, fmt.Errorf("invalid secretservice attribute %q", part) + } + attrs = append(attrs, [2]string{k, v}) + } + return attrs, nil +} + +func init() { secrets.Register(secretServicePlugin{}) } diff --git a/app/secrets/plugins/secretservice/plugin_test.go b/app/secrets/plugins/secretservice/plugin_test.go new file mode 100644 index 0000000..f6514b0 --- /dev/null +++ b/app/secrets/plugins/secretservice/plugin_test.go @@ -0,0 +1,103 @@ +package plugins + +import ( + "context" + "errors" + "reflect" + "testing" +) + +func TestSecretServicePluginLoad(t *testing.T) { + old := execSecretTool + t.Cleanup(func() { execSecretTool = old }) + + var gotArgs []string + execSecretTool = func(ctx context.Context, args ...string) ([]byte, error) { + gotArgs = append([]string{}, args...) + return []byte("secret\n"), nil + } + + p := secretServicePlugin{} + got, err := p.Load(context.Background(), "service=slack,user=bot") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if got != "secret\n" { + t.Fatalf("expected exact secret bytes, got %q", got) + } + + wantArgs := []string{"lookup", "service", "slack", "user", "bot"} + if !reflect.DeepEqual(gotArgs, wantArgs) { + t.Fatalf("args = %v, want %v", gotArgs, wantArgs) + } +} + +func TestSecretServicePluginLoadPreservesWhitespace(t *testing.T) { + old := execSecretTool + t.Cleanup(func() { execSecretTool = old }) + + execSecretTool = func(ctx context.Context, args ...string) ([]byte, error) { + return []byte(" secret with spaces \n"), nil + } + + p := secretServicePlugin{} + got, err := p.Load(context.Background(), "service=slack") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if got != " secret with spaces \n" { + t.Fatalf("expected exact secret bytes, got %q", got) + } +} + +func TestSecretServicePluginLoadInvalidID(t *testing.T) { + p := secretServicePlugin{} + if _, err := p.Load(context.Background(), "bad"); err == nil { + t.Fatal("expected parse error") + } +} + +func TestSecretServicePluginLoadCommandError(t *testing.T) { + old := execSecretTool + t.Cleanup(func() { execSecretTool = old }) + + execSecretTool = func(ctx context.Context, args ...string) ([]byte, error) { + return nil, errors.New("secret-tool failed") + } + + p := secretServicePlugin{} + if _, err := p.Load(context.Background(), "service=slack"); err == nil { + t.Fatal("expected command error") + } +} + +func TestParseSecretServiceAttrs(t *testing.T) { + attrs, err := parseSecretServiceAttrs("service=slack,user=bot") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + want := [][2]string{{"service", "slack"}, {"user", "bot"}} + if !reflect.DeepEqual(attrs, want) { + t.Fatalf("attrs = %v, want %v", attrs, want) + } +} + +func TestExecSecretToolDefault(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + cancel() + + if _, err := execSecretTool(ctx, "lookup", "service", "unused"); err == nil { + t.Fatal("expected error from canceled context") + } +} + +func TestParseSecretServiceAttrsErrors(t *testing.T) { + cases := []string{"", "missingequals", "=value", "key="} + for _, tc := range cases { + t.Run(tc, func(t *testing.T) { + if _, err := parseSecretServiceAttrs(tc); err == nil { + t.Fatalf("expected error for %q", tc) + } + }) + } +} diff --git a/app/secrets/plugins/wincred/decode.go b/app/secrets/plugins/wincred/decode.go new file mode 100644 index 0000000..d010d1c --- /dev/null +++ b/app/secrets/plugins/wincred/decode.go @@ -0,0 +1,76 @@ +package plugins + +import ( + "encoding/binary" + "fmt" + "unicode/utf16" + "unicode/utf8" +) + +func decodeCredentialBlob(blob []byte, mode string) (string, error) { + switch mode { + case "raw": + return string(blob), nil + case "utf8": + if !utf8.Valid(blob) { + return "", fmt.Errorf("credential blob is not valid utf-8") + } + return string(blob), nil + case "utf16le": + s, ok := decodeUTF16LEBlob(blob) + if !ok { + return "", fmt.Errorf("credential blob is not valid utf-16le") + } + return s, nil + default: + return "", fmt.Errorf("unsupported decode mode %q", mode) + } +} + +func decodeUTF16LEBlob(blob []byte) (string, bool) { + if len(blob)%2 != 0 { + return "", false + } + + u16 := make([]uint16, len(blob)/2) + for i := 0; i < len(u16); i++ { + u16[i] = binary.LittleEndian.Uint16(blob[i*2:]) + } + + for len(u16) > 0 && u16[len(u16)-1] == 0 { + u16 = u16[:len(u16)-1] + } + if len(u16) > 0 && u16[0] == 0xFEFF { + u16 = u16[1:] + } + if len(u16) == 0 { + return "", true + } + + if !isValidUTF16(u16) { + return "", false + } + + return string(utf16.Decode(u16)), true +} + +func isValidUTF16(u16 []uint16) bool { + for i := 0; i < len(u16); i++ { + v := u16[i] + if v >= 0xD800 && v <= 0xDBFF { + if i+1 >= len(u16) { + return false + } + next := u16[i+1] + if next < 0xDC00 || next > 0xDFFF { + return false + } + i++ + continue + } + if v >= 0xDC00 && v <= 0xDFFF { + return false + } + } + return true +} diff --git a/app/secrets/plugins/wincred/load_other.go b/app/secrets/plugins/wincred/load_other.go new file mode 100644 index 0000000..6b8b832 --- /dev/null +++ b/app/secrets/plugins/wincred/load_other.go @@ -0,0 +1,9 @@ +//go:build !windows + +package plugins + +import "fmt" + +func loadWindowsCredential(targetName, mode string) (string, error) { + return "", fmt.Errorf("wincred plugin is only supported on windows") +} diff --git a/app/secrets/plugins/wincred/load_other_test.go b/app/secrets/plugins/wincred/load_other_test.go new file mode 100644 index 0000000..ddaa6f9 --- /dev/null +++ b/app/secrets/plugins/wincred/load_other_test.go @@ -0,0 +1,11 @@ +//go:build !windows + +package plugins + +import "testing" + +func TestLoadWindowsCredentialUnsupported(t *testing.T) { + if _, err := loadWindowsCredential("target", "raw"); err == nil { + t.Fatal("expected unsupported-platform error") + } +} diff --git a/app/secrets/plugins/wincred/load_windows.go b/app/secrets/plugins/wincred/load_windows.go new file mode 100644 index 0000000..2686523 --- /dev/null +++ b/app/secrets/plugins/wincred/load_windows.go @@ -0,0 +1,62 @@ +//go:build windows + +package plugins + +import ( + "fmt" + "syscall" + "unsafe" +) + +const credTypeGeneric = 1 + +type credential struct { + Flags uint32 + Type uint32 + TargetName *uint16 + Comment *uint16 + LastWritten syscall.Filetime + CredentialBlobSize uint32 + CredentialBlob *byte + Persist uint32 + AttributeCount uint32 + Attributes uintptr + TargetAlias *uint16 + UserName *uint16 +} + +var ( + advapi32 = syscall.NewLazyDLL("advapi32.dll") + procReadW = advapi32.NewProc("CredReadW") + procFree = advapi32.NewProc("CredFree") +) + +func loadWindowsCredential(targetName, mode string) (string, error) { + target, err := syscall.UTF16PtrFromString(targetName) + if err != nil { + return "", err + } + + var credPtr uintptr + r1, _, callErr := procReadW.Call( + uintptr(unsafe.Pointer(target)), + uintptr(credTypeGeneric), + 0, + uintptr(unsafe.Pointer(&credPtr)), + ) + if r1 == 0 { + if callErr != nil && callErr != syscall.Errno(0) { + return "", fmt.Errorf("credread failed: %w", callErr) + } + return "", fmt.Errorf("credread failed") + } + defer procFree.Call(credPtr) + + cred := (*credential)(unsafe.Pointer(credPtr)) + if cred.CredentialBlob == nil || cred.CredentialBlobSize == 0 { + return "", fmt.Errorf("credential has no secret data") + } + + blob := unsafe.Slice(cred.CredentialBlob, cred.CredentialBlobSize) + return decodeCredentialBlob(blob, mode) +} diff --git a/app/secrets/plugins/wincred/plugin.go b/app/secrets/plugins/wincred/plugin.go new file mode 100644 index 0000000..33b62de --- /dev/null +++ b/app/secrets/plugins/wincred/plugin.go @@ -0,0 +1,50 @@ +package plugins + +import ( + "context" + "fmt" + "strings" + + "github.com/winhowes/AuthTranslator/app/secrets" +) + +// winCredPlugin loads generic credentials from Windows Credential Manager. +// id format: +// - "target" (raw bytes) +// - "target#utf8" +// - "target#utf16le" +type winCredPlugin struct{} + +var winCredLoader = loadWindowsCredential + +func (winCredPlugin) Prefix() string { return "wincred" } + +func (winCredPlugin) Load(ctx context.Context, id string) (string, error) { + target, mode, err := parseWinCredID(id) + if err != nil { + return "", err + } + return winCredLoader(target, mode) +} + +func parseWinCredID(id string) (target, mode string, err error) { + parts := strings.SplitN(strings.TrimSpace(id), "#", 2) + target = strings.TrimSpace(parts[0]) + if target == "" { + return "", "", fmt.Errorf("wincred target is required") + } + + mode = "raw" + if len(parts) == 2 { + mode = strings.ToLower(strings.TrimSpace(parts[1])) + } + + switch mode { + case "raw", "utf8", "utf16le": + return target, mode, nil + default: + return "", "", fmt.Errorf("unsupported wincred decode mode %q", mode) + } +} + +func init() { secrets.Register(winCredPlugin{}) } diff --git a/app/secrets/plugins/wincred/plugin_test.go b/app/secrets/plugins/wincred/plugin_test.go new file mode 100644 index 0000000..b6f0d6c --- /dev/null +++ b/app/secrets/plugins/wincred/plugin_test.go @@ -0,0 +1,209 @@ +package plugins + +import ( + "context" + "encoding/binary" + "testing" + "unicode/utf16" +) + +func TestWinCredPluginLoad(t *testing.T) { + old := winCredLoader + t.Cleanup(func() { winCredLoader = old }) + + winCredLoader = func(targetName, mode string) (string, error) { + if targetName != "my-target" || mode != "raw" { + t.Fatalf("unexpected loader args: %q %q", targetName, mode) + } + return "loaded-secret", nil + } + + p := winCredPlugin{} + got, err := p.Load(context.Background(), "my-target") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if got != "loaded-secret" { + t.Fatalf("expected loaded secret, got %q", got) + } +} + +func TestWinCredPluginLoadInvalidID(t *testing.T) { + p := winCredPlugin{} + if _, err := p.Load(context.Background(), "#utf8"); err == nil { + t.Fatal("expected parse error") + } +} + +func TestParseWinCredID(t *testing.T) { + tests := []struct { + name string + input string + wantTgt string + wantMode string + wantError bool + }{ + {name: "default raw", input: "target", wantTgt: "target", wantMode: "raw"}, + {name: "utf8 mode", input: "target#utf8", wantTgt: "target", wantMode: "utf8"}, + {name: "utf16 mode", input: "target#utf16le", wantTgt: "target", wantMode: "utf16le"}, + {name: "invalid mode", input: "target#auto", wantError: true}, + {name: "missing target", input: " #utf8", wantError: true}, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + target, mode, err := parseWinCredID(tc.input) + if tc.wantError { + if err == nil { + t.Fatal("expected error") + } + return + } + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if target != tc.wantTgt || mode != tc.wantMode { + t.Fatalf("parseWinCredID(%q) = (%q,%q), want (%q,%q)", tc.input, target, mode, tc.wantTgt, tc.wantMode) + } + }) + } +} + +func TestDecodeCredentialBlobRaw(t *testing.T) { + blob := []byte{0x00, 0xAB, 0xCD, 0xEF} + got, err := decodeCredentialBlob(blob, "raw") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if got != string(blob) { + t.Fatalf("decodeCredentialBlob(raw) = %q, want raw bytes", got) + } +} + +func TestDecodeCredentialBlobUTF8(t *testing.T) { + blob := []byte("päss-東京") + got, err := decodeCredentialBlob(blob, "utf8") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if got != "päss-東京" { + t.Fatalf("decodeCredentialBlob(utf8) = %q", got) + } +} + +func TestDecodeCredentialBlobUTF8Invalid(t *testing.T) { + blob := []byte{0xff, 0xfe} + if _, err := decodeCredentialBlob(blob, "utf8"); err == nil { + t.Fatal("expected utf8 decode error") + } +} + +func TestDecodeCredentialBlobUTF16ASCII(t *testing.T) { + blob := encodeUTF16LE("secret") + got, err := decodeCredentialBlob(blob, "utf16le") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if got != "secret" { + t.Fatalf("decodeCredentialBlob(utf16le) = %q, want %q", got, "secret") + } +} + +func TestDecodeCredentialBlobUTF16WithBOM(t *testing.T) { + blob := append([]byte{0xFF, 0xFE}, encodeUTF16LE("secret")...) + got, err := decodeCredentialBlob(blob, "utf16le") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if got != "secret" { + t.Fatalf("decodeCredentialBlob(utf16le) = %q, want %q", got, "secret") + } +} + +func TestDecodeCredentialBlobUTF16UnicodeNoTerminator(t *testing.T) { + blob := encodeUTF16LENoTerminator("東京🔐") + got, err := decodeCredentialBlob(blob, "utf16le") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if got != "東京🔐" { + t.Fatalf("decodeCredentialBlob(utf16le) = %q, want %q", got, "東京🔐") + } +} + +func TestDecodeCredentialBlobUTF16Invalid(t *testing.T) { + blob := []byte{0x00, 0xD8, 0x41, 0x00} + if _, err := decodeCredentialBlob(blob, "utf16le"); err == nil { + t.Fatal("expected utf16 decode error") + } +} + +func TestDecodeCredentialBlobUnsupportedMode(t *testing.T) { + if _, err := decodeCredentialBlob([]byte("x"), "bogus"); err == nil { + t.Fatal("expected unsupported mode error") + } +} + +func TestDecodeUTF16LEBlobInvalid(t *testing.T) { + blob := []byte{0x00, 0xDC} // lone low surrogate + if _, ok := decodeUTF16LEBlob(blob); ok { + t.Fatal("expected invalid UTF-16 blob") + } +} + +func TestDecodeUTF16LEBlobEmptyAfterTrim(t *testing.T) { + blob := []byte{0x00, 0x00} + got, ok := decodeUTF16LEBlob(blob) + if !ok || got != "" { + t.Fatalf("decodeUTF16LEBlob() = (%q,%v), want (\"\",true)", got, ok) + } +} + +func TestDecodeUTF16LEBlobValidSurrogatePair(t *testing.T) { + blob := encodeUTF16LE("🔐") + got, ok := decodeUTF16LEBlob(blob) + if !ok || got != "🔐" { + t.Fatalf("decodeUTF16LEBlob() = (%q,%v), want (\"🔐\",true)", got, ok) + } +} + +func TestDecodeUTF16LEBlobInvalidHighSurrogatePairing(t *testing.T) { + blob := []byte{0x00, 0xD8, 0x41, 0x00} + if _, ok := decodeUTF16LEBlob(blob); ok { + t.Fatal("expected invalid surrogate pairing") + } +} + +func TestDecodeUTF16LEBlobLoneHighSurrogate(t *testing.T) { + blob := []byte{0x00, 0xD8} + if _, ok := decodeUTF16LEBlob(blob); ok { + t.Fatal("expected lone high surrogate to be invalid") + } +} + +func TestDecodeUTF16LEBlobOddLength(t *testing.T) { + blob := []byte{0x41, 0x00, 0x42} + if _, ok := decodeUTF16LEBlob(blob); ok { + t.Fatal("expected odd-length blob to be invalid UTF-16") + } +} + +func encodeUTF16LENoTerminator(s string) []byte { + u16 := utf16.Encode([]rune(s)) + blob := make([]byte, len(u16)*2) + for i, v := range u16 { + binary.LittleEndian.PutUint16(blob[i*2:], v) + } + return blob +} + +func encodeUTF16LE(s string) []byte { + u16 := utf16.Encode([]rune(s)) + blob := make([]byte, (len(u16)+1)*2) + for i, v := range u16 { + binary.LittleEndian.PutUint16(blob[i*2:], v) + } + // include a trailing UTF-16 NUL as Windows APIs commonly do. + binary.LittleEndian.PutUint16(blob[len(u16)*2:], 0) + return blob +} diff --git a/docs/secret-backends.md b/docs/secret-backends.md index e2dbf3b..2803057 100644 --- a/docs/secret-backends.md +++ b/docs/secret-backends.md @@ -27,6 +27,9 @@ outgoing_auth: | `aws` | `aws:Ci0KU29tZUNpcGhlcnRleHQ=` | AES‑GCM encrypted values decrypted using `AWS_KMS_KEY`. | | `azure` | `azure:https://kv-name.vault.azure.net/secrets/secret-name` | AKS or VM SS with **Managed Identity**. | | `vault` | `vault:secret/data/slack` | Self‑hosted **HashiCorp Vault** cluster. | +| `keychain` | `keychain:github-cli#octocat` | macOS hosts with secrets in Keychain (`service#account`). | +| `secretservice` | `secretservice:service=slack,user=bot` | Linux desktops/servers with D-Bus Secret Service (`secret-tool`). | +| `wincred` | `wincred:github-cli#utf16le` | Windows hosts using Credential Manager generic credentials. Use `#raw` (default), `#utf8`, or `#utf16le`. | | `dangerousLiteral` | `dangerousLiteral:__PLACEHOLDER__` | Rare cases where you need a literal sentinel string. | ### Literal placeholders (`dangerousLiteral:`) @@ -63,6 +66,9 @@ Some schemes rely on environment variables for authentication or decryption: | `azure` | `AZURE_TENANT_ID`, `AZURE_CLIENT_ID`, `AZURE_CLIENT_SECRET` | Credentials for fetching `azure:` secrets from Key Vault. | `azure:https://kv-name.vault.azure.net/secrets/token` | | `gcp` | _none_ | Uses the GCP metadata service when resolving `gcp:` secrets. | `gcp:projects/p/locations/l/keyRings/r/cryptoKeys/k:cipher` | | `vault` | `VAULT_ADDR`, `VAULT_TOKEN` | Fetches secrets from HashiCorp Vault via its HTTP API. | `vault:secret/data/api` reads from Vault | +| `keychain` | _none_ | Uses the macOS `security` CLI and current keychain access permissions. | `keychain:service#account` | +| `secretservice` | _none_ | Uses Linux `secret-tool` to query attributes like `service=...`. | `secretservice:service=slack,user=bot` | +| `wincred` | _none_ | Reads generic credentials by target name from Windows Credential Manager. | `wincred:github-cli#raw` | | `dangerousLiteral` | _none_ | Value is stored directly in config; no external dependencies. | `dangerousLiteral:__PLACEHOLDER__` | For `file:` URIs that use the `:KEY` suffix, AuthTranslator treats the file as a simple `KEY=value` list: