Compare commits

...

2 Commits

Author SHA1 Message Date
Peter Steinberger
04b66fbdee fix(secrets): tighten token verification tests (#270) (thanks @zerone0x)
Some checks failed
ci / test (push) Has been cancelled
ci / worker (push) Has been cancelled
ci / windows (push) Has been cancelled
ci / darwin-cgo-build (push) Has been cancelled
2026-03-07 22:55:41 +00:00
zerone0x
81ded37565 fix(secrets): verify token persisted after keyring write
On macOS, the Keychain can silently write 0-byte entries when it is
locked in a headless/server environment, even though Set returns no
error.  The pre-flight check (EnsureKeychainAccess) does not catch
every case because the Keychain can appear unlocked yet still drop
data.

Add a read-back verification after SetToken writes the primary token.
If the read-back fails or returns empty data, return an actionable
error that suggests switching to the file-based keyring backend.

Fixes #206

Co-Authored-By: Claude <noreply@anthropic.com>
2026-03-07 22:54:28 +00:00
3 changed files with 79 additions and 2 deletions

View File

@ -30,6 +30,7 @@
- Gmail: allow Workspace-managed send-as aliases with empty verification status in `send` and `drafts create`. (#407) — thanks @salmonumbrella.
- Gmail: preserve the selected `--client` during `watch serve` push handling instead of falling back to the default client. (#411) — thanks @chrysb.
- CI: validate release tags and quote the checkout ref in the release workflow to block tag-script injection on manual releases. (#299) — thanks @salmonumbrella.
- Secrets: verify keyring token writes by reading them back, so macOS headless Keychain failures return an actionable error instead of silently storing 0 bytes. (#270) — thanks @zerone0x.
- Secrets: respect empty `GOG_KEYRING_PASSWORD` (treat set-to-empty as intentional; avoids headless prompts). (#269) — thanks @zerone0x.
- Calendar: reject ambiguous calendar-name selectors for `calendar events` instead of guessing. (#131) — thanks @salmonumbrella.
- Gmail: `drafts update --quote` now picks a non-draft, non-self message from thread fallback (or errors clearly), avoiding self-quote loops and wrong reply headers. (#394) — thanks @salmonumbrella.

View File

@ -58,6 +58,7 @@ var (
errNoTTY = errors.New("no TTY available for keyring file backend password prompt")
errInvalidKeyringBackend = errors.New("invalid keyring backend")
errKeyringTimeout = errors.New("keyring connection timed out")
errTokenVerifyFailed = errors.New("token verification failed: keyring wrote 0 bytes")
openKeyringFunc = openKeyring
keyringOpenFunc = keyring.Open
)
@ -337,10 +338,23 @@ func (s *KeyringStore) SetToken(client string, email string, tok Token) error {
return fmt.Errorf("encode token: %w", err)
}
if err := s.ring.Set(keyringItem(tokenKey(normalizedClient, email), payload)); err != nil {
primaryKey := tokenKey(normalizedClient, email)
if err := s.ring.Set(keyringItem(primaryKey, payload)); err != nil {
return wrapKeychainError(fmt.Errorf("store token: %w", err))
}
// Verify the token was actually persisted. On macOS, the Keychain can
// silently write 0 bytes when it is locked in a headless/server environment
// even though Set returns no error. Read back to catch this.
if item, readErr := s.ring.Get(primaryKey); readErr != nil {
return fmt.Errorf("%w: could not read back token after write: %w\n\n"+
"Workaround: switch to file-based keyring with: gog auth keyring file", errTokenVerifyFailed, readErr)
} else if len(item.Data) == 0 {
return fmt.Errorf("%w\n\n"+
"This usually happens when the macOS Keychain is locked in a headless environment.\n"+
"Workaround: switch to file-based keyring with: gog auth keyring file", errTokenVerifyFailed)
}
if normalizedClient == config.DefaultClientName {
if err := s.ring.Set(keyringItem(legacyTokenKey(email), payload)); err != nil {
return wrapKeychainError(fmt.Errorf("store legacy token: %w", err))

View File

@ -13,7 +13,10 @@ import (
"github.com/steipete/gogcli/internal/config"
)
var errTestKeychain = errors.New("test -25308 error")
var (
errTestKeychain = errors.New("test -25308 error")
errTestReadBack = errors.New("test read-back failure")
)
func TestKeyringStore_ListDeleteDefault(t *testing.T) {
ring := keyring.NewArrayKeyring(nil)
@ -251,6 +254,65 @@ func TestGetTokenMigrationSetsLabel(t *testing.T) {
}
}
// silentDropKeyring simulates a macOS Keychain that silently writes 0 bytes.
// Set returns nil (success), but Get returns an item with empty Data.
type silentDropKeyring struct {
keyring.ArrayKeyring
}
func (s *silentDropKeyring) Set(_ keyring.Item) error { return nil }
func (s *silentDropKeyring) Get(_ string) (keyring.Item, error) {
return keyring.Item{Data: nil}, nil
}
func (s *silentDropKeyring) Keys() ([]string, error) { return nil, nil }
func TestSetTokenVerifyCatchesEmptyWrite(t *testing.T) {
store := &KeyringStore{ring: &silentDropKeyring{}}
client := config.DefaultClientName
err := store.SetToken(client, "a@b.com", Token{RefreshToken: "rt", CreatedAt: time.Now()})
if err == nil {
t.Fatal("expected error when keyring silently drops data")
}
if !errors.Is(err, errTokenVerifyFailed) {
t.Fatalf("expected errTokenVerifyFailed, got: %v", err)
}
if !strings.Contains(err.Error(), "gog auth keyring file") {
t.Fatalf("expected workaround suggestion in error, got: %v", err)
}
}
// readBackErrorKeyring simulates a keyring where Set succeeds but Get fails.
type readBackErrorKeyring struct {
keyring.ArrayKeyring
}
func (r *readBackErrorKeyring) Set(_ keyring.Item) error { return nil }
func (r *readBackErrorKeyring) Get(_ string) (keyring.Item, error) {
return keyring.Item{}, errTestReadBack
}
func (r *readBackErrorKeyring) Keys() ([]string, error) { return nil, nil }
func TestSetTokenVerifyCatchesReadBackError(t *testing.T) {
store := &KeyringStore{ring: &readBackErrorKeyring{}}
client := config.DefaultClientName
err := store.SetToken(client, "a@b.com", Token{RefreshToken: "rt", CreatedAt: time.Now()})
if err == nil {
t.Fatal("expected error when keyring read-back fails")
}
if !errors.Is(err, errTokenVerifyFailed) {
t.Fatalf("expected errTokenVerifyFailed, got: %v", err)
}
if !strings.Contains(err.Error(), "could not read back") {
t.Fatalf("expected read-back error detail, got: %v", err)
}
}
func TestSetSecretSetsLabel(t *testing.T) {
ring := keyring.NewArrayKeyring(nil)
origOpen := openKeyringFunc