Skip to content

Commit 7245864

Browse files
fix(secrets): handle file-keyring passphrase on headless tty
1 parent e4bf53b commit 7245864

2 files changed

Lines changed: 196 additions & 6 deletions

File tree

internal/secrets/store.go

Lines changed: 75 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -47,8 +47,10 @@ func keyringItem(key string, data []byte) keyring.Item {
4747
}
4848

4949
const (
50-
keyringPasswordEnv = "GOG_KEYRING_PASSWORD" //nolint:gosec // env var name, not a credential
51-
keyringBackendEnv = "GOG_KEYRING_BACKEND" //nolint:gosec // env var name, not a credential
50+
keyringPasswordEnv = "GOG_KEYRING_PASSWORD" //nolint:gosec // env var name, not a credential
51+
keyringPasswordCompatEnv = "GOGCLI_KEYRING_PASSPHRASE" //nolint:gosec // env var name, not a credential
52+
keyringPasswordLegacyEnv = "KEYRING_FILE_PASSPHRASE" //nolint:gosec // env var name, not a credential
53+
keyringBackendEnv = "GOG_KEYRING_BACKEND" //nolint:gosec // env var name, not a credential
5254
)
5355

5456
var (
@@ -60,6 +62,8 @@ var (
6062
errKeyringTimeout = errors.New("keyring connection timed out")
6163
openKeyringFunc = openKeyring
6264
keyringOpenFunc = keyring.Open
65+
openTTYFileFunc = func() (*os.File, error) { return os.OpenFile("/dev/tty", os.O_RDWR, 0) }
66+
terminalPromptFunc = promptOnTerminal
6367
)
6468

6569
type KeyringBackendInfo struct {
@@ -126,7 +130,7 @@ func fileKeyringPasswordFuncFrom(password string, passwordSet bool, isTTY bool)
126130
}
127131

128132
if isTTY {
129-
return keyring.TerminalPrompt
133+
return terminalPromptFunc
130134
}
131135

132136
return func(_ string) (string, error) {
@@ -135,8 +139,74 @@ func fileKeyringPasswordFuncFrom(password string, passwordSet bool, isTTY bool)
135139
}
136140

137141
func fileKeyringPasswordFunc() keyring.PromptFunc {
138-
password, passwordSet := os.LookupEnv(keyringPasswordEnv)
139-
return fileKeyringPasswordFuncFrom(password, passwordSet, term.IsTerminal(int(os.Stdin.Fd())))
142+
password, passwordSet := keyringPasswordFromEnv()
143+
return fileKeyringPasswordFuncFrom(password, passwordSet, hasTerminalPromptInput())
144+
}
145+
146+
func keyringPasswordFromEnvFrom(lookup func(string) (string, bool)) (string, bool) {
147+
for _, envKey := range []string{keyringPasswordEnv, keyringPasswordCompatEnv, keyringPasswordLegacyEnv} {
148+
if value, ok := lookup(envKey); ok {
149+
return value, true
150+
}
151+
}
152+
153+
return "", false
154+
}
155+
156+
func keyringPasswordFromEnv() (string, bool) {
157+
return keyringPasswordFromEnvFrom(os.LookupEnv)
158+
}
159+
160+
func hasTerminalPromptInputFrom(stdinIsTTY bool, openTTY func() error) bool {
161+
if stdinIsTTY {
162+
return true
163+
}
164+
165+
if openTTY == nil {
166+
return false
167+
}
168+
169+
return openTTY() == nil
170+
}
171+
172+
func hasTerminalPromptInput() bool {
173+
return hasTerminalPromptInputFrom(term.IsTerminal(int(os.Stdin.Fd())), func() error {
174+
tty, err := openTTYFileFunc()
175+
if err != nil {
176+
return err
177+
}
178+
179+
return tty.Close()
180+
})
181+
}
182+
183+
func promptOnTerminal(prompt string) (string, error) {
184+
tty, err := openTTYFileFunc()
185+
if err != nil {
186+
password, promptErr := keyring.TerminalPrompt(prompt)
187+
if promptErr != nil {
188+
return "", fmt.Errorf("prompt password from stdin: %w", promptErr)
189+
}
190+
191+
return password, nil
192+
}
193+
194+
defer tty.Close()
195+
196+
if _, err = fmt.Fprintf(tty, "%s: ", prompt); err != nil {
197+
return "", fmt.Errorf("write tty prompt: %w", err)
198+
}
199+
200+
password, err := term.ReadPassword(int(tty.Fd()))
201+
if err != nil {
202+
return "", fmt.Errorf("read tty password: %w", err)
203+
}
204+
205+
if _, err = fmt.Fprintln(tty); err != nil {
206+
return "", fmt.Errorf("write tty newline: %w", err)
207+
}
208+
209+
return string(password), nil
140210
}
141211

142212
func normalizeKeyringBackend(value string) string {

internal/secrets/store_more_test.go

Lines changed: 121 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,10 @@ import (
1313
"github.com/steipete/gogcli/internal/config"
1414
)
1515

16-
var errTestKeychain = errors.New("test -25308 error")
16+
var (
17+
errTestKeychain = errors.New("test -25308 error")
18+
errNoTTYForTest = errors.New("no tty")
19+
)
1720

1821
func TestKeyringStore_ListDeleteDefault(t *testing.T) {
1922
ring := keyring.NewArrayKeyring(nil)
@@ -126,6 +129,123 @@ func TestFileKeyringPasswordFuncFrom(t *testing.T) {
126129
if _, err := fn("prompt"); err == nil || !errors.Is(err, errNoTTY) {
127130
t.Fatalf("expected no TTY error, got: %v", err)
128131
}
132+
133+
// No env var and TTY available uses terminal prompt function.
134+
origTerminalPrompt := terminalPromptFunc
135+
terminalPromptFunc = func(prompt string) (string, error) {
136+
return "typed:" + prompt, nil
137+
}
138+
139+
t.Cleanup(func() { terminalPromptFunc = origTerminalPrompt })
140+
141+
fn = fileKeyringPasswordFuncFrom("", false, true)
142+
if got, err := fn("prompt"); err != nil {
143+
t.Fatalf("expected terminal prompt success, got err: %v", err)
144+
} else if got != "typed:prompt" {
145+
t.Fatalf("unexpected prompt value: %q", got)
146+
}
147+
}
148+
149+
func TestKeyringPasswordFromEnvFrom(t *testing.T) {
150+
tests := []struct {
151+
name string
152+
env map[string]string
153+
wantValue string
154+
wantSet bool
155+
}{
156+
{
157+
name: "unset",
158+
env: map[string]string{},
159+
wantValue: "",
160+
wantSet: false,
161+
},
162+
{
163+
name: "canonical wins",
164+
env: map[string]string{
165+
keyringPasswordEnv: "canon",
166+
keyringPasswordCompatEnv: "compat",
167+
keyringPasswordLegacyEnv: "legacy",
168+
},
169+
wantValue: "canon",
170+
wantSet: true,
171+
},
172+
{
173+
name: "compat fallback",
174+
env: map[string]string{
175+
keyringPasswordCompatEnv: "compat",
176+
keyringPasswordLegacyEnv: "legacy",
177+
},
178+
wantValue: "compat",
179+
wantSet: true,
180+
},
181+
{
182+
name: "legacy fallback",
183+
env: map[string]string{
184+
keyringPasswordLegacyEnv: "legacy",
185+
},
186+
wantValue: "legacy",
187+
wantSet: true,
188+
},
189+
{
190+
name: "empty canonical is intentional",
191+
env: map[string]string{
192+
keyringPasswordEnv: "",
193+
},
194+
wantValue: "",
195+
wantSet: true,
196+
},
197+
}
198+
199+
for _, tt := range tests {
200+
t.Run(tt.name, func(t *testing.T) {
201+
gotValue, gotSet := keyringPasswordFromEnvFrom(func(k string) (string, bool) {
202+
v, ok := tt.env[k]
203+
return v, ok
204+
})
205+
if gotValue != tt.wantValue || gotSet != tt.wantSet {
206+
t.Fatalf("got (%q, %v), want (%q, %v)", gotValue, gotSet, tt.wantValue, tt.wantSet)
207+
}
208+
})
209+
}
210+
}
211+
212+
func TestHasTerminalPromptInputFrom(t *testing.T) {
213+
t.Run("stdin tty", func(t *testing.T) {
214+
called := false
215+
216+
got := hasTerminalPromptInputFrom(true, func() error {
217+
called = true
218+
return nil
219+
})
220+
if !got {
221+
t.Fatalf("expected prompt input when stdin is tty")
222+
}
223+
224+
if called {
225+
t.Fatalf("did not expect openTTY call when stdin is tty")
226+
}
227+
})
228+
229+
t.Run("tty fallback works", func(t *testing.T) {
230+
got := hasTerminalPromptInputFrom(false, func() error { return nil })
231+
if !got {
232+
t.Fatalf("expected prompt input from tty fallback")
233+
}
234+
})
235+
236+
t.Run("tty fallback unavailable", func(t *testing.T) {
237+
got := hasTerminalPromptInputFrom(false, func() error { return errNoTTYForTest })
238+
if got {
239+
t.Fatalf("expected no prompt input without tty")
240+
}
241+
})
242+
243+
t.Run("no open func", func(t *testing.T) {
244+
got := hasTerminalPromptInputFrom(false, nil)
245+
if got {
246+
t.Fatalf("expected no prompt input without openTTY function")
247+
}
248+
})
129249
}
130250

131251
func TestKeyringStoreSetTokenErrors(t *testing.T) {

0 commit comments

Comments
 (0)