From be061a4cfd624f38257369df273e821e53cc564a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Johan=20Kj=C3=B6lhede?= Date: Sat, 24 Jan 2026 13:28:03 +0100 Subject: [PATCH 1/3] Add system keychain support for secure API key storage Adds `sag keychain set/get/delete` commands for storing the ElevenLabs API key in the system keychain. Supports macOS Keychain, Windows Credential Manager, and Linux Secret Service (GNOME Keyring). The keychain is checked after --api-key-file but before environment variables, providing a more secure alternative to storing keys in plain text files or environment variables. Closes #6 Co-Authored-By: Claude Opus 4.5 --- CHANGELOG.md | 2 + README.md | 26 ++++++++- cmd/api_key.go | 5 +- cmd/keychain.go | 105 ++++++++++++++++++++++++++++++++++ cmd/keychain_test.go | 130 +++++++++++++++++++++++++++++++++++++++++++ go.mod | 4 ++ go.sum | 20 +++++++ 7 files changed, 289 insertions(+), 3 deletions(-) create mode 100644 cmd/keychain.go create mode 100644 cmd/keychain_test.go diff --git a/CHANGELOG.md b/CHANGELOG.md index 6364ef9..d5f1802 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,8 @@ # Changelog ## Unreleased +### Added +- System keychain support for secure API key storage via `sag keychain set/get/delete`. Supports macOS Keychain, Windows Credential Manager, and Linux Secret Service. (#6, thanks @GiGurra) ## 0.2.2 - 2026-01-24 ### Fixed diff --git a/README.md b/README.md index 3184546..8f2baf9 100644 --- a/README.md +++ b/README.md @@ -15,9 +15,31 @@ go install ./cmd/sag Requires Go 1.24+. ## Configuration -- `ELEVENLABS_API_KEY` (required) + +API key (in order of priority): +1. `--api-key` flag +2. `--api-key-file` flag or `ELEVENLABS_API_KEY_FILE`/`SAG_API_KEY_FILE` env vars +3. System keychain (see below) +4. `ELEVENLABS_API_KEY` or `SAG_API_KEY` env vars + +### System Keychain (recommended) +Store your API key securely in the system keychain: +```bash +sag keychain set # prompts for API key +sag keychain get # retrieve stored key +sag keychain delete # remove from keychain +``` + +Supported backends: +- **macOS**: Keychain +- **Windows**: Credential Manager +- **Linux**: Secret Service (GNOME Keyring) + +### Other options - `--api-key-file` or `ELEVENLABS_API_KEY_FILE`/`SAG_API_KEY_FILE` to load the key from a file -- Optional defaults: `ELEVENLABS_VOICE_ID` or `SAG_VOICE_ID` +- `ELEVENLABS_API_KEY` or `SAG_API_KEY` environment variables + +Optional defaults: `ELEVENLABS_VOICE_ID` or `SAG_VOICE_ID` ## Usage diff --git a/cmd/api_key.go b/cmd/api_key.go index 8b22232..1c15560 100644 --- a/cmd/api_key.go +++ b/cmd/api_key.go @@ -14,6 +14,9 @@ func ensureAPIKey() error { } cfg.APIKey = key } + if cfg.APIKey == "" { + cfg.APIKey = getAPIKeyFromKeychain() + } if cfg.APIKey == "" { cfg.APIKey = os.Getenv("ELEVENLABS_API_KEY") } @@ -21,7 +24,7 @@ func ensureAPIKey() error { cfg.APIKey = os.Getenv("SAG_API_KEY") } if cfg.APIKey == "" { - return fmt.Errorf("missing ElevenLabs API key (set --api-key, --api-key-file, or ELEVENLABS_API_KEY)") + return fmt.Errorf("missing ElevenLabs API key (set --api-key, --api-key-file, keychain, or ELEVENLABS_API_KEY)") } return nil } diff --git a/cmd/keychain.go b/cmd/keychain.go new file mode 100644 index 0000000..eae2c87 --- /dev/null +++ b/cmd/keychain.go @@ -0,0 +1,105 @@ +package cmd + +import ( + "bufio" + "fmt" + "os" + "strings" + + "github.com/spf13/cobra" + "github.com/zalando/go-keyring" +) + +const ( + keychainService = "sag" + keychainUser = "elevenlabs-api-key" +) + +var keychainCmd = &cobra.Command{ + Use: "keychain", + Short: "Manage API key in system keychain", + Long: "Store, retrieve, or delete the ElevenLabs API key from the system keychain.\n\nSupported backends:\n - macOS: Keychain\n - Windows: Credential Manager\n - Linux: Secret Service (GNOME Keyring)", +} + +var keychainSetCmd = &cobra.Command{ + Use: "set", + Short: "Store API key in system keychain", + Long: "Store the ElevenLabs API key securely in the system keychain.\nYou will be prompted to enter the key (input is hidden).", + RunE: func(_ *cobra.Command, args []string) error { + var apiKey string + if len(args) > 0 { + apiKey = args[0] + } else { + fmt.Print("Enter ElevenLabs API key: ") + reader := bufio.NewReader(os.Stdin) + input, err := reader.ReadString('\n') + if err != nil { + return fmt.Errorf("failed to read input: %w", err) + } + apiKey = strings.TrimSpace(input) + } + + if apiKey == "" { + return fmt.Errorf("API key cannot be empty") + } + + if err := keyring.Set(keychainService, keychainUser, apiKey); err != nil { + return fmt.Errorf("failed to store API key in keychain: %w", err) + } + + fmt.Println("API key stored in system keychain") + return nil + }, +} + +var keychainGetCmd = &cobra.Command{ + Use: "get", + Short: "Retrieve API key from system keychain", + Long: "Retrieve and display the ElevenLabs API key from the system keychain.", + RunE: func(_ *cobra.Command, _ []string) error { + secret, err := keyring.Get(keychainService, keychainUser) + if err != nil { + if err == keyring.ErrNotFound { + return fmt.Errorf("no API key found in keychain (use 'sag keychain set' to store one)") + } + return fmt.Errorf("failed to retrieve API key from keychain: %w", err) + } + + fmt.Println(secret) + return nil + }, +} + +var keychainDeleteCmd = &cobra.Command{ + Use: "delete", + Short: "Delete API key from system keychain", + Long: "Remove the ElevenLabs API key from the system keychain.", + RunE: func(_ *cobra.Command, _ []string) error { + if err := keyring.Delete(keychainService, keychainUser); err != nil { + if err == keyring.ErrNotFound { + return fmt.Errorf("no API key found in keychain") + } + return fmt.Errorf("failed to delete API key from keychain: %w", err) + } + + fmt.Println("API key deleted from system keychain") + return nil + }, +} + +func init() { + keychainCmd.AddCommand(keychainSetCmd) + keychainCmd.AddCommand(keychainGetCmd) + keychainCmd.AddCommand(keychainDeleteCmd) + rootCmd.AddCommand(keychainCmd) +} + +// getAPIKeyFromKeychain attempts to retrieve the API key from the system keychain. +// Returns empty string if not found or on error. +func getAPIKeyFromKeychain() string { + secret, err := keyring.Get(keychainService, keychainUser) + if err != nil { + return "" + } + return secret +} diff --git a/cmd/keychain_test.go b/cmd/keychain_test.go new file mode 100644 index 0000000..2ba5a20 --- /dev/null +++ b/cmd/keychain_test.go @@ -0,0 +1,130 @@ +package cmd + +import ( + "os" + "testing" + + "github.com/zalando/go-keyring" +) + +func TestGetAPIKeyFromKeychain(t *testing.T) { + keyring.MockInit() + + // Test empty keychain returns empty string + key := getAPIKeyFromKeychain() + if key != "" { + t.Fatalf("expected empty string for empty keychain, got %q", key) + } + + // Store a key and verify it can be retrieved + testKey := "test-api-key-12345" + if err := keyring.Set(keychainService, keychainUser, testKey); err != nil { + t.Fatalf("failed to set keychain key: %v", err) + } + + key = getAPIKeyFromKeychain() + if key != testKey { + t.Fatalf("expected %q, got %q", testKey, key) + } + + // Clean up + _ = keyring.Delete(keychainService, keychainUser) +} + +func TestEnsureAPIKeyFromKeychain(t *testing.T) { + keyring.MockInit() + defer keepEnv(t)() + + cfg.APIKey = "" + cfg.APIKeyFile = "" + _ = os.Unsetenv("ELEVENLABS_API_KEY") + _ = os.Unsetenv("SAG_API_KEY") + _ = os.Unsetenv("ELEVENLABS_API_KEY_FILE") + _ = os.Unsetenv("SAG_API_KEY_FILE") + + // Store key in mock keychain + testKey := "keychain-api-key" + if err := keyring.Set(keychainService, keychainUser, testKey); err != nil { + t.Fatalf("failed to set keychain key: %v", err) + } + + if err := ensureAPIKey(); err != nil { + t.Fatalf("ensureAPIKey error: %v", err) + } + if cfg.APIKey != testKey { + t.Fatalf("expected keychain key to be used, got %q", cfg.APIKey) + } + + // Clean up + _ = keyring.Delete(keychainService, keychainUser) +} + +func TestKeychainPriorityAfterFile(t *testing.T) { + keyring.MockInit() + defer keepEnv(t)() + + cfg.APIKey = "" + cfg.APIKeyFile = "" + _ = os.Unsetenv("ELEVENLABS_API_KEY") + _ = os.Unsetenv("SAG_API_KEY") + _ = os.Unsetenv("ELEVENLABS_API_KEY_FILE") + _ = os.Unsetenv("SAG_API_KEY_FILE") + + // Create temp file with API key + tmp, err := os.CreateTemp("", "sag_api_key") + if err != nil { + t.Fatalf("temp file: %v", err) + } + defer func() { _ = os.Remove(tmp.Name()) }() + if _, err := tmp.WriteString("file-key\n"); err != nil { + t.Fatalf("write temp: %v", err) + } + if err := tmp.Close(); err != nil { + t.Fatalf("close temp: %v", err) + } + + // Store key in mock keychain + if err := keyring.Set(keychainService, keychainUser, "keychain-key"); err != nil { + t.Fatalf("failed to set keychain key: %v", err) + } + + // File should take priority over keychain + cfg.APIKeyFile = tmp.Name() + if err := ensureAPIKey(); err != nil { + t.Fatalf("ensureAPIKey error: %v", err) + } + if cfg.APIKey != "file-key" { + t.Fatalf("expected file key to take priority, got %q", cfg.APIKey) + } + + // Clean up + _ = keyring.Delete(keychainService, keychainUser) +} + +func TestKeychainPriorityOverEnv(t *testing.T) { + keyring.MockInit() + defer keepEnv(t)() + + cfg.APIKey = "" + cfg.APIKeyFile = "" + _ = os.Setenv("ELEVENLABS_API_KEY", "env-key") + _ = os.Unsetenv("SAG_API_KEY") + _ = os.Unsetenv("ELEVENLABS_API_KEY_FILE") + _ = os.Unsetenv("SAG_API_KEY_FILE") + + // Store key in mock keychain + if err := keyring.Set(keychainService, keychainUser, "keychain-key"); err != nil { + t.Fatalf("failed to set keychain key: %v", err) + } + + // Keychain should take priority over env + if err := ensureAPIKey(); err != nil { + t.Fatalf("ensureAPIKey error: %v", err) + } + if cfg.APIKey != "keychain-key" { + t.Fatalf("expected keychain key to take priority over env, got %q", cfg.APIKey) + } + + // Clean up + _ = keyring.Delete(keychainService, keychainUser) +} diff --git a/go.mod b/go.mod index adfa966..f8cc0ab 100644 --- a/go.mod +++ b/go.mod @@ -6,10 +6,14 @@ require ( github.com/ebitengine/oto/v3 v3.4.0 github.com/hajimehoshi/go-mp3 v0.3.4 github.com/spf13/cobra v1.10.2 + github.com/zalando/go-keyring v0.2.6 ) require ( + al.essio.dev/pkg/shellescape v1.5.1 // indirect + github.com/danieljoos/wincred v1.2.2 // indirect github.com/ebitengine/purego v0.9.1 // indirect + github.com/godbus/dbus/v5 v5.1.0 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/spf13/pflag v1.0.10 // indirect golang.org/x/sys v0.40.0 // indirect diff --git a/go.sum b/go.sum index 280fd5c..a183544 100644 --- a/go.sum +++ b/go.sum @@ -1,21 +1,41 @@ +al.essio.dev/pkg/shellescape v1.5.1 h1:86HrALUujYS/h+GtqoB26SBEdkWfmMI6FubjXlsXyho= +al.essio.dev/pkg/shellescape v1.5.1/go.mod h1:6sIqp7X2P6mThCQ7twERpZTuigpr6KbZWtls1U8I890= github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= +github.com/danieljoos/wincred v1.2.2 h1:774zMFJrqaeYCK2W57BgAem/MLi6mtSE47MB6BOJ0i0= +github.com/danieljoos/wincred v1.2.2/go.mod h1:w7w4Utbrz8lqeMbDAK0lkNJUv5sAOkFi7nd/ogr0Uh8= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/ebitengine/oto/v3 v3.4.0 h1:br0PgASsEWaoWn38b2Goe7m1GKFYfNgnsjSd5Gg+/bQ= github.com/ebitengine/oto/v3 v3.4.0/go.mod h1:IOleLVD0m+CMak3mRVwsYY8vTctQgOM0iiL6S7Ar7eI= github.com/ebitengine/purego v0.9.1 h1:a/k2f2HQU3Pi399RPW1MOaZyhKJL9w/xFpKAg4q1s0A= github.com/ebitengine/purego v0.9.1/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ= +github.com/godbus/dbus/v5 v5.1.0 h1:4KLkAxT3aOY8Li4FRJe/KvhoNFFxo0m6fNuFUO8QJUk= +github.com/godbus/dbus/v5 v5.1.0/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= +github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510 h1:El6M4kTTCOh6aBiKaUGG7oYTSPP8MxqL4YI3kZKwcP4= +github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510/go.mod h1:pupxD2MaaD3pAXIBCelhxNneeOaAeabZDe5s4K6zSpQ= github.com/hajimehoshi/go-mp3 v0.3.4 h1:NUP7pBYH8OguP4diaTZ9wJbUbk3tC0KlfzsEpWmYj68= github.com/hajimehoshi/go-mp3 v0.3.4/go.mod h1:fRtZraRFcWb0pu7ok0LqyFhCUrPeMsGRSVop0eemFmo= github.com/hajimehoshi/oto/v2 v2.3.1/go.mod h1:seWLbgHH7AyUMYKfKYT9pg7PhUu9/SisyJvNTT+ASQo= github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU= github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4= github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk= github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY= +github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= +github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= +github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +github.com/zalando/go-keyring v0.2.6 h1:r7Yc3+H+Ux0+M72zacZoItR3UDxeWfKTcabvkI8ua9s= +github.com/zalando/go-keyring v0.2.6/go.mod h1:2TCrxYrbUNYfNS/Kgy/LSrkSQzZ5UPVH85RwfczwvcI= go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= golang.org/x/sys v0.0.0-20220712014510-0a85c31ab51e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.40.0 h1:DBZZqJ2Rkml6QMQsZywtnjnnGvHza6BTfYFWY9kjEWQ= golang.org/x/sys v0.40.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= From f31f001ad3df73bad6d7f9d8a34b4cb28e006a88 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Johan=20Kj=C3=B6lhede?= Date: Sat, 24 Jan 2026 13:33:51 +0100 Subject: [PATCH 2/3] Remove author mention from changelog entry Co-Authored-By: Claude Opus 4.5 --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d5f1802..0408466 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,7 +2,7 @@ ## Unreleased ### Added -- System keychain support for secure API key storage via `sag keychain set/get/delete`. Supports macOS Keychain, Windows Credential Manager, and Linux Secret Service. (#6, thanks @GiGurra) +- System keychain support for secure API key storage via `sag keychain set/get/delete`. Supports macOS Keychain, Windows Credential Manager, and Linux Secret Service. (#6) ## 0.2.2 - 2026-01-24 ### Fixed From 4ae3f26f3331693de78df414da69804da1e83107 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Johan=20Kj=C3=B6lhede?= Date: Sat, 24 Jan 2026 15:27:09 +0100 Subject: [PATCH 3/3] Replace keychain get with status command For security, don't expose a command that prints the API key - a compromised agent could extract it. Instead, provide a status command that only reports whether a key is stored. Co-Authored-By: Claude Opus 4.5 --- README.md | 2 +- cmd/keychain.go | 19 ++++++++++--------- 2 files changed, 11 insertions(+), 10 deletions(-) diff --git a/README.md b/README.md index 8f2baf9..5653d0b 100644 --- a/README.md +++ b/README.md @@ -26,7 +26,7 @@ API key (in order of priority): Store your API key securely in the system keychain: ```bash sag keychain set # prompts for API key -sag keychain get # retrieve stored key +sag keychain status # check if key is stored sag keychain delete # remove from keychain ``` diff --git a/cmd/keychain.go b/cmd/keychain.go index eae2c87..6841150 100644 --- a/cmd/keychain.go +++ b/cmd/keychain.go @@ -52,20 +52,21 @@ var keychainSetCmd = &cobra.Command{ }, } -var keychainGetCmd = &cobra.Command{ - Use: "get", - Short: "Retrieve API key from system keychain", - Long: "Retrieve and display the ElevenLabs API key from the system keychain.", +var keychainStatusCmd = &cobra.Command{ + Use: "status", + Short: "Check if API key is stored in system keychain", + Long: "Check whether an ElevenLabs API key is stored in the system keychain.", RunE: func(_ *cobra.Command, _ []string) error { - secret, err := keyring.Get(keychainService, keychainUser) + _, err := keyring.Get(keychainService, keychainUser) if err != nil { if err == keyring.ErrNotFound { - return fmt.Errorf("no API key found in keychain (use 'sag keychain set' to store one)") + fmt.Println("No API key stored in keychain") + return nil } - return fmt.Errorf("failed to retrieve API key from keychain: %w", err) + return fmt.Errorf("failed to check keychain: %w", err) } - fmt.Println(secret) + fmt.Println("API key is stored in keychain") return nil }, } @@ -89,7 +90,7 @@ var keychainDeleteCmd = &cobra.Command{ func init() { keychainCmd.AddCommand(keychainSetCmd) - keychainCmd.AddCommand(keychainGetCmd) + keychainCmd.AddCommand(keychainStatusCmd) keychainCmd.AddCommand(keychainDeleteCmd) rootCmd.AddCommand(keychainCmd) }