Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
68 changes: 68 additions & 0 deletions app/secrets/plugins/keychain/plugin.go
Original file line number Diff line number Diff line change
@@ -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)
Comment thread
winhowes marked this conversation as resolved.
}

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{}) }
163 changes: 163 additions & 0 deletions app/secrets/plugins/keychain/plugin_test.go
Original file line number Diff line number Diff line change
@@ -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")
}
}
3 changes: 3 additions & 0 deletions app/secrets/plugins/plugins.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
)
63 changes: 63 additions & 0 deletions app/secrets/plugins/secretservice/plugin.go
Original file line number Diff line number Diff line change
@@ -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{}) }
Loading
Loading