Skip to content

Commit b3c2856

Browse files
malobclaudesteipete
authored
feat(secrets): make keyring open timeout configurable, default 30s (#845)
* feat(secrets): make keyring open timeout configurable, default 30s Raise the keyring open/operation timeout from 10s to 30s and make it configurable via GOG_KEYRING_OPEN_TIMEOUT (Go duration), mirroring the existing GOG_KEYRING_LOCK_TIMEOUT. 10s is too short to satisfy an interactive macOS Keychain permission prompt (password entry plus "Always Allow"), so a legitimate prompt times out before it can be approved. 30s still bounds the indefinite hang the timeout was originally added to prevent. The timeout error now points at GOG_KEYRING_OPEN_TIMEOUT. Co-Authored-By: Claude Opus 4.8 <[email protected]> * fix(secrets): scope longer keyring timeout to macOS --------- Co-authored-by: Claude Opus 4.8 <[email protected]> Co-authored-by: Peter Steinberger <[email protected]>
1 parent b35481e commit b3c2856

6 files changed

Lines changed: 92 additions & 14 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44

55
### Added
66

7+
- Auth: make keyring open/operation timeouts configurable with `GOG_KEYRING_OPEN_TIMEOUT`, using a `30s` macOS default for permission prompts while retaining `10s` elsewhere. (#845) — thanks @malob.
78
- Calendar: add `create --timezone`/`--tz` to apply one IANA timezone to both event endpoints while retaining granular start/end timezone flags. (#844) — thanks @malob.
89
- Docs: add non-destructive `insert-image --before` and `--after` anchors while clarifying that `--at` replaces its placeholder. (#839) — thanks @sebsnyk.
910
- Slides: allow `insert-image` and `replace-slide` to use public HTTPS image URLs without temporary Drive sharing. (#825) — thanks @sebsnyk.

docs/spec.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -112,7 +112,7 @@ Implementation: `internal/config/*`.
112112
- Legacy key format: `token:<email>` (migrated on first read)
113113
- Stored payload is JSON (refresh token + metadata like OIDC subject, current email, selected services/scopes).
114114
- Email is treated as display/contact state; Google's OIDC `sub` is used to detect the same account after an email rename and migrate aliases/defaults/client mappings on reauthorization.
115-
- macOS Keychain operations are bounded by a timeout so non-surfacing permission prompts return actionable guidance instead of hanging indefinitely.
115+
- Keyring operations are bounded by a timeout (default `30s` on macOS and `10s` elsewhere, configurable via `GOG_KEYRING_OPEN_TIMEOUT`) so non-surfacing permission prompts and unresponsive backends return actionable guidance instead of hanging indefinitely.
116116
- Fallback: if no OS credential store is available, keyring may use its encrypted "file" backend:
117117
- Directory: `$(os.UserConfigDir())/gogcli/keyring/` (one file per key; gog-managed key names are encoded for portable filenames)
118118
- Password: prompts on TTY; for non-interactive runs set `GOG_KEYRING_PASSWORD`
@@ -169,6 +169,7 @@ Environment:
169169
- `GOG_KEYRING_PASSWORD=...` (used when keyring falls back to encrypted file backend in non-interactive environments)
170170
- `GOG_KEYRING_BACKEND={auto|keychain|file}` (force backend; use `file` to avoid Keychain prompts and pair with `GOG_KEYRING_PASSWORD` for non-interactive)
171171
- `GOG_KEYRING_SERVICE_NAME=...` (override keyring namespace/service name; default `gogcli`)
172+
- `GOG_KEYRING_OPEN_TIMEOUT=30s` (max time to wait for a keyring open/operation — e.g. a macOS Keychain permission prompt — before failing; Go duration, default `30s` on macOS and `10s` elsewhere)
172173
- `GOG_TIMEZONE=America/New_York` (default output timezone; IANA name or `UTC`; `local` forces local timezone)
173174
- `GOG_ENABLE_COMMANDS=calendar,tasks,gmail.search` (optional prefix allowlist; dot paths allowed; parent paths allow children)
174175
- `GOG_ENABLE_COMMANDS_EXACT=calendar.events,gmail.search` (optional exact allowlist; dot paths allowed; parent paths do not allow children)

internal/secrets/backend.go

Lines changed: 35 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ const (
1616
keyringPasswordEnv = "GOG_KEYRING_PASSWORD" //nolint:gosec // env var name, not a credential
1717
keyringBackendEnv = "GOG_KEYRING_BACKEND" //nolint:gosec // env var name, not a credential
1818
keyringServiceNameEnv = "GOG_KEYRING_SERVICE_NAME"
19+
keyringOpenTimeoutEnv = "GOG_KEYRING_OPEN_TIMEOUT"
1920
)
2021

2122
var (
@@ -68,6 +69,7 @@ func OpenOptionsFromLookup(
6869
password, passwordSet := lookup(keyringPasswordEnv)
6970
serviceName, _ := lookup(keyringServiceNameEnv)
7071
dbusAddress, _ := lookup("DBUS_SESSION_BUS_ADDRESS")
72+
openTimeoutRaw, _ := lookup(keyringOpenTimeoutEnv)
7173
lockTimeoutRaw, _ := lookup(keyringLockTimeoutEnv)
7274

7375
return OpenOptions{
@@ -80,7 +82,7 @@ func OpenOptionsFromLookup(
8082
GOOS: goos,
8183
DBusAddress: dbusAddress,
8284
IsTTY: isTTY,
83-
OpenTimeout: keyringOpenTimeout,
85+
OpenTimeout: parseKeyringOpenTimeout(openTimeoutRaw, goos),
8486
LockTimeout: parseKeyringLockTimeout(lockTimeoutRaw),
8587
}
8688
}
@@ -161,15 +163,39 @@ func serviceNameFor(options OpenOptions) string {
161163
return config.AppName
162164
}
163165

164-
// keyringOpenTimeout is the maximum time to wait for keyring.Open() to complete.
165-
// On headless Linux, D-Bus SecretService can hang indefinitely if gnome-keyring
166-
// is installed but not running.
166+
// Keyring timeouts guard against unresponsive backends. macOS gets longer for
167+
// interactive permission prompts; other platforms retain the existing limit.
167168
const (
168-
keyringOpenTimeout = 10 * time.Second
169-
goosDarwin = "darwin"
170-
goosLinux = "linux"
169+
keyringOpenTimeout = 10 * time.Second
170+
darwinKeyringOpenTimeout = 30 * time.Second
171+
goosDarwin = "darwin"
172+
goosLinux = "linux"
171173
)
172174

175+
func defaultKeyringOpenTimeout(goos string) time.Duration {
176+
if goos == goosDarwin {
177+
return darwinKeyringOpenTimeout
178+
}
179+
180+
return keyringOpenTimeout
181+
}
182+
183+
// parseKeyringOpenTimeout resolves GOG_KEYRING_OPEN_TIMEOUT, falling back to
184+
// the platform default when unset, unparseable, or non-positive.
185+
func parseKeyringOpenTimeout(raw, goos string) time.Duration {
186+
fallback := defaultKeyringOpenTimeout(goos)
187+
if raw == "" {
188+
return fallback
189+
}
190+
191+
timeout, err := time.ParseDuration(raw)
192+
if err != nil || timeout <= 0 {
193+
return fallback
194+
}
195+
196+
return timeout
197+
}
198+
173199
func shouldForceFileBackend(goos string, backendInfo KeyringBackendInfo, dbusAddr string) bool {
174200
return goos == goosLinux && backendInfo.Value == keyringBackendAuto && dbusAddr == ""
175201
}
@@ -249,7 +275,7 @@ func openKeyringWithOptions(options OpenOptions) (keyring.Keyring, error) {
249275

250276
openTimeout := options.OpenTimeout
251277
if openTimeout <= 0 {
252-
openTimeout = keyringOpenTimeout
278+
openTimeout = defaultKeyringOpenTimeout(options.GOOS)
253279
}
254280

255281
open := options.openKeyringFn
@@ -295,7 +321,7 @@ func prepareKeyring(
295321
if shouldUseKeyringOperationTimeout(options.GOOS, backendInfo, options.DBusAddress) {
296322
timeout := options.OpenTimeout
297323
if timeout <= 0 {
298-
timeout = keyringOpenTimeout
324+
timeout = defaultKeyringOpenTimeout(options.GOOS)
299325
}
300326
ring = newTimeoutKeyring(ring, timeout, keyringTimeoutHint(options.GOOS))
301327
}

internal/secrets/store_test.go

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,55 @@ func TestOpenOptionsFromLookupCapturesEnvironment(t *testing.T) {
7676
}
7777
}
7878

79+
func TestOpenOptionsFromLookupOpenTimeout(t *testing.T) {
80+
t.Parallel()
81+
82+
values := map[string]string{keyringOpenTimeoutEnv: "45s"}
83+
options := OpenOptionsFromLookup(
84+
config.Layout{ConfigDir: "/config", DataDir: "/data"},
85+
config.NewConfigStore(config.Layout{ConfigDir: "/config"}),
86+
func(key string) (string, bool) {
87+
value, ok := values[key]
88+
return value, ok
89+
},
90+
"darwin",
91+
true,
92+
)
93+
94+
if options.OpenTimeout != 45*time.Second {
95+
t.Fatalf("OpenTimeout = %v, want 45s", options.OpenTimeout)
96+
}
97+
}
98+
99+
func TestParseKeyringOpenTimeout(t *testing.T) {
100+
t.Parallel()
101+
102+
tests := []struct {
103+
name string
104+
raw string
105+
goos string
106+
want time.Duration
107+
}{
108+
{name: "darwin default", goos: "darwin", want: darwinKeyringOpenTimeout},
109+
{name: "linux default", goos: "linux", want: keyringOpenTimeout},
110+
{name: "other default", goos: "windows", want: keyringOpenTimeout},
111+
{name: "valid duration overrides darwin", raw: "1m", goos: "darwin", want: time.Minute},
112+
{name: "valid duration overrides linux", raw: "45s", goos: "linux", want: 45 * time.Second},
113+
{name: "invalid uses darwin default", raw: "nonsense", goos: "darwin", want: darwinKeyringOpenTimeout},
114+
{name: "non-positive uses linux default", raw: "-5s", goos: "linux", want: keyringOpenTimeout},
115+
}
116+
117+
for _, tt := range tests {
118+
t.Run(tt.name, func(t *testing.T) {
119+
t.Parallel()
120+
121+
if got := parseKeyringOpenTimeout(tt.raw, tt.goos); got != tt.want {
122+
t.Fatalf("parseKeyringOpenTimeout(%q, %q) = %v, want %v", tt.raw, tt.goos, got, tt.want)
123+
}
124+
})
125+
}
126+
}
127+
79128
func TestOpenUsesInjectedOptions(t *testing.T) {
80129
t.Parallel()
81130

internal/secrets/timeout_keyring.go

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -47,8 +47,8 @@ func withKeyringTimeout[T any](timeout time.Duration, operation string, hint str
4747

4848
func keyringTimeoutError(operation string, timeout time.Duration, hint string) error {
4949
return fmt.Errorf("%w after %v while %s (%s); "+
50-
"set GOG_KEYRING_BACKEND=file and GOG_KEYRING_PASSWORD=<password> to use encrypted file storage instead",
51-
errKeyringTimeout, timeout, operation, hint)
50+
"set %s to allow more time, or set GOG_KEYRING_BACKEND=file and GOG_KEYRING_PASSWORD=<password> to use encrypted file storage instead",
51+
errKeyringTimeout, timeout, operation, hint, keyringOpenTimeoutEnv)
5252
}
5353

5454
func IsKeyringTimeout(err error) bool {

internal/secrets/timeout_keyring_test.go

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -68,8 +68,9 @@ func TestTimeoutKeyringTimesOutOperations(t *testing.T) {
6868
t.Fatalf("expected timeout error, got %v", err)
6969
}
7070

71-
if !strings.Contains(err.Error(), "listing keyring items") || !strings.Contains(err.Error(), "Always Allow") {
72-
t.Fatalf("expected operation and macOS hint in timeout, got %v", err)
71+
if !strings.Contains(err.Error(), "listing keyring items") || !strings.Contains(err.Error(), "Always Allow") ||
72+
!strings.Contains(err.Error(), "GOG_KEYRING_OPEN_TIMEOUT") {
73+
t.Fatalf("expected operation, macOS hint, and timeout env in error, got %v", err)
7374
}
7475
}
7576

0 commit comments

Comments
 (0)