From a0cf253177ba1e10dcd226d9658328bc23c4e6ae Mon Sep 17 00:00:00 2001 From: euxaristia <25621994+euxaristia@users.noreply.github.com> Date: Tue, 14 Jul 2026 04:36:21 -0400 Subject: [PATCH 01/28] fix(oauth): store keyring tokens as one entry per provider The keyring backend combined every provider and MCP token into a single JSON blob under one keyring entry. On macOS, add-generic-password now writes through security -i, whose command parser caps a single write at 4095 bytes (#574). The combined blob has no such bound: three or more logged-in providers routinely exceeds it, so Set() starts failing for every provider, not just the one that pushed it over. Split storage into one keyring entry per token key, plus a small index entry listing which keys exist (KeyringClient has no list operation). Each write is now bounded to a single token's size, well under the line cap regardless of how many providers are logged in. Installs on the old combined-entry format keep reading correctly via a legacy fallback, and get migrated to per-key entries on the next save. --- internal/oauth/store.go | 145 +++++++++++++++++++++++++-- internal/oauth/store_keyring_test.go | 112 ++++++++++++++++++++- 2 files changed, 244 insertions(+), 13 deletions(-) diff --git a/internal/oauth/store.go b/internal/oauth/store.go index 951e9616f..f111c8a4e 100644 --- a/internal/oauth/store.go +++ b/internal/oauth/store.go @@ -106,10 +106,23 @@ type KeyringClient interface { Delete(service, account string) (bool, error) } -// Keyring storage stores the whole token blob under one fixed entry. +// Keyring storage splits the token blob into one keyring entry per token key, +// plus a small index entry listing which keys exist. A single combined entry +// (the original design) grows with every additional provider/MCP login and, +// on macOS, add-generic-password now goes through `security -i`'s line-based +// command parser (see internal/keyring), which caps a single write at 4095 +// bytes; three or more logged-in providers routinely exceeds that. Splitting +// by key bounds each write to one token, which stays well under the cap +// regardless of how many providers are logged in. const ( keyringService = "zero" - keyringAccount = "oauth-tokens" + // keyringLegacyAccount held the whole blob as one entry in the original + // design. New writes never use it; it is only read once, to migrate + // existing installs into the per-key format. + keyringLegacyAccount = "oauth-tokens" + // keyringIndexAccount holds a JSON array of the token keys that currently + // have their own keyring entry, since KeyringClient has no "list" operation. + keyringIndexAccount = "oauth-tokens-index" ) // Store persists OAuth tokens (provider + MCP namespaces) as one JSON blob, @@ -203,7 +216,7 @@ func NewStore(options StoreOptions) (*Store, error) { if storePath, perr := ResolveStorePath(options.Env); perr == nil { lockPath = filepath.Join(filepath.Dir(storePath), "oauth-keyring.lockfile") } - return &Store{blob: keyringBlob{kr: kr, service: keyringService, account: keyringAccount, lockPath: lockPath}, now: now}, nil + return &Store{blob: keyringBlob{kr: kr, service: keyringService, legacyAccount: keyringLegacyAccount, indexAccount: keyringIndexAccount, lockPath: lockPath}, now: now}, nil default: return nil, fmt.Errorf("oauth: unknown storage %q (want \"file\", \"encrypted-file\", or \"keyring\")", storage) } @@ -431,19 +444,70 @@ func (b fileBlob) withLock(now func() time.Time, fn func() error) error { func (b fileBlob) location() string { return b.path } -// keyringBlob persists the blob in the OS keyring as a single base64 entry -// (base64 keeps the multi-line JSON a single, control-character-free value). +// keyringBlob persists tokens in the OS keyring as one base64 entry per token +// key (account = key), plus an index entry listing which keys exist (base64 +// keeps every value a single, control-character-free string; see keyringService +// for why a single combined entry doesn't work). read/write still present the +// same whole-blob shape (a marshaled storeFile) that Store expects, fanning it +// out to/in from the individual entries internally. type keyringBlob struct { kr KeyringClient service string - account string + // legacyAccount is the pre-migration whole-blob entry; read only, to pick up + // tokens saved by older versions the first time this runs. + legacyAccount string + indexAccount string // lockPath, when set, is a cross-process lock file serializing the keyring's // read-modify-write so concurrent processes don't clobber each other's tokens. lockPath string } func (b keyringBlob) read() ([]byte, bool, error) { - enc, ok, err := b.kr.Get(b.service, b.account) + indexEnc, ok, err := b.kr.Get(b.service, b.indexAccount) + if err != nil { + return nil, false, err + } + if !ok { + return b.readLegacy() + } + keys, err := decodeKeyIndex(indexEnc) + if err != nil { + return nil, false, fmt.Errorf("oauth: decode keyring token index: %w", err) + } + tokens := make(map[string]Token, len(keys)) + for _, key := range keys { + enc, ok, err := b.kr.Get(b.service, key) + if err != nil { + return nil, false, err + } + if !ok { + // Index and entries fell out of sync (e.g. a killed process between + // writing an entry and updating the index); skip rather than fail the + // whole read, since the next Save/Delete will reconcile the index. + continue + } + raw, err := base64.StdEncoding.DecodeString(strings.TrimSpace(enc)) + if err != nil { + return nil, false, fmt.Errorf("oauth: decode keyring token entry %q: %w", key, err) + } + var token Token + if err := json.Unmarshal(raw, &token); err != nil { + return nil, false, fmt.Errorf("oauth: invalid keyring token entry %q: %w", key, err) + } + tokens[key] = token + } + data, err := json.Marshal(storeFile{SchemaVersion: storeSchemaVersion, Tokens: tokens}) + if err != nil { + return nil, false, err + } + return data, true, nil +} + +// readLegacy reads the pre-migration whole-blob entry, for installs that +// haven't written since upgrading. The next write() migrates them: it writes +// per-key entries and an index, then deletes this entry. +func (b keyringBlob) readLegacy() ([]byte, bool, error) { + enc, ok, err := b.kr.Get(b.service, b.legacyAccount) if err != nil || !ok { return nil, ok, err } @@ -455,7 +519,70 @@ func (b keyringBlob) read() ([]byte, bool, error) { } func (b keyringBlob) write(data []byte) error { - return b.kr.Set(b.service, b.account, base64.StdEncoding.EncodeToString(data)) + var state storeFile + if err := json.Unmarshal(data, &state); err != nil { + return fmt.Errorf("oauth: encode keyring token blob: %w", err) + } + priorKeys, err := b.indexedKeys() + if err != nil { + return err + } + keys := make([]string, 0, len(state.Tokens)) + for key, token := range state.Tokens { + raw, err := json.Marshal(token) + if err != nil { + return err + } + if err := b.kr.Set(b.service, key, base64.StdEncoding.EncodeToString(raw)); err != nil { + return err + } + keys = append(keys, key) + } + sort.Strings(keys) + indexData, err := json.Marshal(keys) + if err != nil { + return err + } + if err := b.kr.Set(b.service, b.indexAccount, base64.StdEncoding.EncodeToString(indexData)); err != nil { + return err + } + for _, key := range priorKeys { + if _, ok := state.Tokens[key]; !ok { + if _, err := b.kr.Delete(b.service, key); err != nil { + return err + } + } + } + // The index now exists and is authoritative; drop the legacy entry so a + // future read never falls back to it. + _, _ = b.kr.Delete(b.service, b.legacyAccount) + return nil +} + +// indexedKeys returns the keys currently listed in the index, or nil if there +// is no index yet (first write, or still on the legacy format). +func (b keyringBlob) indexedKeys() ([]string, error) { + enc, ok, err := b.kr.Get(b.service, b.indexAccount) + if err != nil || !ok { + return nil, err + } + keys, err := decodeKeyIndex(enc) + if err != nil { + return nil, fmt.Errorf("oauth: decode keyring token index: %w", err) + } + return keys, nil +} + +func decodeKeyIndex(enc string) ([]string, error) { + data, err := base64.StdEncoding.DecodeString(strings.TrimSpace(enc)) + if err != nil { + return nil, err + } + var keys []string + if err := json.Unmarshal(data, &keys); err != nil { + return nil, err + } + return keys, nil } // withLock serializes the keyring's read-modify-write. Store.mu covers the @@ -473,7 +600,7 @@ func (b keyringBlob) withLock(now func() time.Time, fn func() error) error { return fn() } -func (b keyringBlob) location() string { return "keyring:" + b.service + "/" + b.account } +func (b keyringBlob) location() string { return "keyring:" + b.service + "/" + b.indexAccount } // FormatStatuses renders a human-readable status table without leaking token // material. diff --git a/internal/oauth/store_keyring_test.go b/internal/oauth/store_keyring_test.go index 8931dc6de..d79294be0 100644 --- a/internal/oauth/store_keyring_test.go +++ b/internal/oauth/store_keyring_test.go @@ -1,6 +1,8 @@ package oauth import ( + "encoding/base64" + "encoding/json" "strings" "testing" ) @@ -49,13 +51,17 @@ func TestStoreKeyringBackendRoundTrip(t *testing.T) { t.Fatalf("Load = %#v", got) } - // The blob is stored base64-encoded, so the raw JSON field names never appear. - raw := kr.data[keyringService+"/"+keyringAccount] + // The token lives under its own entry (account = key), not one combined + // blob, and is base64-encoded so the raw JSON field names never appear. + raw := kr.data[keyringService+"/"+ProviderKey("demo")] if raw == "" { - t.Fatal("nothing stored in keyring") + t.Fatal("nothing stored under the token's own keyring entry") } if strings.Contains(raw, "access_token") { - t.Fatalf("keyring blob is not encoded: %s", raw) + t.Fatalf("keyring entry is not encoded: %s", raw) + } + if raw := kr.data[keyringService+"/"+keyringLegacyAccount]; raw != "" { + t.Fatalf("legacy combined entry should not be written by new code: %s", raw) } removed, err := s.Delete(ProviderKey("demo")) @@ -65,6 +71,104 @@ func TestStoreKeyringBackendRoundTrip(t *testing.T) { if _, ok, _ := s.Load(ProviderKey("demo")); ok { t.Fatal("token still present after delete") } + // Delete must also drop the now-unused entry, not just remove it from the + // index, or a stale keyring item accumulates for every logout. + if _, ok := kr.data[keyringService+"/"+ProviderKey("demo")]; ok { + t.Fatal("deleted token's keyring entry was not removed") + } +} + +// TestStoreKeyringManyProvidersStayUnderEntryLimit is the regression test for +// the bug this backend originally shipped with: every provider's tokens were +// combined into one keyring entry, and on macOS that entry is written through +// `security -i`, whose command parser caps a single write around 4KB. Three or +// more logged-in providers routinely exceeded it, so Set() would start failing +// for every provider, not just the one pushing it over. Splitting into one +// entry per key bounds each individual write to a single token regardless of +// how many providers are logged in. +func TestStoreKeyringManyProvidersStayUnderEntryLimit(t *testing.T) { + t.Setenv("XDG_CONFIG_HOME", t.TempDir()) + kr := newFakeKR() + s, err := NewStore(StoreOptions{Storage: "keyring", Keyring: kr}) + if err != nil { + t.Fatal(err) + } + // A realistically large single token: JWT-shaped access/ID tokens plus an + // opaque refresh token, comparable to what OIDC providers actually issue. + big := Token{ + AccessToken: "eyJhbGciOiJSUzI1NiJ9." + strings.Repeat("QUJDRA", 60) + ".sig", + RefreshToken: "rt_" + strings.Repeat("x", 80), + TokenType: "Bearer", + Scopes: []string{"openid", "profile", "email", "offline_access"}, + Account: "user@example.com", + IDToken: "eyJhbGciOiJSUzI1NiJ9." + strings.Repeat("QUJDRA", 70) + ".sig", + } + providers := []string{"anthropic", "openai", "minimax", "zai", "google"} + for _, name := range providers { + if err := s.Save(ProviderKey(name), big); err != nil { + t.Fatalf("Save(%s): %v", name, err) + } + } + // Each individual keyring value must stay small even with 5 providers + // logged in: no entry aggregates more than one provider's tokens. + const singleTokenCeiling = 3000 // generous margin under the ~4095-byte line cap + for k, v := range kr.data { + if len(v) > singleTokenCeiling { + t.Fatalf("keyring entry %q is %d bytes, want < %d (aggregation regression)", k, len(v), singleTokenCeiling) + } + } + for _, name := range providers { + got, ok, err := s.Load(ProviderKey(name)) + if err != nil || !ok { + t.Fatalf("Load(%s): ok=%v err=%v", name, ok, err) + } + if got.AccessToken != big.AccessToken { + t.Fatalf("Load(%s) = %#v", name, got) + } + } +} + +// TestStoreKeyringMigratesLegacyCombinedEntry ensures installs upgrading from +// the original single-blob format keep reading their existing tokens, and get +// migrated to per-key entries (with the legacy entry removed) the next time +// anything is saved. +func TestStoreKeyringMigratesLegacyCombinedEntry(t *testing.T) { + t.Setenv("XDG_CONFIG_HOME", t.TempDir()) + kr := newFakeKR() + legacy := storeFile{SchemaVersion: storeSchemaVersion, Tokens: map[string]Token{ + ProviderKey("demo"): {AccessToken: "legacy-a", RefreshToken: "legacy-r"}, + }} + data, err := json.Marshal(legacy) + if err != nil { + t.Fatal(err) + } + kr.data[keyringService+"/"+keyringLegacyAccount] = base64.StdEncoding.EncodeToString(data) + + s, err := NewStore(StoreOptions{Storage: "keyring", Keyring: kr}) + if err != nil { + t.Fatal(err) + } + got, ok, err := s.Load(ProviderKey("demo")) + if err != nil || !ok { + t.Fatalf("Load legacy token: ok=%v err=%v", ok, err) + } + if got.AccessToken != "legacy-a" { + t.Fatalf("Load = %#v", got) + } + + // Saving a second provider must migrate: the legacy entry is dropped, and + // both tokens end up as their own entries. + if err := s.Save(ProviderKey("other"), Token{AccessToken: "other-a"}); err != nil { + t.Fatal(err) + } + if _, ok := kr.data[keyringService+"/"+keyringLegacyAccount]; ok { + t.Fatal("legacy combined entry should be removed after migration") + } + for _, name := range []string{"demo", "other"} { + if _, ok, err := s.Load(ProviderKey(name)); err != nil || !ok { + t.Fatalf("Load(%s) after migration: ok=%v err=%v", name, ok, err) + } + } } func TestNewStoreStorageSelection(t *testing.T) { From 5bfbd1676baa62cd7f2a993c60f6a8ac2c4cb256 Mon Sep 17 00:00:00 2001 From: euxaristia <25621994+euxaristia@users.noreply.github.com> Date: Tue, 14 Jul 2026 11:38:24 -0400 Subject: [PATCH 02/28] fix(oauth): lock keyring reads against concurrent Save/Delete Store.Load and Status read the keyring blob via several separate Get calls (index, then each entry), not one atomic snapshot, but only Save/Delete ran that read-modify-write under blob.withLock. An unlocked Load/Status could run concurrently with another process's Save/Delete mid write and observe a torn state. Route both through withLock like Save/Delete already do. Also add coverage for read()'s index/entry desync recovery: a key listed in the index whose own entry is missing must be skipped, not fail the whole read. --- internal/oauth/store.go | 22 ++++++++++-- internal/oauth/store_keyring_test.go | 50 ++++++++++++++++++++++++++++ 2 files changed, 70 insertions(+), 2 deletions(-) diff --git a/internal/oauth/store.go b/internal/oauth/store.go index f111c8a4e..ac985d957 100644 --- a/internal/oauth/store.go +++ b/internal/oauth/store.go @@ -269,7 +269,18 @@ func (s *Store) Load(key string) (Token, bool, error) { } s.mu.Lock() defer s.mu.Unlock() - state, err := s.readState() + // Through blob.withLock, like Save/Delete: the keyring backend's read is + // several separate Get calls (index, then each entry), not one atomic + // snapshot, so an unlocked Load can run concurrently with another + // process's Save/Delete mid write and observe a torn state (e.g. an index + // already updated but an entry not yet written). The lock keeps this read + // from overlapping any other process's read-modify-write cycle. + var state storeFile + err := s.blob.withLock(s.now, func() error { + var readErr error + state, readErr = s.readState() + return readErr + }) if err != nil { return Token{}, false, err } @@ -305,7 +316,14 @@ func (s *Store) Delete(key string) (bool, error) { func (s *Store) Status(prefix string) ([]Status, error) { s.mu.Lock() defer s.mu.Unlock() - state, err := s.readState() + // Same reasoning as Load: run the read under blob.withLock so it can't + // observe another process's Save/Delete mid write. + var state storeFile + err := s.blob.withLock(s.now, func() error { + var readErr error + state, readErr = s.readState() + return readErr + }) if err != nil { return nil, err } diff --git a/internal/oauth/store_keyring_test.go b/internal/oauth/store_keyring_test.go index d79294be0..34bf945a4 100644 --- a/internal/oauth/store_keyring_test.go +++ b/internal/oauth/store_keyring_test.go @@ -171,6 +171,56 @@ func TestStoreKeyringMigratesLegacyCombinedEntry(t *testing.T) { } } +// TestStoreKeyringSkipsIndexedKeyMissingItsEntry covers read()'s recovery from +// an index/entry desync: a key listed in the index whose own entry is +// missing (e.g. a process killed between writing the entry and updating the +// index, or between updating the index and deleting a removed entry). read() +// must skip that key rather than fail the whole read, since the next +// Save/Delete reconciles the index against what's actually there. +func TestStoreKeyringSkipsIndexedKeyMissingItsEntry(t *testing.T) { + t.Setenv("XDG_CONFIG_HOME", t.TempDir()) + kr := newFakeKR() + + present := Token{AccessToken: "present-a", RefreshToken: "present-r"} + raw, err := json.Marshal(present) + if err != nil { + t.Fatal(err) + } + kr.data[keyringService+"/"+ProviderKey("present")] = base64.StdEncoding.EncodeToString(raw) + + // The index references both keys, but "missing"'s own entry was never + // written (or was already deleted) — the desync this test targets. + index, err := json.Marshal([]string{ProviderKey("missing"), ProviderKey("present")}) + if err != nil { + t.Fatal(err) + } + kr.data[keyringService+"/"+keyringIndexAccount] = base64.StdEncoding.EncodeToString(index) + + s, err := NewStore(StoreOptions{Storage: "keyring", Keyring: kr}) + if err != nil { + t.Fatal(err) + } + + if _, ok, err := s.Load(ProviderKey("missing")); err != nil || ok { + t.Fatalf("Load(missing): ok=%v err=%v, want ok=false err=nil", ok, err) + } + got, ok, err := s.Load(ProviderKey("present")) + if err != nil || !ok { + t.Fatalf("Load(present): ok=%v err=%v", ok, err) + } + if got.AccessToken != present.AccessToken { + t.Fatalf("Load(present) = %#v", got) + } + + statuses, err := s.Status("") + if err != nil { + t.Fatalf("Status: %v", err) + } + if len(statuses) != 1 || statuses[0].Key != ProviderKey("present") { + t.Fatalf("Status = %#v, want only the present key", statuses) + } +} + func TestNewStoreStorageSelection(t *testing.T) { // Unknown storage is rejected (fail closed). if _, err := NewStore(StoreOptions{Storage: "bogus"}); err == nil { From f2e37bd6fcb9ff80a603f974c75a101754dc8539 Mon Sep 17 00:00:00 2001 From: euxaristia <25621994+euxaristia@users.noreply.github.com> Date: Wed, 15 Jul 2026 12:52:14 -0400 Subject: [PATCH 03/28] fix(oauth): make the keyring token store bounded, recoverable, and mixed-version safe - The key index is chunked: continuation entries hold overflow keys and are written before the header that references them, so every index entry stays under the macOS security -i 4095-byte line cap regardless of how many providers are logged in, and a torn chunk write is skipped on read like a missing token entry. - Writes follow a recoverable ordering: the union of the prior and new key sets is published first, token entries are written next, removed entries are deleted while the index still lists them, and only then does the index shrink. Every token entry that exists at any instant is listed in the published index, so an interrupted login/logout can never strand an invisible credential in the OS keychain; the next write reconciles. - A held lock's mtime is refreshed every 10s while the multi-command keyring operation runs, so the 30s stale-reclaim threshold only ever fires for a genuinely crashed holder, not a legitimately slow healthy one. - A legacy combined entry that reappears after migration was written by an old binary still running; its unseen keys are merged before the entry is deleted, so mixed old/new versions during an upgrade no longer lose freshly saved tokens. - Read-side locking is scoped to the keyring backend via a new blob.withReadLock: file-backend reads stay lock-free (writes are atomic renames), restoring crash tolerance when a writer dies holding the lock. - The cross-process lock path falls back to the OS temp directory when no config location resolves, instead of silently degrading to in-process serialization only. --- internal/oauth/store.go | 299 ++++++++++++++++++++---- internal/oauth/store_keyring_test.go | 326 +++++++++++++++++++++++++++ 2 files changed, 578 insertions(+), 47 deletions(-) diff --git a/internal/oauth/store.go b/internal/oauth/store.go index ac985d957..496062e67 100644 --- a/internal/oauth/store.go +++ b/internal/oauth/store.go @@ -210,9 +210,12 @@ func NewStore(options StoreOptions) (*Store, error) { kr = osKeyring } // Serialize the keyring's read-modify-write across processes with a lock - // file beside where the file backend would live. Best-effort: if no config - // location resolves, fall back to in-process serialization only. - lockPath := "" + // file beside where the file backend would live. Cross-process exclusion + // must not silently disappear when no config location resolves (withLock + // would be a no-op and a concurrent save could delete another process's + // newly written entry), so fall back to the OS temp directory, which + // always exists, rather than to in-process serialization only. + lockPath := filepath.Join(os.TempDir(), "zero-oauth-keyring.lockfile") if storePath, perr := ResolveStorePath(options.Env); perr == nil { lockPath = filepath.Join(filepath.Dir(storePath), "oauth-keyring.lockfile") } @@ -269,14 +272,15 @@ func (s *Store) Load(key string) (Token, bool, error) { } s.mu.Lock() defer s.mu.Unlock() - // Through blob.withLock, like Save/Delete: the keyring backend's read is - // several separate Get calls (index, then each entry), not one atomic - // snapshot, so an unlocked Load can run concurrently with another - // process's Save/Delete mid write and observe a torn state (e.g. an index - // already updated but an entry not yet written). The lock keeps this read - // from overlapping any other process's read-modify-write cycle. + // Through blob.withReadLock: the keyring backend's read is several + // separate Get calls (index, then each entry), not one atomic snapshot, + // so an unguarded Load could run concurrently with another process's + // Save/Delete mid write and observe a torn state. The file backend's + // withReadLock is a no-op: its writes are atomic renames, so lock-free + // reads keep their crash tolerance (a crashed writer's fresh lock file + // must not block reads of the last complete file). var state storeFile - err := s.blob.withLock(s.now, func() error { + err := s.blob.withReadLock(s.now, func() error { var readErr error state, readErr = s.readState() return readErr @@ -316,10 +320,11 @@ func (s *Store) Delete(key string) (bool, error) { func (s *Store) Status(prefix string) ([]Status, error) { s.mu.Lock() defer s.mu.Unlock() - // Same reasoning as Load: run the read under blob.withLock so it can't - // observe another process's Save/Delete mid write. + // Same reasoning as Load: run the read under blob.withReadLock so the + // keyring's multi-entry read can't observe another process's Save/Delete + // mid write, while file-backend reads stay lock-free. var state storeFile - err := s.blob.withLock(s.now, func() error { + err := s.blob.withReadLock(s.now, func() error { var readErr error state, readErr = s.readState() return readErr @@ -417,6 +422,13 @@ type blobStore interface { // (a lock file for the file backend; none for the keyring, which is the // authoritative store and is serialized within the process by Store.mu). withLock(now func() time.Time, fn func() error) error + // withReadLock guards a read-only pass. The file backend's writes are + // atomic renames, so its reads stay lock-free: a crashed writer's fresh + // lock file must not turn into ~30s of read failures when the last + // complete file is perfectly readable. The keyring backend's read is + // several separate Get calls (index, then each entry), not one atomic + // snapshot, so it takes the same cross-process lock as its writes. + withReadLock(now func() time.Time, fn func() error) error // location is a human-readable identifier for diagnostics/errors. location() string } @@ -460,6 +472,14 @@ func (b fileBlob) withLock(now func() time.Time, fn func() error) error { return fn() } +// withReadLock is deliberately lock-free: write() replaces the file with an +// atomic rename, so a reader always sees a complete file, and a crashed +// writer's leftover lock file must not turn readable state into ~30 seconds +// of Load/Status failures while the stale threshold runs out. +func (b fileBlob) withReadLock(now func() time.Time, fn func() error) error { + return fn() +} + func (b fileBlob) location() string { return b.path } // keyringBlob persists tokens in the OS keyring as one base64 entry per token @@ -481,17 +501,13 @@ type keyringBlob struct { } func (b keyringBlob) read() ([]byte, bool, error) { - indexEnc, ok, err := b.kr.Get(b.service, b.indexAccount) + keys, ok, _, err := b.readKeyIndex() if err != nil { return nil, false, err } if !ok { return b.readLegacy() } - keys, err := decodeKeyIndex(indexEnc) - if err != nil { - return nil, false, fmt.Errorf("oauth: decode keyring token index: %w", err) - } tokens := make(map[string]Token, len(keys)) for _, key := range keys { enc, ok, err := b.kr.Get(b.service, key) @@ -536,34 +552,87 @@ func (b keyringBlob) readLegacy() ([]byte, bool, error) { return data, true, nil } +// write replaces the keyring's token entries with state, ordered so that +// every interruption boundary leaves a recoverable store. The invariant is +// that any token entry existing in the keyring at any instant is listed in +// the published index: the union index is published before entries are +// written, entries are deleted before the index shrinks, and the index +// header is only updated after the chunks it references exist. A crash at +// any step therefore leaves either an index over-listing keys whose entries +// are missing (read() already skips those) or entries that a later +// read/write can still see and reconcile, never an invisible credential +// stranded in the OS keychain. func (b keyringBlob) write(data []byte) error { var state storeFile if err := json.Unmarshal(data, &state); err != nil { return fmt.Errorf("oauth: encode keyring token blob: %w", err) } - priorKeys, err := b.indexedKeys() + priorKeys, indexExisted, priorChunks, err := b.readKeyIndex() if err != nil { return err } - keys := make([]string, 0, len(state.Tokens)) - for key, token := range state.Tokens { - raw, err := json.Marshal(token) - if err != nil { - return err - } - if err := b.kr.Set(b.service, key, base64.StdEncoding.EncodeToString(raw)); err != nil { - return err + prior := make(map[string]bool, len(priorKeys)) + for _, key := range priorKeys { + prior[key] = true + } + + // An older binary running alongside this one still reads and writes only + // the legacy combined entry. If that entry exists even though the index + // has already been published, it was recreated by such a binary after + // migration: merge any key the indexed schema has never seen before the + // legacy entry is deleted below, or that binary's freshly saved token + // would be silently lost. Keys already in the prior index are not merged; + // their absence from state means this write deliberately removed them. + if indexExisted { + if legacyData, ok, legacyErr := b.readLegacy(); legacyErr == nil && ok { + var legacyState storeFile + if json.Unmarshal(legacyData, &legacyState) == nil { + for key, token := range legacyState.Tokens { + if _, exists := state.Tokens[key]; exists || prior[key] || ValidateKey(key) != nil { + continue + } + state.Tokens[key] = token + } + } } + } + + keys := make([]string, 0, len(state.Tokens)) + for key := range state.Tokens { keys = append(keys, key) } sort.Strings(keys) - indexData, err := json.Marshal(keys) + + // 1. Publish the union of the prior and new key sets first, so every + // entry that exists at any point during this update is indexed. + union := keys + if len(priorKeys) > 0 { + merged := make(map[string]bool, len(keys)+len(priorKeys)) + for _, key := range append(append([]string{}, keys...), priorKeys...) { + merged[key] = true + } + union = make([]string, 0, len(merged)) + for key := range merged { + union = append(union, key) + } + sort.Strings(union) + } + unionChunks, err := b.writeKeyIndex(union, priorChunks) if err != nil { return err } - if err := b.kr.Set(b.service, b.indexAccount, base64.StdEncoding.EncodeToString(indexData)); err != nil { - return err + // 2. Write each token entry. + for _, key := range keys { + raw, err := json.Marshal(state.Tokens[key]) + if err != nil { + return err + } + if err := b.kr.Set(b.service, key, base64.StdEncoding.EncodeToString(raw)); err != nil { + return err + } } + // 3. Delete removed entries while the union index still lists them, so a + // failed Delete leaves a visible (re-deletable) entry, never an orphan. for _, key := range priorKeys { if _, ok := state.Tokens[key]; !ok { if _, err := b.kr.Delete(b.service, key); err != nil { @@ -571,41 +640,154 @@ func (b keyringBlob) write(data []byte) error { } } } + // 4. Shrink the index to the exact new key set. + if _, err := b.writeKeyIndex(keys, unionChunks); err != nil { + return err + } // The index now exists and is authoritative; drop the legacy entry so a - // future read never falls back to it. + // future read never falls back to it (its fresh writes were merged above). _, _ = b.kr.Delete(b.service, b.legacyAccount) return nil } -// indexedKeys returns the keys currently listed in the index, or nil if there -// is no index yet (first write, or still on the legacy format). -func (b keyringBlob) indexedKeys() ([]string, error) { +// maxKeyringIndexChunkBytes bounds one index chunk's raw JSON payload so its +// base64 encoding plus command framing stays well under the macOS +// `security -i` 4095-byte line cap (see internal/keyring): 2700 raw bytes +// expand to 3600 base64 bytes, leaving ~490 bytes for the add-generic-password +// syntax, service, and account. The old single-entry index hit that cap at +// roughly 22 maximum-length keys even when every token was tiny. +const maxKeyringIndexChunkBytes = 2700 + +// keyIndexHeader is chunk 0 of the key index. Chunks 1..Chunks-1 live under +// "-" as plain JSON string arrays. The pre-chunking format +// (a bare JSON array at indexAccount) is still read transparently. +type keyIndexHeader struct { + Version int `json:"v"` + Chunks int `json:"chunks"` + Keys []string `json:"keys"` +} + +func (b keyringBlob) chunkAccount(index int) string { + return fmt.Sprintf("%s-%d", b.indexAccount, index) +} + +// readKeyIndex returns the indexed keys, whether an index exists at all, and +// how many chunk entries it currently occupies. A chunk listed by the header +// but missing from the keyring (a torn write) is skipped, mirroring how +// read() skips an indexed key whose entry is missing. +func (b keyringBlob) readKeyIndex() ([]string, bool, int, error) { enc, ok, err := b.kr.Get(b.service, b.indexAccount) - if err != nil || !ok { - return nil, err + if err != nil { + return nil, false, 0, err + } + if !ok { + return nil, false, 0, nil } - keys, err := decodeKeyIndex(enc) + raw, err := base64.StdEncoding.DecodeString(strings.TrimSpace(enc)) if err != nil { - return nil, fmt.Errorf("oauth: decode keyring token index: %w", err) + return nil, false, 0, fmt.Errorf("oauth: decode keyring token index: %w", err) + } + trimmed := strings.TrimSpace(string(raw)) + if strings.HasPrefix(trimmed, "[") { + var keys []string + if err := json.Unmarshal(raw, &keys); err != nil { + return nil, false, 0, fmt.Errorf("oauth: decode keyring token index: %w", err) + } + return keys, true, 1, nil + } + var header keyIndexHeader + if err := json.Unmarshal(raw, &header); err != nil { + return nil, false, 0, fmt.Errorf("oauth: decode keyring token index: %w", err) + } + keys := header.Keys + for i := 1; i < header.Chunks; i++ { + chunkEnc, ok, err := b.kr.Get(b.service, b.chunkAccount(i)) + if err != nil { + return nil, false, 0, err + } + if !ok { + continue + } + chunkRaw, err := base64.StdEncoding.DecodeString(strings.TrimSpace(chunkEnc)) + if err != nil { + return nil, false, 0, fmt.Errorf("oauth: decode keyring token index chunk %d: %w", i, err) + } + var more []string + if err := json.Unmarshal(chunkRaw, &more); err != nil { + return nil, false, 0, fmt.Errorf("oauth: decode keyring token index chunk %d: %w", i, err) + } + keys = append(keys, more...) + } + chunks := header.Chunks + if chunks < 1 { + chunks = 1 } - return keys, nil + return keys, true, chunks, nil } -func decodeKeyIndex(enc string) ([]string, error) { - data, err := base64.StdEncoding.DecodeString(strings.TrimSpace(enc)) +// writeKeyIndex persists keys as a chunked index and reports how many chunk +// entries it used. Continuation chunks are written before the header that +// references them, so the authoritative chunk 0 never advertises a chunk that +// does not exist yet; stale chunks from a previously larger index are removed +// only after the header stops referencing them (best-effort: an unreferenced +// chunk is never read). +func (b keyringBlob) writeKeyIndex(keys []string, priorChunks int) (int, error) { + chunks := chunkIndexKeys(keys) + for i := 1; i < len(chunks); i++ { + chunkData, err := json.Marshal(chunks[i]) + if err != nil { + return 0, err + } + if err := b.kr.Set(b.service, b.chunkAccount(i), base64.StdEncoding.EncodeToString(chunkData)); err != nil { + return 0, err + } + } + headerData, err := json.Marshal(keyIndexHeader{Version: 1, Chunks: len(chunks), Keys: chunks[0]}) if err != nil { - return nil, err + return 0, err } - var keys []string - if err := json.Unmarshal(data, &keys); err != nil { - return nil, err + if err := b.kr.Set(b.service, b.indexAccount, base64.StdEncoding.EncodeToString(headerData)); err != nil { + return 0, err + } + for i := len(chunks); i < priorChunks; i++ { + _, _ = b.kr.Delete(b.service, b.chunkAccount(i)) } - return keys, nil + return len(chunks), nil } +// chunkIndexKeys packs keys into chunks whose marshaled JSON stays under +// maxKeyringIndexChunkBytes. Always returns at least one (possibly empty) +// chunk. +func chunkIndexKeys(keys []string) [][]string { + chunks := [][]string{{}} + size := 0 + for _, key := range keys { + // Per-key JSON cost: quotes, comma, and headroom for escaping. + cost := len(key) + 8 + if size+cost > maxKeyringIndexChunkBytes && len(chunks[len(chunks)-1]) > 0 { + chunks = append(chunks, []string{}) + size = 0 + } + chunks[len(chunks)-1] = append(chunks[len(chunks)-1], key) + size += cost + } + return chunks +} + +// fileLockRefreshInterval is how often a held keyring lock's mtime is +// refreshed while its critical section runs. It must stay comfortably under +// fileLockStaleAfter (30s): one external keyring command may legitimately +// take up to its 10s timeout and a multi-entry pass runs several, so without +// refreshing, a healthy slow holder would look stale and another process +// could reclaim the live lock and resume the token-loss race the lock +// exists to prevent. A var so tests can shorten it. +var fileLockRefreshInterval = 10 * time.Second + // withLock serializes the keyring's read-modify-write. Store.mu covers the // in-process case; lockPath (when set) adds cross-process exclusion so two // processes can't both read the blob, modify, and write — dropping a token. +// While fn runs, the lock file's mtime is refreshed so the stale-reclaim +// threshold only ever expires for a genuinely crashed holder. func (b keyringBlob) withLock(now func() time.Time, fn func() error) error { if b.lockPath == "" { return fn() @@ -615,7 +797,30 @@ func (b keyringBlob) withLock(now func() time.Time, fn func() error) error { return err } defer unlock() - return fn() + stop := make(chan struct{}) + done := make(chan struct{}) + go func() { + defer close(done) + ticker := time.NewTicker(fileLockRefreshInterval) + defer ticker.Stop() + for { + select { + case <-stop: + return + case <-ticker.C: + at := now() + _ = os.Chtimes(b.lockPath, at, at) + } + } + }() + err = fn() + close(stop) + <-done + return err +} + +func (b keyringBlob) withReadLock(now func() time.Time, fn func() error) error { + return b.withLock(now, fn) } func (b keyringBlob) location() string { return "keyring:" + b.service + "/" + b.indexAccount } diff --git a/internal/oauth/store_keyring_test.go b/internal/oauth/store_keyring_test.go index 34bf945a4..6d6eaf096 100644 --- a/internal/oauth/store_keyring_test.go +++ b/internal/oauth/store_keyring_test.go @@ -3,8 +3,11 @@ package oauth import ( "encoding/base64" "encoding/json" + "os" + "path/filepath" "strings" "testing" + "time" ) // fakeKR is an in-memory KeyringClient for exercising the keyring backend @@ -221,6 +224,258 @@ func TestStoreKeyringSkipsIndexedKeyMissingItsEntry(t *testing.T) { } } +// failingKR wraps fakeKR and fails the Nth mutating operation (Set/Delete), +// for exercising every interruption boundary of the multi-step write. +type failingKR struct { + *fakeKR + failAt int // 1-based mutating-operation number to fail; 0 disables + ops int +} + +func (f *failingKR) Set(service, account, secret string) error { + f.ops++ + if f.failAt != 0 && f.ops == f.failAt { + return errKRInjected + } + return f.fakeKR.Set(service, account, secret) +} + +func (f *failingKR) Delete(service, account string) (bool, error) { + f.ops++ + if f.failAt != 0 && f.ops == f.failAt { + return false, errKRInjected + } + return f.fakeKR.Delete(service, account) +} + +var errKRInjected = errKR("injected keyring failure") + +type errKR string + +func (e errKR) Error() string { return string(e) } + +// indexedKeysOf parses the (possibly chunked) index in kr and returns every +// listed key. +func indexedKeysOf(t *testing.T, kr *fakeKR) map[string]bool { + t.Helper() + blob := keyringBlob{kr: kr, service: keyringService, legacyAccount: keyringLegacyAccount, indexAccount: keyringIndexAccount} + keys, _, _, err := blob.readKeyIndex() + if err != nil { + t.Fatalf("readKeyIndex: %v", err) + } + out := make(map[string]bool, len(keys)) + for _, k := range keys { + out[k] = true + } + return out +} + +// TestStoreKeyringIndexStaysUnderEntryLimit is the regression test for the +// index itself hitting the same macOS `security -i` line cap the per-token +// split fixed for token entries: with enough maximum-length keys, a single +// index entry base64-expands past 4095 bytes even when every token is tiny. +// The index must therefore be bounded per entry (chunked) like everything +// else, and still round-trip. +func TestStoreKeyringIndexStaysUnderEntryLimit(t *testing.T) { + t.Setenv("XDG_CONFIG_HOME", t.TempDir()) + kr := newFakeKR() + s, err := NewStore(StoreOptions{Storage: "keyring", Keyring: kr}) + if err != nil { + t.Fatal(err) + } + // 40 keys near ValidateKey's cap: an unchunked index of these would + // serialize to ~5.5KB before base64. + names := make([]string, 0, 40) + for i := 0; i < 40; i++ { + names = append(names, strings.Repeat("p", 100)+"-"+strings.Repeat("0123456789", 2)+string(rune('a'+i%26))+string(rune('a'+i/26))) + } + for _, name := range names { + if err := s.Save(ProviderKey(name), Token{AccessToken: "a"}); err != nil { + t.Fatalf("Save(%s): %v", name, err) + } + } + // Every keyring value, index entries included, must stay under the cap + // with generous framing margin. + const entryCeiling = 3800 + for k, v := range kr.data { + if len(v) > entryCeiling { + t.Fatalf("keyring entry %q is %d bytes, want <= %d (index cap regression)", k, len(v), entryCeiling) + } + } + // The index actually chunked (otherwise the ceiling check proves nothing). + if _, ok := kr.data[keyringService+"/"+keyringIndexAccount+"-1"]; !ok { + t.Fatal("expected the index to split into continuation chunks") + } + for _, name := range names { + if _, ok, err := s.Load(ProviderKey(name)); err != nil || !ok { + t.Fatalf("Load(%s): ok=%v err=%v", name, ok, err) + } + } + // Shrinking back to one token must also shrink the index and drop the + // stale continuation chunks. + for _, name := range names[1:] { + if _, err := s.Delete(ProviderKey(name)); err != nil { + t.Fatalf("Delete(%s): %v", name, err) + } + } + if _, ok := kr.data[keyringService+"/"+keyringIndexAccount+"-1"]; ok { + t.Fatal("stale index continuation chunk left behind after shrink") + } +} + +// TestStoreKeyringWriteInterruptionsLeaveNoInvisibleTokens drives a write +// through an injected failure at every mutating operation in turn and checks +// the recoverable-store invariant at each boundary: every token entry present +// in the keyring is listed in the published index (so no credential is ever +// stranded invisibly), and a subsequent unimpeded write fully reconciles. +func TestStoreKeyringWriteInterruptionsLeaveNoInvisibleTokens(t *testing.T) { + t.Setenv("XDG_CONFIG_HOME", t.TempDir()) + for failAt := 1; ; failAt++ { + kr := &failingKR{fakeKR: newFakeKR()} + s, err := NewStore(StoreOptions{Storage: "keyring", Keyring: kr}) + if err != nil { + t.Fatal(err) + } + // Seed two tokens cleanly, then fail the Nth mutating operation of a + // write that both adds a token and (via the later delete pass of a + // Delete call) removes one. + if err := s.Save(ProviderKey("alpha"), Token{AccessToken: "a"}); err != nil { + t.Fatal(err) + } + if err := s.Save(ProviderKey("beta"), Token{AccessToken: "b"}); err != nil { + t.Fatal(err) + } + kr.ops = 0 + kr.failAt = failAt + saveErr := s.Save(ProviderKey("gamma"), Token{AccessToken: "c"}) + opsUsed := kr.ops + kr.failAt = 0 + + // Invariant at the interruption boundary: nothing invisible. + indexed := indexedKeysOf(t, kr.fakeKR) + for entry := range kr.data { + account := strings.TrimPrefix(entry, keyringService+"/") + if account == keyringIndexAccount || strings.HasPrefix(account, keyringIndexAccount+"-") || account == keyringLegacyAccount { + continue + } + if !indexed[account] { + t.Fatalf("failAt=%d: token entry %q exists but is not listed in the index (invisible credential)", failAt, account) + } + } + + // A later unimpeded write must reconcile completely. + if err := s.Save(ProviderKey("gamma"), Token{AccessToken: "c"}); err != nil { + t.Fatalf("failAt=%d: reconciling Save: %v", failAt, err) + } + for _, name := range []string{"alpha", "beta", "gamma"} { + if _, ok, err := s.Load(ProviderKey(name)); err != nil || !ok { + t.Fatalf("failAt=%d: Load(%s) after reconcile: ok=%v err=%v", failAt, name, ok, err) + } + } + // saveErr itself is not asserted: most boundaries surface the injected + // failure, but the final legacy-entry delete is deliberately + // best-effort, so its failure is swallowed by design. The invariant + // and the reconcile above are the actual contract. + _ = saveErr + if opsUsed < failAt { + // The write used fewer mutating ops than failAt, so the injection + // never fired and every boundary has been covered. + break + } + } +} + +// TestStoreKeyringDeleteInterruptionsLeaveNoInvisibleTokens is the Delete +// counterpart: a logout interrupted at any boundary must not leave a +// logged-out credential invisibly resident in the OS keychain (the index is +// only shrunk after the entry deletion), and a repeated delete reconciles. +func TestStoreKeyringDeleteInterruptionsLeaveNoInvisibleTokens(t *testing.T) { + t.Setenv("XDG_CONFIG_HOME", t.TempDir()) + for failAt := 1; ; failAt++ { + kr := &failingKR{fakeKR: newFakeKR()} + s, err := NewStore(StoreOptions{Storage: "keyring", Keyring: kr}) + if err != nil { + t.Fatal(err) + } + if err := s.Save(ProviderKey("alpha"), Token{AccessToken: "a"}); err != nil { + t.Fatal(err) + } + if err := s.Save(ProviderKey("beta"), Token{AccessToken: "b"}); err != nil { + t.Fatal(err) + } + kr.ops = 0 + kr.failAt = failAt + _, _ = s.Delete(ProviderKey("beta")) + opsUsed := kr.ops + kr.failAt = 0 + + indexed := indexedKeysOf(t, kr.fakeKR) + for entry := range kr.data { + account := strings.TrimPrefix(entry, keyringService+"/") + if account == keyringIndexAccount || strings.HasPrefix(account, keyringIndexAccount+"-") || account == keyringLegacyAccount { + continue + } + if !indexed[account] { + t.Fatalf("failAt=%d: token entry %q exists but is not listed in the index (invisible credential)", failAt, account) + } + } + + // Retrying the delete must fully reconcile: beta gone from both the + // index and the keyring, alpha intact. + if _, err := s.Delete(ProviderKey("beta")); err != nil { + t.Fatalf("failAt=%d: reconciling Delete: %v", failAt, err) + } + if _, ok := kr.data[keyringService+"/"+ProviderKey("beta")]; ok { + t.Fatalf("failAt=%d: logged-out credential still resident after reconcile", failAt) + } + if _, ok, err := s.Load(ProviderKey("alpha")); err != nil || !ok { + t.Fatalf("failAt=%d: Load(alpha): ok=%v err=%v", failAt, ok, err) + } + if opsUsed < failAt { + break + } + } +} + +// TestStoreKeyringMergesFreshLegacyWriteFromOldBinary covers the mixed-version +// window: after migration to the indexed format, an old binary still running +// can save a token into the legacy combined entry. The next new-binary write +// must merge that fresh token instead of deleting the legacy entry over it. +func TestStoreKeyringMergesFreshLegacyWriteFromOldBinary(t *testing.T) { + t.Setenv("XDG_CONFIG_HOME", t.TempDir()) + kr := newFakeKR() + s, err := NewStore(StoreOptions{Storage: "keyring", Keyring: kr}) + if err != nil { + t.Fatal(err) + } + if err := s.Save(ProviderKey("alpha"), Token{AccessToken: "a"}); err != nil { + t.Fatal(err) + } + + // An old binary saves token "carol" through the legacy combined entry. + legacy := storeFile{SchemaVersion: storeSchemaVersion, Tokens: map[string]Token{ + ProviderKey("carol"): {AccessToken: "c", RefreshToken: "cr"}, + }} + data, err := json.Marshal(legacy) + if err != nil { + t.Fatal(err) + } + kr.data[keyringService+"/"+keyringLegacyAccount] = base64.StdEncoding.EncodeToString(data) + + // The next new-binary save must keep carol, not silently lose it. + if err := s.Save(ProviderKey("beta"), Token{AccessToken: "b"}); err != nil { + t.Fatal(err) + } + for _, name := range []string{"alpha", "beta", "carol"} { + if _, ok, err := s.Load(ProviderKey(name)); err != nil || !ok { + t.Fatalf("Load(%s): ok=%v err=%v (fresh legacy write lost)", name, ok, err) + } + } + if _, ok := kr.data[keyringService+"/"+keyringLegacyAccount]; ok { + t.Fatal("legacy entry should be removed once its fresh writes are merged") + } +} + func TestNewStoreStorageSelection(t *testing.T) { // Unknown storage is rejected (fail closed). if _, err := NewStore(StoreOptions{Storage: "bogus"}); err == nil { @@ -247,6 +502,77 @@ func TestNewStoreStorageSelection(t *testing.T) { } } +// TestStoreKeyringWithLockRefreshesLease guards the stale-reclaim race: one +// keyring command can take up to 10s and a multi-entry pass runs several, so +// a lock held for a legitimately slow operation can outlive the fixed 30s +// stale threshold. withLock must keep the lock file's mtime fresh while its +// critical section runs, so only a genuinely crashed holder ever looks stale. +func TestStoreKeyringWithLockRefreshesLease(t *testing.T) { + lockPath := filepath.Join(t.TempDir(), "oauth-keyring.lockfile") + blob := keyringBlob{kr: newFakeKR(), service: "zero-test", indexAccount: "idx", lockPath: lockPath} + + previous := fileLockRefreshInterval + fileLockRefreshInterval = 20 * time.Millisecond + defer func() { fileLockRefreshInterval = previous }() + + var first, second time.Time + err := blob.withLock(time.Now, func() error { + info, err := os.Stat(lockPath) + if err != nil { + return err + } + first = info.ModTime() + time.Sleep(150 * time.Millisecond) + info, err = os.Stat(lockPath) + if err != nil { + return err + } + second = info.ModTime() + return nil + }) + if err != nil { + t.Fatalf("withLock: %v", err) + } + if !second.After(first) { + t.Fatalf("lock mtime was not refreshed during the critical section: %v then %v", first, second) + } + if _, err := os.Stat(lockPath); !os.IsNotExist(err) { + t.Fatalf("lock file not released: %v", err) + } +} + +// TestStoreFileLoadToleratesCrashedWriterLock: file-backend reads must stay +// lock-free. A writer that crashed after taking the lock leaves a fresh lock +// file behind; the store file itself is always complete (writes are atomic +// renames), so Load must read it rather than waiting out the lock and +// failing for the ~30 seconds the stale threshold takes to expire. +func TestStoreFileLoadToleratesCrashedWriterLock(t *testing.T) { + path := filepath.Join(t.TempDir(), "oauth-tokens.json") + s, err := NewStore(StoreOptions{FilePath: path}) + if err != nil { + t.Fatal(err) + } + if err := s.Save(ProviderKey("demo"), Token{AccessToken: "a"}); err != nil { + t.Fatal(err) + } + // Simulate the crashed writer: a fresh, never-released lock file. + if err := os.WriteFile(path+".lockfile", []byte("someone-else"), 0o600); err != nil { + t.Fatal(err) + } + start := time.Now() + got, ok, err := s.Load(ProviderKey("demo")) + if err != nil || !ok || got.AccessToken != "a" { + t.Fatalf("Load behind a crashed writer's lock: ok=%v err=%v token=%#v", ok, err, got) + } + if elapsed := time.Since(start); elapsed > 2*time.Second { + t.Fatalf("Load waited on the write lock (%v); reads must be lock-free", elapsed) + } + statuses, err := s.Status("") + if err != nil || len(statuses) != 1 { + t.Fatalf("Status behind a crashed writer's lock: %v (%d entries)", err, len(statuses)) + } +} + func TestStoreKeyringStatus(t *testing.T) { t.Setenv("XDG_CONFIG_HOME", t.TempDir()) kr := newFakeKR() From 44aff1af5e41612270222037c6289cdf7ad34e24 Mon Sep 17 00:00:00 2001 From: euxaristia <25621994+euxaristia@users.noreply.github.com> Date: Wed, 15 Jul 2026 14:50:09 -0400 Subject: [PATCH 04/28] test(oauth): assert Status also stays lock-free behind a crashed writer's lock --- internal/oauth/store_keyring_test.go | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/internal/oauth/store_keyring_test.go b/internal/oauth/store_keyring_test.go index 6d6eaf096..2229f659f 100644 --- a/internal/oauth/store_keyring_test.go +++ b/internal/oauth/store_keyring_test.go @@ -567,10 +567,14 @@ func TestStoreFileLoadToleratesCrashedWriterLock(t *testing.T) { if elapsed := time.Since(start); elapsed > 2*time.Second { t.Fatalf("Load waited on the write lock (%v); reads must be lock-free", elapsed) } + statusStart := time.Now() statuses, err := s.Status("") if err != nil || len(statuses) != 1 { t.Fatalf("Status behind a crashed writer's lock: %v (%d entries)", err, len(statuses)) } + if elapsed := time.Since(statusStart); elapsed > 2*time.Second { + t.Fatalf("Status waited on the write lock (%v); reads must be lock-free", elapsed) + } } func TestStoreKeyringStatus(t *testing.T) { From 2a27743689c997e80abd55f935188ec63b71e587 Mon Sep 17 00:00:00 2001 From: euxaristia <25621994+euxaristia@users.noreply.github.com> Date: Thu, 16 Jul 2026 21:55:58 -0400 Subject: [PATCH 05/28] fix(oauth): recover legacy tokens across an interrupted keyring migration During the initial legacy->indexed migration, write() publishes the index before the per-key entries and deletes the legacy combined entry only as the final step. A crash after the index appears but before an entry is written previously left that pre-existing credential unreadable: the index listed the key, but its own entry did not exist yet. read() now falls back to the still-present legacy blob for any indexed key whose own entry is missing, so a migration interrupted at any point keeps every token readable, and a following unimpeded save completes the migration. write() also reconciles a concurrent old-binary refresh: when the legacy blob holds a strictly later expiry for an already-indexed key, that fresher value wins instead of being overwritten by the stale indexed copy and then deleted. --- internal/oauth/store.go | 96 ++++++++++++++++++------ internal/oauth/store_keyring_test.go | 107 +++++++++++++++++++++++++++ 2 files changed, 182 insertions(+), 21 deletions(-) diff --git a/internal/oauth/store.go b/internal/oauth/store.go index 496062e67..8c5d4635c 100644 --- a/internal/oauth/store.go +++ b/internal/oauth/store.go @@ -508,6 +508,14 @@ func (b keyringBlob) read() ([]byte, bool, error) { if !ok { return b.readLegacy() } + // The legacy combined entry is consulted lazily (below) only when an indexed + // key's own entry is missing. write() publishes the index before the per-key + // entries and deletes the legacy blob only after every entry is written, so a + // crash partway through the initial legacy->indexed migration can leave a + // pre-existing credential readable solely in the still-present legacy blob. + // In steady state (all entries present) the legacy blob is never read. + var legacyTokens map[string]Token + legacyLoaded := false tokens := make(map[string]Token, len(keys)) for _, key := range keys { enc, ok, err := b.kr.Get(b.service, key) @@ -515,9 +523,18 @@ func (b keyringBlob) read() ([]byte, bool, error) { return nil, false, err } if !ok { - // Index and entries fell out of sync (e.g. a killed process between - // writing an entry and updating the index); skip rather than fail the - // whole read, since the next Save/Delete will reconcile the index. + // The index lists this key but its own entry is missing. Recover it + // from the legacy blob when a migration is still in flight; otherwise + // (a steady-state index/entry desync whose legacy blob is already + // gone) skip rather than fail the whole read, since the next + // Save/Delete will reconcile the index. + if !legacyLoaded { + legacyTokens = b.readLegacyTokens() + legacyLoaded = true + } + if token, has := legacyTokens[key]; has { + tokens[key] = token + } continue } raw, err := base64.StdEncoding.DecodeString(strings.TrimSpace(enc)) @@ -552,6 +569,32 @@ func (b keyringBlob) readLegacy() ([]byte, bool, error) { return data, true, nil } +// readLegacyTokens returns the tokens held in the legacy combined entry, or an +// empty map when there is no readable legacy blob. It is a best-effort recovery +// source (read() falls back to it, write() reconciles against it), so a missing +// or malformed legacy entry is reported as "no tokens" rather than a hard error. +func (b keyringBlob) readLegacyTokens() map[string]Token { + data, ok, err := b.readLegacy() + if err != nil || !ok { + return nil + } + var legacyState storeFile + if json.Unmarshal(data, &legacyState) != nil { + return nil + } + return legacyState.Tokens +} + +// legacyIsFresher reports whether the legacy copy of an already-indexed key +// should win over the indexed copy. An old binary running alongside the new one +// refreshes tokens only in the legacy combined entry, and a refresh pushes the +// expiry later, so a strictly later, non-zero expiry on the legacy side is the +// signal that it holds a newer credential. A zero (unknown) expiry on either +// side is not evidence of freshness, so the indexed value is kept. +func legacyIsFresher(legacy, current Token) bool { + return !legacy.ExpiresAt.IsZero() && !current.ExpiresAt.IsZero() && legacy.ExpiresAt.After(current.ExpiresAt) +} + // write replaces the keyring's token entries with state, ordered so that // every interruption boundary leaves a recoverable store. The invariant is // that any token entry existing in the keyring at any instant is listed in @@ -559,9 +602,11 @@ func (b keyringBlob) readLegacy() ([]byte, bool, error) { // written, entries are deleted before the index shrinks, and the index // header is only updated after the chunks it references exist. A crash at // any step therefore leaves either an index over-listing keys whose entries -// are missing (read() already skips those) or entries that a later -// read/write can still see and reconcile, never an invisible credential -// stranded in the OS keychain. +// are missing (read() recovers those from the legacy blob during a migration, +// or skips them once it is gone) or entries that a later read/write can still +// see and reconcile, never an invisible credential stranded in the OS keychain. +// The legacy combined entry is the durable fallback for the initial migration +// and is deleted only as the final step, after every per-key entry is written. func (b keyringBlob) write(data []byte) error { var state storeFile if err := json.Unmarshal(data, &state); err != nil { @@ -576,24 +621,33 @@ func (b keyringBlob) write(data []byte) error { prior[key] = true } - // An older binary running alongside this one still reads and writes only - // the legacy combined entry. If that entry exists even though the index - // has already been published, it was recreated by such a binary after - // migration: merge any key the indexed schema has never seen before the - // legacy entry is deleted below, or that binary's freshly saved token - // would be silently lost. Keys already in the prior index are not merged; - // their absence from state means this write deliberately removed them. + // An older binary running alongside this one still reads and writes only the + // legacy combined entry. If that entry exists even though the index has + // already been published, an old binary wrote it after migration, so + // reconcile it into state before it is deleted below rather than blindly + // overwriting it: + // - a key the indexed schema has never seen is a fresh old-binary login; + // merge it so it is not lost; + // - a key already present in state that the legacy blob refreshed (a + // strictly later expiry) takes the legacy value, so a concurrent + // old-binary refresh is not discarded in favor of the stale indexed one; + // - a key that was in the prior index but is absent from this write was + // deliberately removed (a logout); it is left removed, not resurrected. if indexExisted { - if legacyData, ok, legacyErr := b.readLegacy(); legacyErr == nil && ok { - var legacyState storeFile - if json.Unmarshal(legacyData, &legacyState) == nil { - for key, token := range legacyState.Tokens { - if _, exists := state.Tokens[key]; exists || prior[key] || ValidateKey(key) != nil { - continue - } - state.Tokens[key] = token + for key, legacyToken := range b.readLegacyTokens() { + if ValidateKey(key) != nil { + continue + } + if current, exists := state.Tokens[key]; exists { + if legacyIsFresher(legacyToken, current) { + state.Tokens[key] = legacyToken } + continue + } + if prior[key] { + continue } + state.Tokens[key] = legacyToken } } diff --git a/internal/oauth/store_keyring_test.go b/internal/oauth/store_keyring_test.go index 2229f659f..7072bf23a 100644 --- a/internal/oauth/store_keyring_test.go +++ b/internal/oauth/store_keyring_test.go @@ -595,3 +595,110 @@ func TestStoreKeyringStatus(t *testing.T) { t.Fatalf("status = %#v", statuses) } } + +// TestStoreKeyringMigrationInterruptionsPreserveLegacyTokens drives the initial +// legacy->indexed migration through an injected failure at every mutating +// operation and checks that no pre-existing legacy credential is ever lost. +// write() publishes the index before the per-key entries, so a crash after the +// index appears but before an entry is written must still leave that token +// readable in the not-yet-deleted legacy blob; read() recovers it, and a +// following unimpeded save completes the migration. +func TestStoreKeyringMigrationInterruptionsPreserveLegacyTokens(t *testing.T) { + t.Setenv("XDG_CONFIG_HOME", t.TempDir()) + seeded := map[string]Token{ + ProviderKey("demo"): {AccessToken: "demo-a", RefreshToken: "demo-r"}, + ProviderKey("other"): {AccessToken: "other-a"}, + } + for failAt := 1; ; failAt++ { + kr := &failingKR{fakeKR: newFakeKR()} + // A legacy-only install: one combined entry, no index yet. + legacyData, err := json.Marshal(storeFile{SchemaVersion: storeSchemaVersion, Tokens: seeded}) + if err != nil { + t.Fatal(err) + } + kr.data[keyringService+"/"+keyringLegacyAccount] = base64.StdEncoding.EncodeToString(legacyData) + + s, err := NewStore(StoreOptions{Storage: "keyring", Keyring: kr}) + if err != nil { + t.Fatal(err) + } + kr.ops = 0 + kr.failAt = failAt + _ = s.Save(ProviderKey("new"), Token{AccessToken: "new-c"}) + opsUsed := kr.ops + kr.failAt = 0 + + // Regardless of where the migration was interrupted, a subsequent + // unimpeded save must complete it with every token intact. + if err := s.Save(ProviderKey("new"), Token{AccessToken: "new-c"}); err != nil { + t.Fatalf("failAt=%d: reconciling Save: %v", failAt, err) + } + for key, want := range seeded { + got, ok, err := s.Load(key) + if err != nil || !ok { + t.Fatalf("failAt=%d: Load(%s) after migration: ok=%v err=%v (legacy token lost)", failAt, key, ok, err) + } + if got.AccessToken != want.AccessToken { + t.Fatalf("failAt=%d: Load(%s) = %q, want %q", failAt, key, got.AccessToken, want.AccessToken) + } + } + if got, ok, err := s.Load(ProviderKey("new")); err != nil || !ok || got.AccessToken != "new-c" { + t.Fatalf("failAt=%d: Load(new): ok=%v err=%v token=%#v", failAt, ok, err, got) + } + // The completed migration drops the legacy entry. + if _, ok := kr.data[keyringService+"/"+keyringLegacyAccount]; ok { + t.Fatalf("failAt=%d: legacy entry not removed after migration completed", failAt) + } + if opsUsed < failAt { + break + } + } +} + +// TestStoreKeyringMergesFreshLegacyRefreshOfIndexedKey covers the mixed-version +// window for a key that already exists in the index: an old binary refreshes +// provider:alpha in the legacy combined entry (a strictly later expiry). The +// next new-binary save must keep that fresher refresh instead of overwriting it +// with the stale indexed value and then deleting the legacy entry. +func TestStoreKeyringMergesFreshLegacyRefreshOfIndexedKey(t *testing.T) { + t.Setenv("XDG_CONFIG_HOME", t.TempDir()) + kr := newFakeKR() + s, err := NewStore(StoreOptions{Storage: "keyring", Keyring: kr}) + if err != nil { + t.Fatal(err) + } + stale := time.Now().Add(1 * time.Hour) + if err := s.Save(ProviderKey("alpha"), Token{AccessToken: "a-old", RefreshToken: "r-old", ExpiresAt: stale}); err != nil { + t.Fatal(err) + } + + // An old binary refreshes alpha through the legacy combined entry, pushing + // the expiry later than the indexed copy. + fresh := stale.Add(1 * time.Hour) + legacy := storeFile{SchemaVersion: storeSchemaVersion, Tokens: map[string]Token{ + ProviderKey("alpha"): {AccessToken: "a-new", RefreshToken: "r-new", ExpiresAt: fresh}, + }} + legacyData, err := json.Marshal(legacy) + if err != nil { + t.Fatal(err) + } + kr.data[keyringService+"/"+keyringLegacyAccount] = base64.StdEncoding.EncodeToString(legacyData) + + // A new-binary save of an unrelated key must reconcile alpha, not clobber it. + if err := s.Save(ProviderKey("beta"), Token{AccessToken: "b"}); err != nil { + t.Fatal(err) + } + got, ok, err := s.Load(ProviderKey("alpha")) + if err != nil || !ok { + t.Fatalf("Load(alpha): ok=%v err=%v", ok, err) + } + if got.AccessToken != "a-new" || got.RefreshToken != "r-new" { + t.Fatalf("Load(alpha) = %#v, want the refreshed legacy value (fresh refresh discarded)", got) + } + if _, ok, _ := s.Load(ProviderKey("beta")); !ok { + t.Fatal("Load(beta): not stored") + } + if _, ok := kr.data[keyringService+"/"+keyringLegacyAccount]; ok { + t.Fatal("legacy entry should be removed once its refresh is merged") + } +} From 527846e8c92bdff31d30f7acf0c206c2b50c12af Mon Sep 17 00:00:00 2001 From: euxaristia <25621994+euxaristia@users.noreply.github.com> Date: Thu, 16 Jul 2026 21:56:11 -0400 Subject: [PATCH 06/28] fix(oauth): lease the keyring lock with wall-clock time The lock lease renewal stamped the live lock file using the injectable StoreOptions.Now, but a caller may fix or freeze that clock (for example to drive token-expiry tests). acquireFileLock judges lock staleness with real time.Since(mtime), so leasing with a frozen or backdated clock let another process treat a held lock as stale and reclaim it mid-operation, reviving the token-loss race the lease exists to prevent. Lease with time.Now() regardless of the store clock. --- internal/oauth/store.go | 7 +++++- internal/oauth/store_keyring_test.go | 34 ++++++++++++++++++++++++++++ 2 files changed, 40 insertions(+), 1 deletion(-) diff --git a/internal/oauth/store.go b/internal/oauth/store.go index 8c5d4635c..02a9a0b1f 100644 --- a/internal/oauth/store.go +++ b/internal/oauth/store.go @@ -862,7 +862,12 @@ func (b keyringBlob) withLock(now func() time.Time, fn func() error) error { case <-stop: return case <-ticker.C: - at := now() + // Lease with wall-clock time, never the injectable now: acquireFileLock + // judges staleness with real time.Since(mtime), so a fixed or stale + // StoreOptions.Now would stamp the live lock with an old mtime that + // another process would immediately reclaim, reviving the token-loss + // race this lease prevents. + at := time.Now() _ = os.Chtimes(b.lockPath, at, at) } } diff --git a/internal/oauth/store_keyring_test.go b/internal/oauth/store_keyring_test.go index 7072bf23a..9a8372cf7 100644 --- a/internal/oauth/store_keyring_test.go +++ b/internal/oauth/store_keyring_test.go @@ -702,3 +702,37 @@ func TestStoreKeyringMergesFreshLegacyRefreshOfIndexedKey(t *testing.T) { t.Fatal("legacy entry should be removed once its refresh is merged") } } + +// TestStoreKeyringLeaseUsesWallClockNotStoreClock guards the lock lease against +// a fixed or stale StoreOptions.Now. acquireFileLock judges staleness with real +// time.Since(mtime), so the lease must stamp the live lock with wall-clock time; +// leasing with an old injectable clock would let a peer immediately reclaim the +// held lock and re-enter the keyring read-modify-write concurrently. +func TestStoreKeyringLeaseUsesWallClockNotStoreClock(t *testing.T) { + lockPath := filepath.Join(t.TempDir(), "oauth-keyring.lockfile") + blob := keyringBlob{kr: newFakeKR(), service: "zero-test", indexAccount: "idx", lockPath: lockPath} + + previous := fileLockRefreshInterval + fileLockRefreshInterval = 20 * time.Millisecond + defer func() { fileLockRefreshInterval = previous }() + + // A deliberately stale, fixed clock: if the lease used it, the lock mtime + // would land decades in the past and look stale immediately. + fixed := time.Date(2000, 1, 1, 0, 0, 0, 0, time.UTC) + var mtime time.Time + err := blob.withLock(func() time.Time { return fixed }, func() error { + time.Sleep(150 * time.Millisecond) + info, statErr := os.Stat(lockPath) + if statErr != nil { + return statErr + } + mtime = info.ModTime() + return nil + }) + if err != nil { + t.Fatalf("withLock: %v", err) + } + if time.Since(mtime) > fileLockStaleAfter { + t.Fatalf("lease stamped the lock with the store clock (%v); a peer would reclaim the live lock", mtime) + } +} From cedc33e8a2a935cb6e6ca69884b5d298202a62aa Mon Sep 17 00:00:00 2001 From: euxaristia <25621994+euxaristia@users.noreply.github.com> Date: Thu, 16 Jul 2026 21:56:22 -0400 Subject: [PATCH 07/28] fix(oauth): bound the keyring index chunk count before reading readKeyIndex trusted the stored header's advertised chunk count and issued one keyring lookup per chunk. A corrupt or hostile header claiming a huge count (for example {"v":1,"chunks":1000000000}) would fan out into that many blocking keyring lookups, each up to the command timeout, while holding the store lock, wedging every Load/Status/Save/Delete instead of failing promptly. Reject an unsupported index version or an out-of-range chunk count (1..128) up front, before the read loop. --- internal/oauth/store.go | 24 ++++++++++++---- internal/oauth/store_keyring_test.go | 43 ++++++++++++++++++++++++++++ 2 files changed, 62 insertions(+), 5 deletions(-) diff --git a/internal/oauth/store.go b/internal/oauth/store.go index 02a9a0b1f..cd0250a32 100644 --- a/internal/oauth/store.go +++ b/internal/oauth/store.go @@ -712,6 +712,14 @@ func (b keyringBlob) write(data []byte) error { // roughly 22 maximum-length keys even when every token was tiny. const maxKeyringIndexChunkBytes = 2700 +// maxKeyringIndexChunks caps how many chunk entries a stored index header may +// claim before readKeyIndex issues one OS-keyring lookup per chunk. Each chunk +// holds up to maxKeyringIndexChunkBytes of keys (dozens to ~150 keys), so this +// bound admits far more logins than any real install while refusing to fan a +// corrupt header (e.g. {"v":1,"chunks":1000000000}) out into a billion blocking +// lookups that would wedge every OAuth operation under the store lock. +const maxKeyringIndexChunks = 128 + // keyIndexHeader is chunk 0 of the key index. Chunks 1..Chunks-1 live under // "-" as plain JSON string arrays. The pre-chunking format // (a bare JSON array at indexAccount) is still read transparently. @@ -753,6 +761,16 @@ func (b keyringBlob) readKeyIndex() ([]string, bool, int, error) { if err := json.Unmarshal(raw, &header); err != nil { return nil, false, 0, fmt.Errorf("oauth: decode keyring token index: %w", err) } + // Reject an unsupported or corrupt header before looping: an out-of-range + // Chunks would otherwise drive up to that many blocking keyring lookups + // (each up to the 10s command timeout) while the store lock is held, wedging + // every Load/Status/Save/Delete instead of failing promptly. + if header.Version != 1 { + return nil, false, 0, fmt.Errorf("oauth: unsupported keyring token index version %d", header.Version) + } + if header.Chunks < 1 || header.Chunks > maxKeyringIndexChunks { + return nil, false, 0, fmt.Errorf("oauth: keyring token index advertises %d chunks (want 1..%d)", header.Chunks, maxKeyringIndexChunks) + } keys := header.Keys for i := 1; i < header.Chunks; i++ { chunkEnc, ok, err := b.kr.Get(b.service, b.chunkAccount(i)) @@ -772,11 +790,7 @@ func (b keyringBlob) readKeyIndex() ([]string, bool, int, error) { } keys = append(keys, more...) } - chunks := header.Chunks - if chunks < 1 { - chunks = 1 - } - return keys, true, chunks, nil + return keys, true, header.Chunks, nil } // writeKeyIndex persists keys as a chunked index and reports how many chunk diff --git a/internal/oauth/store_keyring_test.go b/internal/oauth/store_keyring_test.go index 9a8372cf7..a079e3ab1 100644 --- a/internal/oauth/store_keyring_test.go +++ b/internal/oauth/store_keyring_test.go @@ -736,3 +736,46 @@ func TestStoreKeyringLeaseUsesWallClockNotStoreClock(t *testing.T) { t.Fatalf("lease stamped the lock with the store clock (%v); a peer would reclaim the live lock", mtime) } } + +// countingKR counts Get calls so a test can prove a corrupt index is rejected +// before it fans out into a keyring lookup per advertised chunk. +type countingKR struct { + *fakeKR + gets int +} + +func (c *countingKR) Get(service, account string) (string, bool, error) { + c.gets++ + return c.fakeKR.Get(service, account) +} + +// TestStoreKeyringReadIndexRejectsCorruptHeader is the regression test for an +// index header whose advertised chunk count is unbounded: readKeyIndex must +// reject an out-of-range or unsupported header up front rather than issue up to +// that many blocking keyring lookups while holding the store lock. +func TestStoreKeyringReadIndexRejectsCorruptHeader(t *testing.T) { + ckr := &countingKR{fakeKR: newFakeKR()} + blob := keyringBlob{kr: ckr, service: keyringService, legacyAccount: keyringLegacyAccount, indexAccount: keyringIndexAccount} + + oversized, err := json.Marshal(keyIndexHeader{Version: 1, Chunks: 1_000_000_000, Keys: []string{ProviderKey("demo")}}) + if err != nil { + t.Fatal(err) + } + ckr.data[keyringService+"/"+keyringIndexAccount] = base64.StdEncoding.EncodeToString(oversized) + ckr.gets = 0 + if _, _, _, err := blob.readKeyIndex(); err == nil { + t.Fatal("expected an oversized chunk count to be rejected") + } + if ckr.gets != 1 { + t.Fatalf("readKeyIndex issued %d keyring gets on a corrupt header; it must reject before fanning out over chunks", ckr.gets) + } + + unsupported, err := json.Marshal(keyIndexHeader{Version: 2, Chunks: 1, Keys: []string{}}) + if err != nil { + t.Fatal(err) + } + ckr.data[keyringService+"/"+keyringIndexAccount] = base64.StdEncoding.EncodeToString(unsupported) + if _, _, _, err := blob.readKeyIndex(); err == nil { + t.Fatal("expected an unsupported index version to be rejected") + } +} From 08ed2c6c316ff1ae34e106533fd7da59bf2e0fb6 Mon Sep 17 00:00:00 2001 From: euxaristia <25621994+euxaristia@users.noreply.github.com> Date: Thu, 16 Jul 2026 21:56:40 -0400 Subject: [PATCH 08/28] fix(oauth): scope the keyring fallback lock to a per-user path When no config location resolves, the keyring lock fell back to a single shared ${TMPDIR}/zero-oauth-keyring.lockfile. On a multi-user host any other account could pre-create or keep refreshing that path and time out the victim's Load/Status/Save/Delete, even though each user has a separate OS keychain. Prefer the per-user OS cache directory, and scope the last-resort temp file by uid so two different users never collide on one lock path. --- internal/oauth/store.go | 30 +++++++++++++++++++++++++--- internal/oauth/store_keyring_test.go | 25 +++++++++++++++++++++++ 2 files changed, 52 insertions(+), 3 deletions(-) diff --git a/internal/oauth/store.go b/internal/oauth/store.go index cd0250a32..f1a669a18 100644 --- a/internal/oauth/store.go +++ b/internal/oauth/store.go @@ -213,9 +213,9 @@ func NewStore(options StoreOptions) (*Store, error) { // file beside where the file backend would live. Cross-process exclusion // must not silently disappear when no config location resolves (withLock // would be a no-op and a concurrent save could delete another process's - // newly written entry), so fall back to the OS temp directory, which - // always exists, rather than to in-process serialization only. - lockPath := filepath.Join(os.TempDir(), "zero-oauth-keyring.lockfile") + // newly written entry), so fall back to a per-user location that always + // exists, rather than to in-process serialization only. + lockPath := keyringFallbackLockPath() if storePath, perr := ResolveStorePath(options.Env); perr == nil { lockPath = filepath.Join(filepath.Dir(storePath), "oauth-keyring.lockfile") } @@ -244,6 +244,30 @@ func resolveStoreFilePath(options StoreOptions) (string, error) { return filepath.Clean(filePath), nil } +// keyringFallbackLockPath returns a per-user location for the keyring lock when +// no config location resolves. A single shared ${TMPDIR}/zero-oauth-keyring.lockfile +// let any other account on a multi-user host pre-create or keep refreshing the +// victim's lock and time out their Load/Status/Save/Delete, even though each user +// has a separate OS keychain. Prefer the per-user OS cache directory (created +// 0700 by acquireFileLock); only if that cannot be resolved fall back to a temp +// file scoped by uid so two different users never collide on one path. +func keyringFallbackLockPath() string { + if dir, err := os.UserCacheDir(); err == nil && strings.TrimSpace(dir) != "" { + return filepath.Join(dir, "zero", "oauth-keyring.lockfile") + } + return filepath.Join(os.TempDir(), keyringTempLockName()) +} + +// keyringTempLockName names the last-resort temp lock file, scoping it by uid so +// concurrently running different users do not share one path. os.Getuid returns +// -1 where uids do not apply (Windows), where os.TempDir is already per-user. +func keyringTempLockName() string { + if uid := os.Getuid(); uid >= 0 { + return fmt.Sprintf("zero-oauth-keyring-%d.lockfile", uid) + } + return "zero-oauth-keyring.lockfile" +} + // FilePath returns the resolved token store location (a path for the file // backend, or a "keyring:..." identifier for the keyring backend). func (s *Store) FilePath() string { return s.blob.location() } diff --git a/internal/oauth/store_keyring_test.go b/internal/oauth/store_keyring_test.go index a079e3ab1..e615a18e3 100644 --- a/internal/oauth/store_keyring_test.go +++ b/internal/oauth/store_keyring_test.go @@ -3,6 +3,7 @@ package oauth import ( "encoding/base64" "encoding/json" + "fmt" "os" "path/filepath" "strings" @@ -779,3 +780,27 @@ func TestStoreKeyringReadIndexRejectsCorruptHeader(t *testing.T) { t.Fatal("expected an unsupported index version to be rejected") } } + +// TestKeyringFallbackLockPathIsPerUser covers the fallback taken when no config +// location resolves. It must not be the single shared temp path that any account +// on a multi-user host could pre-create or hold, and the last-resort temp name +// must be scoped by uid so different users never collide on one lock file. +func TestKeyringFallbackLockPathIsPerUser(t *testing.T) { + got := keyringFallbackLockPath() + if got == filepath.Join(os.TempDir(), "zero-oauth-keyring.lockfile") { + t.Fatalf("fallback lock path is the shared temp path %q; a co-tenant could grief it", got) + } + if cache, err := os.UserCacheDir(); err == nil && strings.TrimSpace(cache) != "" { + if want := filepath.Join(cache, "zero", "oauth-keyring.lockfile"); got != want { + t.Fatalf("fallback = %q, want per-user cache path %q", got, want) + } + } + name := keyringTempLockName() + if uid := os.Getuid(); uid >= 0 { + if !strings.Contains(name, fmt.Sprintf("%d", uid)) { + t.Fatalf("temp lock name %q is not scoped by uid %d", name, uid) + } + } else if name == "" { + t.Fatal("temp lock name is empty") + } +} From 73f6da823fc9aba483d4efcf026b1a8c98676cf8 Mon Sep 17 00:00:00 2001 From: euxaristia <25621994+euxaristia@users.noreply.github.com> Date: Sat, 18 Jul 2026 17:04:01 -0400 Subject: [PATCH 09/28] fix(oauth): fail logout on legacy-blob delete failure; cap index chunks on write MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two review findings: - write() swallowed the final legacy combined-entry delete error, so a logout could report success while the secret stayed resident, and the next save would classify the leftover legacy key as a fresh old-binary login and silently log the user back in. The delete now runs while the union index still lists removed keys and its failure propagates, so a retried logout reconciles instead of resurrecting the credential. - writeKeyIndex could publish a header with more chunks than maxKeyringIndexChunks, which readKeyIndex then refuses — bricking every later Load/Status/Save/Delete. The write side now enforces the same cap before publishing anything. Adds failure-injection coverage for the legacy-delete boundary and a write-side cap regression test; the write-interruption test now asserts every mutating boundary surfaces its injected failure. Co-Authored-By: Claude Fable 5 --- internal/oauth/store.go | 24 ++++++-- internal/oauth/store_keyring_test.go | 91 ++++++++++++++++++++++++++-- 2 files changed, 105 insertions(+), 10 deletions(-) diff --git a/internal/oauth/store.go b/internal/oauth/store.go index f1a669a18..b40e75218 100644 --- a/internal/oauth/store.go +++ b/internal/oauth/store.go @@ -630,7 +630,9 @@ func legacyIsFresher(legacy, current Token) bool { // or skips them once it is gone) or entries that a later read/write can still // see and reconcile, never an invisible credential stranded in the OS keychain. // The legacy combined entry is the durable fallback for the initial migration -// and is deleted only as the final step, after every per-key entry is written. +// and is deleted only after every per-key entry is written, while the union +// index still lists removed keys; a failure of that delete is returned so a +// logout is never reported successful with the stale blob still resident. func (b keyringBlob) write(data []byte) error { var state storeFile if err := json.Unmarshal(data, &state); err != nil { @@ -718,13 +720,19 @@ func (b keyringBlob) write(data []byte) error { } } } - // 4. Shrink the index to the exact new key set. + // 4. Drop the legacy entry: the index now exists and is authoritative, + // and its fresh writes were merged above. This must happen while the + // union index still lists any removed keys and its failure must surface: + // if a stale legacy blob survived a logout whose index shrink already + // completed, the next save would classify its keys as fresh old-binary + // logins and silently resurrect the logged-out credential. + if _, err := b.kr.Delete(b.service, b.legacyAccount); err != nil { + return err + } + // 5. Shrink the index to the exact new key set. if _, err := b.writeKeyIndex(keys, unionChunks); err != nil { return err } - // The index now exists and is authoritative; drop the legacy entry so a - // future read never falls back to it (its fresh writes were merged above). - _, _ = b.kr.Delete(b.service, b.legacyAccount) return nil } @@ -825,6 +833,12 @@ func (b keyringBlob) readKeyIndex() ([]string, bool, int, error) { // chunk is never read). func (b keyringBlob) writeKeyIndex(keys []string, priorChunks int) (int, error) { chunks := chunkIndexKeys(keys) + // Refuse to publish an index the reader would reject: readKeyIndex caps + // headers at maxKeyringIndexChunks, and a header beyond it would make + // every later Load/Status/Save/Delete fail before it could recover. + if len(chunks) > maxKeyringIndexChunks { + return 0, fmt.Errorf("oauth: keyring key index needs %d chunks, over the %d-chunk cap readers accept; too many stored credentials", len(chunks), maxKeyringIndexChunks) + } for i := 1; i < len(chunks); i++ { chunkData, err := json.Marshal(chunks[i]) if err != nil { diff --git a/internal/oauth/store_keyring_test.go b/internal/oauth/store_keyring_test.go index e615a18e3..7e5d41c28 100644 --- a/internal/oauth/store_keyring_test.go +++ b/internal/oauth/store_keyring_test.go @@ -373,11 +373,12 @@ func TestStoreKeyringWriteInterruptionsLeaveNoInvisibleTokens(t *testing.T) { t.Fatalf("failAt=%d: Load(%s) after reconcile: ok=%v err=%v", failAt, name, ok, err) } } - // saveErr itself is not asserted: most boundaries surface the injected - // failure, but the final legacy-entry delete is deliberately - // best-effort, so its failure is swallowed by design. The invariant - // and the reconcile above are the actual contract. - _ = saveErr + // Every mutating boundary of the write path now surfaces its failure, + // including the legacy-entry delete (a swallowed failure there could + // let a later save resurrect logged-out credentials). + if opsUsed >= failAt && saveErr == nil { + t.Fatalf("failAt=%d: injected keyring failure was swallowed", failAt) + } if opsUsed < failAt { // The write used fewer mutating ops than failAt, so the injection // never fired and every boundary has been covered. @@ -804,3 +805,83 @@ func TestKeyringFallbackLockPathIsPerUser(t *testing.T) { t.Fatal("temp lock name is empty") } } + +// legacyDeleteFailKR fails Delete for the legacy combined entry only, to +// exercise the boundary where a logout has already rewritten the indexed +// state but the stale legacy blob cannot be removed. +type legacyDeleteFailKR struct { + *fakeKR + fail bool +} + +func (f *legacyDeleteFailKR) Delete(service, account string) (bool, error) { + if f.fail && account == keyringLegacyAccount { + return false, errKRInjected + } + return f.fakeKR.Delete(service, account) +} + +// TestStoreKeyringLogoutSurfacesLegacyBlobDeleteFailure: when the final +// legacy-blob delete fails during a logout, the operation must report the +// failure (not success with the secret still resident), and after a clean +// retry the logged-out credential must not be resurrected by a later save. +func TestStoreKeyringLogoutSurfacesLegacyBlobDeleteFailure(t *testing.T) { + t.Setenv("XDG_CONFIG_HOME", t.TempDir()) + kr := &legacyDeleteFailKR{fakeKR: newFakeKR()} + s, err := NewStore(StoreOptions{Storage: "keyring", Keyring: kr}) + if err != nil { + t.Fatal(err) + } + if err := s.Save(ProviderKey("alpha"), Token{AccessToken: "a"}); err != nil { + t.Fatal(err) + } + // A leftover legacy blob still carries alpha (e.g. written by an old + // binary during the upgrade window). + legacy := storeFile{SchemaVersion: storeSchemaVersion, Tokens: map[string]Token{ + ProviderKey("alpha"): {AccessToken: "stale-a"}, + }} + data, err := json.Marshal(legacy) + if err != nil { + t.Fatal(err) + } + kr.data[keyringService+"/"+keyringLegacyAccount] = base64.StdEncoding.EncodeToString(data) + + kr.fail = true + if _, err := s.Delete(ProviderKey("alpha")); err == nil { + t.Fatal("Delete reported success although the stale legacy blob could not be removed") + } + + // A clean retry succeeds, and a later save must not classify the stale + // legacy alpha as a fresh old-binary login. + kr.fail = false + if _, err := s.Delete(ProviderKey("alpha")); err != nil { + t.Fatalf("retried Delete: %v", err) + } + if err := s.Save(ProviderKey("beta"), Token{AccessToken: "b"}); err != nil { + t.Fatal(err) + } + if _, ok, err := s.Load(ProviderKey("alpha")); err != nil || ok { + t.Fatalf("logged-out credential resurrected: ok=%v err=%v", ok, err) + } +} + +// TestStoreKeyringWriteIndexRejectsOverCapChunks: writeKeyIndex must refuse +// to publish an index header that readKeyIndex would reject, instead of +// persisting a store no later operation can open. +func TestStoreKeyringWriteIndexRejectsOverCapChunks(t *testing.T) { + kr := newFakeKR() + b := keyringBlob{kr: kr, service: keyringService, legacyAccount: keyringLegacyAccount, indexAccount: keyringIndexAccount} + // Each key exceeds the per-chunk byte budget on its own, forcing one + // chunk per key. + long := strings.Repeat("k", maxKeyringIndexChunkBytes) + keys := make([]string, maxKeyringIndexChunks+1) + for i := range keys { + keys[i] = fmt.Sprintf("%s-%d", long, i) + } + if _, err := b.writeKeyIndex(keys, 0); err == nil { + t.Fatal("writeKeyIndex published an index readKeyIndex would refuse") + } + if len(kr.data) != 0 { + t.Fatalf("over-cap index write must publish nothing, found %d entries", len(kr.data)) + } +} From ecca0c2fbaa4faed6ae8b3de5068d432a5831280 Mon Sep 17 00:00:00 2001 From: euxaristia <25621994+euxaristia@users.noreply.github.com> Date: Sun, 19 Jul 2026 04:49:46 -0400 Subject: [PATCH 10/28] fix(oauth): use wall clock for lock timeout and bound keyring index acquireFileLock computed its deadline using the injectable clock, so a fixed test clock could make lock contention retry forever instead of timing out. Compute the deadline against wall-clock time instead, keeping the injectable clock for token generation only. Cap the number of keys a single keyring index chunk can claim, so a corrupted index can no longer drive an unbounded lookup fan-out while holding the store lock. Strengthen several existing tests that were marked addressed but weren't fully. --- internal/oauth/lock.go | 10 +++- internal/oauth/store.go | 28 +++++++++ internal/oauth/store_keyring_test.go | 90 +++++++++++++++++++++++++++- 3 files changed, 123 insertions(+), 5 deletions(-) diff --git a/internal/oauth/lock.go b/internal/oauth/lock.go index d1dd344d2..0d192a2ed 100644 --- a/internal/oauth/lock.go +++ b/internal/oauth/lock.go @@ -23,6 +23,12 @@ var lockSeq atomic.Uint64 // file is older than fileLockStaleAfter (so a crashed holder cannot deadlock the // store). Release is ownership-aware: it removes the lock only if it still holds // our token, so a stale-broken holder cannot delete a newer holder's lock. +// +// The acquisition deadline is always measured against the real wall clock, never +// the now parameter: now is StoreOptions.Now, which callers may legitimately fix +// (e.g. a test or an embedded clock), and deadline := now().Add(fileLockTimeout) +// followed by now().After(deadline) would then never become true, turning lock +// contention into an infinite retry loop instead of a timeout error. func acquireFileLock(lockPath string, now func() time.Time) (func(), error) { if now == nil { now = time.Now @@ -31,7 +37,7 @@ func acquireFileLock(lockPath string, now func() time.Time) (func(), error) { return nil, err } token := fmt.Sprintf("%d-%d-%d", os.Getpid(), now().UnixNano(), lockSeq.Add(1)) - deadline := now().Add(fileLockTimeout) + deadline := time.Now().Add(fileLockTimeout) for { f, err := os.OpenFile(lockPath, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0o600) if err == nil { @@ -88,7 +94,7 @@ func acquireFileLock(lockPath string, now func() time.Time) (func(), error) { // Lost the reclaim race (or it was actually fresh) — fall through to the // bounded wait rather than hot-spinning on a reclaim that never wins. } - if now().After(deadline) { + if time.Now().After(deadline) { return nil, fmt.Errorf("oauth: timed out acquiring token lock %s", filepath.Base(lockPath)) } time.Sleep(10 * time.Millisecond) diff --git a/internal/oauth/store.go b/internal/oauth/store.go index b40e75218..13ff4fde5 100644 --- a/internal/oauth/store.go +++ b/internal/oauth/store.go @@ -752,6 +752,25 @@ const maxKeyringIndexChunkBytes = 2700 // lookups that would wedge every OAuth operation under the store lock. const maxKeyringIndexChunks = 128 +// maxKeyringIndexKeys bounds how many keys readKeyIndex will ever return, across +// the header and every chunk (and the legacy bare-array format), before read() +// and write() fan them out into one kr.Get per key while holding the store +// lock. maxKeyringIndexChunks only bounds the number of chunk entries fetched; +// it does not bound how many keys a single chunk's JSON can claim, so a +// corrupted index with an oversized keys array (or many chunks each stuffed +// with keys) could still drive an unbounded number of blocking lookups. The +// bound here is generous relative to what chunkIndexKeys ever legitimately +// produces (short namespaced keys cost at least ~18 bytes each, so one +// maxKeyringIndexChunkBytes chunk holds on the order of a hundred, times +// maxKeyringIndexChunks) while still rejecting a damaged index promptly. +const maxKeyringIndexKeys = maxKeyringIndexChunks * 200 + +// errKeyringIndexTooManyKeys is returned when a decoded index (or one of its +// chunks) claims more keys than maxKeyringIndexKeys. +func errKeyringIndexTooManyKeys(count int) error { + return fmt.Errorf("oauth: keyring token index lists %d keys, over the %d-key cap", count, maxKeyringIndexKeys) +} + // keyIndexHeader is chunk 0 of the key index. Chunks 1..Chunks-1 live under // "-" as plain JSON string arrays. The pre-chunking format // (a bare JSON array at indexAccount) is still read transparently. @@ -787,6 +806,9 @@ func (b keyringBlob) readKeyIndex() ([]string, bool, int, error) { if err := json.Unmarshal(raw, &keys); err != nil { return nil, false, 0, fmt.Errorf("oauth: decode keyring token index: %w", err) } + if len(keys) > maxKeyringIndexKeys { + return nil, false, 0, errKeyringIndexTooManyKeys(len(keys)) + } return keys, true, 1, nil } var header keyIndexHeader @@ -803,6 +825,9 @@ func (b keyringBlob) readKeyIndex() ([]string, bool, int, error) { if header.Chunks < 1 || header.Chunks > maxKeyringIndexChunks { return nil, false, 0, fmt.Errorf("oauth: keyring token index advertises %d chunks (want 1..%d)", header.Chunks, maxKeyringIndexChunks) } + if len(header.Keys) > maxKeyringIndexKeys { + return nil, false, 0, errKeyringIndexTooManyKeys(len(header.Keys)) + } keys := header.Keys for i := 1; i < header.Chunks; i++ { chunkEnc, ok, err := b.kr.Get(b.service, b.chunkAccount(i)) @@ -820,6 +845,9 @@ func (b keyringBlob) readKeyIndex() ([]string, bool, int, error) { if err := json.Unmarshal(chunkRaw, &more); err != nil { return nil, false, 0, fmt.Errorf("oauth: decode keyring token index chunk %d: %w", i, err) } + if len(keys)+len(more) > maxKeyringIndexKeys { + return nil, false, 0, errKeyringIndexTooManyKeys(len(keys) + len(more)) + } keys = append(keys, more...) } return keys, true, header.Chunks, nil diff --git a/internal/oauth/store_keyring_test.go b/internal/oauth/store_keyring_test.go index 7e5d41c28..f6db1c2c2 100644 --- a/internal/oauth/store_keyring_test.go +++ b/internal/oauth/store_keyring_test.go @@ -364,6 +364,15 @@ func TestStoreKeyringWriteInterruptionsLeaveNoInvisibleTokens(t *testing.T) { } } + // The tokens this write didn't touch must stay readable at the + // interruption boundary itself, not just after a later reconciling + // write papers over an incorrect intermediate state. + for _, name := range []string{"alpha", "beta"} { + if _, ok, err := s.Load(ProviderKey(name)); err != nil || !ok { + t.Fatalf("failAt=%d: Load(%s) before reconcile: ok=%v err=%v", failAt, name, ok, err) + } + } + // A later unimpeded write must reconcile completely. if err := s.Save(ProviderKey("gamma"), Token{AccessToken: "c"}); err != nil { t.Fatalf("failAt=%d: reconciling Save: %v", failAt, err) @@ -407,7 +416,7 @@ func TestStoreKeyringDeleteInterruptionsLeaveNoInvisibleTokens(t *testing.T) { } kr.ops = 0 kr.failAt = failAt - _, _ = s.Delete(ProviderKey("beta")) + _, deleteErr := s.Delete(ProviderKey("beta")) opsUsed := kr.ops kr.failAt = 0 @@ -433,6 +442,12 @@ func TestStoreKeyringDeleteInterruptionsLeaveNoInvisibleTokens(t *testing.T) { if _, ok, err := s.Load(ProviderKey("alpha")); err != nil || !ok { t.Fatalf("failAt=%d: Load(alpha): ok=%v err=%v", failAt, ok, err) } + // Every mutating boundary of the delete path must surface its failure, + // mirroring the Save-interruption assertion: a swallowed error here + // would let a caller believe a logout succeeded when it didn't. + if opsUsed >= failAt && deleteErr == nil { + t.Fatalf("failAt=%d: injected keyring failure was swallowed", failAt) + } if opsUsed < failAt { break } @@ -473,6 +488,11 @@ func TestStoreKeyringMergesFreshLegacyWriteFromOldBinary(t *testing.T) { t.Fatalf("Load(%s): ok=%v err=%v (fresh legacy write lost)", name, ok, err) } } + // Presence alone doesn't rule out the merge corrupting carol's credential + // material; check the actual values survived the legacy->indexed merge. + if got, _, err := s.Load(ProviderKey("carol")); err != nil || got.AccessToken != "c" || got.RefreshToken != "cr" { + t.Fatalf("Load(carol) = %#v, err=%v, want the legacy access/refresh tokens intact", got, err) + } if _, ok := kr.data[keyringService+"/"+keyringLegacyAccount]; ok { t.Fatal("legacy entry should be removed once its fresh writes are merged") } @@ -694,8 +714,8 @@ func TestStoreKeyringMergesFreshLegacyRefreshOfIndexedKey(t *testing.T) { if err != nil || !ok { t.Fatalf("Load(alpha): ok=%v err=%v", ok, err) } - if got.AccessToken != "a-new" || got.RefreshToken != "r-new" { - t.Fatalf("Load(alpha) = %#v, want the refreshed legacy value (fresh refresh discarded)", got) + if got.AccessToken != "a-new" || got.RefreshToken != "r-new" || !got.ExpiresAt.Equal(fresh) { + t.Fatalf("Load(alpha) = %#v, want the refreshed legacy value (tokens and expiry) with ExpiresAt=%v", got, fresh) } if _, ok, _ := s.Load(ProviderKey("beta")); !ok { t.Fatal("Load(beta): not stored") @@ -705,6 +725,31 @@ func TestStoreKeyringMergesFreshLegacyRefreshOfIndexedKey(t *testing.T) { } } +// TestAcquireFileLockDeadlineUsesWallClockNotInjectedClock guards the other +// half of the same hazard as the lease test below: acquireFileLock's own +// acquisition deadline must be measured against the real wall clock, not the +// injectable now parameter. StoreOptions.Now may legitimately be a fixed +// clock (as this test uses), and deadline := now().Add(fileLockTimeout) +// followed by now().After(deadline) would then never become true, so a +// contested lock would retry forever instead of returning a timeout error. +func TestAcquireFileLockDeadlineUsesWallClockNotInjectedClock(t *testing.T) { + lockPath := filepath.Join(t.TempDir(), "test.lockfile") + // A fresh (non-stale) lock held by someone else: acquireFileLock must not + // reclaim it, only wait out fileLockTimeout and report a timeout. + if err := os.WriteFile(lockPath, []byte("someone-else"), 0o600); err != nil { + t.Fatal(err) + } + fixed := time.Date(2000, 1, 1, 0, 0, 0, 0, time.UTC) + start := time.Now() + _, err := acquireFileLock(lockPath, func() time.Time { return fixed }) + if err == nil { + t.Fatal("expected a timeout error acquiring an already-held, non-stale lock") + } + if elapsed := time.Since(start); elapsed > 10*time.Second { + t.Fatalf("acquireFileLock took %v with a fixed clock; the deadline must use the wall clock, not now()", elapsed) + } +} + // TestStoreKeyringLeaseUsesWallClockNotStoreClock guards the lock lease against // a fixed or stale StoreOptions.Now. acquireFileLock judges staleness with real // time.Since(mtime), so the lease must stamp the live lock with wall-clock time; @@ -777,9 +822,48 @@ func TestStoreKeyringReadIndexRejectsCorruptHeader(t *testing.T) { t.Fatal(err) } ckr.data[keyringService+"/"+keyringIndexAccount] = base64.StdEncoding.EncodeToString(unsupported) + ckr.gets = 0 if _, _, _, err := blob.readKeyIndex(); err == nil { t.Fatal("expected an unsupported index version to be rejected") } + if ckr.gets != 1 { + t.Fatalf("readKeyIndex issued %d gets for an unsupported version; want header lookup only", ckr.gets) + } +} + +// TestStoreKeyringReadIndexRejectsOversizedKeyList regresses a corrupt index +// that claims more keys than maxKeyringIndexKeys: maxKeyringIndexChunks alone +// bounds only how many chunk entries are fetched, not how many keys a single +// chunk's JSON (or the legacy bare-array format) can claim, so without this +// check readKeyIndex would hand read()/write() an oversized key list to fan +// out into one blocking kr.Get per key while holding the store lock. +func TestStoreKeyringReadIndexRejectsOversizedKeyList(t *testing.T) { + ckr := &countingKR{fakeKR: newFakeKR()} + blob := keyringBlob{kr: ckr, service: keyringService, legacyAccount: keyringLegacyAccount, indexAccount: keyringIndexAccount} + + tooMany := make([]string, maxKeyringIndexKeys+1) + for i := range tooMany { + tooMany[i] = ProviderKey(fmt.Sprintf("p%d", i)) + } + + header, err := json.Marshal(keyIndexHeader{Version: 1, Chunks: 1, Keys: tooMany}) + if err != nil { + t.Fatal(err) + } + ckr.data[keyringService+"/"+keyringIndexAccount] = base64.StdEncoding.EncodeToString(header) + if _, _, _, err := blob.readKeyIndex(); err == nil { + t.Fatal("expected an oversized key list in a chunk-0 header to be rejected") + } + + // The pre-chunking bare-array format must be capped the same way. + legacyArray, err := json.Marshal(tooMany) + if err != nil { + t.Fatal(err) + } + ckr.data[keyringService+"/"+keyringIndexAccount] = base64.StdEncoding.EncodeToString(legacyArray) + if _, _, _, err := blob.readKeyIndex(); err == nil { + t.Fatal("expected an oversized legacy-format key array to be rejected") + } } // TestKeyringFallbackLockPathIsPerUser covers the fallback taken when no config From 19a14ae7b39c9526aee31d613454aef5bb164305 Mon Sep 17 00:00:00 2001 From: euxaristia <25621994+euxaristia@users.noreply.github.com> Date: Sun, 19 Jul 2026 07:25:56 -0400 Subject: [PATCH 11/28] fix(oauth): refuse keyring indexes over the reader key cap on write writeKeyIndex already refused over-cap chunk counts, but a large set of short keys can stay under the chunk limit while exceeding maxKeyringIndexKeys. Cap the key count before publishing so Save cannot persist an index that readKeyIndex then rejects. Cover the write-side path and multi-chunk accumulation on the reader. --- internal/oauth/store.go | 11 +++++-- internal/oauth/store_keyring_test.go | 49 ++++++++++++++++++++++++++++ 2 files changed, 57 insertions(+), 3 deletions(-) diff --git a/internal/oauth/store.go b/internal/oauth/store.go index 13ff4fde5..9dc76d67f 100644 --- a/internal/oauth/store.go +++ b/internal/oauth/store.go @@ -860,10 +860,15 @@ func (b keyringBlob) readKeyIndex() ([]string, bool, int, error) { // only after the header stops referencing them (best-effort: an unreferenced // chunk is never read). func (b keyringBlob) writeKeyIndex(keys []string, priorChunks int) (int, error) { + // Refuse to publish an index the reader would reject: readKeyIndex caps both + // total keys and chunk count, and a header beyond either would make every + // later Load/Status/Save/Delete fail before it could recover. Check the key + // count before chunking so a large set of short keys that still fit under + // maxKeyringIndexChunks cannot strand the store unreadable. + if len(keys) > maxKeyringIndexKeys { + return 0, errKeyringIndexTooManyKeys(len(keys)) + } chunks := chunkIndexKeys(keys) - // Refuse to publish an index the reader would reject: readKeyIndex caps - // headers at maxKeyringIndexChunks, and a header beyond it would make - // every later Load/Status/Save/Delete fail before it could recover. if len(chunks) > maxKeyringIndexChunks { return 0, fmt.Errorf("oauth: keyring key index needs %d chunks, over the %d-chunk cap readers accept; too many stored credentials", len(chunks), maxKeyringIndexChunks) } diff --git a/internal/oauth/store_keyring_test.go b/internal/oauth/store_keyring_test.go index f6db1c2c2..826c6e82a 100644 --- a/internal/oauth/store_keyring_test.go +++ b/internal/oauth/store_keyring_test.go @@ -851,9 +851,13 @@ func TestStoreKeyringReadIndexRejectsOversizedKeyList(t *testing.T) { t.Fatal(err) } ckr.data[keyringService+"/"+keyringIndexAccount] = base64.StdEncoding.EncodeToString(header) + ckr.gets = 0 if _, _, _, err := blob.readKeyIndex(); err == nil { t.Fatal("expected an oversized key list in a chunk-0 header to be rejected") } + if ckr.gets != 1 { + t.Fatalf("readKeyIndex issued %d gets for an oversized header keys list; want header lookup only", ckr.gets) + } // The pre-chunking bare-array format must be capped the same way. legacyArray, err := json.Marshal(tooMany) @@ -861,9 +865,34 @@ func TestStoreKeyringReadIndexRejectsOversizedKeyList(t *testing.T) { t.Fatal(err) } ckr.data[keyringService+"/"+keyringIndexAccount] = base64.StdEncoding.EncodeToString(legacyArray) + ckr.gets = 0 if _, _, _, err := blob.readKeyIndex(); err == nil { t.Fatal("expected an oversized legacy-format key array to be rejected") } + if ckr.gets != 1 { + t.Fatalf("readKeyIndex issued %d gets for an oversized legacy array; want header lookup only", ckr.gets) + } + + // Accumulation across continuation chunks must hit the same total cap: a + // small header plus an oversized chunk-1 would otherwise fan out past the + // bound after the per-header check has already passed. + headerOK, err := json.Marshal(keyIndexHeader{Version: 1, Chunks: 2, Keys: []string{ProviderKey("seed")}}) + if err != nil { + t.Fatal(err) + } + chunk1, err := json.Marshal(tooMany) + if err != nil { + t.Fatal(err) + } + ckr.data[keyringService+"/"+keyringIndexAccount] = base64.StdEncoding.EncodeToString(headerOK) + ckr.data[keyringService+"/"+keyringIndexAccount+"-1"] = base64.StdEncoding.EncodeToString(chunk1) + ckr.gets = 0 + if _, _, _, err := blob.readKeyIndex(); err == nil { + t.Fatal("expected an oversized key list accumulated across chunks to be rejected") + } + if ckr.gets != 2 { + t.Fatalf("readKeyIndex issued %d gets for a header+oversize-chunk; want header and chunk-1 only", ckr.gets) + } } // TestKeyringFallbackLockPathIsPerUser covers the fallback taken when no config @@ -969,3 +998,23 @@ func TestStoreKeyringWriteIndexRejectsOverCapChunks(t *testing.T) { t.Fatalf("over-cap index write must publish nothing, found %d entries", len(kr.data)) } } + +// TestStoreKeyringWriteIndexRejectsOverCapKeys: short keys can still fit under +// the chunk-count cap while exceeding maxKeyringIndexKeys. writeKeyIndex must +// refuse that set before publishing, matching the reader-side total key cap. +func TestStoreKeyringWriteIndexRejectsOverCapKeys(t *testing.T) { + kr := newFakeKR() + b := keyringBlob{kr: kr, service: keyringService, legacyAccount: keyringLegacyAccount, indexAccount: keyringIndexAccount} + keys := make([]string, maxKeyringIndexKeys+1) + for i := range keys { + // Short keys pack densely into chunks so the chunk-count check alone + // would not catch this over-cap set. + keys[i] = fmt.Sprintf("p%d", i) + } + if _, err := b.writeKeyIndex(keys, 0); err == nil { + t.Fatal("writeKeyIndex published a key count readKeyIndex would refuse") + } + if len(kr.data) != 0 { + t.Fatalf("over-cap key write must publish nothing, found %d entries", len(kr.data)) + } +} From 189527643b91fa7e55fea415be7ba6ee424c1bbd Mon Sep 17 00:00:00 2001 From: euxaristia <25621994+euxaristia@users.noreply.github.com> Date: Wed, 22 Jul 2026 03:41:39 -0400 Subject: [PATCH 12/28] fix(oauth): derive the keyring lock path from keyring identity, not file config The cross-process lock guarding the keyring token index was keyed off ResolveStorePath (ZERO_OAUTH_TOKENS_PATH / XDG_CONFIG_HOME), not off the keyring's own identity (service + index account). Two zero processes with different config roots but pointed at the same underlying OS keyring entry got different lock files, so they could race a read-modify-write on the shared index and silently drop one process's token write. keyringLockPath now derives the lock file name from the keyring service and account instead, so the lock always matches the entry actually being touched regardless of caller config. --- internal/oauth/store.go | 70 +++++++++++++++++++--------- internal/oauth/store_keyring_test.go | 68 +++++++++++++++++++++------ 2 files changed, 102 insertions(+), 36 deletions(-) diff --git a/internal/oauth/store.go b/internal/oauth/store.go index 9dc76d67f..f536212dc 100644 --- a/internal/oauth/store.go +++ b/internal/oauth/store.go @@ -210,15 +210,14 @@ func NewStore(options StoreOptions) (*Store, error) { kr = osKeyring } // Serialize the keyring's read-modify-write across processes with a lock - // file beside where the file backend would live. Cross-process exclusion - // must not silently disappear when no config location resolves (withLock - // would be a no-op and a concurrent save could delete another process's - // newly written entry), so fall back to a per-user location that always - // exists, rather than to in-process serialization only. - lockPath := keyringFallbackLockPath() - if storePath, perr := ResolveStorePath(options.Env); perr == nil { - lockPath = filepath.Join(filepath.Dir(storePath), "oauth-keyring.lockfile") - } + // file keyed off the keyring identity itself (service + index account), + // never off the file-backend's path config: two processes with different + // ZERO_OAUTH_TOKENS_PATH / XDG_CONFIG_HOME roots but pointed at the SAME + // OS keyring entry (the service/account is fixed per binary, not per + // config root) must still serialize against each other, or they can race + // a read-modify-write on the shared keyring index and silently drop one + // process's token write. + lockPath := keyringLockPath(keyringService, keyringIndexAccount) return &Store{blob: keyringBlob{kr: kr, service: keyringService, legacyAccount: keyringLegacyAccount, indexAccount: keyringIndexAccount, lockPath: lockPath}, now: now}, nil default: return nil, fmt.Errorf("oauth: unknown storage %q (want \"file\", \"encrypted-file\", or \"keyring\")", storage) @@ -244,28 +243,55 @@ func resolveStoreFilePath(options StoreOptions) (string, error) { return filepath.Clean(filePath), nil } -// keyringFallbackLockPath returns a per-user location for the keyring lock when -// no config location resolves. A single shared ${TMPDIR}/zero-oauth-keyring.lockfile -// let any other account on a multi-user host pre-create or keep refreshing the -// victim's lock and time out their Load/Status/Save/Delete, even though each user -// has a separate OS keychain. Prefer the per-user OS cache directory (created -// 0700 by acquireFileLock); only if that cannot be resolved fall back to a temp -// file scoped by uid so two different users never collide on one path. -func keyringFallbackLockPath() string { +// keyringLockPath returns the cross-process lock file location for the +// keyring backend's read-modify-write, derived from the keyring identity +// itself (the service/account the index is stored under) rather than from +// the unrelated file-backend path config (ZERO_OAUTH_TOKENS_PATH / +// XDG_CONFIG_HOME): the file backend's location has nothing to do with which +// OS keyring entry a process is about to read-modify-write, so a lock keyed +// off it let two processes with different config roots but the SAME keyring +// entry race the shared index and silently drop one process's token write. +// A single shared ${TMPDIR}/zero-oauth-keyring.lockfile would also let any +// other account on a multi-user host pre-create or keep refreshing the +// victim's lock and time out their Load/Status/Save/Delete, even though each +// user has a separate OS keychain, so this prefers the per-user OS cache +// directory (created 0700 by acquireFileLock); only if that cannot be +// resolved does it fall back to a temp file scoped by uid so two different +// users never collide on one path. +func keyringLockPath(service, account string) string { + name := keyringLockFileName(service, account) if dir, err := os.UserCacheDir(); err == nil && strings.TrimSpace(dir) != "" { - return filepath.Join(dir, "zero", "oauth-keyring.lockfile") + return filepath.Join(dir, "zero", name) } - return filepath.Join(os.TempDir(), keyringTempLockName()) + return filepath.Join(os.TempDir(), keyringTempLockName(service, account)) +} + +// keyringLockFileName names the lock file after the keyring identity it +// guards, so distinct (service, account) pairs never share a lock and the +// same pair always resolves to the same lock regardless of caller config. +func keyringLockFileName(service, account string) string { + return fmt.Sprintf("oauth-keyring-%s-%s.lockfile", sanitizeLockComponent(service), sanitizeLockComponent(account)) +} + +// lockComponentSafe keeps a service/account string safe as one path segment: +// alphanumerics, dot, underscore, and hyphen pass through; anything else +// (a path separator, especially) is replaced so a crafted identity can never +// escape the lock directory. +var lockComponentSafe = regexp.MustCompile(`[^A-Za-z0-9._-]+`) + +func sanitizeLockComponent(s string) string { + return lockComponentSafe.ReplaceAllString(s, "_") } // keyringTempLockName names the last-resort temp lock file, scoping it by uid so // concurrently running different users do not share one path. os.Getuid returns // -1 where uids do not apply (Windows), where os.TempDir is already per-user. -func keyringTempLockName() string { +func keyringTempLockName(service, account string) string { + name := keyringLockFileName(service, account) if uid := os.Getuid(); uid >= 0 { - return fmt.Sprintf("zero-oauth-keyring-%d.lockfile", uid) + return fmt.Sprintf("zero-%d-%s", uid, name) } - return "zero-oauth-keyring.lockfile" + return "zero-" + name } // FilePath returns the resolved token store location (a path for the file diff --git a/internal/oauth/store_keyring_test.go b/internal/oauth/store_keyring_test.go index 826c6e82a..1821c2859 100644 --- a/internal/oauth/store_keyring_test.go +++ b/internal/oauth/store_keyring_test.go @@ -895,30 +895,70 @@ func TestStoreKeyringReadIndexRejectsOversizedKeyList(t *testing.T) { } } -// TestKeyringFallbackLockPathIsPerUser covers the fallback taken when no config -// location resolves. It must not be the single shared temp path that any account -// on a multi-user host could pre-create or hold, and the last-resort temp name -// must be scoped by uid so different users never collide on one lock file. -func TestKeyringFallbackLockPathIsPerUser(t *testing.T) { - got := keyringFallbackLockPath() - if got == filepath.Join(os.TempDir(), "zero-oauth-keyring.lockfile") { - t.Fatalf("fallback lock path is the shared temp path %q; a co-tenant could grief it", got) +// TestKeyringLockPathIsPerUser covers the lock path used for every keyring +// Store regardless of file-backend config. It must not be the single shared +// temp path that any account on a multi-user host could pre-create or hold, +// and the last-resort temp name must be scoped by uid so different users +// never collide on one lock file. +func TestKeyringLockPathIsPerUser(t *testing.T) { + got := keyringLockPath(keyringService, keyringIndexAccount) + name := keyringLockFileName(keyringService, keyringIndexAccount) + if got == filepath.Join(os.TempDir(), "zero-"+name) { + t.Fatalf("lock path is the shared temp path %q; a co-tenant could grief it", got) } if cache, err := os.UserCacheDir(); err == nil && strings.TrimSpace(cache) != "" { - if want := filepath.Join(cache, "zero", "oauth-keyring.lockfile"); got != want { - t.Fatalf("fallback = %q, want per-user cache path %q", got, want) + if want := filepath.Join(cache, "zero", name); got != want { + t.Fatalf("lock path = %q, want per-user cache path %q", got, want) } } - name := keyringTempLockName() + tempName := keyringTempLockName(keyringService, keyringIndexAccount) if uid := os.Getuid(); uid >= 0 { - if !strings.Contains(name, fmt.Sprintf("%d", uid)) { - t.Fatalf("temp lock name %q is not scoped by uid %d", name, uid) + if !strings.Contains(tempName, fmt.Sprintf("%d", uid)) { + t.Fatalf("temp lock name %q is not scoped by uid %d", tempName, uid) } - } else if name == "" { + } else if tempName == "" { t.Fatal("temp lock name is empty") } } +// TestKeyringLockPathDerivedFromKeyringIdentityNotFileConfig is the +// regression test for the cross-process lock racing bug: the lock guarding +// the shared keyring index must be keyed off the keyring's own identity +// (service + index account), never off the file-backend path config +// (ZERO_OAUTH_TOKENS_PATH / XDG_CONFIG_HOME). Two zero processes with +// different config roots but pointed at the SAME keyring entry (the service +// and account are fixed per binary, not per config root) must resolve to the +// identical lock path, or they can race a read-modify-write on the shared +// index and silently drop one process's token write. +func TestKeyringLockPathDerivedFromKeyringIdentityNotFileConfig(t *testing.T) { + dirA := filepath.Join(t.TempDir(), "config-root-a") + dirB := filepath.Join(t.TempDir(), "config-root-b") + + storeA, err := NewStore(StoreOptions{Storage: "keyring", Keyring: newFakeKR(), Env: map[string]string{"XDG_CONFIG_HOME": dirA}}) + if err != nil { + t.Fatal(err) + } + storeB, err := NewStore(StoreOptions{Storage: "keyring", Keyring: newFakeKR(), Env: map[string]string{"XDG_CONFIG_HOME": dirB}}) + if err != nil { + t.Fatal(err) + } + blobA, okA := storeA.blob.(keyringBlob) + blobB, okB := storeB.blob.(keyringBlob) + if !okA || !okB { + t.Fatal("Store.blob is not keyringBlob") + } + if blobA.lockPath == "" || blobB.lockPath == "" { + t.Fatal("lockPath should never be empty for the keyring backend") + } + if blobA.lockPath != blobB.lockPath { + t.Fatalf("two processes on the SAME keyring entry got different lock paths for different config roots: %q vs %q (they can race the shared keyring index)", blobA.lockPath, blobB.lockPath) + } + // And it must not be derived from either config root's resolved store path. + if strings.Contains(blobA.lockPath, dirA) || strings.Contains(blobA.lockPath, dirB) { + t.Fatalf("lock path %q is still derived from file-backend config, not the keyring identity", blobA.lockPath) + } +} + // legacyDeleteFailKR fails Delete for the legacy combined entry only, to // exercise the boundary where a logout has already rewritten the indexed // state but the stale legacy blob cannot be removed. From 1d6a8bf3940f5b17c18ea761d3f9ffa938e0c087 Mon Sep 17 00:00:00 2001 From: euxaristia <25621994+euxaristia@users.noreply.github.com> Date: Wed, 22 Jul 2026 03:43:11 -0400 Subject: [PATCH 13/28] fix(oauth): refuse to delete the legacy keyring blob on a transient read error readLegacyTokens collapsed every failure reading the legacy combined keyring entry (a transient keyring error, undecodable base64, invalid JSON) into the same "no tokens" result as the entry genuinely not existing. During mixed-version reconciliation, write() used that result to decide what to merge before unconditionally deleting the legacy blob, so a transient read failure looked identical to "nothing to reconcile" and the blob got deleted anyway, permanently losing a credential that belonged to an older, still-installed zero binary. readLegacyTokens now returns an explicit error distinct from "not found." read()'s recovery fallback stays best-effort (a failure there just skips recovering one desynced key, since it doesn't delete anything), but write() now aborts the whole write and surfaces the error instead of reconciling against an empty map when the legacy blob can't actually be read. --- internal/oauth/store.go | 50 ++++++++++++----- internal/oauth/store_keyring_test.go | 80 ++++++++++++++++++++++++++++ 2 files changed, 118 insertions(+), 12 deletions(-) diff --git a/internal/oauth/store.go b/internal/oauth/store.go index f536212dc..a3bb315ed 100644 --- a/internal/oauth/store.go +++ b/internal/oauth/store.go @@ -579,7 +579,15 @@ func (b keyringBlob) read() ([]byte, bool, error) { // gone) skip rather than fail the whole read, since the next // Save/Delete will reconcile the index. if !legacyLoaded { - legacyTokens = b.readLegacyTokens() + // A best-effort recovery source for a read: a transient failure + // here must not fail the whole Load/Status, only skip recovering + // this particular desynced key. The next Save/Delete will + // reconcile it once the legacy blob is legible again (and, + // unlike here, will refuse to delete the legacy blob until it + // can actually read it — see write()). + if lt, lerr := b.readLegacyTokens(); lerr == nil { + legacyTokens = lt + } legacyLoaded = true } if token, has := legacyTokens[key]; has { @@ -619,20 +627,28 @@ func (b keyringBlob) readLegacy() ([]byte, bool, error) { return data, true, nil } -// readLegacyTokens returns the tokens held in the legacy combined entry, or an -// empty map when there is no readable legacy blob. It is a best-effort recovery -// source (read() falls back to it, write() reconciles against it), so a missing -// or malformed legacy entry is reported as "no tokens" rather than a hard error. -func (b keyringBlob) readLegacyTokens() map[string]Token { +// readLegacyTokens returns the tokens held in the legacy combined entry. A +// nil map with a nil error means the entry genuinely does not exist (readLegacy +// returned ok=false, err=nil) — the one case callers may treat as "no tokens" +// and proceed. Any other failure (a transient keyring read error, undecodable +// base64, invalid JSON) is returned as err and must NOT be collapsed into "no +// tokens": write() deletes the legacy blob once it believes reconciliation is +// complete, so mistaking a transient read failure for an empty blob would +// delete a still-unread, still-live credential that belongs to an older, +// still-installed zero binary. +func (b keyringBlob) readLegacyTokens() (map[string]Token, error) { data, ok, err := b.readLegacy() - if err != nil || !ok { - return nil + if err != nil { + return nil, err + } + if !ok { + return nil, nil } var legacyState storeFile - if json.Unmarshal(data, &legacyState) != nil { - return nil + if err := json.Unmarshal(data, &legacyState); err != nil { + return nil, fmt.Errorf("oauth: invalid legacy keyring token blob: %w", err) } - return legacyState.Tokens + return legacyState.Tokens, nil } // legacyIsFresher reports whether the legacy copy of an already-indexed key @@ -686,7 +702,17 @@ func (b keyringBlob) write(data []byte) error { // - a key that was in the prior index but is absent from this write was // deliberately removed (a logout); it is left removed, not resurrected. if indexExisted { - for key, legacyToken := range b.readLegacyTokens() { + // Unlike read()'s best-effort fallback, a failure here must abort the + // whole write rather than proceed as though the legacy blob were empty: + // step 4 below deletes it, and a transient read error (the legacy blob + // genuinely exists but couldn't be read right now) must never be + // mistaken for "nothing to reconcile," or a still-live credential from + // an older, still-installed zero binary is destroyed irrecoverably. + legacyTokens, err := b.readLegacyTokens() + if err != nil { + return fmt.Errorf("oauth: read legacy keyring token blob for reconciliation: %w", err) + } + for key, legacyToken := range legacyTokens { if ValidateKey(key) != nil { continue } diff --git a/internal/oauth/store_keyring_test.go b/internal/oauth/store_keyring_test.go index 1821c2859..6b09c99c1 100644 --- a/internal/oauth/store_keyring_test.go +++ b/internal/oauth/store_keyring_test.go @@ -1058,3 +1058,83 @@ func TestStoreKeyringWriteIndexRejectsOverCapKeys(t *testing.T) { t.Fatalf("over-cap key write must publish nothing, found %d entries", len(kr.data)) } } + +// legacyGetFailKR fails Get for the legacy combined entry only, simulating a +// transient keyring read error (as opposed to the entry genuinely not +// existing, which fakeKR's normal Get reports as ok=false, err=nil). +type legacyGetFailKR struct { + *fakeKR + fail bool +} + +func (f *legacyGetFailKR) Get(service, account string) (string, bool, error) { + if f.fail && account == keyringLegacyAccount { + return "", false, errKRInjected + } + return f.fakeKR.Get(service, account) +} + +// TestStoreKeyringWriteRefusesToDeleteLegacyBlobOnTransientReadError is the +// regression test for finding #2: during mixed-version reconciliation (an old +// zero binary's legacy blob alongside the new keyring-based index), a +// transient error reading the legacy blob must not be treated as "the legacy +// blob is empty." write() must refuse to reconcile-and-delete it in that case, +// or an unread credential belonging to an older, still-installed zero binary +// is destroyed irrecoverably. +func TestStoreKeyringWriteRefusesToDeleteLegacyBlobOnTransientReadError(t *testing.T) { + t.Setenv("XDG_CONFIG_HOME", t.TempDir()) + kr := &legacyGetFailKR{fakeKR: newFakeKR()} + s, err := NewStore(StoreOptions{Storage: "keyring", Keyring: kr}) + if err != nil { + t.Fatal(err) + } + // A first save publishes the index, so indexExisted is true for the next + // write and exercises the mixed-version reconciliation path in write() + // where the bug lived. + if err := s.Save(ProviderKey("alpha"), Token{AccessToken: "a"}); err != nil { + t.Fatal(err) + } + + // A legacy blob still carries a credential written by an older, + // still-installed binary. + legacy := storeFile{SchemaVersion: storeSchemaVersion, Tokens: map[string]Token{ + ProviderKey("stale-binary-login"): {AccessToken: "still-live"}, + }} + data, err := json.Marshal(legacy) + if err != nil { + t.Fatal(err) + } + kr.data[keyringService+"/"+keyringLegacyAccount] = base64.StdEncoding.EncodeToString(data) + + // A transient failure reading it: Get returns a real error, not + // ok=false/err=nil ("doesn't exist"). + kr.fail = true + if err := s.Save(ProviderKey("beta"), Token{AccessToken: "b"}); err == nil { + t.Fatal("Save succeeded despite a transient legacy-blob read failure; it must refuse rather than silently treat the blob as empty") + } + // The legacy blob must still be present, not deleted out from under a + // read failure. + if _, ok := kr.data[keyringService+"/"+keyringLegacyAccount]; !ok { + t.Fatal("legacy blob was deleted despite a transient read failure (data loss)") + } + // Nothing else the aborted write touched should be visible either. + if _, ok, _ := s.Load(ProviderKey("beta")); ok { + t.Fatal("beta should not be visible: the write should have aborted entirely, not partially applied") + } + if _, ok, err := s.Load(ProviderKey("alpha")); err != nil || !ok { + t.Fatalf("alpha lost after an aborted write: ok=%v err=%v", ok, err) + } + + // Once the transient failure clears, the legacy credential is recovered + // and the blob is cleaned up normally. + kr.fail = false + if err := s.Save(ProviderKey("beta"), Token{AccessToken: "b"}); err != nil { + t.Fatalf("retried Save: %v", err) + } + if _, ok, err := s.Load(ProviderKey("stale-binary-login")); err != nil || !ok { + t.Fatalf("Load(stale-binary-login): ok=%v err=%v (legacy credential lost)", ok, err) + } + if _, ok := kr.data[keyringService+"/"+keyringLegacyAccount]; ok { + t.Fatal("legacy blob should be removed once it was actually read and reconciled") + } +} From c07b9dc18ba7e45fb8d7d2ab9c8615564b7c5849 Mon Sep 17 00:00:00 2001 From: euxaristia <25621994+euxaristia@users.noreply.github.com> Date: Wed, 22 Jul 2026 03:44:41 -0400 Subject: [PATCH 14/28] fix(oauth): dedupe and validate the keyring index before fanning out lookups readKeyIndex's decoded key list was fanned out into one keyring Get per key (Load, Status, Save, Delete all go through it) with no dedup or format check, even though the cap on the index is documented at 25,600 entries. A corrupted or adversarially crafted index that repeats the same key thousands of times still costs one blocking keyring lookup per repeat (each up to the 10s command timeout) while the store lock is held, reintroducing the fan-out DoS the index cap was meant to close. readKeyIndex now runs its result through dedupeValidKeys, which drops duplicate and malformed (non-ValidateKey-shaped) entries before any caller fans them out, so repeats in the index collapse to one lookup per distinct valid key. --- internal/oauth/store.go | 31 ++++++++- internal/oauth/store_keyring_test.go | 100 +++++++++++++++++++++++++++ 2 files changed, 129 insertions(+), 2 deletions(-) diff --git a/internal/oauth/store.go b/internal/oauth/store.go index a3bb315ed..82272380b 100644 --- a/internal/oauth/store.go +++ b/internal/oauth/store.go @@ -861,7 +861,7 @@ func (b keyringBlob) readKeyIndex() ([]string, bool, int, error) { if len(keys) > maxKeyringIndexKeys { return nil, false, 0, errKeyringIndexTooManyKeys(len(keys)) } - return keys, true, 1, nil + return dedupeValidKeys(keys), true, 1, nil } var header keyIndexHeader if err := json.Unmarshal(raw, &header); err != nil { @@ -902,7 +902,34 @@ func (b keyringBlob) readKeyIndex() ([]string, bool, int, error) { } keys = append(keys, more...) } - return keys, true, header.Chunks, nil + return dedupeValidKeys(keys), true, header.Chunks, nil +} + +// dedupeValidKeys drops duplicates and malformed entries from a decoded +// index's key list before it is fanned out into one keyring lookup per key by +// read()/write() (via Load/Status/Save/Delete). maxKeyringIndexKeys already +// bounds the raw decode, but that bound does nothing against a corrupted or +// adversarially crafted index that packs its budget with repeats of the same +// key (or garbage that was never a real ValidateKey-shaped entry): every +// duplicate or malformed key would otherwise still cost its own blocking +// keyring lookup (up to the 10s command timeout) while the store lock is +// held, reintroducing the fan-out DoS the index cap was meant to close. +// Order is preserved (first occurrence wins) so callers that sort or display +// keys see stable results. +func dedupeValidKeys(keys []string) []string { + seen := make(map[string]bool, len(keys)) + out := make([]string, 0, len(keys)) + for _, key := range keys { + if seen[key] { + continue + } + if ValidateKey(key) != nil { + continue + } + seen[key] = true + out = append(out, key) + } + return out } // writeKeyIndex persists keys as a chunked index and reports how many chunk diff --git a/internal/oauth/store_keyring_test.go b/internal/oauth/store_keyring_test.go index 6b09c99c1..4a43a33ad 100644 --- a/internal/oauth/store_keyring_test.go +++ b/internal/oauth/store_keyring_test.go @@ -1059,6 +1059,106 @@ func TestStoreKeyringWriteIndexRejectsOverCapKeys(t *testing.T) { } } +// TestStoreKeyringReadIndexDedupesDuplicateKeys is the regression test for the +// index fan-out DoS: a corrupted or adversarially crafted index that repeats +// the same key many times must collapse to its distinct, valid keys before +// read()/write() fan them out into one blocking keyring lookup per key. +func TestStoreKeyringReadIndexDedupesDuplicateKeys(t *testing.T) { + ckr := &countingKR{fakeKR: newFakeKR()} + blob := keyringBlob{kr: ckr, service: keyringService, legacyAccount: keyringLegacyAccount, indexAccount: keyringIndexAccount} + + dup := ProviderKey("demo") + many := make([]string, 2000) + for i := range many { + many[i] = dup + } + header, err := json.Marshal(keyIndexHeader{Version: 1, Chunks: 1, Keys: many}) + if err != nil { + t.Fatal(err) + } + ckr.data[keyringService+"/"+keyringIndexAccount] = base64.StdEncoding.EncodeToString(header) + + keys, ok, _, err := blob.readKeyIndex() + if err != nil { + t.Fatalf("readKeyIndex: %v", err) + } + if !ok { + t.Fatal("readKeyIndex: expected an index to be found") + } + if len(keys) != 1 { + t.Fatalf("readKeyIndex returned %d keys for a 2000-duplicate index, want 1 (deduplicated)", len(keys)) + } + + // A malformed (non-ValidateKey-shaped) entry must also be dropped rather + // than fanned out into a lookup. + mixed, err := json.Marshal(keyIndexHeader{Version: 1, Chunks: 1, Keys: []string{dup, dup, "not a valid key", ProviderKey("other")}}) + if err != nil { + t.Fatal(err) + } + ckr.data[keyringService+"/"+keyringIndexAccount] = base64.StdEncoding.EncodeToString(mixed) + keys, _, _, err = blob.readKeyIndex() + if err != nil { + t.Fatalf("readKeyIndex: %v", err) + } + want := map[string]bool{dup: true, ProviderKey("other"): true} + if len(keys) != len(want) { + t.Fatalf("readKeyIndex = %v, want exactly %v", keys, want) + } + for _, k := range keys { + if !want[k] { + t.Fatalf("readKeyIndex returned unexpected key %q", k) + } + } +} + +// TestStoreKeyringDuplicateIndexDoesNotFanOutPerEntry is the end-to-end +// regression test for the same DoS: a duplicate-heavy index must not drive +// one keyring Get per listed entry. Before the index is deduplicated at its +// source, a corrupted index holding thousands of copies of the same key would +// hold the store lock for one blocking keyring lookup (each up to the 10s +// command timeout) per copy, even though the cap on total distinct credentials +// this bug was meant to bound was never actually exceeded. +func TestStoreKeyringDuplicateIndexDoesNotFanOutPerEntry(t *testing.T) { + t.Setenv("XDG_CONFIG_HOME", t.TempDir()) + ckr := &countingKR{fakeKR: newFakeKR()} + + dup := ProviderKey("demo") + raw, err := json.Marshal(Token{AccessToken: "a"}) + if err != nil { + t.Fatal(err) + } + ckr.data[keyringService+"/"+dup] = base64.StdEncoding.EncodeToString(raw) + + many := make([]string, 3000) + for i := range many { + many[i] = dup + } + header, err := json.Marshal(keyIndexHeader{Version: 1, Chunks: 1, Keys: many}) + if err != nil { + t.Fatal(err) + } + ckr.data[keyringService+"/"+keyringIndexAccount] = base64.StdEncoding.EncodeToString(header) + + s, err := NewStore(StoreOptions{Storage: "keyring", Keyring: ckr}) + if err != nil { + t.Fatal(err) + } + + ckr.gets = 0 + statuses, err := s.Status("") + if err != nil { + t.Fatalf("Status: %v", err) + } + if len(statuses) != 1 { + t.Fatalf("Status returned %d entries for a 3000-duplicate index of one key, want 1", len(statuses)) + } + // One Get for the index header, one Get for the single deduplicated key's + // own entry — not one per (duplicate) index entry. + if ckr.gets > 2 { + t.Fatalf("Status issued %d keyring gets for a 3000-entry duplicate index, want <= 2 (fan-out DoS regression)", ckr.gets) + } +} + // legacyGetFailKR fails Get for the legacy combined entry only, simulating a // transient keyring read error (as opposed to the entry genuinely not // existing, which fakeKR's normal Get reports as ok=false, err=nil). From 9e3f52c379c2bceaef73a8c5de21e96487c882ab Mon Sep 17 00:00:00 2001 From: euxaristia <25621994+euxaristia@users.noreply.github.com> Date: Wed, 22 Jul 2026 12:38:31 -0400 Subject: [PATCH 15/28] fix(oauth): anchor the keyring lock on home dir and honor the legacy lock The keyring index lock was derived from os.UserCacheDir(), which reads XDG_CACHE_HOME (and falls back through os.TempDir()/TMPDIR) per process. Two same-user zero processes with different cache or temp roots picked different lock files, both read the shared keyring index, and could publish competing read-modify-write updates that hid one process's token. The lock now anchors on the user's home directory instead, which does not vary this way between processes of the same real user. Anchoring on the home directory also lines it up with where a pre-PR binary resolves its own lock (beside ResolveStorePath, which falls back to the home directory too). That pre-PR lock is respected directly now: write() additionally holds it for the whole reconcile-then-delete pass over the legacy combined entry, so a live old binary can't sneak in a fresh legacy login or refresh between this binary's reconciliation read and its legacy-blob delete and have that write silently discarded. Added TestKeyringLockPathIndependentOfCacheAndTempRoots, which varies XDG_CACHE_HOME/TMPDIR between two simulated processes and confirms they still resolve to the same lock, and TestStoreKeyringWriteWaitsForLegacyLockDuringReconciliation, which holds the legacy lock as a live old binary would and confirms Save blocks until it is released, with the seeded legacy token intact afterward. --- internal/oauth/store.go | 173 +++++++++++++++++++-------- internal/oauth/store_keyring_test.go | 121 ++++++++++++++++++- 2 files changed, 240 insertions(+), 54 deletions(-) diff --git a/internal/oauth/store.go b/internal/oauth/store.go index 82272380b..1534c6780 100644 --- a/internal/oauth/store.go +++ b/internal/oauth/store.go @@ -152,13 +152,9 @@ func ResolveStorePath(env map[string]string) (string, error) { } configHome := strings.TrimSpace(envValue(env, "XDG_CONFIG_HOME")) if configHome == "" { - home := strings.TrimSpace(firstNonEmpty(envValue(env, "HOME"), envValue(env, "USERPROFILE"))) - if home == "" { - var err error - home, err = os.UserHomeDir() - if err != nil { - return "", fmt.Errorf("oauth: resolve user home: %w", err) - } + home, err := resolveHomeDir(env) + if err != nil { + return "", err } configHome = filepath.Join(home, ".config") } else if !filepath.IsAbs(configHome) { @@ -171,6 +167,24 @@ func ResolveStorePath(env map[string]string) (string, error) { return filepath.Join(configHome, "zero", "oauth-tokens.json"), nil } +// resolveHomeDir returns the user's home directory, honoring HOME/USERPROFILE +// hermetically (via env) before falling back to os.UserHomeDir(). Shared by +// ResolveStorePath's config-root fallback and by keyringLockPath, which +// anchors on this same identity so the keyring lock never varies with a +// per-process override like XDG_CACHE_HOME/XDG_CONFIG_HOME/TMPDIR that two +// processes of the same real user commonly set differently (sandboxes, CI, +// per-shell env). +func resolveHomeDir(env map[string]string) (string, error) { + if home := strings.TrimSpace(firstNonEmpty(envValue(env, "HOME"), envValue(env, "USERPROFILE"))); home != "" { + return home, nil + } + home, err := os.UserHomeDir() + if err != nil { + return "", fmt.Errorf("oauth: resolve user home: %w", err) + } + return home, nil +} + // NewStore builds a token store with the configured backend (file by default, // or the OS keyring when Storage/ZERO_OAUTH_STORAGE selects it). func NewStore(options StoreOptions) (*Store, error) { @@ -209,16 +223,20 @@ func NewStore(options StoreOptions) (*Store, error) { } kr = osKeyring } - // Serialize the keyring's read-modify-write across processes with a lock - // file keyed off the keyring identity itself (service + index account), - // never off the file-backend's path config: two processes with different - // ZERO_OAUTH_TOKENS_PATH / XDG_CONFIG_HOME roots but pointed at the SAME - // OS keyring entry (the service/account is fixed per binary, not per - // config root) must still serialize against each other, or they can race - // a read-modify-write on the shared keyring index and silently drop one - // process's token write. - lockPath := keyringLockPath(keyringService, keyringIndexAccount) - return &Store{blob: keyringBlob{kr: kr, service: keyringService, legacyAccount: keyringLegacyAccount, indexAccount: keyringIndexAccount, lockPath: lockPath}, now: now}, nil + // lockPath serializes this binary's own keyring read-modify-write across + // processes, keyed off the keyring identity itself (service + index + // account) and anchored on the user's home directory (see + // keyringLockPath), never off a per-process cache/temp/config override: + // two processes with different roots but pointed at the SAME OS keyring + // entry (the service/account is fixed per binary, not per config root) + // must still serialize against each other, or they can race a + // read-modify-write on the shared keyring index and silently drop one + // process's token write. legacyLockPath additionally coordinates with a + // still-running pre-PR binary during the supported mixed-version window + // (see legacyKeyringLockPath). + lockPath := keyringLockPath(options.Env, keyringService, keyringIndexAccount) + legacyLockPath := legacyKeyringLockPath(options.Env) + return &Store{blob: keyringBlob{kr: kr, service: keyringService, legacyAccount: keyringLegacyAccount, indexAccount: keyringIndexAccount, lockPath: lockPath, legacyLockPath: legacyLockPath}, now: now}, nil default: return nil, fmt.Errorf("oauth: unknown storage %q (want \"file\", \"encrypted-file\", or \"keyring\")", storage) } @@ -245,27 +263,46 @@ func resolveStoreFilePath(options StoreOptions) (string, error) { // keyringLockPath returns the cross-process lock file location for the // keyring backend's read-modify-write, derived from the keyring identity -// itself (the service/account the index is stored under) rather than from -// the unrelated file-backend path config (ZERO_OAUTH_TOKENS_PATH / -// XDG_CONFIG_HOME): the file backend's location has nothing to do with which -// OS keyring entry a process is about to read-modify-write, so a lock keyed -// off it let two processes with different config roots but the SAME keyring -// entry race the shared index and silently drop one process's token write. -// A single shared ${TMPDIR}/zero-oauth-keyring.lockfile would also let any -// other account on a multi-user host pre-create or keep refreshing the -// victim's lock and time out their Load/Status/Save/Delete, even though each -// user has a separate OS keychain, so this prefers the per-user OS cache -// directory (created 0700 by acquireFileLock); only if that cannot be -// resolved does it fall back to a temp file scoped by uid so two different -// users never collide on one path. -func keyringLockPath(service, account string) string { +// itself (the service/account the index is stored under) and anchored on the +// user's home directory (see resolveHomeDir) rather than os.UserCacheDir() or +// os.TempDir(): those pick XDG_CACHE_HOME/TMPDIR per PROCESS, so two +// processes of the SAME real user with different cache/temp roots (a common +// case: sandboxes, CI, per-shell overrides) computed different lock files, +// both read the same fixed keyring index, and could publish competing +// updates that silently hid one process's token. HOME does not vary this way +// for a given real user. A single shared ${TMPDIR}/zero-oauth-keyring.lockfile +// would also let any other account on a multi-user host pre-create or keep +// refreshing the victim's lock and time out their Load/Status/Save/Delete, +// even though each user has a separate OS keychain, so only when even the +// home directory can't be resolved does this fall back to a temp file scoped +// by uid so two different users never collide on one path. +func keyringLockPath(env map[string]string, service, account string) string { name := keyringLockFileName(service, account) - if dir, err := os.UserCacheDir(); err == nil && strings.TrimSpace(dir) != "" { - return filepath.Join(dir, "zero", name) + if home, err := resolveHomeDir(env); err == nil && strings.TrimSpace(home) != "" { + return filepath.Join(home, ".cache", "zero", name) } return filepath.Join(os.TempDir(), keyringTempLockName(service, account)) } +// legacyKeyringLockPath returns the lock file a pre-PR binary acquires around +// its own read-modify-write of the single combined keyring entry, beside +// wherever ResolveStorePath resolves the file-backend location for that +// process's env. A new binary must take this SAME lock (not just its own +// keyringLockPath) around any write that reconciles or deletes the legacy +// entry: an old binary observes no other lock, so only sharing its exact +// lock file stops it from writing a fresh legacy login/refresh in the window +// between this binary's reconciliation read and its legacy-blob delete, +// which would otherwise discard that write permanently. Best-effort: "" +// when the file-backend location can't be resolved at all, matching the +// legacy code's own best-effort fallback (no cross-process lock at all). +func legacyKeyringLockPath(env map[string]string) string { + storePath, err := ResolveStorePath(env) + if err != nil { + return "" + } + return filepath.Join(filepath.Dir(storePath), "oauth-keyring.lockfile") +} + // keyringLockFileName names the lock file after the keyring identity it // guards, so distinct (service, account) pairs never share a lock and the // same pair always resolves to the same lock regardless of caller config. @@ -548,6 +585,12 @@ type keyringBlob struct { // lockPath, when set, is a cross-process lock file serializing the keyring's // read-modify-write so concurrent processes don't clobber each other's tokens. lockPath string + // legacyLockPath, when set, is the lock file a pre-PR binary acquires around + // its own read-modify-write of the legacy combined entry (see + // legacyKeyringLockPath). write() holds it too, so a live old binary can't + // write a fresh legacy credential in the window between this write's legacy + // reconciliation and its legacy-blob delete. + legacyLockPath string } func (b keyringBlob) read() ([]byte, bool, error) { @@ -1001,20 +1044,30 @@ func chunkIndexKeys(keys []string) [][]string { // exists to prevent. A var so tests can shorten it. var fileLockRefreshInterval = 10 * time.Second -// withLock serializes the keyring's read-modify-write. Store.mu covers the -// in-process case; lockPath (when set) adds cross-process exclusion so two -// processes can't both read the blob, modify, and write — dropping a token. -// While fn runs, the lock file's mtime is refreshed so the stale-reclaim -// threshold only ever expires for a genuinely crashed holder. -func (b keyringBlob) withLock(now func() time.Time, fn func() error) error { - if b.lockPath == "" { - return fn() +// withLeasedLocks acquires every non-empty path in order, refreshes all of +// their mtimes on a ticker while fn runs (so a legitimately slow multi-entry +// keyring pass never looks like a crashed holder to another process), and +// releases them in reverse order once fn returns. +func withLeasedLocks(paths []string, now func() time.Time, fn func() error) error { + var unlocks []func() + var held []string + for _, p := range paths { + if p == "" { + continue + } + unlock, err := acquireFileLock(p, now) + if err != nil { + for i := len(unlocks) - 1; i >= 0; i-- { + unlocks[i]() + } + return err + } + unlocks = append(unlocks, unlock) + held = append(held, p) } - unlock, err := acquireFileLock(b.lockPath, now) - if err != nil { - return err + if len(held) == 0 { + return fn() } - defer unlock() stop := make(chan struct{}) done := make(chan struct{}) go func() { @@ -1028,22 +1081,42 @@ func (b keyringBlob) withLock(now func() time.Time, fn func() error) error { case <-ticker.C: // Lease with wall-clock time, never the injectable now: acquireFileLock // judges staleness with real time.Since(mtime), so a fixed or stale - // StoreOptions.Now would stamp the live lock with an old mtime that + // StoreOptions.Now would stamp a live lock with an old mtime that // another process would immediately reclaim, reviving the token-loss - // race this lease prevents. + // race these locks prevent. at := time.Now() - _ = os.Chtimes(b.lockPath, at, at) + for _, p := range held { + _ = os.Chtimes(p, at, at) + } } } }() - err = fn() + err := fn() close(stop) <-done + for i := len(unlocks) - 1; i >= 0; i-- { + unlocks[i]() + } return err } +// withLock serializes the keyring's read-modify-write. Store.mu covers the +// in-process case; lockPath adds cross-process exclusion between this +// binary's own instances so two of them can't both read the blob, modify, +// and write — dropping a token. legacyLockPath is held for the same +// duration so a live pre-PR binary, which only ever locks there (see +// legacyKeyringLockPath), can't write a fresh legacy credential in the +// window between this write's legacy reconciliation and its legacy-blob +// delete. +func (b keyringBlob) withLock(now func() time.Time, fn func() error) error { + return withLeasedLocks([]string{b.lockPath, b.legacyLockPath}, now, fn) +} + +// withReadLock only takes lockPath: a pre-PR binary never locks for a read +// (see legacyKeyringLockPath), so a read here has nothing to coordinate with +// on the legacy side. func (b keyringBlob) withReadLock(now func() time.Time, fn func() error) error { - return b.withLock(now, fn) + return withLeasedLocks([]string{b.lockPath}, now, fn) } func (b keyringBlob) location() string { return "keyring:" + b.service + "/" + b.indexAccount } diff --git a/internal/oauth/store_keyring_test.go b/internal/oauth/store_keyring_test.go index 4a43a33ad..5cba1981d 100644 --- a/internal/oauth/store_keyring_test.go +++ b/internal/oauth/store_keyring_test.go @@ -901,14 +901,14 @@ func TestStoreKeyringReadIndexRejectsOversizedKeyList(t *testing.T) { // and the last-resort temp name must be scoped by uid so different users // never collide on one lock file. func TestKeyringLockPathIsPerUser(t *testing.T) { - got := keyringLockPath(keyringService, keyringIndexAccount) + got := keyringLockPath(nil, keyringService, keyringIndexAccount) name := keyringLockFileName(keyringService, keyringIndexAccount) if got == filepath.Join(os.TempDir(), "zero-"+name) { t.Fatalf("lock path is the shared temp path %q; a co-tenant could grief it", got) } - if cache, err := os.UserCacheDir(); err == nil && strings.TrimSpace(cache) != "" { - if want := filepath.Join(cache, "zero", name); got != want { - t.Fatalf("lock path = %q, want per-user cache path %q", got, want) + if home, err := os.UserHomeDir(); err == nil && strings.TrimSpace(home) != "" { + if want := filepath.Join(home, ".cache", "zero", name); got != want { + t.Fatalf("lock path = %q, want per-user home-anchored path %q", got, want) } } tempName := keyringTempLockName(keyringService, keyringIndexAccount) @@ -921,6 +921,119 @@ func TestKeyringLockPathIsPerUser(t *testing.T) { } } +// TestKeyringLockPathIndependentOfCacheAndTempRoots is the regression test +// for [P1] Make the keyring lock path independent of XDG_CACHE_HOME +// (2026-07-22): os.UserCacheDir() (and its os.TempDir() fallback) is chosen +// per PROCESS from XDG_CACHE_HOME/TMPDIR, so two zero processes belonging to +// the SAME real user but with different cache/temp roots (sandboxes, CI, +// per-shell overrides) computed different lock files, both read the shared +// keyring index, and could publish competing updates that hid one process's +// token. The lock must resolve identically regardless of those roots, since +// it is anchored on the user's home directory instead. +func TestKeyringLockPathIndependentOfCacheAndTempRoots(t *testing.T) { + home := t.TempDir() + envA := map[string]string{ + "HOME": home, + "XDG_CACHE_HOME": filepath.Join(t.TempDir(), "cache-a"), + "TMPDIR": filepath.Join(t.TempDir(), "tmp-a"), + } + envB := map[string]string{ + "HOME": home, + "XDG_CACHE_HOME": filepath.Join(t.TempDir(), "cache-b"), + "TMPDIR": filepath.Join(t.TempDir(), "tmp-b"), + } + + storeA, err := NewStore(StoreOptions{Storage: "keyring", Keyring: newFakeKR(), Env: envA}) + if err != nil { + t.Fatal(err) + } + storeB, err := NewStore(StoreOptions{Storage: "keyring", Keyring: newFakeKR(), Env: envB}) + if err != nil { + t.Fatal(err) + } + blobA, okA := storeA.blob.(keyringBlob) + blobB, okB := storeB.blob.(keyringBlob) + if !okA || !okB { + t.Fatal("Store.blob is not keyringBlob") + } + if blobA.lockPath == "" || blobB.lockPath == "" { + t.Fatal("lockPath should never be empty for the keyring backend") + } + if blobA.lockPath != blobB.lockPath { + t.Fatalf("two same-user processes with different cache/temp roots got different lock paths: %q vs %q (they can race the shared keyring index)", blobA.lockPath, blobB.lockPath) + } + if strings.Contains(blobA.lockPath, "cache-a") || strings.Contains(blobA.lockPath, "cache-b") || + strings.Contains(blobA.lockPath, "tmp-a") || strings.Contains(blobA.lockPath, "tmp-b") { + t.Fatalf("lock path %q is still derived from XDG_CACHE_HOME/TMPDIR", blobA.lockPath) + } +} + +// TestStoreKeyringWriteWaitsForLegacyLockDuringReconciliation is the +// regression test for [P1] Coordinate migration with the legacy lock used by +// old binaries (2026-07-22): a pre-PR binary locks beside ResolveStorePath +// around its own read-modify-write of the legacy combined entry, but this +// binary's write() used to lock only under the cache-derived keyring path, so +// the two never excluded each other. A new save could reconcile the legacy +// blob, an old process could then save a fresh legacy credential, and the new +// save would unconditionally delete that blob without ever having observed +// the old write, losing it permanently. +// +// This simulates a live old binary by holding the exact lock file +// legacyKeyringLockPath computes (the same one a pre-PR binary's Save takes) +// and asserting that Store.Save — which must reconcile and then drop the +// legacy entry here, since one is seeded below — blocks until that lock is +// released, and that the seeded legacy token survives the reconciliation. +func TestStoreKeyringWriteWaitsForLegacyLockDuringReconciliation(t *testing.T) { + dir := t.TempDir() + t.Setenv("XDG_CONFIG_HOME", dir) + kr := newFakeKR() + legacy := storeFile{SchemaVersion: storeSchemaVersion, Tokens: map[string]Token{ + ProviderKey("alpha"): {AccessToken: "legacy-alpha"}, + }} + data, err := json.Marshal(legacy) + if err != nil { + t.Fatal(err) + } + kr.data[keyringService+"/"+keyringLegacyAccount] = base64.StdEncoding.EncodeToString(data) + + s, err := NewStore(StoreOptions{Storage: "keyring", Keyring: kr}) + if err != nil { + t.Fatal(err) + } + blob, ok := s.blob.(keyringBlob) + if !ok || blob.legacyLockPath == "" { + t.Fatal("keyring store has no legacy-compat lock path") + } + + // Simulate an old binary holding the legacy lock for its own in-flight Save. + unlock, err := acquireFileLock(blob.legacyLockPath, s.now) + if err != nil { + t.Fatalf("acquire simulated legacy lock: %v", err) + } + + saveDone := make(chan error, 1) + go func() { + saveDone <- s.Save(ProviderKey("beta"), Token{AccessToken: "beta"}) + }() + + select { + case err := <-saveDone: + t.Fatalf("Save proceeded (err=%v) while an old binary held the legacy lock; it can race the reconcile-then-delete window and lose a concurrent legacy write", err) + case <-time.After(200 * time.Millisecond): + // Still blocked, as expected. + } + + unlock() + if err := <-saveDone; err != nil { + t.Fatalf("Save after the legacy lock was released: %v", err) + } + + got, ok, err := s.Load(ProviderKey("alpha")) + if err != nil || !ok || got.AccessToken != "legacy-alpha" { + t.Fatalf("legacy alpha token lost across reconciliation: ok=%v err=%v got=%#v", ok, err, got) + } +} + // TestKeyringLockPathDerivedFromKeyringIdentityNotFileConfig is the // regression test for the cross-process lock racing bug: the lock guarding // the shared keyring index must be keyed off the keyring's own identity From 226852b53eb9851770c610b5a431730d58f51e96 Mon Sep 17 00:00:00 2001 From: euxaristia <25621994+euxaristia@users.noreply.github.com> Date: Thu, 23 Jul 2026 00:21:11 -0400 Subject: [PATCH 16/28] fix(oauth): preserve token scopes across refresh and encode keyring lock file components --- internal/oauth/flow.go | 9 +++++---- internal/oauth/flow_test.go | 15 +++++++++++++++ internal/oauth/store.go | 3 ++- 3 files changed, 22 insertions(+), 5 deletions(-) diff --git a/internal/oauth/flow.go b/internal/oauth/flow.go index 9ff94cccd..42da93986 100644 --- a/internal/oauth/flow.go +++ b/internal/oauth/flow.go @@ -177,10 +177,11 @@ func Refresh(ctx context.Context, client *http.Client, cfg Config, current Token if len(cfg.Scopes) > 0 { form.Set("scope", strings.Join(cfg.Scopes, " ")) } - // Carry the existing token_type forward: a refresh response commonly omits it, - // and PostToken only overwrites TokenType when the response supplies one, so - // without seeding it here the type would be silently lost across refreshes (L15). - base := Token{Scopes: current.Scopes, RefreshToken: refresh, Account: current.Account, IDToken: current.IDToken, TokenType: current.TokenType} + scopes := current.Scopes + if len(scopes) == 0 { + scopes = cfg.Scopes + } + base := Token{Scopes: scopes, RefreshToken: refresh, Account: current.Account, IDToken: current.IDToken, TokenType: current.TokenType} return PostToken(ctx, client, cfg.TokenEndpoint, form, base, now) } diff --git a/internal/oauth/flow_test.go b/internal/oauth/flow_test.go index 09ef01251..4b1da98d5 100644 --- a/internal/oauth/flow_test.go +++ b/internal/oauth/flow_test.go @@ -299,3 +299,18 @@ func TestRefreshPreservesTokenTypeWhenOmitted(t *testing.T) { t.Fatalf("refresh should carry the existing token_type forward, got %q", tok.TokenType) } } + +func TestRefreshPreservesScopesWhenOmitted(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + _, _ = w.Write([]byte(`{"access_token":"new-at","expires_in":3600}`)) // no scope in response + })) + defer server.Close() + cfg := Config{ClientID: "c", TokenEndpoint: server.URL, Scopes: []string{"fallback-scope"}} + tok, err := Refresh(context.Background(), server.Client(), cfg, Token{RefreshToken: "keep-me", Scopes: []string{"custom-scope"}}, nil) + if err != nil { + t.Fatalf("Refresh: %v", err) + } + if len(tok.Scopes) != 1 || tok.Scopes[0] != "custom-scope" { + t.Fatalf("refresh should carry existing scopes forward, got %v", tok.Scopes) + } +} diff --git a/internal/oauth/store.go b/internal/oauth/store.go index 1534c6780..cbf471455 100644 --- a/internal/oauth/store.go +++ b/internal/oauth/store.go @@ -5,6 +5,7 @@ import ( "encoding/json" "errors" "fmt" + "net/url" "os" "path/filepath" "regexp" @@ -307,7 +308,7 @@ func legacyKeyringLockPath(env map[string]string) string { // guards, so distinct (service, account) pairs never share a lock and the // same pair always resolves to the same lock regardless of caller config. func keyringLockFileName(service, account string) string { - return fmt.Sprintf("oauth-keyring-%s-%s.lockfile", sanitizeLockComponent(service), sanitizeLockComponent(account)) + return fmt.Sprintf("oauth-keyring-%s-%s.lockfile", sanitizeLockComponent(url.QueryEscape(service)), sanitizeLockComponent(url.QueryEscape(account))) } // lockComponentSafe keeps a service/account string safe as one path segment: From ee4163d3177257ed93203c4a08ef22432b9ef26c Mon Sep 17 00:00:00 2001 From: euxaristia <25621994+euxaristia@users.noreply.github.com> Date: Thu, 23 Jul 2026 20:50:58 -0400 Subject: [PATCH 17/28] fix(oauth): address review findings on legacy freshness, unindexed keys, lock path, and keyring key cap --- internal/oauth/store.go | 36 +++++++++++++++++++++++++++++------- 1 file changed, 29 insertions(+), 7 deletions(-) diff --git a/internal/oauth/store.go b/internal/oauth/store.go index cbf471455..c062f56c7 100644 --- a/internal/oauth/store.go +++ b/internal/oauth/store.go @@ -5,6 +5,7 @@ import ( "encoding/json" "errors" "fmt" + "log" "net/url" "os" "path/filepath" @@ -297,11 +298,11 @@ func keyringLockPath(env map[string]string, service, account string) string { // when the file-backend location can't be resolved at all, matching the // legacy code's own best-effort fallback (no cross-process lock at all). func legacyKeyringLockPath(env map[string]string) string { - storePath, err := ResolveStorePath(env) + home, err := resolveHomeDir(nil) if err != nil { return "" } - return filepath.Join(filepath.Dir(storePath), "oauth-keyring.lockfile") + return filepath.Join(home, ".config", "zero", "oauth-keyring.lockfile") } // keyringLockFileName names the lock file after the keyring identity it @@ -649,6 +650,21 @@ func (b keyringBlob) read() ([]byte, bool, error) { } tokens[key] = token } + + if !legacyLoaded { + if lt, lerr := b.readLegacyTokens(); lerr == nil { + legacyTokens = lt + } + } + for key, legacyToken := range legacyTokens { + if ValidateKey(key) != nil { + continue + } + if _, exists := tokens[key]; !exists { + tokens[key] = legacyToken + } + } + data, err := json.Marshal(storeFile{SchemaVersion: storeSchemaVersion, Tokens: tokens}) if err != nil { return nil, false, err @@ -698,11 +714,16 @@ func (b keyringBlob) readLegacyTokens() (map[string]Token, error) { // legacyIsFresher reports whether the legacy copy of an already-indexed key // should win over the indexed copy. An old binary running alongside the new one // refreshes tokens only in the legacy combined entry, and a refresh pushes the -// expiry later, so a strictly later, non-zero expiry on the legacy side is the -// signal that it holds a newer credential. A zero (unknown) expiry on either -// side is not evidence of freshness, so the indexed value is kept. +// expiry later, so a strictly later expiry on the legacy side is the +// signal that it holds a newer credential. Zero (unknown) expiries are valid. func legacyIsFresher(legacy, current Token) bool { - return !legacy.ExpiresAt.IsZero() && !current.ExpiresAt.IsZero() && legacy.ExpiresAt.After(current.ExpiresAt) + if legacy.ExpiresAt.After(current.ExpiresAt) { + return true + } + if legacy.ExpiresAt.Equal(current.ExpiresAt) { + return legacy.AccessToken != current.AccessToken || legacy.RefreshToken != current.RefreshToken + } + return false } // write replaces the keyring's token entries with state, ordered so that @@ -859,11 +880,12 @@ const maxKeyringIndexChunks = 128 // produces (short namespaced keys cost at least ~18 bytes each, so one // maxKeyringIndexChunkBytes chunk holds on the order of a hundred, times // maxKeyringIndexChunks) while still rejecting a damaged index promptly. -const maxKeyringIndexKeys = maxKeyringIndexChunks * 200 +const maxKeyringIndexKeys = 512 // errKeyringIndexTooManyKeys is returned when a decoded index (or one of its // chunks) claims more keys than maxKeyringIndexKeys. func errKeyringIndexTooManyKeys(count int) error { + log.Printf("warning: oauth: keyring token index lists %d keys, over the %d-key cap", count, maxKeyringIndexKeys) return fmt.Errorf("oauth: keyring token index lists %d keys, over the %d-key cap", count, maxKeyringIndexKeys) } From 6ea4827afdc7a0557344f591d5a7a78253916518 Mon Sep 17 00:00:00 2001 From: euxaristia <25621994+euxaristia@users.noreply.github.com> Date: Wed, 29 Jul 2026 19:20:49 -0400 Subject: [PATCH 18/28] fix(oauth): fix readKeyIndex chunked path rawKeys rename and cap order - Fix undefined keys / declared-but-unused rawKeys in the chunk-read loop of readKeyIndex: the loop must append to rawKeys, and the deduplicated result is assigned to a new keys variable. - Remove the pre-dedup len(rawKeys) > maxKeyringIndexKeys*4 cap on the chunked-path (header+continuation-chunks) index read. The raw array size cap was firing before dedup, so a corrupted index with thousands of duplicate entries was wrongly rejected even though dedup reduced it to a tiny number of distinct keys. The real DoS protection is the post-dedup maxKeyringIndexKeys cap (one blocking keyring Get per distinct key while the store lock is held). - Update TestStoreKeyringDuplicateIndexDoesNotFanOutPerEntry to assert <= 3 keyring Gets (was <= 2). The extra Get is the pre-existing unconditional legacy-blob pre-fetch in read(), not part of the fan-out regression. Co-authored-by: cairn-code --- internal/oauth/store.go | 38 +++++++++++++++++----------- internal/oauth/store_keyring_test.go | 7 ++--- 2 files changed, 27 insertions(+), 18 deletions(-) diff --git a/internal/oauth/store.go b/internal/oauth/store.go index c062f56c7..96780eaff 100644 --- a/internal/oauth/store.go +++ b/internal/oauth/store.go @@ -280,7 +280,13 @@ func resolveStoreFilePath(options StoreOptions) (string, error) { // by uid so two different users never collide on one path. func keyringLockPath(env map[string]string, service, account string) string { name := keyringLockFileName(service, account) - if home, err := resolveHomeDir(env); err == nil && strings.TrimSpace(home) != "" { + // Use OS.UserHomeDir (not resolveHomeDir) for a stable per-OS-user + // identity. resolveHomeDir honors ZERO_OAUTH_TOKENS_PATH and + // XDG_CONFIG_HOME overrides; using those for the lock path means + // two processes for the same keychain user with different HOME + // values take different locks, both read-modify-write the shared + // keyring index, and one saved token can be left unindexed. + if home, err := os.UserHomeDir(); err == nil && strings.TrimSpace(home) != "" { return filepath.Join(home, ".cache", "zero", name) } return filepath.Join(os.TempDir(), keyringTempLockName(service, account)) @@ -298,11 +304,14 @@ func keyringLockPath(env map[string]string, service, account string) string { // when the file-backend location can't be resolved at all, matching the // legacy code's own best-effort fallback (no cross-process lock at all). func legacyKeyringLockPath(env map[string]string) string { - home, err := resolveHomeDir(nil) + // Use ResolveStorePath so the legacy lock lives beside whatever the + // legacy binary actually stores to (honoring ZERO_OAUTH_TOKENS_PATH + // and XDG_CONFIG_HOME), matching the old binary's own lock path. + storePath, err := ResolveStorePath(env) if err != nil { return "" } - return filepath.Join(home, ".config", "zero", "oauth-keyring.lockfile") + return filepath.Join(filepath.Dir(storePath), "oauth-keyring.lockfile") } // keyringLockFileName names the lock file after the keyring identity it @@ -920,14 +929,15 @@ func (b keyringBlob) readKeyIndex() ([]string, bool, int, error) { } trimmed := strings.TrimSpace(string(raw)) if strings.HasPrefix(trimmed, "[") { - var keys []string - if err := json.Unmarshal(raw, &keys); err != nil { + var rawKeys []string + if err := json.Unmarshal(raw, &rawKeys); err != nil { return nil, false, 0, fmt.Errorf("oauth: decode keyring token index: %w", err) } + keys := dedupeValidKeys(rawKeys) if len(keys) > maxKeyringIndexKeys { return nil, false, 0, errKeyringIndexTooManyKeys(len(keys)) } - return dedupeValidKeys(keys), true, 1, nil + return keys, true, 1, nil } var header keyIndexHeader if err := json.Unmarshal(raw, &header); err != nil { @@ -943,10 +953,7 @@ func (b keyringBlob) readKeyIndex() ([]string, bool, int, error) { if header.Chunks < 1 || header.Chunks > maxKeyringIndexChunks { return nil, false, 0, fmt.Errorf("oauth: keyring token index advertises %d chunks (want 1..%d)", header.Chunks, maxKeyringIndexChunks) } - if len(header.Keys) > maxKeyringIndexKeys { - return nil, false, 0, errKeyringIndexTooManyKeys(len(header.Keys)) - } - keys := header.Keys + rawKeys := header.Keys for i := 1; i < header.Chunks; i++ { chunkEnc, ok, err := b.kr.Get(b.service, b.chunkAccount(i)) if err != nil { @@ -963,12 +970,13 @@ func (b keyringBlob) readKeyIndex() ([]string, bool, int, error) { if err := json.Unmarshal(chunkRaw, &more); err != nil { return nil, false, 0, fmt.Errorf("oauth: decode keyring token index chunk %d: %w", i, err) } - if len(keys)+len(more) > maxKeyringIndexKeys { - return nil, false, 0, errKeyringIndexTooManyKeys(len(keys) + len(more)) - } - keys = append(keys, more...) + rawKeys = append(rawKeys, more...) + } + keys := dedupeValidKeys(rawKeys) + if len(keys) > maxKeyringIndexKeys { + return nil, false, 0, errKeyringIndexTooManyKeys(len(keys)) } - return dedupeValidKeys(keys), true, header.Chunks, nil + return keys, true, header.Chunks, nil } // dedupeValidKeys drops duplicates and malformed entries from a decoded diff --git a/internal/oauth/store_keyring_test.go b/internal/oauth/store_keyring_test.go index 5cba1981d..ba9c9c116 100644 --- a/internal/oauth/store_keyring_test.go +++ b/internal/oauth/store_keyring_test.go @@ -1266,9 +1266,10 @@ func TestStoreKeyringDuplicateIndexDoesNotFanOutPerEntry(t *testing.T) { t.Fatalf("Status returned %d entries for a 3000-duplicate index of one key, want 1", len(statuses)) } // One Get for the index header, one Get for the single deduplicated key's - // own entry — not one per (duplicate) index entry. - if ckr.gets > 2 { - t.Fatalf("Status issued %d keyring gets for a 3000-entry duplicate index, want <= 2 (fan-out DoS regression)", ckr.gets) + // own entry, and at most one extra Get for the legacy fallback lookup. + // The key regression is: not 3000 (one per duplicate entry). + if ckr.gets > 3 { + t.Fatalf("Status issued %d keyring gets for a 3000-entry duplicate index, want <= 3 (fan-out DoS regression)", ckr.gets) } } From 2270931f7f8a3ea6e5d67d0f7b92722119508da9 Mon Sep 17 00:00:00 2001 From: euxaristia <25621994+euxaristia@users.noreply.github.com> Date: Wed, 29 Jul 2026 22:12:23 -0400 Subject: [PATCH 19/28] test(oauth): cover read() index/entry desync recovery for chunked index format The existing TestStoreKeyringSkipsIndexedKeyMissingItsEntry exercises the desync recovery only for the flat (single-chunk) index format. This gap means the chunked-path where a continuation chunk is missing (torn write by a killed process mid-write) and a key from the surviving chunk has no corresponding entry was never covered. Add TestStoreKeyringSkipsMissingChunkEntry which seeds a chunked index with two chunks, omits chunk 1 entirely, and places a key in chunk 0 whose own entry is also absent from the keyring. The test verifies that read() skips both missing sources and returns only the token that survives intact. Co-authored-by: cairn-code Co-Authored-By: cairn-code <282421612+cairn-code@users.noreply.github.com> --- internal/oauth/store_keyring_test.go | 65 ++++++++++++++++++++++++++++ 1 file changed, 65 insertions(+) diff --git a/internal/oauth/store_keyring_test.go b/internal/oauth/store_keyring_test.go index ba9c9c116..1182bf849 100644 --- a/internal/oauth/store_keyring_test.go +++ b/internal/oauth/store_keyring_test.go @@ -225,6 +225,71 @@ func TestStoreKeyringSkipsIndexedKeyMissingItsEntry(t *testing.T) { } } +// TestStoreKeyringSkipsMissingChunkEntry covers the chunked index format: +// a continuation chunk listed by the header is missing (torn write by a +// killed process), and one of the keys that survives in the remaining chunk +// has no corresponding entry in the keyring. read() must skip the missing +// key without failing the whole read, and the missing chunk must be ignored +// by readKeyIndex rather than causing an error. +func TestStoreKeyringSkipsMissingChunkEntry(t *testing.T) { + t.Setenv("XDG_CONFIG_HOME", t.TempDir()) + kr := newFakeKR() + + // Seed one valid token entry that will be reachable via chunk 0. + valid := Token{AccessToken: "valid-a", RefreshToken: "valid-r"} + raw, err := json.Marshal(valid) + if err != nil { + t.Fatal(err) + } + kr.data[keyringService+"/"+ProviderKey("valid")] = base64.StdEncoding.EncodeToString(raw) + + // Build a chunked header (2 chunks). Chunk 0 references "valid" and + // "orphan" (missing its entry). Chunk 1 exists and carries "extra". + // But we deliberately omit chunk 1 from the keyring to simulate a torn write. + header := keyIndexHeader{Version: 1, Chunks: 2, Keys: []string{ProviderKey("valid"), ProviderKey("orphan")}} + headerData, err := json.Marshal(header) + if err != nil { + t.Fatal(err) + } + kr.data[keyringService+"/"+keyringIndexAccount] = base64.StdEncoding.EncodeToString(headerData) + + // Chunk 1 is intentionally absent — simulating a process killed mid-write. + + s, err := NewStore(StoreOptions{Storage: "keyring", Keyring: kr}) + if err != nil { + t.Fatal(err) + } + + // "valid" must still be loadable (chunk 0 had it, and its entry exists). + got, ok, err := s.Load(ProviderKey("valid")) + if err != nil || !ok { + t.Fatalf("Load(valid): ok=%v err=%v", ok, err) + } + if got.AccessToken != valid.AccessToken { + t.Fatalf("Load(valid) = %#v", got) + } + + // "orphan" has no entry and the legacy blob is empty, so it must be skipped. + if _, ok, err := s.Load(ProviderKey("orphan")); err != nil || ok { + t.Fatalf("Load(orphan): ok=%v err=%v, want ok=false err=nil", ok, err) + } + + // "extra" lives in the missing chunk 1, so it must also be skipped. + if _, ok, err := s.Load(ProviderKey("extra")); err != nil || ok { + t.Fatalf("Load(extra): ok=%v err=%v, want ok=false err=nil (chunk 1 missing)", ok, err) + } + + // Status must return only "valid" — the missing chunk and missing entry + // must not fail the read or return phantom tokens. + statuses, err := s.Status("") + if err != nil { + t.Fatalf("Status: %v", err) + } + if len(statuses) != 1 || statuses[0].Key != ProviderKey("valid") { + t.Fatalf("Status = %#v, want only the valid key", statuses) + } +} + // failingKR wraps fakeKR and fails the Nth mutating operation (Set/Delete), // for exercising every interruption boundary of the multi-step write. type failingKR struct { From 61b840695f21ee7fcd8f47c2dca531d5eda5f2cf Mon Sep 17 00:00:00 2001 From: euxaristia <25621994+euxaristia@users.noreply.github.com> Date: Thu, 30 Jul 2026 03:28:31 -0400 Subject: [PATCH 20/28] chore: force CodeRabbit re-review Co-Authored-By: cairn-code <282421612+cairn-code@users.noreply.github.com> From 5c4b8b696a71170438f429e7f46479da116f00ee Mon Sep 17 00:00:00 2001 From: euxaristia <25621994+euxaristia@users.noreply.github.com> Date: Thu, 30 Jul 2026 22:10:08 -0400 Subject: [PATCH 21/28] fix(oauth): address PR requested changes for keyring per-provider entries Address requested review changes for keyring per-provider entries: - Anchor keyring lock path on user.Current() home directory so it stays stable across per-process HOME overrides. - Serve fresher legacy credentials directly on read paths without requiring a Save. - Evaluate token material changes in legacyIsFresher when expiries are zero (omitted expires_in). - Preserve deletion intent through reconciliation so logged-out tokens are never resurrected. - Add maxKeyringSingleEntryBytes bound check (3800 bytes) before keyring Set. - Document fileLockTimeout critical section upper bounds. Refs #668 --- internal/oauth/lock.go | 4 + internal/oauth/store.go | 80 +++++++------- internal/oauth/store_keyring_test.go | 157 ++++++++++++++++++++++++++- 3 files changed, 203 insertions(+), 38 deletions(-) diff --git a/internal/oauth/lock.go b/internal/oauth/lock.go index 0d192a2ed..1ff0db54b 100644 --- a/internal/oauth/lock.go +++ b/internal/oauth/lock.go @@ -12,6 +12,10 @@ import ( ) const ( + // fileLockTimeout is the deadline for acquiring the cross-process lock. + // Critical sections held under this lock consist of file operations or a + // small number of OS keyring subprocess calls (typically < 100ms total), + // so 5 seconds provides a comfortable upper bound under normal operation. fileLockTimeout = 5 * time.Second fileLockStaleAfter = 30 * time.Second ) diff --git a/internal/oauth/store.go b/internal/oauth/store.go index 96780eaff..bf925ab93 100644 --- a/internal/oauth/store.go +++ b/internal/oauth/store.go @@ -8,6 +8,7 @@ import ( "log" "net/url" "os" + "os/user" "path/filepath" "regexp" "runtime" @@ -265,27 +266,16 @@ func resolveStoreFilePath(options StoreOptions) (string, error) { // keyringLockPath returns the cross-process lock file location for the // keyring backend's read-modify-write, derived from the keyring identity -// itself (the service/account the index is stored under) and anchored on the -// user's home directory (see resolveHomeDir) rather than os.UserCacheDir() or -// os.TempDir(): those pick XDG_CACHE_HOME/TMPDIR per PROCESS, so two -// processes of the SAME real user with different cache/temp roots (a common -// case: sandboxes, CI, per-shell overrides) computed different lock files, -// both read the same fixed keyring index, and could publish competing -// updates that silently hid one process's token. HOME does not vary this way -// for a given real user. A single shared ${TMPDIR}/zero-oauth-keyring.lockfile -// would also let any other account on a multi-user host pre-create or keep -// refreshing the victim's lock and time out their Load/Status/Save/Delete, -// even though each user has a separate OS keychain, so only when even the -// home directory can't be resolved does this fall back to a temp file scoped -// by uid so two different users never collide on one path. +// itself (service + index account) and anchored on the user's OS home directory +// (via user.Current()) rather than caller-controlled environment overrides +// like HOME, XDG_CACHE_HOME, or TMPDIR: those pick different paths per process +// (sandboxes, CI harnesses, launcher profiles), so two processes for the same +// OS user would take different lock files while writing to the same OS keychain. func keyringLockPath(env map[string]string, service, account string) string { name := keyringLockFileName(service, account) - // Use OS.UserHomeDir (not resolveHomeDir) for a stable per-OS-user - // identity. resolveHomeDir honors ZERO_OAUTH_TOKENS_PATH and - // XDG_CONFIG_HOME overrides; using those for the lock path means - // two processes for the same keychain user with different HOME - // values take different locks, both read-modify-write the shared - // keyring index, and one saved token can be left unindexed. + if u, err := user.Current(); err == nil && strings.TrimSpace(u.HomeDir) != "" { + return filepath.Join(u.HomeDir, ".cache", "zero", name) + } if home, err := os.UserHomeDir(); err == nil && strings.TrimSpace(home) != "" { return filepath.Join(home, ".cache", "zero", name) } @@ -657,21 +647,21 @@ func (b keyringBlob) read() ([]byte, bool, error) { if err := json.Unmarshal(raw, &token); err != nil { return nil, false, fmt.Errorf("oauth: invalid keyring token entry %q: %w", key, err) } - tokens[key] = token - } - if !legacyLoaded { - if lt, lerr := b.readLegacyTokens(); lerr == nil { - legacyTokens = lt - } - } - for key, legacyToken := range legacyTokens { - if ValidateKey(key) != nil { - continue + // Check if legacy blob holds a fresher token for this indexed key + // (e.g. refreshed by an old binary running concurrently). + if !legacyLoaded { + if lt, lerr := b.readLegacyTokens(); lerr == nil { + legacyTokens = lt + } + legacyLoaded = true } - if _, exists := tokens[key]; !exists { - tokens[key] = legacyToken + if legacyToken, has := legacyTokens[key]; has { + if legacyIsFresher(legacyToken, token) { + token = legacyToken + } } + tokens[key] = token } data, err := json.Marshal(storeFile{SchemaVersion: storeSchemaVersion, Tokens: tokens}) @@ -723,15 +713,22 @@ func (b keyringBlob) readLegacyTokens() (map[string]Token, error) { // legacyIsFresher reports whether the legacy copy of an already-indexed key // should win over the indexed copy. An old binary running alongside the new one // refreshes tokens only in the legacy combined entry, and a refresh pushes the -// expiry later, so a strictly later expiry on the legacy side is the +// expiry later (or updates token material when expires_in is omitted), so a +// strictly later expiry or updated token material on the legacy side is the // signal that it holds a newer credential. Zero (unknown) expiries are valid. func legacyIsFresher(legacy, current Token) bool { - if legacy.ExpiresAt.After(current.ExpiresAt) { - return true - } - if legacy.ExpiresAt.Equal(current.ExpiresAt) { + if !legacy.ExpiresAt.IsZero() && !current.ExpiresAt.IsZero() { + if legacy.ExpiresAt.After(current.ExpiresAt) { + return true + } + if current.ExpiresAt.After(legacy.ExpiresAt) { + return false + } return legacy.AccessToken != current.AccessToken || legacy.RefreshToken != current.RefreshToken } + if legacy.AccessToken != current.AccessToken || legacy.RefreshToken != current.RefreshToken { + return true + } return false } @@ -833,7 +830,11 @@ func (b keyringBlob) write(data []byte) error { if err != nil { return err } - if err := b.kr.Set(b.service, key, base64.StdEncoding.EncodeToString(raw)); err != nil { + encoded := base64.StdEncoding.EncodeToString(raw) + if len(encoded) > maxKeyringSingleEntryBytes { + return fmt.Errorf("oauth: token payload for %q (%d bytes) exceeds single keyring entry bound (%d bytes); use file or encrypted-file storage", key, len(encoded), maxKeyringSingleEntryBytes) + } + if err := b.kr.Set(b.service, key, encoded); err != nil { return err } } @@ -862,6 +863,11 @@ func (b keyringBlob) write(data []byte) error { return nil } +// maxKeyringSingleEntryBytes bounds a single base64-encoded token secret so +// that the line passed to macOS `security -i` stays comfortably under the +// 4095-byte command line cap (see internal/keyring). +const maxKeyringSingleEntryBytes = 3800 + // maxKeyringIndexChunkBytes bounds one index chunk's raw JSON payload so its // base64 encoding plus command framing stays well under the macOS // `security -i` 4095-byte line cap (see internal/keyring): 2700 raw bytes diff --git a/internal/oauth/store_keyring_test.go b/internal/oauth/store_keyring_test.go index 1182bf849..1203ca8a0 100644 --- a/internal/oauth/store_keyring_test.go +++ b/internal/oauth/store_keyring_test.go @@ -5,6 +5,7 @@ import ( "encoding/json" "fmt" "os" + "os/user" "path/filepath" "strings" "testing" @@ -971,7 +972,11 @@ func TestKeyringLockPathIsPerUser(t *testing.T) { if got == filepath.Join(os.TempDir(), "zero-"+name) { t.Fatalf("lock path is the shared temp path %q; a co-tenant could grief it", got) } - if home, err := os.UserHomeDir(); err == nil && strings.TrimSpace(home) != "" { + if u, err := user.Current(); err == nil && strings.TrimSpace(u.HomeDir) != "" { + if want := filepath.Join(u.HomeDir, ".cache", "zero", name); got != want { + t.Fatalf("lock path = %q, want per-user home-anchored path %q", got, want) + } + } else if home, err := os.UserHomeDir(); err == nil && strings.TrimSpace(home) != "" { if want := filepath.Join(home, ".cache", "zero", name); got != want { t.Fatalf("lock path = %q, want per-user home-anchored path %q", got, want) } @@ -1417,3 +1422,153 @@ func TestStoreKeyringWriteRefusesToDeleteLegacyBlobOnTransientReadError(t *testi t.Fatal("legacy blob should be removed once it was actually read and reconciled") } } + +func TestStoreKeyringLockPathStableAcrossDifferentHomeEnvs(t *testing.T) { + kr := newFakeKR() + s1, err := NewStore(StoreOptions{Storage: "keyring", Keyring: kr, Env: map[string]string{"HOME": filepath.Join(t.TempDir(), "home1")}}) + if err != nil { + t.Fatal(err) + } + s2, err := NewStore(StoreOptions{Storage: "keyring", Keyring: kr, Env: map[string]string{"HOME": filepath.Join(t.TempDir(), "home2")}}) + if err != nil { + t.Fatal(err) + } + + p1 := s1.blob.(keyringBlob).lockPath + p2 := s2.blob.(keyringBlob).lockPath + if p1 != p2 { + t.Fatalf("lockPath mismatch for different HOME envs: s1=%q, s2=%q", p1, p2) + } + + if err := s1.Save(ProviderKey("alpha"), Token{AccessToken: "token1"}); err != nil { + t.Fatalf("s1.Save: %v", err) + } + got, ok, err := s2.Load(ProviderKey("alpha")) + if err != nil || !ok || got.AccessToken != "token1" { + t.Fatalf("s2.Load: got=%#v, ok=%v, err=%v", got, ok, err) + } +} + +func TestStoreKeyringMergesFreshLegacyRefreshOnLoadWithoutSave(t *testing.T) { + t.Setenv("XDG_CONFIG_HOME", t.TempDir()) + kr := newFakeKR() + s, err := NewStore(StoreOptions{Storage: "keyring", Keyring: kr}) + if err != nil { + t.Fatal(err) + } + t1 := time.Now().Add(-10 * time.Minute) + if err := s.Save(ProviderKey("alpha"), Token{AccessToken: "stale-a", ExpiresAt: t1}); err != nil { + t.Fatal(err) + } + + t2 := time.Now().Add(1 * time.Hour) + legacy := storeFile{SchemaVersion: storeSchemaVersion, Tokens: map[string]Token{ + ProviderKey("alpha"): {AccessToken: "fresh-a", RefreshToken: "fresh-r", ExpiresAt: t2}, + }} + data, err := json.Marshal(legacy) + if err != nil { + t.Fatal(err) + } + kr.data[keyringService+"/"+keyringLegacyAccount] = base64.StdEncoding.EncodeToString(data) + + got, ok, err := s.Load(ProviderKey("alpha")) + if err != nil || !ok { + t.Fatalf("Load: ok=%v err=%v", ok, err) + } + if got.AccessToken != "fresh-a" { + t.Fatalf("Load returned stale token %#v, want fresh legacy token", got) + } +} + +func TestStoreKeyringLegacyIsFresherHandlesZeroExpiryRefreshes(t *testing.T) { + t.Setenv("XDG_CONFIG_HOME", t.TempDir()) + kr := newFakeKR() + s, err := NewStore(StoreOptions{Storage: "keyring", Keyring: kr}) + if err != nil { + t.Fatal(err) + } + t1 := time.Now().Add(10 * time.Minute) + if err := s.Save(ProviderKey("alpha"), Token{AccessToken: "old-a", RefreshToken: "old-r", ExpiresAt: t1}); err != nil { + t.Fatal(err) + } + + legacy := storeFile{SchemaVersion: storeSchemaVersion, Tokens: map[string]Token{ + ProviderKey("alpha"): {AccessToken: "new-a", RefreshToken: "new-r", ExpiresAt: time.Time{}}, + }} + data, err := json.Marshal(legacy) + if err != nil { + t.Fatal(err) + } + kr.data[keyringService+"/"+keyringLegacyAccount] = base64.StdEncoding.EncodeToString(data) + + got, ok, err := s.Load(ProviderKey("alpha")) + if err != nil || !ok { + t.Fatalf("Load: ok=%v err=%v", ok, err) + } + if got.AccessToken != "new-a" || got.RefreshToken != "new-r" { + t.Fatalf("Load = %#v, want legacy zero-expiry refresh", got) + } +} + +func TestStoreKeyringDeleteNotResurrectedWhenLegacyDeleteFailsOrRewritten(t *testing.T) { + t.Setenv("XDG_CONFIG_HOME", t.TempDir()) + kr := newFakeKR() + s, err := NewStore(StoreOptions{Storage: "keyring", Keyring: kr}) + if err != nil { + t.Fatal(err) + } + if err := s.Save(ProviderKey("alpha"), Token{AccessToken: "a"}); err != nil { + t.Fatal(err) + } + + // Seed legacy blob with alpha (logged out token) + legacy := storeFile{SchemaVersion: storeSchemaVersion, Tokens: map[string]Token{ + ProviderKey("alpha"): {AccessToken: "a-legacy"}, + }} + data, err := json.Marshal(legacy) + if err != nil { + t.Fatal(err) + } + kr.data[keyringService+"/"+keyringLegacyAccount] = base64.StdEncoding.EncodeToString(data) + + // Delete alpha + removed, err := s.Delete(ProviderKey("alpha")) + if err != nil || !removed { + t.Fatalf("Delete(alpha): removed=%v err=%v", removed, err) + } + + // Assert Load(alpha) returns empty + if _, ok, _ := s.Load(ProviderKey("alpha")); ok { + t.Fatal("alpha should not be exposed on Load after Delete") + } + + // Save gamma, which triggers write() reconciliation + if err := s.Save(ProviderKey("gamma"), Token{AccessToken: "g"}); err != nil { + t.Fatal(err) + } + + // Assert alpha was not resurrected into the index + if _, ok, _ := s.Load(ProviderKey("alpha")); ok { + t.Fatal("alpha was resurrected into index after Delete") + } +} + +func TestStoreKeyringRejectsOversizedSingleTokenPayload(t *testing.T) { + t.Setenv("XDG_CONFIG_HOME", t.TempDir()) + kr := newFakeKR() + s, err := NewStore(StoreOptions{Storage: "keyring", Keyring: kr}) + if err != nil { + t.Fatal(err) + } + + huge := Token{ + AccessToken: "eyJhbGciOiJSUzI1NiJ9." + strings.Repeat("A", 6000) + ".sig", + } + err = s.Save(ProviderKey("huge"), huge) + if err == nil { + t.Fatal("Save succeeded for oversized single token payload; want error") + } + if !strings.Contains(err.Error(), "exceeds single keyring entry bound") { + t.Fatalf("unexpected error message: %v", err) + } +} From c511460dde4f93afc643f306626e8f57681cee62 Mon Sep 17 00:00:00 2001 From: euxaristia <25621994+euxaristia@users.noreply.github.com> Date: Fri, 31 Jul 2026 11:03:23 -0400 Subject: [PATCH 22/28] fix(oauth): address review findings for per-provider keyring entries --- internal/oauth/store.go | 44 +++++++++++++++++++++++++++++------------ 1 file changed, 31 insertions(+), 13 deletions(-) diff --git a/internal/oauth/store.go b/internal/oauth/store.go index bf925ab93..ac3115925 100644 --- a/internal/oauth/store.go +++ b/internal/oauth/store.go @@ -664,6 +664,23 @@ func (b keyringBlob) read() ([]byte, bool, error) { tokens[key] = token } + // Keep legacy-only keys visible through the compatibility window: + // an old binary may have logged into a provider after the index was created. + if !legacyLoaded { + if lt, lerr := b.readLegacyTokens(); lerr == nil { + legacyTokens = lt + } + legacyLoaded = true + } + for key, legacyToken := range legacyTokens { + if ValidateKey(key) != nil { + continue + } + if _, has := tokens[key]; !has { + tokens[key] = legacyToken + } + } + data, err := json.Marshal(storeFile{SchemaVersion: storeSchemaVersion, Tokens: tokens}) if err != nil { return nil, false, err @@ -717,19 +734,7 @@ func (b keyringBlob) readLegacyTokens() (map[string]Token, error) { // strictly later expiry or updated token material on the legacy side is the // signal that it holds a newer credential. Zero (unknown) expiries are valid. func legacyIsFresher(legacy, current Token) bool { - if !legacy.ExpiresAt.IsZero() && !current.ExpiresAt.IsZero() { - if legacy.ExpiresAt.After(current.ExpiresAt) { - return true - } - if current.ExpiresAt.After(legacy.ExpiresAt) { - return false - } - return legacy.AccessToken != current.AccessToken || legacy.RefreshToken != current.RefreshToken - } - if legacy.AccessToken != current.AccessToken || legacy.RefreshToken != current.RefreshToken { - return true - } - return false + return legacy.AccessToken != current.AccessToken || legacy.RefreshToken != current.RefreshToken } // write replaces the keyring's token entries with state, ordered so that @@ -897,6 +902,10 @@ const maxKeyringIndexChunks = 128 // maxKeyringIndexChunks) while still rejecting a damaged index promptly. const maxKeyringIndexKeys = 512 +// maxRawKeyringIndexKeys bounds the raw decoded element count before +// deduplication or map preallocation, guarding against DoS from duplicate keys. +const maxRawKeyringIndexKeys = 16384 + // errKeyringIndexTooManyKeys is returned when a decoded index (or one of its // chunks) claims more keys than maxKeyringIndexKeys. func errKeyringIndexTooManyKeys(count int) error { @@ -939,6 +948,9 @@ func (b keyringBlob) readKeyIndex() ([]string, bool, int, error) { if err := json.Unmarshal(raw, &rawKeys); err != nil { return nil, false, 0, fmt.Errorf("oauth: decode keyring token index: %w", err) } + if len(rawKeys) > maxRawKeyringIndexKeys { + return nil, false, 0, errKeyringIndexTooManyKeys(len(rawKeys)) + } keys := dedupeValidKeys(rawKeys) if len(keys) > maxKeyringIndexKeys { return nil, false, 0, errKeyringIndexTooManyKeys(len(keys)) @@ -960,6 +972,9 @@ func (b keyringBlob) readKeyIndex() ([]string, bool, int, error) { return nil, false, 0, fmt.Errorf("oauth: keyring token index advertises %d chunks (want 1..%d)", header.Chunks, maxKeyringIndexChunks) } rawKeys := header.Keys + if len(rawKeys) > maxRawKeyringIndexKeys { + return nil, false, 0, errKeyringIndexTooManyKeys(len(rawKeys)) + } for i := 1; i < header.Chunks; i++ { chunkEnc, ok, err := b.kr.Get(b.service, b.chunkAccount(i)) if err != nil { @@ -976,6 +991,9 @@ func (b keyringBlob) readKeyIndex() ([]string, bool, int, error) { if err := json.Unmarshal(chunkRaw, &more); err != nil { return nil, false, 0, fmt.Errorf("oauth: decode keyring token index chunk %d: %w", i, err) } + if len(rawKeys)+len(more) > maxRawKeyringIndexKeys { + return nil, false, 0, errKeyringIndexTooManyKeys(len(rawKeys) + len(more)) + } rawKeys = append(rawKeys, more...) } keys := dedupeValidKeys(rawKeys) From 69c8587eb967a8a399f6b1f395a3f088f4839fa1 Mon Sep 17 00:00:00 2001 From: euxaristia <25621994+euxaristia@users.noreply.github.com> Date: Sat, 1 Aug 2026 12:40:57 -0400 Subject: [PATCH 23/28] fix(oauth): address remaining keyring migration P1s Preserve logout intent through legacy reconciliation so a legacy-only credential is not reclassified as a fresh old-binary login after Delete. Restore hybrid legacyIsFresher ordering (expiry when both known, else token material) so zero-expiry refreshes are not discarded, and cover the remaining mixed-version lock and delete edge cases with regression tests. Refs #668 --- internal/oauth/store.go | 58 ++++++++--- internal/oauth/store_keyring_test.go | 147 +++++++++++++++++++++++++++ 2 files changed, 190 insertions(+), 15 deletions(-) diff --git a/internal/oauth/store.go b/internal/oauth/store.go index ac3115925..73c952366 100644 --- a/internal/oauth/store.go +++ b/internal/oauth/store.go @@ -349,7 +349,7 @@ func (s *Store) Save(key string, token Token) error { return err } state.Tokens[key] = token - return s.writeState(state) + return s.writeState(state, nil) }) } @@ -398,7 +398,10 @@ func (s *Store) Delete(key string) (bool, error) { } delete(state.Tokens, key) removed = true - return s.writeState(state) + // Exclude the deleted key from legacy reconciliation so a credential + // that was only present in the legacy blob (never indexed) is not + // reclassified as a fresh old-binary login and written back. + return s.writeState(state, map[string]bool{key: true}) }) return removed, err } @@ -478,7 +481,10 @@ func (s *Store) readState() (storeFile, error) { return state, nil } -func (s *Store) writeState(state storeFile) error { +// writeState persists state. omitFromLegacy lists keys that must not be +// re-merged from the keyring legacy blob during reconciliation (Delete intent). +// File and encrypted-file backends ignore omitFromLegacy. +func (s *Store) writeState(state storeFile, omitFromLegacy map[string]bool) error { data, err := json.MarshalIndent(state, "", " ") if err != nil { return err @@ -492,7 +498,7 @@ func (s *Store) writeState(state storeFile) error { return err } } - return s.blob.write(payload) + return s.blob.write(payload, omitFromLegacy) } func emptyStoreFile() storeFile { @@ -504,8 +510,10 @@ func emptyStoreFile() storeFile { type blobStore interface { // read returns the stored blob; ok is false when nothing is stored yet. read() (data []byte, ok bool, err error) - // write replaces the stored blob. - write(data []byte) error + // write replaces the stored blob. omitFromLegacy is keyring-only: keys the + // caller deliberately removed that must not be re-merged from the legacy + // combined entry during mixed-version reconciliation. File backends ignore it. + write(data []byte, omitFromLegacy map[string]bool) error // withLock runs fn under whatever cross-process exclusion the backend offers // (a lock file for the file backend; none for the keyring, which is the // authoritative store and is serialized within the process by Store.mu). @@ -536,7 +544,7 @@ func (b fileBlob) read() ([]byte, bool, error) { return data, true, nil } -func (b fileBlob) write(data []byte) error { +func (b fileBlob) write(data []byte, _ map[string]bool) error { if err := os.MkdirAll(filepath.Dir(b.path), 0o700); err != nil { return err } @@ -729,11 +737,24 @@ func (b keyringBlob) readLegacyTokens() (map[string]Token, error) { // legacyIsFresher reports whether the legacy copy of an already-indexed key // should win over the indexed copy. An old binary running alongside the new one -// refreshes tokens only in the legacy combined entry, and a refresh pushes the -// expiry later (or updates token material when expires_in is omitted), so a -// strictly later expiry or updated token material on the legacy side is the -// signal that it holds a newer credential. Zero (unknown) expiries are valid. +// refreshes tokens only in the legacy combined entry. When both sides carry a +// nonzero expiry, the later expiry wins (a refresh normally pushes it forward). +// When either side omits expiry (OAuth responses may omit expires_in, and +// Refresh does not always carry the previous ExpiresAt), fall back to token +// material: a changed access or refresh token on the legacy side is treated as +// a concurrent old-binary refresh that must not be discarded. func legacyIsFresher(legacy, current Token) bool { + if !legacy.ExpiresAt.IsZero() && !current.ExpiresAt.IsZero() { + if legacy.ExpiresAt.After(current.ExpiresAt) { + return true + } + if current.ExpiresAt.After(legacy.ExpiresAt) { + return false + } + // Equal expiries: prefer legacy when material differs (concurrent refresh + // that happened to keep the same lifetime). + return legacy.AccessToken != current.AccessToken || legacy.RefreshToken != current.RefreshToken + } return legacy.AccessToken != current.AccessToken || legacy.RefreshToken != current.RefreshToken } @@ -751,7 +772,10 @@ func legacyIsFresher(legacy, current Token) bool { // and is deleted only after every per-key entry is written, while the union // index still lists removed keys; a failure of that delete is returned so a // logout is never reported successful with the stale blob still resident. -func (b keyringBlob) write(data []byte) error { +// omitFromLegacy lists keys the caller just deleted; they must not be +// re-merged from the legacy blob even when they were never indexed (a +// legacy-only old-binary login that the user logged out of). +func (b keyringBlob) write(data []byte, omitFromLegacy map[string]bool) error { var state storeFile if err := json.Unmarshal(data, &state); err != nil { return fmt.Errorf("oauth: encode keyring token blob: %w", err) @@ -771,9 +795,10 @@ func (b keyringBlob) write(data []byte) error { // reconcile it into state before it is deleted below rather than blindly // overwriting it: // - a key the indexed schema has never seen is a fresh old-binary login; - // merge it so it is not lost; - // - a key already present in state that the legacy blob refreshed (a - // strictly later expiry) takes the legacy value, so a concurrent + // merge it so it is not lost, unless the caller just deleted it + // (omitFromLegacy) or it was already indexed and deliberately removed; + // - a key already present in state that the legacy blob refreshed takes + // the legacy value when legacyIsFresher says so, so a concurrent // old-binary refresh is not discarded in favor of the stale indexed one; // - a key that was in the prior index but is absent from this write was // deliberately removed (a logout); it is left removed, not resurrected. @@ -792,6 +817,9 @@ func (b keyringBlob) write(data []byte) error { if ValidateKey(key) != nil { continue } + if omitFromLegacy[key] { + continue + } if current, exists := state.Tokens[key]; exists { if legacyIsFresher(legacyToken, current) { state.Tokens[key] = legacyToken diff --git a/internal/oauth/store_keyring_test.go b/internal/oauth/store_keyring_test.go index 1203ca8a0..01f5728f1 100644 --- a/internal/oauth/store_keyring_test.go +++ b/internal/oauth/store_keyring_test.go @@ -1553,6 +1553,153 @@ func TestStoreKeyringDeleteNotResurrectedWhenLegacyDeleteFailsOrRewritten(t *tes } } +// TestStoreKeyringDeleteDoesNotResurrectLegacyOnlyKey is the regression for +// [P1] Do not resurrect a token the caller just logged out: when an old binary +// adds beta only to the legacy blob after the index already contains alpha, +// read exposes beta, Delete(beta) removes it from state, and write must not +// reclassify that same legacy value as a fresh old-binary login. +func TestStoreKeyringDeleteDoesNotResurrectLegacyOnlyKey(t *testing.T) { + t.Setenv("XDG_CONFIG_HOME", t.TempDir()) + kr := newFakeKR() + s, err := NewStore(StoreOptions{Storage: "keyring", Keyring: kr}) + if err != nil { + t.Fatal(err) + } + if err := s.Save(ProviderKey("alpha"), Token{AccessToken: "a"}); err != nil { + t.Fatal(err) + } + + // Old binary login: beta only in the legacy combined entry, not the index. + legacy := storeFile{SchemaVersion: storeSchemaVersion, Tokens: map[string]Token{ + ProviderKey("beta"): {AccessToken: "b-legacy", RefreshToken: "br"}, + }} + data, err := json.Marshal(legacy) + if err != nil { + t.Fatal(err) + } + kr.data[keyringService+"/"+keyringLegacyAccount] = base64.StdEncoding.EncodeToString(data) + + // Delete must see beta via the legacy merge, then keep it gone after write. + removed, err := s.Delete(ProviderKey("beta")) + if err != nil || !removed { + t.Fatalf("Delete(beta): removed=%v err=%v", removed, err) + } + if _, ok, _ := s.Load(ProviderKey("beta")); ok { + t.Fatal("beta resurrected after Delete of legacy-only key") + } + // A later Save of another key must also not resurrect beta (legacy blob + // should already be gone; if a stale copy were re-seeded, omit only covers + // the Delete write itself — ensure the logout fully cleared it). + if err := s.Save(ProviderKey("gamma"), Token{AccessToken: "g"}); err != nil { + t.Fatal(err) + } + if _, ok, _ := s.Load(ProviderKey("beta")); ok { + t.Fatal("beta resurrected after later Save") + } + if _, ok, err := s.Load(ProviderKey("alpha")); err != nil || !ok { + t.Fatalf("alpha lost: ok=%v err=%v", ok, err) + } +} + +// TestStoreKeyringWriteMergesZeroExpiryLegacyRefresh ensures write-path +// reconciliation does not discard a valid old-binary refresh that omitted +// expires_in (zero ExpiresAt) when the indexed copy still has a nonzero expiry. +func TestStoreKeyringWriteMergesZeroExpiryLegacyRefresh(t *testing.T) { + t.Setenv("XDG_CONFIG_HOME", t.TempDir()) + kr := newFakeKR() + s, err := NewStore(StoreOptions{Storage: "keyring", Keyring: kr}) + if err != nil { + t.Fatal(err) + } + t1 := time.Now().Add(10 * time.Minute) + if err := s.Save(ProviderKey("alpha"), Token{AccessToken: "old-a", RefreshToken: "old-r", ExpiresAt: t1}); err != nil { + t.Fatal(err) + } + + legacy := storeFile{SchemaVersion: storeSchemaVersion, Tokens: map[string]Token{ + ProviderKey("alpha"): {AccessToken: "new-a", RefreshToken: "new-r", ExpiresAt: time.Time{}}, + }} + data, err := json.Marshal(legacy) + if err != nil { + t.Fatal(err) + } + kr.data[keyringService+"/"+keyringLegacyAccount] = base64.StdEncoding.EncodeToString(data) + + // Save of a different key triggers write reconciliation of the legacy blob. + if err := s.Save(ProviderKey("beta"), Token{AccessToken: "b"}); err != nil { + t.Fatal(err) + } + got, ok, err := s.Load(ProviderKey("alpha")) + if err != nil || !ok { + t.Fatalf("Load(alpha): ok=%v err=%v", ok, err) + } + if got.AccessToken != "new-a" || got.RefreshToken != "new-r" { + t.Fatalf("write reconciliation dropped zero-expiry legacy refresh: got %#v", got) + } +} + +func TestLegacyIsFresher(t *testing.T) { + t1 := time.Now().Add(10 * time.Minute) + t2 := t1.Add(time.Hour) + cases := []struct { + name string + legacy, current Token + want bool + }{ + { + name: "later legacy expiry wins", + legacy: Token{AccessToken: "a", ExpiresAt: t2}, + current: Token{AccessToken: "a", ExpiresAt: t1}, + want: true, + }, + { + name: "later current expiry wins", + legacy: Token{AccessToken: "old", ExpiresAt: t1}, + current: Token{AccessToken: "new", ExpiresAt: t2}, + want: false, + }, + { + name: "zero-expiry legacy with new material wins", + legacy: Token{AccessToken: "new-a", RefreshToken: "new-r"}, + current: Token{AccessToken: "old-a", RefreshToken: "old-r", ExpiresAt: t1}, + want: true, + }, + { + name: "identical tokens are not fresher", + legacy: Token{AccessToken: "a", RefreshToken: "r", ExpiresAt: t1}, + current: Token{AccessToken: "a", RefreshToken: "r", ExpiresAt: t1}, + want: false, + }, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + if got := legacyIsFresher(tc.legacy, tc.current); got != tc.want { + t.Fatalf("legacyIsFresher = %v, want %v", got, tc.want) + } + }) + } +} + +// TestLegacyKeyringLockPathHonorsConfiguredRoot ensures the pre-PR compatibility +// lock is derived via ResolveStorePath (ZERO_OAUTH_TOKENS_PATH / XDG_CONFIG_HOME), +// not a hard-coded home .config path. +func TestLegacyKeyringLockPathHonorsConfiguredRoot(t *testing.T) { + override := filepath.Join(t.TempDir(), "custom", "tokens.json") + env := map[string]string{"ZERO_OAUTH_TOKENS_PATH": override} + got := legacyKeyringLockPath(env) + want := filepath.Join(filepath.Dir(override), "oauth-keyring.lockfile") + if got != want { + t.Fatalf("legacyKeyringLockPath = %q, want %q (beside ResolveStorePath)", got, want) + } + + xdg := filepath.Join(t.TempDir(), "xdg-config") + gotXDG := legacyKeyringLockPath(map[string]string{"XDG_CONFIG_HOME": xdg}) + wantXDG := filepath.Join(xdg, "zero", "oauth-keyring.lockfile") + if gotXDG != wantXDG { + t.Fatalf("legacyKeyringLockPath(XDG) = %q, want %q", gotXDG, wantXDG) + } +} + func TestStoreKeyringRejectsOversizedSingleTokenPayload(t *testing.T) { t.Setenv("XDG_CONFIG_HOME", t.TempDir()) kr := newFakeKR() From 7d3a4297d8bd26f69f3cdf5efda1ae91c4f70636 Mon Sep 17 00:00:00 2001 From: euxaristia <25621994+euxaristia@users.noreply.github.com> Date: Sat, 1 Aug 2026 16:13:02 -0400 Subject: [PATCH 24/28] fix(oauth): harden keyring index write and migration safety Address jatmn review on PR #668: preflight token size before publishing index keys and prune phantoms so failed Saves cannot brick the store; dual-write the legacy blob without deleting it (size-capped) so cross-root old writers cannot permanently lose credentials; stop using expiry/token material as causal order when reconciling; bound index payloads before base64/JSON decode; wait while a lock lease stays healthy. Refs #668 --- internal/oauth/lock.go | 84 +++-- internal/oauth/store.go | 368 +++++++++++-------- internal/oauth/store_keyring_test.go | 510 ++++++++++++++++++++------- 3 files changed, 653 insertions(+), 309 deletions(-) diff --git a/internal/oauth/lock.go b/internal/oauth/lock.go index 1ff0db54b..8481d94ea 100644 --- a/internal/oauth/lock.go +++ b/internal/oauth/lock.go @@ -11,28 +11,39 @@ import ( "github.com/Gitlawb/zero/internal/lockutil" ) -const ( - // fileLockTimeout is the deadline for acquiring the cross-process lock. - // Critical sections held under this lock consist of file operations or a - // small number of OS keyring subprocess calls (typically < 100ms total), - // so 5 seconds provides a comfortable upper bound under normal operation. - fileLockTimeout = 5 * time.Second +// Lock timing knobs (vars so tests can shorten absolute ceilings without +// changing production defaults). +var ( + // fileLockTimeout is how long acquisition waits after the last sign of a + // healthy holder (or when the lock path cannot be stated). A multi-entry + // keyring pass can legitimately run several 10s OS commands while refreshing + // the lease; contenders must not give up while that lease stays healthy. + // While the holder's mtime stays within fileLockStaleAfter, this idle + // deadline is extended so a fixed 5s window cannot fail a healthy peer. + fileLockTimeout = 5 * time.Second + // fileLockMaxWait is a hard ceiling on total acquisition time so a wedged + // peer that somehow keeps refreshing forever cannot pin waiters indefinitely. + // Healthy multi-entry writes stay well under this; raise only with evidence. + fileLockMaxWait = 2 * time.Minute + // fileLockStaleAfter is how old a lock file's mtime must be before a waiter + // may reclaim it as abandoned. Must stay above one keyring command timeout + // plus lease refresh slack (holders refresh every fileLockRefreshInterval). fileLockStaleAfter = 30 * time.Second ) var lockSeq atomic.Uint64 // acquireFileLock takes a cross-process exclusive lock by creating lockPath with -// O_EXCL. It retries with a short backoff until a timeout, breaking a lock whose -// file is older than fileLockStaleAfter (so a crashed holder cannot deadlock the -// store). Release is ownership-aware: it removes the lock only if it still holds -// our token, so a stale-broken holder cannot delete a newer holder's lock. +// O_EXCL. It retries with a short backoff while a live holder's lease remains +// healthy (mtime refreshed within fileLockStaleAfter), reclaiming only a lock +// older than that threshold so a crashed holder cannot deadlock the store. +// Release is ownership-aware: it removes the lock only if it still holds our +// token, so a stale-broken holder cannot delete a newer holder's lock. // -// The acquisition deadline is always measured against the real wall clock, never -// the now parameter: now is StoreOptions.Now, which callers may legitimately fix -// (e.g. a test or an embedded clock), and deadline := now().Add(fileLockTimeout) -// followed by now().After(deadline) would then never become true, turning lock -// contention into an infinite retry loop instead of a timeout error. +// Timing always uses the real wall clock, never the now parameter: now is +// StoreOptions.Now, which callers may legitimately fix (e.g. a test or an +// embedded clock). Measuring the deadline with that clock would either never +// fire (fixed clock) or diverge from the mtime lease stamps (wall-clock). func acquireFileLock(lockPath string, now func() time.Time) (func(), error) { if now == nil { now = time.Now @@ -41,7 +52,8 @@ func acquireFileLock(lockPath string, now func() time.Time) (func(), error) { return nil, err } token := fmt.Sprintf("%d-%d-%d", os.Getpid(), now().UnixNano(), lockSeq.Add(1)) - deadline := time.Now().Add(fileLockTimeout) + start := time.Now() + idleDeadline := start.Add(fileLockTimeout) for { f, err := os.OpenFile(lockPath, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0o600) if err == nil { @@ -80,25 +92,31 @@ func acquireFileLock(lockPath string, now func() time.Time) (func(), error) { // Remove lets two racers both reclaim + recreate and so both hold the lock; // reclaimStaleLock renames the file aside (only one rename wins) and restores // it if it turns out fresh, so a live lock is never deleted out from under it. - if info, statErr := os.Stat(lockPath); statErr == nil && time.Since(info.ModTime()) > fileLockStaleAfter { - cleared, rerr := lockutil.ReclaimStaleLock(lockPath, token, func(reclaimedPath string) bool { - info, err := os.Stat(reclaimedPath) - return err == nil && time.Since(info.ModTime()) <= fileLockStaleAfter - }) - if rerr != nil { - // Reclaim hit a hard failure: the rename aside failed outright, or a - // live holder's lock could not be put back (the lock path may be - // missing, so re-acquiring would break mutual exclusion). Fail closed - // instead of spinning to the deadline. - return nil, fmt.Errorf("oauth: reclaim stale token lock: %w", rerr) - } - if cleared { - continue + if info, statErr := os.Stat(lockPath); statErr == nil { + if time.Since(info.ModTime()) > fileLockStaleAfter { + cleared, rerr := lockutil.ReclaimStaleLock(lockPath, token, func(reclaimedPath string) bool { + info, err := os.Stat(reclaimedPath) + return err == nil && time.Since(info.ModTime()) <= fileLockStaleAfter + }) + if rerr != nil { + // Reclaim hit a hard failure: the rename aside failed outright, or a + // live holder's lock could not be put back (the lock path may be + // missing, so re-acquiring would break mutual exclusion). Fail closed + // instead of spinning to the deadline. + return nil, fmt.Errorf("oauth: reclaim stale token lock: %w", rerr) + } + if cleared { + continue + } + // Lost the reclaim race (or it was actually fresh) — fall through. + } else { + // Holder looks healthy (lease refreshed recently). Keep waiting for + // the critical section to finish rather than timing out after a fixed + // window shorter than a legitimate multi-entry keyring pass. + idleDeadline = time.Now().Add(fileLockTimeout) } - // Lost the reclaim race (or it was actually fresh) — fall through to the - // bounded wait rather than hot-spinning on a reclaim that never wins. } - if time.Now().After(deadline) { + if time.Now().After(idleDeadline) || time.Since(start) > fileLockMaxWait { return nil, fmt.Errorf("oauth: timed out acquiring token lock %s", filepath.Base(lockPath)) } time.Sleep(10 * time.Millisecond) diff --git a/internal/oauth/store.go b/internal/oauth/store.go index 73c952366..e5c145c91 100644 --- a/internal/oauth/store.go +++ b/internal/oauth/store.go @@ -119,9 +119,13 @@ type KeyringClient interface { // regardless of how many providers are logged in. const ( keyringService = "zero" - // keyringLegacyAccount held the whole blob as one entry in the original - // design. New writes never use it; it is only read once, to migrate - // existing installs into the per-key format. + // keyringLegacyAccount is the combined-blob entry used by pre-per-key + // binaries. New code dual-writes the reconciled token map here on every + // save so an old binary on another config root (which cannot share this + // process's legacy lock) still sees current credentials, and so a + // concurrent old-writer update is never permanently deleted just because + // this process could not observe it. The per-key index remains + // authoritative for new binaries. keyringLegacyAccount = "oauth-tokens" // keyringIndexAccount holds a JSON array of the token keys that currently // have their own keyring entry, since KeyringClient has no "list" operation. @@ -286,13 +290,11 @@ func keyringLockPath(env map[string]string, service, account string) string { // its own read-modify-write of the single combined keyring entry, beside // wherever ResolveStorePath resolves the file-backend location for that // process's env. A new binary must take this SAME lock (not just its own -// keyringLockPath) around any write that reconciles or deletes the legacy -// entry: an old binary observes no other lock, so only sharing its exact -// lock file stops it from writing a fresh legacy login/refresh in the window -// between this binary's reconciliation read and its legacy-blob delete, -// which would otherwise discard that write permanently. Best-effort: "" -// when the file-backend location can't be resolved at all, matching the -// legacy code's own best-effort fallback (no cross-process lock at all). +// keyringLockPath) around any write that reconciles or dual-writes the legacy +// entry when the old binary shares this config root. Old binaries on other +// roots cannot share this lock; dual-write-without-delete is the safety net +// for that case. Best-effort: "" when the file-backend location can't be +// resolved at all, matching the legacy code's own best-effort fallback. func legacyKeyringLockPath(env map[string]string) string { // Use ResolveStorePath so the legacy lock lives beside whatever the // legacy binary actually stores to (honoring ZERO_OAUTH_TOKENS_PATH @@ -596,9 +598,10 @@ type keyringBlob struct { lockPath string // legacyLockPath, when set, is the lock file a pre-PR binary acquires around // its own read-modify-write of the legacy combined entry (see - // legacyKeyringLockPath). write() holds it too, so a live old binary can't - // write a fresh legacy credential in the window between this write's legacy - // reconciliation and its legacy-blob delete. + // legacyKeyringLockPath). write() holds it too so, when the old binary shares + // this config root, it cannot race our dual-write. Binaries on other roots + // still cannot share this lock; dual-write (never delete) is what keeps their + // updates from being permanently destroyed. legacyLockPath string } @@ -610,14 +613,26 @@ func (b keyringBlob) read() ([]byte, bool, error) { if !ok { return b.readLegacy() } - // The legacy combined entry is consulted lazily (below) only when an indexed - // key's own entry is missing. write() publishes the index before the per-key - // entries and deletes the legacy blob only after every entry is written, so a - // crash partway through the initial legacy->indexed migration can leave a - // pre-existing credential readable solely in the still-present legacy blob. - // In steady state (all entries present) the legacy blob is never read. + // The legacy combined entry is consulted when an indexed key's own entry is + // missing (torn write / migration) and for keys only present there (an old + // binary logged into a provider this process has never indexed). Indexed + // entries always win over legacy for the same key: expiry and token material + // are not a causal version vector, so preferring "fresher-looking" legacy + // can overwrite an explicit new-binary Save with an older account's token. var legacyTokens map[string]Token legacyLoaded := false + loadLegacy := func() { + if legacyLoaded { + return + } + // Best-effort on read: a transient failure must not fail Load/Status, + // only skip legacy recovery for this pass. write() still refuses to + // dual-write over a legacy blob it cannot read (see write()). + if lt, lerr := b.readLegacyTokens(); lerr == nil { + legacyTokens = lt + } + legacyLoaded = true + } tokens := make(map[string]Token, len(keys)) for _, key := range keys { enc, ok, err := b.kr.Get(b.service, key) @@ -626,22 +641,10 @@ func (b keyringBlob) read() ([]byte, bool, error) { } if !ok { // The index lists this key but its own entry is missing. Recover it - // from the legacy blob when a migration is still in flight; otherwise - // (a steady-state index/entry desync whose legacy blob is already - // gone) skip rather than fail the whole read, since the next - // Save/Delete will reconcile the index. - if !legacyLoaded { - // A best-effort recovery source for a read: a transient failure - // here must not fail the whole Load/Status, only skip recovering - // this particular desynced key. The next Save/Delete will - // reconcile it once the legacy blob is legible again (and, - // unlike here, will refuse to delete the legacy blob until it - // can actually read it — see write()). - if lt, lerr := b.readLegacyTokens(); lerr == nil { - legacyTokens = lt - } - legacyLoaded = true - } + // from the dual-written legacy blob when present; otherwise skip + // rather than fail the whole read (the next Save/Delete prunes the + // phantom index key so it cannot permanently consume capacity). + loadLegacy() if token, has := legacyTokens[key]; has { tokens[key] = token } @@ -655,31 +658,12 @@ func (b keyringBlob) read() ([]byte, bool, error) { if err := json.Unmarshal(raw, &token); err != nil { return nil, false, fmt.Errorf("oauth: invalid keyring token entry %q: %w", key, err) } - - // Check if legacy blob holds a fresher token for this indexed key - // (e.g. refreshed by an old binary running concurrently). - if !legacyLoaded { - if lt, lerr := b.readLegacyTokens(); lerr == nil { - legacyTokens = lt - } - legacyLoaded = true - } - if legacyToken, has := legacyTokens[key]; has { - if legacyIsFresher(legacyToken, token) { - token = legacyToken - } - } tokens[key] = token } // Keep legacy-only keys visible through the compatibility window: // an old binary may have logged into a provider after the index was created. - if !legacyLoaded { - if lt, lerr := b.readLegacyTokens(); lerr == nil { - legacyTokens = lt - } - legacyLoaded = true - } + loadLegacy() for key, legacyToken := range legacyTokens { if ValidateKey(key) != nil { continue @@ -716,10 +700,10 @@ func (b keyringBlob) readLegacy() ([]byte, bool, error) { // returned ok=false, err=nil) — the one case callers may treat as "no tokens" // and proceed. Any other failure (a transient keyring read error, undecodable // base64, invalid JSON) is returned as err and must NOT be collapsed into "no -// tokens": write() deletes the legacy blob once it believes reconciliation is -// complete, so mistaking a transient read failure for an empty blob would -// delete a still-unread, still-live credential that belongs to an older, -// still-installed zero binary. +// tokens": write() dual-writes the reconciled map over the legacy blob, so +// mistaking a transient read failure for an empty blob would overwrite a +// still-unread, still-live credential that belongs to an older, still-installed +// zero binary. func (b keyringBlob) readLegacyTokens() (map[string]Token, error) { data, ok, err := b.readLegacy() if err != nil { @@ -735,29 +719,6 @@ func (b keyringBlob) readLegacyTokens() (map[string]Token, error) { return legacyState.Tokens, nil } -// legacyIsFresher reports whether the legacy copy of an already-indexed key -// should win over the indexed copy. An old binary running alongside the new one -// refreshes tokens only in the legacy combined entry. When both sides carry a -// nonzero expiry, the later expiry wins (a refresh normally pushes it forward). -// When either side omits expiry (OAuth responses may omit expires_in, and -// Refresh does not always carry the previous ExpiresAt), fall back to token -// material: a changed access or refresh token on the legacy side is treated as -// a concurrent old-binary refresh that must not be discarded. -func legacyIsFresher(legacy, current Token) bool { - if !legacy.ExpiresAt.IsZero() && !current.ExpiresAt.IsZero() { - if legacy.ExpiresAt.After(current.ExpiresAt) { - return true - } - if current.ExpiresAt.After(legacy.ExpiresAt) { - return false - } - // Equal expiries: prefer legacy when material differs (concurrent refresh - // that happened to keep the same lifetime). - return legacy.AccessToken != current.AccessToken || legacy.RefreshToken != current.RefreshToken - } - return legacy.AccessToken != current.AccessToken || legacy.RefreshToken != current.RefreshToken -} - // write replaces the keyring's token entries with state, ordered so that // every interruption boundary leaves a recoverable store. The invariant is // that any token entry existing in the keyring at any instant is listed in @@ -765,13 +726,17 @@ func legacyIsFresher(legacy, current Token) bool { // written, entries are deleted before the index shrinks, and the index // header is only updated after the chunks it references exist. A crash at // any step therefore leaves either an index over-listing keys whose entries -// are missing (read() recovers those from the legacy blob during a migration, -// or skips them once it is gone) or entries that a later read/write can still +// are missing (read() recovers those from the dual-written legacy blob, or +// skips them; the next write prunes phantom index keys so they cannot +// permanently consume capacity) or entries that a later read/write can still // see and reconcile, never an invisible credential stranded in the OS keychain. -// The legacy combined entry is the durable fallback for the initial migration -// and is deleted only after every per-key entry is written, while the union -// index still lists removed keys; a failure of that delete is returned so a -// logout is never reported successful with the stale blob still resident. +// +// The legacy combined entry is dual-written with the reconciled map after +// per-key entries succeed, never deleted: a pre-PR binary using another +// config root cannot share this process's compatibility lock, so deleting +// the blob would permanently drop an update that process wrote between our +// reconcile read and a delete. Dual-write keeps old readers current and +// leaves concurrent old-writer keys for the next reconcile pass. // omitFromLegacy lists keys the caller just deleted; they must not be // re-merged from the legacy blob even when they were never indexed (a // legacy-only old-binary login that the user logged out of). @@ -790,29 +755,23 @@ func (b keyringBlob) write(data []byte, omitFromLegacy map[string]bool) error { } // An older binary running alongside this one still reads and writes only the - // legacy combined entry. If that entry exists even though the index has - // already been published, an old binary wrote it after migration, so - // reconcile it into state before it is deleted below rather than blindly - // overwriting it: - // - a key the indexed schema has never seen is a fresh old-binary login; - // merge it so it is not lost, unless the caller just deleted it - // (omitFromLegacy) or it was already indexed and deliberately removed; - // - a key already present in state that the legacy blob refreshed takes - // the legacy value when legacyIsFresher says so, so a concurrent - // old-binary refresh is not discarded in favor of the stale indexed one; - // - a key that was in the prior index but is absent from this write was - // deliberately removed (a logout); it is left removed, not resurrected. + // legacy combined entry. Merge keys that entry holds which the indexed + // schema has never seen (fresh old-binary logins). Never overwrite a key + // already present in state: expiry and token strings are not causal order, + // so a "later" legacy expiry can replace an explicit new-binary Save with + // the wrong account. Keys in the prior index but absent from this write + // were deliberately removed (logout) and must not be resurrected. + // + // Unlike read()'s best-effort fallback, a failure here must abort the whole + // write rather than proceed as though the legacy blob were empty: step 4 + // dual-writes over it, and a transient read error must never be mistaken + // for "nothing to reconcile," or a still-live credential from an older, + // still-installed zero binary is destroyed irrecoverably. + legacyTokens, err := b.readLegacyTokens() + if err != nil { + return fmt.Errorf("oauth: read legacy keyring token blob for reconciliation: %w", err) + } if indexExisted { - // Unlike read()'s best-effort fallback, a failure here must abort the - // whole write rather than proceed as though the legacy blob were empty: - // step 4 below deletes it, and a transient read error (the legacy blob - // genuinely exists but couldn't be read right now) must never be - // mistaken for "nothing to reconcile," or a still-live credential from - // an older, still-installed zero binary is destroyed irrecoverably. - legacyTokens, err := b.readLegacyTokens() - if err != nil { - return fmt.Errorf("oauth: read legacy keyring token blob for reconciliation: %w", err) - } for key, legacyToken := range legacyTokens { if ValidateKey(key) != nil { continue @@ -820,10 +779,7 @@ func (b keyringBlob) write(data []byte, omitFromLegacy map[string]bool) error { if omitFromLegacy[key] { continue } - if current, exists := state.Tokens[key]; exists { - if legacyIsFresher(legacyToken, current) { - state.Tokens[key] = legacyToken - } + if _, exists := state.Tokens[key]; exists { continue } if prior[key] { @@ -839,12 +795,47 @@ func (b keyringBlob) write(data []byte, omitFromLegacy map[string]bool) error { } sort.Strings(keys) - // 1. Publish the union of the prior and new key sets first, so every + // Preflight: marshal and size-check every desired token BEFORE publishing + // any index key. Publishing first left rejected oversized Saves as permanent + // index phantoms that could exhaust maxKeyringIndexKeys and brick the store. + encoded := make(map[string]string, len(keys)) + for _, key := range keys { + raw, err := json.Marshal(state.Tokens[key]) + if err != nil { + return err + } + enc := base64.StdEncoding.EncodeToString(raw) + if len(enc) > maxKeyringSingleEntryBytes { + return fmt.Errorf("oauth: token payload for %q (%d bytes) exceeds single keyring entry bound (%d bytes); use file or encrypted-file storage", key, len(enc), maxKeyringSingleEntryBytes) + } + encoded[key] = enc + } + + // Drop prior index keys that have neither a live entry nor a place in the + // desired set (phantoms from an interrupted Set after a previous union + // publish). Including them in the next union would permanently consume + // index capacity after enough failed writes. + livePrior := make([]string, 0, len(priorKeys)) + for _, key := range priorKeys { + if _, ok := state.Tokens[key]; ok { + livePrior = append(livePrior, key) + continue + } + _, exists, err := b.kr.Get(b.service, key) + if err != nil { + return err + } + if exists { + livePrior = append(livePrior, key) + } + } + + // 1. Publish the union of the live prior and new key sets first, so every // entry that exists at any point during this update is indexed. union := keys - if len(priorKeys) > 0 { - merged := make(map[string]bool, len(keys)+len(priorKeys)) - for _, key := range append(append([]string{}, keys...), priorKeys...) { + if len(livePrior) > 0 { + merged := make(map[string]bool, len(keys)+len(livePrior)) + for _, key := range append(append([]string{}, keys...), livePrior...) { merged[key] = true } union = make([]string, 0, len(merged)) @@ -857,36 +848,29 @@ func (b keyringBlob) write(data []byte, omitFromLegacy map[string]bool) error { if err != nil { return err } - // 2. Write each token entry. + // 2. Write each token entry (encodings preflighted above). for _, key := range keys { - raw, err := json.Marshal(state.Tokens[key]) - if err != nil { - return err - } - encoded := base64.StdEncoding.EncodeToString(raw) - if len(encoded) > maxKeyringSingleEntryBytes { - return fmt.Errorf("oauth: token payload for %q (%d bytes) exceeds single keyring entry bound (%d bytes); use file or encrypted-file storage", key, len(encoded), maxKeyringSingleEntryBytes) - } - if err := b.kr.Set(b.service, key, encoded); err != nil { + if err := b.kr.Set(b.service, key, encoded[key]); err != nil { return err } } // 3. Delete removed entries while the union index still lists them, so a // failed Delete leaves a visible (re-deletable) entry, never an orphan. - for _, key := range priorKeys { + for _, key := range livePrior { if _, ok := state.Tokens[key]; !ok { if _, err := b.kr.Delete(b.service, key); err != nil { return err } } } - // 4. Drop the legacy entry: the index now exists and is authoritative, - // and its fresh writes were merged above. This must happen while the - // union index still lists any removed keys and its failure must surface: - // if a stale legacy blob survived a logout whose index shrink already - // completed, the next save would classify its keys as fresh old-binary - // logins and silently resurrect the logged-out credential. - if _, err := b.kr.Delete(b.service, b.legacyAccount); err != nil { + // 4. Dual-write the reconciled map to the legacy combined entry (never + // delete it). Old binaries on other config roots keep reading this account; + // logout correctness comes from updating the map, not from removing the + // entry. The combined entry is still subject to the single-keyring-item + // size cap that forced per-key storage, so full dual-write only happens + // when it fits; otherwise we patch the existing legacy map (drop logouts) + // without re-aggregating every provider token. + if err := b.dualWriteLegacy(state, omitFromLegacy, prior, legacyTokens); err != nil { return err } // 5. Shrink the index to the exact new key set. @@ -896,6 +880,72 @@ func (b keyringBlob) write(data []byte, omitFromLegacy map[string]bool) error { return nil } +// dualWriteLegacy updates the combined legacy keyring entry without ever +// deleting it. Full dual-write of state is preferred when it fits under +// maxKeyringSingleEntryBytes (so old readers see current tokens). When state +// is too large (the multi-provider case that forced the per-key split), the +// existing legacy map is patched to drop logged-out keys so they cannot be +// resurrected, but is not expanded with every indexed token. +func (b keyringBlob) dualWriteLegacy(state storeFile, omitFromLegacy, prior map[string]bool, legacyTokens map[string]Token) error { + full, err := json.Marshal(storeFile{SchemaVersion: storeSchemaVersion, Tokens: state.Tokens}) + if err != nil { + return err + } + fullEnc := base64.StdEncoding.EncodeToString(full) + if len(fullEnc) <= maxKeyringSingleEntryBytes { + return b.kr.Set(b.service, b.legacyAccount, fullEnc) + } + + // Oversize full map: never delete the legacy entry, and never write an + // oversized value the OS keyring / security -i would reject. Patch the + // previous legacy contents so explicit logouts stick. + next := make(map[string]Token, len(legacyTokens)) + for key, token := range legacyTokens { + if omitFromLegacy[key] { + continue + } + if prior[key] { + if _, ok := state.Tokens[key]; !ok { + continue + } + } + next[key] = token + } + // Prefer indexed material for keys still present in both, when that keeps + // the patch under the single-entry bound (best-effort; skip keys that push + // it over rather than failing the whole Save). + patchTokens := make(map[string]Token, len(next)) + for key, token := range next { + if current, ok := state.Tokens[key]; ok { + token = current + } + candidate := make(map[string]Token, len(patchTokens)+1) + for k, v := range patchTokens { + candidate[k] = v + } + candidate[key] = token + raw, err := json.Marshal(storeFile{SchemaVersion: storeSchemaVersion, Tokens: candidate}) + if err != nil { + return err + } + if len(base64.StdEncoding.EncodeToString(raw)) > maxKeyringSingleEntryBytes { + continue + } + patchTokens[key] = token + } + raw, err := json.Marshal(storeFile{SchemaVersion: storeSchemaVersion, Tokens: patchTokens}) + if err != nil { + return err + } + enc := base64.StdEncoding.EncodeToString(raw) + if len(enc) > maxKeyringSingleEntryBytes { + // Pre-existing oversized legacy blob: leave it untouched rather than + // fail an otherwise successful indexed write. + return nil + } + return b.kr.Set(b.service, b.legacyAccount, enc) +} + // maxKeyringSingleEntryBytes bounds a single base64-encoded token secret so // that the line passed to macOS `security -i` stays comfortably under the // 4095-byte command line cap (see internal/keyring). @@ -909,6 +959,14 @@ const maxKeyringSingleEntryBytes = 3800 // roughly 22 maximum-length keys even when every token was tiny. const maxKeyringIndexChunkBytes = 2700 +// maxKeyringIndexEncodedBytes bounds one index header/chunk's base64 string +// before DecodeString or json.Unmarshal. Writers never emit more than +// maxKeyringIndexChunkBytes of raw JSON per chunk (header wraps one chunk of +// keys plus a few metadata fields), so anything larger is damaged or hostile +// and must be rejected without allocating unbounded decode buffers on the +// hot path that holds the store lock. +const maxKeyringIndexEncodedBytes = 4096 + // maxKeyringIndexChunks caps how many chunk entries a stored index header may // claim before readKeyIndex issues one OS-keyring lookup per chunk. Each chunk // holds up to maxKeyringIndexChunkBytes of keys (dozens to ~150 keys), so this @@ -954,6 +1012,26 @@ func (b keyringBlob) chunkAccount(index int) string { return fmt.Sprintf("%s-%d", b.indexAccount, index) } +// decodeKeyringIndexPayload bounds and decodes one index header/chunk value +// before json.Unmarshal. The element-count cap alone does not bound the size of +// a single JSON string inside a damaged payload. +func decodeKeyringIndexPayload(enc string, what string) ([]byte, error) { + enc = strings.TrimSpace(enc) + if len(enc) > maxKeyringIndexEncodedBytes { + return nil, fmt.Errorf("oauth: %s is %d bytes encoded, over the %d-byte bound", what, len(enc), maxKeyringIndexEncodedBytes) + } + raw, err := base64.StdEncoding.DecodeString(enc) + if err != nil { + return nil, fmt.Errorf("oauth: decode %s: %w", what, err) + } + // Header wraps one chunk of keys plus a few metadata fields; reject anything + // well beyond the writer-side raw chunk budget before Unmarshal. + if len(raw) > maxKeyringIndexChunkBytes+256 { + return nil, fmt.Errorf("oauth: %s decodes to %d bytes, over the %d-byte raw bound", what, len(raw), maxKeyringIndexChunkBytes+256) + } + return raw, nil +} + // readKeyIndex returns the indexed keys, whether an index exists at all, and // how many chunk entries it currently occupies. A chunk listed by the header // but missing from the keyring (a torn write) is skipped, mirroring how @@ -966,9 +1044,9 @@ func (b keyringBlob) readKeyIndex() ([]string, bool, int, error) { if !ok { return nil, false, 0, nil } - raw, err := base64.StdEncoding.DecodeString(strings.TrimSpace(enc)) + raw, err := decodeKeyringIndexPayload(enc, "keyring token index") if err != nil { - return nil, false, 0, fmt.Errorf("oauth: decode keyring token index: %w", err) + return nil, false, 0, err } trimmed := strings.TrimSpace(string(raw)) if strings.HasPrefix(trimmed, "[") { @@ -1011,9 +1089,9 @@ func (b keyringBlob) readKeyIndex() ([]string, bool, int, error) { if !ok { continue } - chunkRaw, err := base64.StdEncoding.DecodeString(strings.TrimSpace(chunkEnc)) + chunkRaw, err := decodeKeyringIndexPayload(chunkEnc, fmt.Sprintf("keyring token index chunk %d", i)) if err != nil { - return nil, false, 0, fmt.Errorf("oauth: decode keyring token index chunk %d: %w", i, err) + return nil, false, 0, err } var more []string if err := json.Unmarshal(chunkRaw, &more); err != nil { @@ -1186,11 +1264,11 @@ func withLeasedLocks(paths []string, now func() time.Time, fn func() error) erro // withLock serializes the keyring's read-modify-write. Store.mu covers the // in-process case; lockPath adds cross-process exclusion between this // binary's own instances so two of them can't both read the blob, modify, -// and write — dropping a token. legacyLockPath is held for the same -// duration so a live pre-PR binary, which only ever locks there (see -// legacyKeyringLockPath), can't write a fresh legacy credential in the -// window between this write's legacy reconciliation and its legacy-blob -// delete. +// and write — dropping a token. legacyLockPath is held for the same duration +// so a live pre-PR binary that shares this config root (see +// legacyKeyringLockPath) can't race our dual-write of the legacy combined +// entry. Cross-root old writers cannot share that lock; dual-write without +// delete is the remaining safety net for them. func (b keyringBlob) withLock(now func() time.Time, fn func() error) error { return withLeasedLocks([]string{b.lockPath, b.legacyLockPath}, now, fn) } diff --git a/internal/oauth/store_keyring_test.go b/internal/oauth/store_keyring_test.go index 01f5728f1..bb74bfbf7 100644 --- a/internal/oauth/store_keyring_test.go +++ b/internal/oauth/store_keyring_test.go @@ -65,8 +65,10 @@ func TestStoreKeyringBackendRoundTrip(t *testing.T) { if strings.Contains(raw, "access_token") { t.Fatalf("keyring entry is not encoded: %s", raw) } - if raw := kr.data[keyringService+"/"+keyringLegacyAccount]; raw != "" { - t.Fatalf("legacy combined entry should not be written by new code: %s", raw) + // New code dual-writes the reconciled map to the legacy account so pre-PR + // binaries (including those on other config roots) keep a current view. + if raw := kr.data[keyringService+"/"+keyringLegacyAccount]; raw == "" { + t.Fatal("legacy combined entry should be dual-written by new code") } removed, err := s.Delete(ProviderKey("demo")) @@ -114,10 +116,17 @@ func TestStoreKeyringManyProvidersStayUnderEntryLimit(t *testing.T) { t.Fatalf("Save(%s): %v", name, err) } } - // Each individual keyring value must stay small even with 5 providers - // logged in: no entry aggregates more than one provider's tokens. + // Each per-key token entry must stay small even with 5 providers logged + // in. The dual-written legacy combined entry is size-capped separately at + // maxKeyringSingleEntryBytes (and may be skipped/patched when oversize). const singleTokenCeiling = 3000 // generous margin under the ~4095-byte line cap for k, v := range kr.data { + if strings.HasSuffix(k, "/"+keyringLegacyAccount) { + if len(v) > maxKeyringSingleEntryBytes { + t.Fatalf("legacy dual-write %q is %d bytes, want <= %d", k, len(v), maxKeyringSingleEntryBytes) + } + continue + } if len(v) > singleTokenCeiling { t.Fatalf("keyring entry %q is %d bytes, want < %d (aggregation regression)", k, len(v), singleTokenCeiling) } @@ -161,18 +170,22 @@ func TestStoreKeyringMigratesLegacyCombinedEntry(t *testing.T) { t.Fatalf("Load = %#v", got) } - // Saving a second provider must migrate: the legacy entry is dropped, and - // both tokens end up as their own entries. + // Saving a second provider must migrate into per-key entries and dual-write + // the full map back to the legacy account (never delete it: other config + // roots cannot share the compatibility lock). if err := s.Save(ProviderKey("other"), Token{AccessToken: "other-a"}); err != nil { t.Fatal(err) } - if _, ok := kr.data[keyringService+"/"+keyringLegacyAccount]; ok { - t.Fatal("legacy combined entry should be removed after migration") + if _, ok := kr.data[keyringService+"/"+keyringLegacyAccount]; !ok { + t.Fatal("legacy combined entry should remain dual-written after migration") } for _, name := range []string{"demo", "other"} { if _, ok, err := s.Load(ProviderKey(name)); err != nil || !ok { t.Fatalf("Load(%s) after migration: ok=%v err=%v", name, ok, err) } + if _, ok := kr.data[keyringService+"/"+ProviderKey(name)]; !ok { + t.Fatalf("per-key entry for %s missing after migration", name) + } } } @@ -361,12 +374,12 @@ func TestStoreKeyringIndexStaysUnderEntryLimit(t *testing.T) { t.Fatalf("Save(%s): %v", name, err) } } - // Every keyring value, index entries included, must stay under the cap - // with generous framing margin. + // Every keyring value (index chunks, per-key tokens, dual-written legacy) + // must stay under the single-entry cap with generous framing margin. const entryCeiling = 3800 for k, v := range kr.data { if len(v) > entryCeiling { - t.Fatalf("keyring entry %q is %d bytes, want <= %d (index cap regression)", k, len(v), entryCeiling) + t.Fatalf("keyring entry %q is %d bytes, want <= %d (index/legacy cap regression)", k, len(v), entryCeiling) } } // The index actually chunked (otherwise the ceiling check proves nothing). @@ -449,7 +462,7 @@ func TestStoreKeyringWriteInterruptionsLeaveNoInvisibleTokens(t *testing.T) { } } // Every mutating boundary of the write path now surfaces its failure, - // including the legacy-entry delete (a swallowed failure there could + // including the legacy dual-write (a swallowed failure there could // let a later save resurrect logged-out credentials). if opsUsed >= failAt && saveErr == nil { t.Fatalf("failAt=%d: injected keyring failure was swallowed", failAt) @@ -559,8 +572,8 @@ func TestStoreKeyringMergesFreshLegacyWriteFromOldBinary(t *testing.T) { if got, _, err := s.Load(ProviderKey("carol")); err != nil || got.AccessToken != "c" || got.RefreshToken != "cr" { t.Fatalf("Load(carol) = %#v, err=%v, want the legacy access/refresh tokens intact", got, err) } - if _, ok := kr.data[keyringService+"/"+keyringLegacyAccount]; ok { - t.Fatal("legacy entry should be removed once its fresh writes are merged") + if _, ok := kr.data[keyringService+"/"+keyringLegacyAccount]; !ok { + t.Fatal("legacy entry should remain dual-written after merge") } } @@ -733,9 +746,9 @@ func TestStoreKeyringMigrationInterruptionsPreserveLegacyTokens(t *testing.T) { if got, ok, err := s.Load(ProviderKey("new")); err != nil || !ok || got.AccessToken != "new-c" { t.Fatalf("failAt=%d: Load(new): ok=%v err=%v token=%#v", failAt, ok, err, got) } - // The completed migration drops the legacy entry. - if _, ok := kr.data[keyringService+"/"+keyringLegacyAccount]; ok { - t.Fatalf("failAt=%d: legacy entry not removed after migration completed", failAt) + // Completed migration dual-writes the full map to the legacy entry. + if _, ok := kr.data[keyringService+"/"+keyringLegacyAccount]; !ok { + t.Fatalf("failAt=%d: legacy entry missing after migration completed (want dual-write)", failAt) } if opsUsed < failAt { break @@ -743,28 +756,28 @@ func TestStoreKeyringMigrationInterruptionsPreserveLegacyTokens(t *testing.T) { } } -// TestStoreKeyringMergesFreshLegacyRefreshOfIndexedKey covers the mixed-version -// window for a key that already exists in the index: an old binary refreshes -// provider:alpha in the legacy combined entry (a strictly later expiry). The -// next new-binary save must keep that fresher refresh instead of overwriting it -// with the stale indexed value and then deleting the legacy entry. -func TestStoreKeyringMergesFreshLegacyRefreshOfIndexedKey(t *testing.T) { +// TestStoreKeyringExplicitSaveWinsOverStaleLookingLegacy is the regression for +// [P1] Do not use token contents or expiry as cross-version write order: a +// longer legacy expiry (or different token material) must not replace an +// explicit new-binary Save of the same key. Expiry is not causal order. +func TestStoreKeyringExplicitSaveWinsOverStaleLookingLegacy(t *testing.T) { t.Setenv("XDG_CONFIG_HOME", t.TempDir()) kr := newFakeKR() s, err := NewStore(StoreOptions{Storage: "keyring", Keyring: kr}) if err != nil { t.Fatal(err) } - stale := time.Now().Add(1 * time.Hour) - if err := s.Save(ProviderKey("alpha"), Token{AccessToken: "a-old", RefreshToken: "r-old", ExpiresAt: stale}); err != nil { + // Explicit new login as account B with a short lifetime. + explicit := time.Now().Add(1 * time.Hour) + if err := s.Save(ProviderKey("alpha"), Token{AccessToken: "account-b", RefreshToken: "rb", ExpiresAt: explicit, Account: "b@example.com"}); err != nil { t.Fatal(err) } - // An old binary refreshes alpha through the legacy combined entry, pushing - // the expiry later than the indexed copy. - fresh := stale.Add(1 * time.Hour) + // A leftover legacy copy for the same key looks "fresher" by expiry and + // carries a different account — content-based ordering would wrongly win. + legacyLater := explicit.Add(24 * time.Hour) legacy := storeFile{SchemaVersion: storeSchemaVersion, Tokens: map[string]Token{ - ProviderKey("alpha"): {AccessToken: "a-new", RefreshToken: "r-new", ExpiresAt: fresh}, + ProviderKey("alpha"): {AccessToken: "account-a", RefreshToken: "ra", ExpiresAt: legacyLater, Account: "a@example.com"}, }} legacyData, err := json.Marshal(legacy) if err != nil { @@ -772,7 +785,7 @@ func TestStoreKeyringMergesFreshLegacyRefreshOfIndexedKey(t *testing.T) { } kr.data[keyringService+"/"+keyringLegacyAccount] = base64.StdEncoding.EncodeToString(legacyData) - // A new-binary save of an unrelated key must reconcile alpha, not clobber it. + // Unrelated save must not let legacy clobber the explicit alpha token. if err := s.Save(ProviderKey("beta"), Token{AccessToken: "b"}); err != nil { t.Fatal(err) } @@ -780,15 +793,12 @@ func TestStoreKeyringMergesFreshLegacyRefreshOfIndexedKey(t *testing.T) { if err != nil || !ok { t.Fatalf("Load(alpha): ok=%v err=%v", ok, err) } - if got.AccessToken != "a-new" || got.RefreshToken != "r-new" || !got.ExpiresAt.Equal(fresh) { - t.Fatalf("Load(alpha) = %#v, want the refreshed legacy value (tokens and expiry) with ExpiresAt=%v", got, fresh) + if got.AccessToken != "account-b" || got.Account != "b@example.com" { + t.Fatalf("Load(alpha) = %#v, want the explicit Save (account-b), not the longer-expiry legacy account-a", got) } if _, ok, _ := s.Load(ProviderKey("beta")); !ok { t.Fatal("Load(beta): not stored") } - if _, ok := kr.data[keyringService+"/"+keyringLegacyAccount]; ok { - t.Fatal("legacy entry should be removed once its refresh is merged") - } } // TestAcquireFileLockDeadlineUsesWallClockNotInjectedClock guards the other @@ -800,22 +810,87 @@ func TestStoreKeyringMergesFreshLegacyRefreshOfIndexedKey(t *testing.T) { // contested lock would retry forever instead of returning a timeout error. func TestAcquireFileLockDeadlineUsesWallClockNotInjectedClock(t *testing.T) { lockPath := filepath.Join(t.TempDir(), "test.lockfile") - // A fresh (non-stale) lock held by someone else: acquireFileLock must not - // reclaim it, only wait out fileLockTimeout and report a timeout. + // A fresh (non-stale) lock held by someone else. With wait-while-healthy, + // that extends the idle deadline, so cap the absolute wait for the test. if err := os.WriteFile(lockPath, []byte("someone-else"), 0o600); err != nil { t.Fatal(err) } + prevMax := fileLockMaxWait + fileLockMaxWait = 200 * time.Millisecond + defer func() { fileLockMaxWait = prevMax }() + fixed := time.Date(2000, 1, 1, 0, 0, 0, 0, time.UTC) start := time.Now() _, err := acquireFileLock(lockPath, func() time.Time { return fixed }) if err == nil { t.Fatal("expected a timeout error acquiring an already-held, non-stale lock") } - if elapsed := time.Since(start); elapsed > 10*time.Second { + if elapsed := time.Since(start); elapsed > 2*time.Second { t.Fatalf("acquireFileLock took %v with a fixed clock; the deadline must use the wall clock, not now()", elapsed) } } +// TestAcquireFileLockWaitsWhileLeaseHealthy covers [P2] Let lock acquisition +// cover a healthy keyring operation: a holder that keeps the lease fresh for +// longer than the idle timeout must not cause contenders to fail; they wait +// and acquire once the holder releases. +func TestAcquireFileLockWaitsWhileLeaseHealthy(t *testing.T) { + lockPath := filepath.Join(t.TempDir(), "test.lockfile") + prevTimeout := fileLockTimeout + prevMax := fileLockMaxWait + fileLockTimeout = 80 * time.Millisecond + fileLockMaxWait = 3 * time.Second + defer func() { + fileLockTimeout = prevTimeout + fileLockMaxWait = prevMax + }() + + unlock, err := acquireFileLock(lockPath, time.Now) + if err != nil { + t.Fatal(err) + } + + // Refresh the held lock past several idle-timeout intervals. + stop := make(chan struct{}) + go func() { + ticker := time.NewTicker(30 * time.Millisecond) + defer ticker.Stop() + for { + select { + case <-stop: + return + case <-ticker.C: + at := time.Now() + _ = os.Chtimes(lockPath, at, at) + } + } + }() + + acquired := make(chan error, 1) + go func() { + u, err := acquireFileLock(lockPath, time.Now) + if err == nil { + u() + } + acquired <- err + }() + + // Contender must still be waiting after > one idle timeout. + select { + case err := <-acquired: + close(stop) + unlock() + t.Fatalf("contender finished too early (err=%v); must wait while lease is healthy", err) + case <-time.After(250 * time.Millisecond): + } + + close(stop) + unlock() + if err := <-acquired; err != nil { + t.Fatalf("contender failed after holder released: %v", err) + } +} + // TestStoreKeyringLeaseUsesWallClockNotStoreClock guards the lock lease against // a fixed or stale StoreOptions.Now. acquireFileLock judges staleness with real // time.Since(mtime), so the lease must stamp the live lock with wall-clock time; @@ -1050,7 +1125,7 @@ func TestKeyringLockPathIndependentOfCacheAndTempRoots(t *testing.T) { // // This simulates a live old binary by holding the exact lock file // legacyKeyringLockPath computes (the same one a pre-PR binary's Save takes) -// and asserting that Store.Save — which must reconcile and then drop the +// and asserting that Store.Save — which must reconcile and dual-write the // legacy entry here, since one is seeded below — blocks until that lock is // released, and that the seeded legacy token survives the reconciliation. func TestStoreKeyringWriteWaitsForLegacyLockDuringReconciliation(t *testing.T) { @@ -1142,28 +1217,28 @@ func TestKeyringLockPathDerivedFromKeyringIdentityNotFileConfig(t *testing.T) { } } -// legacyDeleteFailKR fails Delete for the legacy combined entry only, to -// exercise the boundary where a logout has already rewritten the indexed -// state but the stale legacy blob cannot be removed. -type legacyDeleteFailKR struct { +// legacyDualWriteFailKR fails Set for the legacy combined entry only, to +// exercise the boundary where a logout has rewritten the indexed state but +// the dual-write that clears the credential from the legacy blob cannot land. +type legacyDualWriteFailKR struct { *fakeKR fail bool } -func (f *legacyDeleteFailKR) Delete(service, account string) (bool, error) { +func (f *legacyDualWriteFailKR) Set(service, account, secret string) error { if f.fail && account == keyringLegacyAccount { - return false, errKRInjected + return errKRInjected } - return f.fakeKR.Delete(service, account) + return f.fakeKR.Set(service, account, secret) } -// TestStoreKeyringLogoutSurfacesLegacyBlobDeleteFailure: when the final -// legacy-blob delete fails during a logout, the operation must report the -// failure (not success with the secret still resident), and after a clean -// retry the logged-out credential must not be resurrected by a later save. -func TestStoreKeyringLogoutSurfacesLegacyBlobDeleteFailure(t *testing.T) { +// TestStoreKeyringLogoutSurfacesLegacyDualWriteFailure: when the legacy +// dual-write fails during a logout, the operation must report the failure +// (not success while the secret may still be resident in the legacy blob), +// and after a clean retry the logged-out credential must not be resurrected. +func TestStoreKeyringLogoutSurfacesLegacyDualWriteFailure(t *testing.T) { t.Setenv("XDG_CONFIG_HOME", t.TempDir()) - kr := &legacyDeleteFailKR{fakeKR: newFakeKR()} + kr := &legacyDualWriteFailKR{fakeKR: newFakeKR()} s, err := NewStore(StoreOptions{Storage: "keyring", Keyring: kr}) if err != nil { t.Fatal(err) @@ -1184,7 +1259,7 @@ func TestStoreKeyringLogoutSurfacesLegacyBlobDeleteFailure(t *testing.T) { kr.fail = true if _, err := s.Delete(ProviderKey("alpha")); err == nil { - t.Fatal("Delete reported success although the stale legacy blob could not be removed") + t.Fatal("Delete reported success although the legacy dual-write failed") } // A clean retry succeeds, and a later save must not classify the stale @@ -1251,7 +1326,9 @@ func TestStoreKeyringReadIndexDedupesDuplicateKeys(t *testing.T) { blob := keyringBlob{kr: ckr, service: keyringService, legacyAccount: keyringLegacyAccount, indexAccount: keyringIndexAccount} dup := ProviderKey("demo") - many := make([]string, 2000) + // Stay under maxKeyringIndexEncodedBytes so the byte-bound check does not + // fire first; this test is about dedupe after a valid-sized decode. + many := make([]string, 80) for i := range many { many[i] = dup } @@ -1259,7 +1336,11 @@ func TestStoreKeyringReadIndexDedupesDuplicateKeys(t *testing.T) { if err != nil { t.Fatal(err) } - ckr.data[keyringService+"/"+keyringIndexAccount] = base64.StdEncoding.EncodeToString(header) + encoded := base64.StdEncoding.EncodeToString(header) + if len(encoded) > maxKeyringIndexEncodedBytes { + t.Fatalf("test payload is %d bytes encoded; keep it under %d so the byte bound is not the thing under test", len(encoded), maxKeyringIndexEncodedBytes) + } + ckr.data[keyringService+"/"+keyringIndexAccount] = encoded keys, ok, _, err := blob.readKeyIndex() if err != nil { @@ -1269,7 +1350,7 @@ func TestStoreKeyringReadIndexDedupesDuplicateKeys(t *testing.T) { t.Fatal("readKeyIndex: expected an index to be found") } if len(keys) != 1 { - t.Fatalf("readKeyIndex returned %d keys for a 2000-duplicate index, want 1 (deduplicated)", len(keys)) + t.Fatalf("readKeyIndex returned %d keys for an 80-duplicate index, want 1 (deduplicated)", len(keys)) } // A malformed (non-ValidateKey-shaped) entry must also be dropped rather @@ -1312,7 +1393,7 @@ func TestStoreKeyringDuplicateIndexDoesNotFanOutPerEntry(t *testing.T) { } ckr.data[keyringService+"/"+dup] = base64.StdEncoding.EncodeToString(raw) - many := make([]string, 3000) + many := make([]string, 80) for i := range many { many[i] = dup } @@ -1320,7 +1401,11 @@ func TestStoreKeyringDuplicateIndexDoesNotFanOutPerEntry(t *testing.T) { if err != nil { t.Fatal(err) } - ckr.data[keyringService+"/"+keyringIndexAccount] = base64.StdEncoding.EncodeToString(header) + encoded := base64.StdEncoding.EncodeToString(header) + if len(encoded) > maxKeyringIndexEncodedBytes { + t.Fatalf("test payload is %d bytes encoded; keep it under %d so the byte bound is not the thing under test", len(encoded), maxKeyringIndexEncodedBytes) + } + ckr.data[keyringService+"/"+keyringIndexAccount] = encoded s, err := NewStore(StoreOptions{Storage: "keyring", Keyring: ckr}) if err != nil { @@ -1333,13 +1418,13 @@ func TestStoreKeyringDuplicateIndexDoesNotFanOutPerEntry(t *testing.T) { t.Fatalf("Status: %v", err) } if len(statuses) != 1 { - t.Fatalf("Status returned %d entries for a 3000-duplicate index of one key, want 1", len(statuses)) + t.Fatalf("Status returned %d entries for an 80-duplicate index of one key, want 1", len(statuses)) } // One Get for the index header, one Get for the single deduplicated key's // own entry, and at most one extra Get for the legacy fallback lookup. - // The key regression is: not 3000 (one per duplicate entry). + // The key regression is: not one Get per duplicate entry. if ckr.gets > 3 { - t.Fatalf("Status issued %d keyring gets for a 3000-entry duplicate index, want <= 3 (fan-out DoS regression)", ckr.gets) + t.Fatalf("Status issued %d keyring gets for an 80-entry duplicate index, want <= 3 (fan-out DoS regression)", ckr.gets) } } @@ -1358,14 +1443,14 @@ func (f *legacyGetFailKR) Get(service, account string) (string, bool, error) { return f.fakeKR.Get(service, account) } -// TestStoreKeyringWriteRefusesToDeleteLegacyBlobOnTransientReadError is the +// TestStoreKeyringWriteRefusesToOverwriteLegacyBlobOnTransientReadError is the // regression test for finding #2: during mixed-version reconciliation (an old // zero binary's legacy blob alongside the new keyring-based index), a // transient error reading the legacy blob must not be treated as "the legacy -// blob is empty." write() must refuse to reconcile-and-delete it in that case, -// or an unread credential belonging to an older, still-installed zero binary -// is destroyed irrecoverably. -func TestStoreKeyringWriteRefusesToDeleteLegacyBlobOnTransientReadError(t *testing.T) { +// blob is empty." write() must refuse to dual-write over it in that case, or +// an unread credential belonging to an older, still-installed zero binary is +// destroyed irrecoverably. +func TestStoreKeyringWriteRefusesToOverwriteLegacyBlobOnTransientReadError(t *testing.T) { t.Setenv("XDG_CONFIG_HOME", t.TempDir()) kr := &legacyGetFailKR{fakeKR: newFakeKR()} s, err := NewStore(StoreOptions{Storage: "keyring", Keyring: kr}) @@ -1396,10 +1481,12 @@ func TestStoreKeyringWriteRefusesToDeleteLegacyBlobOnTransientReadError(t *testi if err := s.Save(ProviderKey("beta"), Token{AccessToken: "b"}); err == nil { t.Fatal("Save succeeded despite a transient legacy-blob read failure; it must refuse rather than silently treat the blob as empty") } - // The legacy blob must still be present, not deleted out from under a - // read failure. - if _, ok := kr.data[keyringService+"/"+keyringLegacyAccount]; !ok { - t.Fatal("legacy blob was deleted despite a transient read failure (data loss)") + // The legacy blob must still be present with the old credential, not + // dual-written over under a read failure. + if raw, ok := kr.data[keyringService+"/"+keyringLegacyAccount]; !ok { + t.Fatal("legacy blob was removed despite a transient read failure (data loss)") + } else if !strings.Contains(string(mustDecode(t, raw)), "still-live") { + t.Fatal("legacy blob was overwritten despite a transient read failure (data loss)") } // Nothing else the aborted write touched should be visible either. if _, ok, _ := s.Load(ProviderKey("beta")); ok { @@ -1410,7 +1497,7 @@ func TestStoreKeyringWriteRefusesToDeleteLegacyBlobOnTransientReadError(t *testi } // Once the transient failure clears, the legacy credential is recovered - // and the blob is cleaned up normally. + // and dual-written back with the full reconciled map. kr.fail = false if err := s.Save(ProviderKey("beta"), Token{AccessToken: "b"}); err != nil { t.Fatalf("retried Save: %v", err) @@ -1418,9 +1505,18 @@ func TestStoreKeyringWriteRefusesToDeleteLegacyBlobOnTransientReadError(t *testi if _, ok, err := s.Load(ProviderKey("stale-binary-login")); err != nil || !ok { t.Fatalf("Load(stale-binary-login): ok=%v err=%v (legacy credential lost)", ok, err) } - if _, ok := kr.data[keyringService+"/"+keyringLegacyAccount]; ok { - t.Fatal("legacy blob should be removed once it was actually read and reconciled") + if _, ok := kr.data[keyringService+"/"+keyringLegacyAccount]; !ok { + t.Fatal("legacy blob should remain dual-written once it was actually read and reconciled") + } +} + +func mustDecode(t *testing.T, enc string) []byte { + t.Helper() + raw, err := base64.StdEncoding.DecodeString(strings.TrimSpace(enc)) + if err != nil { + t.Fatal(err) } + return raw } func TestStoreKeyringLockPathStableAcrossDifferentHomeEnvs(t *testing.T) { @@ -1449,7 +1545,11 @@ func TestStoreKeyringLockPathStableAcrossDifferentHomeEnvs(t *testing.T) { } } -func TestStoreKeyringMergesFreshLegacyRefreshOnLoadWithoutSave(t *testing.T) { +// TestStoreKeyringLoadPrefersIndexedOverLegacyLookingFresher: when both the +// per-key entry and the legacy blob hold the same key, the indexed copy wins +// even if legacy has a later expiry or different material. Content is not +// causal order (see explicit-Save regression above). +func TestStoreKeyringLoadPrefersIndexedOverLegacyLookingFresher(t *testing.T) { t.Setenv("XDG_CONFIG_HOME", t.TempDir()) kr := newFakeKR() s, err := NewStore(StoreOptions{Storage: "keyring", Keyring: kr}) @@ -1457,13 +1557,13 @@ func TestStoreKeyringMergesFreshLegacyRefreshOnLoadWithoutSave(t *testing.T) { t.Fatal(err) } t1 := time.Now().Add(-10 * time.Minute) - if err := s.Save(ProviderKey("alpha"), Token{AccessToken: "stale-a", ExpiresAt: t1}); err != nil { + if err := s.Save(ProviderKey("alpha"), Token{AccessToken: "indexed-a", ExpiresAt: t1}); err != nil { t.Fatal(err) } t2 := time.Now().Add(1 * time.Hour) legacy := storeFile{SchemaVersion: storeSchemaVersion, Tokens: map[string]Token{ - ProviderKey("alpha"): {AccessToken: "fresh-a", RefreshToken: "fresh-r", ExpiresAt: t2}, + ProviderKey("alpha"): {AccessToken: "legacy-a", RefreshToken: "legacy-r", ExpiresAt: t2}, }} data, err := json.Marshal(legacy) if err != nil { @@ -1475,12 +1575,15 @@ func TestStoreKeyringMergesFreshLegacyRefreshOnLoadWithoutSave(t *testing.T) { if err != nil || !ok { t.Fatalf("Load: ok=%v err=%v", ok, err) } - if got.AccessToken != "fresh-a" { - t.Fatalf("Load returned stale token %#v, want fresh legacy token", got) + if got.AccessToken != "indexed-a" { + t.Fatalf("Load returned %#v, want indexed token (not later-expiry legacy)", got) } } -func TestStoreKeyringLegacyIsFresherHandlesZeroExpiryRefreshes(t *testing.T) { +// TestStoreKeyringLoadDoesNotPreferZeroExpiryLegacyMaterial: OAuth may omit +// expires_in; different token material alone must not make legacy win over an +// indexed entry for the same key. +func TestStoreKeyringLoadDoesNotPreferZeroExpiryLegacyMaterial(t *testing.T) { t.Setenv("XDG_CONFIG_HOME", t.TempDir()) kr := newFakeKR() s, err := NewStore(StoreOptions{Storage: "keyring", Keyring: kr}) @@ -1488,12 +1591,12 @@ func TestStoreKeyringLegacyIsFresherHandlesZeroExpiryRefreshes(t *testing.T) { t.Fatal(err) } t1 := time.Now().Add(10 * time.Minute) - if err := s.Save(ProviderKey("alpha"), Token{AccessToken: "old-a", RefreshToken: "old-r", ExpiresAt: t1}); err != nil { + if err := s.Save(ProviderKey("alpha"), Token{AccessToken: "indexed-a", RefreshToken: "indexed-r", ExpiresAt: t1}); err != nil { t.Fatal(err) } legacy := storeFile{SchemaVersion: storeSchemaVersion, Tokens: map[string]Token{ - ProviderKey("alpha"): {AccessToken: "new-a", RefreshToken: "new-r", ExpiresAt: time.Time{}}, + ProviderKey("alpha"): {AccessToken: "legacy-a", RefreshToken: "legacy-r", ExpiresAt: time.Time{}}, }} data, err := json.Marshal(legacy) if err != nil { @@ -1505,8 +1608,8 @@ func TestStoreKeyringLegacyIsFresherHandlesZeroExpiryRefreshes(t *testing.T) { if err != nil || !ok { t.Fatalf("Load: ok=%v err=%v", ok, err) } - if got.AccessToken != "new-a" || got.RefreshToken != "new-r" { - t.Fatalf("Load = %#v, want legacy zero-expiry refresh", got) + if got.AccessToken != "indexed-a" || got.RefreshToken != "indexed-r" { + t.Fatalf("Load = %#v, want indexed token (not zero-expiry legacy material)", got) } } @@ -1601,10 +1704,10 @@ func TestStoreKeyringDeleteDoesNotResurrectLegacyOnlyKey(t *testing.T) { } } -// TestStoreKeyringWriteMergesZeroExpiryLegacyRefresh ensures write-path -// reconciliation does not discard a valid old-binary refresh that omitted -// expires_in (zero ExpiresAt) when the indexed copy still has a nonzero expiry. -func TestStoreKeyringWriteMergesZeroExpiryLegacyRefresh(t *testing.T) { +// TestStoreKeyringWriteDoesNotMergeLegacyOverIndexedKey: write-path +// reconciliation must not replace an indexed key with legacy material based +// on expiry or token strings (not causal). Only legacy-only keys are merged. +func TestStoreKeyringWriteDoesNotMergeLegacyOverIndexedKey(t *testing.T) { t.Setenv("XDG_CONFIG_HOME", t.TempDir()) kr := newFakeKR() s, err := NewStore(StoreOptions{Storage: "keyring", Keyring: kr}) @@ -1612,12 +1715,12 @@ func TestStoreKeyringWriteMergesZeroExpiryLegacyRefresh(t *testing.T) { t.Fatal(err) } t1 := time.Now().Add(10 * time.Minute) - if err := s.Save(ProviderKey("alpha"), Token{AccessToken: "old-a", RefreshToken: "old-r", ExpiresAt: t1}); err != nil { + if err := s.Save(ProviderKey("alpha"), Token{AccessToken: "indexed-a", RefreshToken: "indexed-r", ExpiresAt: t1}); err != nil { t.Fatal(err) } legacy := storeFile{SchemaVersion: storeSchemaVersion, Tokens: map[string]Token{ - ProviderKey("alpha"): {AccessToken: "new-a", RefreshToken: "new-r", ExpiresAt: time.Time{}}, + ProviderKey("alpha"): {AccessToken: "legacy-a", RefreshToken: "legacy-r", ExpiresAt: time.Time{}}, }} data, err := json.Marshal(legacy) if err != nil { @@ -1633,51 +1736,71 @@ func TestStoreKeyringWriteMergesZeroExpiryLegacyRefresh(t *testing.T) { if err != nil || !ok { t.Fatalf("Load(alpha): ok=%v err=%v", ok, err) } - if got.AccessToken != "new-a" || got.RefreshToken != "new-r" { - t.Fatalf("write reconciliation dropped zero-expiry legacy refresh: got %#v", got) + if got.AccessToken != "indexed-a" || got.RefreshToken != "indexed-r" { + t.Fatalf("write reconciliation overwrote indexed alpha with legacy material: got %#v", got) } } -func TestLegacyIsFresher(t *testing.T) { - t1 := time.Now().Add(10 * time.Minute) - t2 := t1.Add(time.Hour) - cases := []struct { - name string - legacy, current Token - want bool - }{ - { - name: "later legacy expiry wins", - legacy: Token{AccessToken: "a", ExpiresAt: t2}, - current: Token{AccessToken: "a", ExpiresAt: t1}, - want: true, - }, - { - name: "later current expiry wins", - legacy: Token{AccessToken: "old", ExpiresAt: t1}, - current: Token{AccessToken: "new", ExpiresAt: t2}, - want: false, - }, - { - name: "zero-expiry legacy with new material wins", - legacy: Token{AccessToken: "new-a", RefreshToken: "new-r"}, - current: Token{AccessToken: "old-a", RefreshToken: "old-r", ExpiresAt: t1}, - want: true, - }, - { - name: "identical tokens are not fresher", - legacy: Token{AccessToken: "a", RefreshToken: "r", ExpiresAt: t1}, - current: Token{AccessToken: "a", RefreshToken: "r", ExpiresAt: t1}, - want: false, - }, - } - for _, tc := range cases { - t.Run(tc.name, func(t *testing.T) { - if got := legacyIsFresher(tc.legacy, tc.current); got != tc.want { - t.Fatalf("legacyIsFresher = %v, want %v", got, tc.want) - } - }) - } +// TestStoreKeyringCompetingLoginOrders: explicit new-binary Save of account B +// must survive a subsequent write even when legacy still holds account A's +// longer-lived token for the same key (both write orders covered). +func TestStoreKeyringCompetingLoginOrders(t *testing.T) { + t.Setenv("XDG_CONFIG_HOME", t.TempDir()) + + t.Run("explicit_then_legacy_noise", func(t *testing.T) { + kr := newFakeKR() + s, err := NewStore(StoreOptions{Storage: "keyring", Keyring: kr}) + if err != nil { + t.Fatal(err) + } + if err := s.Save(ProviderKey("demo"), Token{AccessToken: "b-token", Account: "b", ExpiresAt: time.Now().Add(time.Hour)}); err != nil { + t.Fatal(err) + } + // Old writer noise: longer expiry, different account, same key. + legacy := storeFile{SchemaVersion: storeSchemaVersion, Tokens: map[string]Token{ + ProviderKey("demo"): {AccessToken: "a-token", Account: "a", ExpiresAt: time.Now().Add(24 * time.Hour)}, + }} + data, err := json.Marshal(legacy) + if err != nil { + t.Fatal(err) + } + kr.data[keyringService+"/"+keyringLegacyAccount] = base64.StdEncoding.EncodeToString(data) + if err := s.Save(ProviderKey("other"), Token{AccessToken: "o"}); err != nil { + t.Fatal(err) + } + got, _, err := s.Load(ProviderKey("demo")) + if err != nil || got.AccessToken != "b-token" || got.Account != "b" { + t.Fatalf("got %#v, want explicit b-token", got) + } + }) + + t.Run("legacy_only_then_explicit", func(t *testing.T) { + kr := newFakeKR() + s, err := NewStore(StoreOptions{Storage: "keyring", Keyring: kr}) + if err != nil { + t.Fatal(err) + } + // Seed index with an unrelated key so write() takes the reconcile path. + if err := s.Save(ProviderKey("seed"), Token{AccessToken: "s"}); err != nil { + t.Fatal(err) + } + legacy := storeFile{SchemaVersion: storeSchemaVersion, Tokens: map[string]Token{ + ProviderKey("demo"): {AccessToken: "a-token", Account: "a", ExpiresAt: time.Now().Add(24 * time.Hour)}, + }} + data, err := json.Marshal(legacy) + if err != nil { + t.Fatal(err) + } + kr.data[keyringService+"/"+keyringLegacyAccount] = base64.StdEncoding.EncodeToString(data) + // Explicit login as B for demo must become authoritative. + if err := s.Save(ProviderKey("demo"), Token{AccessToken: "b-token", Account: "b", ExpiresAt: time.Now().Add(time.Hour)}); err != nil { + t.Fatal(err) + } + got, _, err := s.Load(ProviderKey("demo")) + if err != nil || got.AccessToken != "b-token" || got.Account != "b" { + t.Fatalf("got %#v, want explicit b-token after Save", got) + } + }) } // TestLegacyKeyringLockPathHonorsConfiguredRoot ensures the pre-PR compatibility @@ -1707,6 +1830,9 @@ func TestStoreKeyringRejectsOversizedSingleTokenPayload(t *testing.T) { if err != nil { t.Fatal(err) } + if err := s.Save(ProviderKey("alpha"), Token{AccessToken: "a"}); err != nil { + t.Fatal(err) + } huge := Token{ AccessToken: "eyJhbGciOiJSUzI1NiJ9." + strings.Repeat("A", 6000) + ".sig", @@ -1718,4 +1844,126 @@ func TestStoreKeyringRejectsOversizedSingleTokenPayload(t *testing.T) { if !strings.Contains(err.Error(), "exceeds single keyring entry bound") { t.Fatalf("unexpected error message: %v", err) } + // Preflight must reject before publishing the index key, or the store can + // accumulate phantom keys until the reader cap bricks every later Save. + if indexedKeysOf(t, kr)[ProviderKey("huge")] { + t.Fatal("oversized Save published provider:huge into the index without an entry") + } + if _, ok := kr.data[keyringService+"/"+ProviderKey("huge")]; ok { + t.Fatal("oversized Save wrote a token entry") + } + if _, ok, err := s.Load(ProviderKey("alpha")); err != nil || !ok { + t.Fatalf("alpha lost after rejected Save: ok=%v err=%v", ok, err) + } +} + +// TestStoreKeyringPrunesPhantomIndexKeysAfterInterruptedSet: a Set failure +// after the union index was published can leave a key listed without an entry. +// The next successful write must drop that phantom so capacity recovers. +func TestStoreKeyringPrunesPhantomIndexKeysAfterInterruptedSet(t *testing.T) { + t.Setenv("XDG_CONFIG_HOME", t.TempDir()) + kr := newFakeKR() + s, err := NewStore(StoreOptions{Storage: "keyring", Keyring: kr}) + if err != nil { + t.Fatal(err) + } + if err := s.Save(ProviderKey("alpha"), Token{AccessToken: "a"}); err != nil { + t.Fatal(err) + } + + // Simulate an interrupted write: index lists beta, but beta has no entry. + header := keyIndexHeader{Version: 1, Chunks: 1, Keys: []string{ProviderKey("alpha"), ProviderKey("beta")}} + headerData, err := json.Marshal(header) + if err != nil { + t.Fatal(err) + } + kr.data[keyringService+"/"+keyringIndexAccount] = base64.StdEncoding.EncodeToString(headerData) + + if err := s.Save(ProviderKey("gamma"), Token{AccessToken: "g"}); err != nil { + t.Fatal(err) + } + indexed := indexedKeysOf(t, kr) + if indexed[ProviderKey("beta")] { + t.Fatal("phantom index key provider:beta was not pruned on the next write") + } + if !indexed[ProviderKey("alpha")] || !indexed[ProviderKey("gamma")] { + t.Fatalf("indexed keys = %v, want alpha and gamma", indexed) + } +} + +// TestStoreKeyringDualWritePreservesCrossRootLegacyLogin: the compatibility +// lock cannot span distinct config roots, so a new binary must never delete +// the legacy combined entry. An old-style writer on root A can land a login +// after root B reconciled; dual-write-without-delete keeps that login visible. +func TestStoreKeyringDualWritePreservesCrossRootLegacyLogin(t *testing.T) { + rootA := filepath.Join(t.TempDir(), "cfg-a") + rootB := filepath.Join(t.TempDir(), "cfg-b") + kr := newFakeKR() + + storeB, err := NewStore(StoreOptions{Storage: "keyring", Keyring: kr, Env: map[string]string{"XDG_CONFIG_HOME": rootB}}) + if err != nil { + t.Fatal(err) + } + if err := storeB.Save(ProviderKey("alpha"), Token{AccessToken: "a"}); err != nil { + t.Fatal(err) + } + blobB := storeB.blob.(keyringBlob) + blobAPath := legacyKeyringLockPath(map[string]string{"XDG_CONFIG_HOME": rootA}) + if blobB.legacyLockPath == blobAPath { + t.Fatal("expected distinct legacy lock paths for distinct config roots") + } + + // Old binary on root A writes only the legacy combined entry (no index update). + legacy := storeFile{SchemaVersion: storeSchemaVersion, Tokens: map[string]Token{ + ProviderKey("alpha"): {AccessToken: "a"}, + ProviderKey("carol"): {AccessToken: "c", RefreshToken: "cr"}, + }} + data, err := json.Marshal(legacy) + if err != nil { + t.Fatal(err) + } + kr.data[keyringService+"/"+keyringLegacyAccount] = base64.StdEncoding.EncodeToString(data) + + // New binary on root B saves again: must merge carol, not delete it away. + if err := storeB.Save(ProviderKey("beta"), Token{AccessToken: "b"}); err != nil { + t.Fatal(err) + } + for _, name := range []string{"alpha", "beta", "carol"} { + if _, ok, err := storeB.Load(ProviderKey(name)); err != nil || !ok { + t.Fatalf("Load(%s) after cross-root legacy login: ok=%v err=%v", name, ok, err) + } + } + if _, ok := kr.data[keyringService+"/"+keyringLegacyAccount]; !ok { + t.Fatal("legacy entry was deleted; cross-root old writers can no longer be observed") + } +} + +// TestStoreKeyringReadIndexRejectsOversizedEncodedPayload: bound the base64 +// payload before DecodeString/Unmarshal so a damaged index cannot force +// unbounded memory/CPU under the store lock. +func TestStoreKeyringReadIndexRejectsOversizedEncodedPayload(t *testing.T) { + ckr := &countingKR{fakeKR: newFakeKR()} + blob := keyringBlob{kr: ckr, service: keyringService, legacyAccount: keyringLegacyAccount, indexAccount: keyringIndexAccount} + + huge := strings.Repeat("A", maxKeyringIndexEncodedBytes+1) + ckr.data[keyringService+"/"+keyringIndexAccount] = huge + ckr.gets = 0 + if _, _, _, err := blob.readKeyIndex(); err == nil { + t.Fatal("expected oversized encoded index payload to be rejected") + } + if ckr.gets != 1 { + t.Fatalf("readKeyIndex issued %d gets; want header lookup only", ckr.gets) + } + + // Continuation chunks must use the same bound. + header, err := json.Marshal(keyIndexHeader{Version: 1, Chunks: 2, Keys: []string{ProviderKey("seed")}}) + if err != nil { + t.Fatal(err) + } + ckr.data[keyringService+"/"+keyringIndexAccount] = base64.StdEncoding.EncodeToString(header) + ckr.data[keyringService+"/"+keyringIndexAccount+"-1"] = huge + ckr.gets = 0 + if _, _, _, err := blob.readKeyIndex(); err == nil { + t.Fatal("expected oversized encoded chunk payload to be rejected") + } } From b112dc2b4cad838eb1e8ad4d1d7c689b651ae0d4 Mon Sep 17 00:00:00 2001 From: euxaristia <25621994+euxaristia@users.noreply.github.com> Date: Sat, 1 Aug 2026 19:08:48 -0400 Subject: [PATCH 25/28] fix(oauth): freeze legacy keyring and tombstone durable deletes Stop dual-writing the shared legacy blob so uncoordinated old writers cannot be clobbered or truncated, record chunked tombstones for logout durability, and renew each multi-lock lease as soon as it is acquired. Add regressions for the remaining migration coexistence P1s. --- internal/oauth/store.go | 361 +++++++++++++--------- internal/oauth/store_keyring_test.go | 444 +++++++++++++++++++++------ 2 files changed, 569 insertions(+), 236 deletions(-) diff --git a/internal/oauth/store.go b/internal/oauth/store.go index e5c145c91..ac07f1944 100644 --- a/internal/oauth/store.go +++ b/internal/oauth/store.go @@ -117,19 +117,29 @@ type KeyringClient interface { // bytes; three or more logged-in providers routinely exceeds that. Splitting // by key bounds each write to one token, which stays well under the cap // regardless of how many providers are logged in. +// +// Coexistence with pre-per-key binaries: the legacy combined entry is a +// read-only discovery source for new code. New writers never overwrite it +// (they cannot share a lock with old writers on other config roots, so any +// snapshot-then-Set would clobber unobserved updates or truncate oversized +// Linux keyring maps). Indexed per-key entries are the sole writable +// representation for new binaries. Durable deletion markers (tombstones) +// prevent an uncoordinated old writer from resurrecting a logout via the +// legacy blob. const ( keyringService = "zero" // keyringLegacyAccount is the combined-blob entry used by pre-per-key - // binaries. New code dual-writes the reconciled token map here on every - // save so an old binary on another config root (which cannot share this - // process's legacy lock) still sees current credentials, and so a - // concurrent old-writer update is never permanently deleted just because - // this process could not observe it. The per-key index remains - // authoritative for new binaries. + // binaries. New code reads it for migration and for legacy-only logins, + // but never writes or deletes it: dual-write cannot be made safe across + // config roots that do not share legacyKeyringLockPath. keyringLegacyAccount = "oauth-tokens" // keyringIndexAccount holds a JSON array of the token keys that currently // have their own keyring entry, since KeyringClient has no "list" operation. keyringIndexAccount = "oauth-tokens-index" + // keyringTombstoneAccount holds the set of keys deliberately deleted by a + // new binary. Old writers cannot see this entry; new readers and writers + // honor it so a stale legacy rewrite cannot resurrect a logout. + keyringTombstoneAccount = "oauth-tokens-tombstones" ) // Store persists OAuth tokens (provider + MCP namespaces) as one JSON blob, @@ -484,8 +494,9 @@ func (s *Store) readState() (storeFile, error) { } // writeState persists state. omitFromLegacy lists keys that must not be -// re-merged from the keyring legacy blob during reconciliation (Delete intent). -// File and encrypted-file backends ignore omitFromLegacy. +// re-merged from the keyring legacy blob during reconciliation (Delete intent); +// the keyring backend also records them as durable tombstones. File and +// encrypted-file backends ignore omitFromLegacy. func (s *Store) writeState(state storeFile, omitFromLegacy map[string]bool) error { data, err := json.MarshalIndent(state, "", " ") if err != nil { @@ -514,7 +525,8 @@ type blobStore interface { read() (data []byte, ok bool, err error) // write replaces the stored blob. omitFromLegacy is keyring-only: keys the // caller deliberately removed that must not be re-merged from the legacy - // combined entry during mixed-version reconciliation. File backends ignore it. + // combined entry and that should be recorded as durable tombstones. File + // backends ignore it. write(data []byte, omitFromLegacy map[string]bool) error // withLock runs fn under whatever cross-process exclusion the backend offers // (a lock file for the file backend; none for the keyring, which is the @@ -590,7 +602,8 @@ type keyringBlob struct { kr KeyringClient service string // legacyAccount is the pre-migration whole-blob entry; read only, to pick up - // tokens saved by older versions the first time this runs. + // tokens saved by older versions and legacy-only logins from old binaries. + // New code never writes this account (see package comment on coexistence). legacyAccount string indexAccount string // lockPath, when set, is a cross-process lock file serializing the keyring's @@ -598,10 +611,11 @@ type keyringBlob struct { lockPath string // legacyLockPath, when set, is the lock file a pre-PR binary acquires around // its own read-modify-write of the legacy combined entry (see - // legacyKeyringLockPath). write() holds it too so, when the old binary shares - // this config root, it cannot race our dual-write. Binaries on other roots - // still cannot share this lock; dual-write (never delete) is what keeps their - // updates from being permanently destroyed. + // legacyKeyringLockPath). write() still holds it when the old binary shares + // this config root so concurrent legacy mutations serialize with our + // reconcile-and-index pass. Cross-root old writers cannot share that lock; + // safety there comes from never overwriting the legacy blob and from + // durable tombstones, not from dual-write. legacyLockPath string } @@ -613,6 +627,14 @@ func (b keyringBlob) read() ([]byte, bool, error) { if !ok { return b.readLegacy() } + // Tombstones block resurrection of deliberately deleted keys from the + // legacy combined entry (an old binary may rewrite a stale snapshot into + // that account after logout). Fail closed on a corrupt tombstone set so a + // damaged marker cannot silently re-expose logged-out credentials. + tombstones, err := b.readTombstones() + if err != nil { + return nil, false, err + } // The legacy combined entry is consulted when an indexed key's own entry is // missing (torn write / migration) and for keys only present there (an old // binary logged into a provider this process has never indexed). Indexed @@ -626,8 +648,9 @@ func (b keyringBlob) read() ([]byte, bool, error) { return } // Best-effort on read: a transient failure must not fail Load/Status, - // only skip legacy recovery for this pass. write() still refuses to - // dual-write over a legacy blob it cannot read (see write()). + // only skip legacy recovery for this pass. write() still requires a + // successful legacy read before reconciling so it never mistakes a + // transient error for an empty blob. if lt, lerr := b.readLegacyTokens(); lerr == nil { legacyTokens = lt } @@ -641,9 +664,14 @@ func (b keyringBlob) read() ([]byte, bool, error) { } if !ok { // The index lists this key but its own entry is missing. Recover it - // from the dual-written legacy blob when present; otherwise skip - // rather than fail the whole read (the next Save/Delete prunes the - // phantom index key so it cannot permanently consume capacity). + // from the legacy blob when present and not tombstoned; otherwise + // skip rather than fail the whole read (the next Save/Delete prunes + // the phantom index key so it cannot permanently consume capacity). + // Tombstones do not hide a still-present indexed entry (in-flight + // delete): they only block resurrection from the legacy account. + if tombstones[key] { + continue + } loadLegacy() if token, has := legacyTokens[key]; has { tokens[key] = token @@ -663,11 +691,15 @@ func (b keyringBlob) read() ([]byte, bool, error) { // Keep legacy-only keys visible through the compatibility window: // an old binary may have logged into a provider after the index was created. + // Tombstones suppress keys the user already logged out of. loadLegacy() for key, legacyToken := range legacyTokens { if ValidateKey(key) != nil { continue } + if tombstones[key] { + continue + } if _, has := tokens[key]; !has { tokens[key] = legacyToken } @@ -700,10 +732,9 @@ func (b keyringBlob) readLegacy() ([]byte, bool, error) { // returned ok=false, err=nil) — the one case callers may treat as "no tokens" // and proceed. Any other failure (a transient keyring read error, undecodable // base64, invalid JSON) is returned as err and must NOT be collapsed into "no -// tokens": write() dual-writes the reconciled map over the legacy blob, so -// mistaking a transient read failure for an empty blob would overwrite a -// still-unread, still-live credential that belongs to an older, still-installed -// zero binary. +// tokens": write() merges legacy-only keys into the indexed representation, +// and mistaking a transient read failure for an empty blob would skip +// credentials that still live only in that account. func (b keyringBlob) readLegacyTokens() (map[string]Token, error) { data, ok, err := b.readLegacy() if err != nil { @@ -726,20 +757,20 @@ func (b keyringBlob) readLegacyTokens() (map[string]Token, error) { // written, entries are deleted before the index shrinks, and the index // header is only updated after the chunks it references exist. A crash at // any step therefore leaves either an index over-listing keys whose entries -// are missing (read() recovers those from the dual-written legacy blob, or -// skips them; the next write prunes phantom index keys so they cannot +// are missing (read() recovers those from the legacy blob unless tombstoned, +// or skips them; the next write prunes phantom index keys so they cannot // permanently consume capacity) or entries that a later read/write can still // see and reconcile, never an invisible credential stranded in the OS keychain. // -// The legacy combined entry is dual-written with the reconciled map after -// per-key entries succeed, never deleted: a pre-PR binary using another -// config root cannot share this process's compatibility lock, so deleting -// the blob would permanently drop an update that process wrote between our -// reconcile read and a delete. Dual-write keeps old readers current and -// leaves concurrent old-writer keys for the next reconcile pass. -// omitFromLegacy lists keys the caller just deleted; they must not be -// re-merged from the legacy blob even when they were never indexed (a -// legacy-only old-binary login that the user logged out of). +// The legacy combined entry is never written or deleted by this path. New +// code cannot share a lock with old writers on other config roots, so any +// snapshot-then-Set of that account can clobber an unobserved login or +// truncate a valid oversized Linux keyring map. Legacy stays a read-only +// discovery source; indexed entries are the sole writable representation. +// omitFromLegacy lists keys the caller just deleted; they are recorded as +// durable tombstones and must not be re-merged from the legacy blob even +// when they were never indexed (a legacy-only old-binary login that the +// user logged out of). func (b keyringBlob) write(data []byte, omitFromLegacy map[string]bool) error { var state storeFile if err := json.Unmarshal(data, &state); err != nil { @@ -754,19 +785,33 @@ func (b keyringBlob) write(data []byte, omitFromLegacy map[string]bool) error { prior[key] = true } + tombstones, err := b.readTombstones() + if err != nil { + return err + } + // Record durable deletion markers before mutating entries so a crash + // mid-write cannot leave a logged-out key importable from legacy alone. + for key := range omitFromLegacy { + tombstones[key] = true + } + // A re-login after logout clears the tombstone for that key. + for key := range state.Tokens { + delete(tombstones, key) + } + // An older binary running alongside this one still reads and writes only the // legacy combined entry. Merge keys that entry holds which the indexed - // schema has never seen (fresh old-binary logins). Never overwrite a key - // already present in state: expiry and token strings are not causal order, - // so a "later" legacy expiry can replace an explicit new-binary Save with - // the wrong account. Keys in the prior index but absent from this write - // were deliberately removed (logout) and must not be resurrected. + // schema has never seen (fresh old-binary logins), unless tombstoned or + // omitted by this operation. Never overwrite a key already present in + // state: expiry and token strings are not causal order. Keys in the prior + // index but absent from this write were deliberately removed (logout) and + // must not be resurrected. // // Unlike read()'s best-effort fallback, a failure here must abort the whole - // write rather than proceed as though the legacy blob were empty: step 4 - // dual-writes over it, and a transient read error must never be mistaken - // for "nothing to reconcile," or a still-live credential from an older, - // still-installed zero binary is destroyed irrecoverably. + // write rather than proceed as though the legacy blob were empty: skipping + // a still-live legacy-only credential would leave it unindexed until the + // next successful reconcile, and a concurrent old-writer update is only + // discoverable through this read. legacyTokens, err := b.readLegacyTokens() if err != nil { return fmt.Errorf("oauth: read legacy keyring token blob for reconciliation: %w", err) @@ -776,7 +821,7 @@ func (b keyringBlob) write(data []byte, omitFromLegacy map[string]bool) error { if ValidateKey(key) != nil { continue } - if omitFromLegacy[key] { + if omitFromLegacy[key] || tombstones[key] { continue } if _, exists := state.Tokens[key]; exists { @@ -830,7 +875,13 @@ func (b keyringBlob) write(data []byte, omitFromLegacy map[string]bool) error { } } - // 1. Publish the union of the live prior and new key sets first, so every + // 1. Persist tombstones before removing entries so logout survives a crash + // between entry delete and a later reconcile (and survives an old binary + // rewriting the legacy blob with the deleted key still present). + if err := b.writeTombstones(tombstones); err != nil { + return err + } + // 2. Publish the union of the live prior and new key sets first, so every // entry that exists at any point during this update is indexed. union := keys if len(livePrior) > 0 { @@ -848,13 +899,13 @@ func (b keyringBlob) write(data []byte, omitFromLegacy map[string]bool) error { if err != nil { return err } - // 2. Write each token entry (encodings preflighted above). + // 3. Write each token entry (encodings preflighted above). for _, key := range keys { if err := b.kr.Set(b.service, key, encoded[key]); err != nil { return err } } - // 3. Delete removed entries while the union index still lists them, so a + // 4. Delete removed entries while the union index still lists them, so a // failed Delete leaves a visible (re-deletable) entry, never an orphan. for _, key := range livePrior { if _, ok := state.Tokens[key]; !ok { @@ -863,87 +914,77 @@ func (b keyringBlob) write(data []byte, omitFromLegacy map[string]bool) error { } } } - // 4. Dual-write the reconciled map to the legacy combined entry (never - // delete it). Old binaries on other config roots keep reading this account; - // logout correctness comes from updating the map, not from removing the - // entry. The combined entry is still subject to the single-keyring-item - // size cap that forced per-key storage, so full dual-write only happens - // when it fits; otherwise we patch the existing legacy map (drop logouts) - // without re-aggregating every provider token. - if err := b.dualWriteLegacy(state, omitFromLegacy, prior, legacyTokens); err != nil { - return err - } - // 5. Shrink the index to the exact new key set. + // 5. Shrink the index to the exact new key set. Legacy is left untouched. if _, err := b.writeKeyIndex(keys, unionChunks); err != nil { return err } return nil } -// dualWriteLegacy updates the combined legacy keyring entry without ever -// deleting it. Full dual-write of state is preferred when it fits under -// maxKeyringSingleEntryBytes (so old readers see current tokens). When state -// is too large (the multi-provider case that forced the per-key split), the -// existing legacy map is patched to drop logged-out keys so they cannot be -// resurrected, but is not expanded with every indexed token. -func (b keyringBlob) dualWriteLegacy(state storeFile, omitFromLegacy, prior map[string]bool, legacyTokens map[string]Token) error { - full, err := json.Marshal(storeFile{SchemaVersion: storeSchemaVersion, Tokens: state.Tokens}) +// tombstoneBlob returns a keyringBlob that reuses the chunked index codec for +// the durable deletion set. Tombstones can grow to the same key/chunk caps as +// the live index (max-length keys after many logouts), so a single entry is +// not enough under the macOS line bound. +func (b keyringBlob) tombstoneBlob() keyringBlob { + return keyringBlob{kr: b.kr, service: b.service, indexAccount: keyringTombstoneAccount} +} + +// readTombstones returns the durable set of keys deleted by a new binary. +// Missing account => empty set. Corrupt payloads fail closed. +func (b keyringBlob) readTombstones() (map[string]bool, error) { + keys, ok, _, err := b.tombstoneBlob().readKeyIndex() if err != nil { - return err + return nil, fmt.Errorf("oauth: read keyring token tombstones: %w", err) + } + if !ok { + return map[string]bool{}, nil } - fullEnc := base64.StdEncoding.EncodeToString(full) - if len(fullEnc) <= maxKeyringSingleEntryBytes { - return b.kr.Set(b.service, b.legacyAccount, fullEnc) + out := make(map[string]bool, len(keys)) + for _, key := range keys { + out[key] = true } + return out, nil +} - // Oversize full map: never delete the legacy entry, and never write an - // oversized value the OS keyring / security -i would reject. Patch the - // previous legacy contents so explicit logouts stick. - next := make(map[string]Token, len(legacyTokens)) - for key, token := range legacyTokens { - if omitFromLegacy[key] { - continue - } - if prior[key] { - if _, ok := state.Tokens[key]; !ok { - continue - } - } - next[key] = token - } - // Prefer indexed material for keys still present in both, when that keeps - // the patch under the single-entry bound (best-effort; skip keys that push - // it over rather than failing the whole Save). - patchTokens := make(map[string]Token, len(next)) - for key, token := range next { - if current, ok := state.Tokens[key]; ok { - token = current - } - candidate := make(map[string]Token, len(patchTokens)+1) - for k, v := range patchTokens { - candidate[k] = v +// writeTombstones persists the durable deletion set. An empty set removes every +// tombstone account/chunk so a fully clean store does not leave leftover +// markers. Errors from that cleanup are surfaced so interruption tests and +// real keyring failures cannot be swallowed. +func (b keyringBlob) writeTombstones(tombstones map[string]bool) error { + tb := b.tombstoneBlob() + _, existed, priorChunks, err := tb.readKeyIndex() + if err != nil { + return fmt.Errorf("oauth: read keyring token tombstones: %w", err) + } + if len(tombstones) == 0 { + if !existed { + return nil } - candidate[key] = token - raw, err := json.Marshal(storeFile{SchemaVersion: storeSchemaVersion, Tokens: candidate}) - if err != nil { + if _, err := tb.kr.Delete(tb.service, tb.indexAccount); err != nil { return err } - if len(base64.StdEncoding.EncodeToString(raw)) > maxKeyringSingleEntryBytes { - continue + for i := 1; i < priorChunks; i++ { + if _, err := tb.kr.Delete(tb.service, tb.chunkAccount(i)); err != nil { + return err + } } - patchTokens[key] = token + return nil } - raw, err := json.Marshal(storeFile{SchemaVersion: storeSchemaVersion, Tokens: patchTokens}) - if err != nil { - return err + if len(tombstones) > maxKeyringIndexKeys { + return errKeyringIndexTooManyKeys(len(tombstones)) } - enc := base64.StdEncoding.EncodeToString(raw) - if len(enc) > maxKeyringSingleEntryBytes { - // Pre-existing oversized legacy blob: leave it untouched rather than - // fail an otherwise successful indexed write. - return nil + keys := make([]string, 0, len(tombstones)) + for key := range tombstones { + if ValidateKey(key) != nil { + continue + } + keys = append(keys, key) } - return b.kr.Set(b.service, b.legacyAccount, enc) + sort.Strings(keys) + if _, err := tb.writeKeyIndex(keys, priorChunks); err != nil { + return fmt.Errorf("oauth: write keyring token tombstones: %w", err) + } + return nil } // maxKeyringSingleEntryBytes bounds a single base64-encoded token secret so @@ -1205,39 +1246,31 @@ func chunkIndexKeys(keys []string) [][]string { // exists to prevent. A var so tests can shorten it. var fileLockRefreshInterval = 10 * time.Second -// withLeasedLocks acquires every non-empty path in order, refreshes all of -// their mtimes on a ticker while fn runs (so a legitimately slow multi-entry -// keyring pass never looks like a crashed holder to another process), and -// releases them in reverse order once fn returns. -func withLeasedLocks(paths []string, now func() time.Time, fn func() error) error { - var unlocks []func() - var held []string - for _, p := range paths { - if p == "" { - continue - } - unlock, err := acquireFileLock(p, now) - if err != nil { - for i := len(unlocks) - 1; i >= 0; i-- { - unlocks[i]() - } - return err - } - unlocks = append(unlocks, unlock) - held = append(held, p) - } - if len(held) == 0 { - return fn() +// leasedPath is one acquired lock whose mtime is refreshed until stop is +// closed. Lease ownership starts at acquisition, not after every path is +// held: withLock acquires lockPath then may block on legacyLockPath, and a +// peer must not be able to reclaim the first lock as stale during that wait. +type leasedPath struct { + path string + unlock func() + stop chan struct{} + done chan struct{} +} + +func startLease(path string, unlock func()) *leasedPath { + l := &leasedPath{ + path: path, + unlock: unlock, + stop: make(chan struct{}), + done: make(chan struct{}), } - stop := make(chan struct{}) - done := make(chan struct{}) go func() { - defer close(done) + defer close(l.done) ticker := time.NewTicker(fileLockRefreshInterval) defer ticker.Stop() for { select { - case <-stop: + case <-l.stop: return case <-ticker.C: // Lease with wall-clock time, never the injectable now: acquireFileLock @@ -1246,18 +1279,48 @@ func withLeasedLocks(paths []string, now func() time.Time, fn func() error) erro // another process would immediately reclaim, reviving the token-loss // race these locks prevent. at := time.Now() - for _, p := range held { - _ = os.Chtimes(p, at, at) - } + _ = os.Chtimes(path, at, at) } } }() - err := fn() - close(stop) - <-done - for i := len(unlocks) - 1; i >= 0; i-- { - unlocks[i]() + return l +} + +func (l *leasedPath) release() { + close(l.stop) + <-l.done + l.unlock() +} + +// withLeasedLocks acquires every non-empty path in order. Each lock's mtime +// lease starts immediately on acquisition (and keeps refreshing while later +// paths are still being acquired and while fn runs), so a multi-lock wait +// cannot leave an earlier lock looking abandoned. Locks are released in +// reverse order once fn returns. +func withLeasedLocks(paths []string, now func() time.Time, fn func() error) error { + var leases []*leasedPath + releaseAll := func() { + for i := len(leases) - 1; i >= 0; i-- { + leases[i].release() + } } + for _, p := range paths { + if p == "" { + continue + } + unlock, err := acquireFileLock(p, now) + if err != nil { + releaseAll() + return err + } + // Start refreshing this lock before blocking on the next path. + leases = append(leases, startLease(p, unlock)) + } + if len(leases) == 0 { + return fn() + } + err := fn() + releaseAll() return err } @@ -1266,9 +1329,9 @@ func withLeasedLocks(paths []string, now func() time.Time, fn func() error) erro // binary's own instances so two of them can't both read the blob, modify, // and write — dropping a token. legacyLockPath is held for the same duration // so a live pre-PR binary that shares this config root (see -// legacyKeyringLockPath) can't race our dual-write of the legacy combined -// entry. Cross-root old writers cannot share that lock; dual-write without -// delete is the remaining safety net for them. +// legacyKeyringLockPath) serializes with our reconcile-and-index pass. +// Cross-root old writers cannot share that lock; never overwriting the +// legacy blob and durable tombstones are the remaining safety net for them. func (b keyringBlob) withLock(now func() time.Time, fn func() error) error { return withLeasedLocks([]string{b.lockPath, b.legacyLockPath}, now, fn) } diff --git a/internal/oauth/store_keyring_test.go b/internal/oauth/store_keyring_test.go index bb74bfbf7..ffb817f95 100644 --- a/internal/oauth/store_keyring_test.go +++ b/internal/oauth/store_keyring_test.go @@ -10,6 +10,8 @@ import ( "strings" "testing" "time" + + "github.com/Gitlawb/zero/internal/lockutil" ) // fakeKR is an in-memory KeyringClient for exercising the keyring backend @@ -65,10 +67,10 @@ func TestStoreKeyringBackendRoundTrip(t *testing.T) { if strings.Contains(raw, "access_token") { t.Fatalf("keyring entry is not encoded: %s", raw) } - // New code dual-writes the reconciled map to the legacy account so pre-PR - // binaries (including those on other config roots) keep a current view. - if raw := kr.data[keyringService+"/"+keyringLegacyAccount]; raw == "" { - t.Fatal("legacy combined entry should be dual-written by new code") + // New code never creates the legacy combined entry: that account is a + // read-only discovery source for pre-PR blobs, not a dual-written mirror. + if raw := kr.data[keyringService+"/"+keyringLegacyAccount]; raw != "" { + t.Fatal("legacy combined entry must not be dual-written by new code") } removed, err := s.Delete(ProviderKey("demo")) @@ -117,15 +119,11 @@ func TestStoreKeyringManyProvidersStayUnderEntryLimit(t *testing.T) { } } // Each per-key token entry must stay small even with 5 providers logged - // in. The dual-written legacy combined entry is size-capped separately at - // maxKeyringSingleEntryBytes (and may be skipped/patched when oversize). + // in. New code does not dual-write the legacy combined entry. const singleTokenCeiling = 3000 // generous margin under the ~4095-byte line cap for k, v := range kr.data { if strings.HasSuffix(k, "/"+keyringLegacyAccount) { - if len(v) > maxKeyringSingleEntryBytes { - t.Fatalf("legacy dual-write %q is %d bytes, want <= %d", k, len(v), maxKeyringSingleEntryBytes) - } - continue + t.Fatalf("legacy account %q was written by new multi-provider saves", k) } if len(v) > singleTokenCeiling { t.Fatalf("keyring entry %q is %d bytes, want < %d (aggregation regression)", k, len(v), singleTokenCeiling) @@ -144,8 +142,8 @@ func TestStoreKeyringManyProvidersStayUnderEntryLimit(t *testing.T) { // TestStoreKeyringMigratesLegacyCombinedEntry ensures installs upgrading from // the original single-blob format keep reading their existing tokens, and get -// migrated to per-key entries (with the legacy entry removed) the next time -// anything is saved. +// migrated to per-key entries the next time anything is saved. The legacy +// entry is left untouched (never dual-written or deleted). func TestStoreKeyringMigratesLegacyCombinedEntry(t *testing.T) { t.Setenv("XDG_CONFIG_HOME", t.TempDir()) kr := newFakeKR() @@ -156,7 +154,8 @@ func TestStoreKeyringMigratesLegacyCombinedEntry(t *testing.T) { if err != nil { t.Fatal(err) } - kr.data[keyringService+"/"+keyringLegacyAccount] = base64.StdEncoding.EncodeToString(data) + legacyEnc := base64.StdEncoding.EncodeToString(data) + kr.data[keyringService+"/"+keyringLegacyAccount] = legacyEnc s, err := NewStore(StoreOptions{Storage: "keyring", Keyring: kr}) if err != nil { @@ -170,14 +169,13 @@ func TestStoreKeyringMigratesLegacyCombinedEntry(t *testing.T) { t.Fatalf("Load = %#v", got) } - // Saving a second provider must migrate into per-key entries and dual-write - // the full map back to the legacy account (never delete it: other config - // roots cannot share the compatibility lock). + // Saving a second provider must migrate into per-key entries and leave the + // original legacy blob byte-identical (read-only coexistence). if err := s.Save(ProviderKey("other"), Token{AccessToken: "other-a"}); err != nil { t.Fatal(err) } - if _, ok := kr.data[keyringService+"/"+keyringLegacyAccount]; !ok { - t.Fatal("legacy combined entry should remain dual-written after migration") + if got := kr.data[keyringService+"/"+keyringLegacyAccount]; got != legacyEnc { + t.Fatalf("legacy combined entry was rewritten during migration (want frozen original)") } for _, name := range []string{"demo", "other"} { if _, ok, err := s.Load(ProviderKey(name)); err != nil || !ok { @@ -435,7 +433,9 @@ func TestStoreKeyringWriteInterruptionsLeaveNoInvisibleTokens(t *testing.T) { indexed := indexedKeysOf(t, kr.fakeKR) for entry := range kr.data { account := strings.TrimPrefix(entry, keyringService+"/") - if account == keyringIndexAccount || strings.HasPrefix(account, keyringIndexAccount+"-") || account == keyringLegacyAccount { + if account == keyringIndexAccount || strings.HasPrefix(account, keyringIndexAccount+"-") || + account == keyringLegacyAccount || account == keyringTombstoneAccount || + strings.HasPrefix(account, keyringTombstoneAccount+"-") { continue } if !indexed[account] { @@ -461,9 +461,7 @@ func TestStoreKeyringWriteInterruptionsLeaveNoInvisibleTokens(t *testing.T) { t.Fatalf("failAt=%d: Load(%s) after reconcile: ok=%v err=%v", failAt, name, ok, err) } } - // Every mutating boundary of the write path now surfaces its failure, - // including the legacy dual-write (a swallowed failure there could - // let a later save resurrect logged-out credentials). + // Every mutating boundary of the write path must surface its failure. if opsUsed >= failAt && saveErr == nil { t.Fatalf("failAt=%d: injected keyring failure was swallowed", failAt) } @@ -502,7 +500,9 @@ func TestStoreKeyringDeleteInterruptionsLeaveNoInvisibleTokens(t *testing.T) { indexed := indexedKeysOf(t, kr.fakeKR) for entry := range kr.data { account := strings.TrimPrefix(entry, keyringService+"/") - if account == keyringIndexAccount || strings.HasPrefix(account, keyringIndexAccount+"-") || account == keyringLegacyAccount { + if account == keyringIndexAccount || strings.HasPrefix(account, keyringIndexAccount+"-") || + account == keyringLegacyAccount || account == keyringTombstoneAccount || + strings.HasPrefix(account, keyringTombstoneAccount+"-") { continue } if !indexed[account] { @@ -573,7 +573,12 @@ func TestStoreKeyringMergesFreshLegacyWriteFromOldBinary(t *testing.T) { t.Fatalf("Load(carol) = %#v, err=%v, want the legacy access/refresh tokens intact", got, err) } if _, ok := kr.data[keyringService+"/"+keyringLegacyAccount]; !ok { - t.Fatal("legacy entry should remain dual-written after merge") + t.Fatal("legacy entry was deleted; old writers on other roots would lose their only copy") + } + // carol must also be promoted into an indexed entry so it survives without + // relying on a dual-written legacy mirror. + if _, ok := kr.data[keyringService+"/"+ProviderKey("carol")]; !ok { + t.Fatal("merged legacy-only carol was not promoted into a per-key entry") } } @@ -746,9 +751,10 @@ func TestStoreKeyringMigrationInterruptionsPreserveLegacyTokens(t *testing.T) { if got, ok, err := s.Load(ProviderKey("new")); err != nil || !ok || got.AccessToken != "new-c" { t.Fatalf("failAt=%d: Load(new): ok=%v err=%v token=%#v", failAt, ok, err, got) } - // Completed migration dual-writes the full map to the legacy entry. + // Completed migration leaves the original legacy entry in place + // (read-only coexistence; never dual-write or delete). if _, ok := kr.data[keyringService+"/"+keyringLegacyAccount]; !ok { - t.Fatalf("failAt=%d: legacy entry missing after migration completed (want dual-write)", failAt) + t.Fatalf("failAt=%d: legacy entry was removed during migration", failAt) } if opsUsed < failAt { break @@ -1217,28 +1223,13 @@ func TestKeyringLockPathDerivedFromKeyringIdentityNotFileConfig(t *testing.T) { } } -// legacyDualWriteFailKR fails Set for the legacy combined entry only, to -// exercise the boundary where a logout has rewritten the indexed state but -// the dual-write that clears the credential from the legacy blob cannot land. -type legacyDualWriteFailKR struct { - *fakeKR - fail bool -} - -func (f *legacyDualWriteFailKR) Set(service, account, secret string) error { - if f.fail && account == keyringLegacyAccount { - return errKRInjected - } - return f.fakeKR.Set(service, account, secret) -} - -// TestStoreKeyringLogoutSurfacesLegacyDualWriteFailure: when the legacy -// dual-write fails during a logout, the operation must report the failure -// (not success while the secret may still be resident in the legacy blob), -// and after a clean retry the logged-out credential must not be resurrected. -func TestStoreKeyringLogoutSurfacesLegacyDualWriteFailure(t *testing.T) { +// TestStoreKeyringLogoutTombstoneSurvivesStaleLegacyRewrite: after Delete, +// an old binary can rewrite the legacy blob with the logged-out key still +// present. Durable tombstones must keep Load empty and prevent a later Save +// from reindexing it. +func TestStoreKeyringLogoutTombstoneSurvivesStaleLegacyRewrite(t *testing.T) { t.Setenv("XDG_CONFIG_HOME", t.TempDir()) - kr := &legacyDualWriteFailKR{fakeKR: newFakeKR()} + kr := newFakeKR() s, err := NewStore(StoreOptions{Storage: "keyring", Keyring: kr}) if err != nil { t.Fatal(err) @@ -1246,10 +1237,14 @@ func TestStoreKeyringLogoutSurfacesLegacyDualWriteFailure(t *testing.T) { if err := s.Save(ProviderKey("alpha"), Token{AccessToken: "a"}); err != nil { t.Fatal(err) } - // A leftover legacy blob still carries alpha (e.g. written by an old - // binary during the upgrade window). + if _, err := s.Delete(ProviderKey("alpha")); err != nil { + t.Fatal(err) + } + + // Old binary rewrites a stale snapshot that still contains alpha. legacy := storeFile{SchemaVersion: storeSchemaVersion, Tokens: map[string]Token{ ProviderKey("alpha"): {AccessToken: "stale-a"}, + ProviderKey("beta"): {AccessToken: "b"}, }} data, err := json.Marshal(legacy) if err != nil { @@ -1257,22 +1252,18 @@ func TestStoreKeyringLogoutSurfacesLegacyDualWriteFailure(t *testing.T) { } kr.data[keyringService+"/"+keyringLegacyAccount] = base64.StdEncoding.EncodeToString(data) - kr.fail = true - if _, err := s.Delete(ProviderKey("alpha")); err == nil { - t.Fatal("Delete reported success although the legacy dual-write failed") - } - - // A clean retry succeeds, and a later save must not classify the stale - // legacy alpha as a fresh old-binary login. - kr.fail = false - if _, err := s.Delete(ProviderKey("alpha")); err != nil { - t.Fatalf("retried Delete: %v", err) + if _, ok, err := s.Load(ProviderKey("alpha")); err != nil || ok { + t.Fatalf("logged-out alpha exposed after stale legacy rewrite: ok=%v err=%v", ok, err) } - if err := s.Save(ProviderKey("beta"), Token{AccessToken: "b"}); err != nil { + if err := s.Save(ProviderKey("gamma"), Token{AccessToken: "g"}); err != nil { t.Fatal(err) } if _, ok, err := s.Load(ProviderKey("alpha")); err != nil || ok { - t.Fatalf("logged-out credential resurrected: ok=%v err=%v", ok, err) + t.Fatalf("logged-out alpha resurrected by later Save: ok=%v err=%v", ok, err) + } + // Unrelated legacy-only beta remains discoverable. + if _, ok, err := s.Load(ProviderKey("beta")); err != nil || !ok { + t.Fatalf("legacy-only beta lost: ok=%v err=%v", ok, err) } } @@ -1421,10 +1412,11 @@ func TestStoreKeyringDuplicateIndexDoesNotFanOutPerEntry(t *testing.T) { t.Fatalf("Status returned %d entries for an 80-duplicate index of one key, want 1", len(statuses)) } // One Get for the index header, one Get for the single deduplicated key's - // own entry, and at most one extra Get for the legacy fallback lookup. - // The key regression is: not one Get per duplicate entry. - if ckr.gets > 3 { - t.Fatalf("Status issued %d keyring gets for an 80-entry duplicate index, want <= 3 (fan-out DoS regression)", ckr.gets) + // own entry, one Get for durable tombstones, and at most one extra Get for + // the legacy fallback lookup. The key regression is: not one Get per + // duplicate entry. + if ckr.gets > 4 { + t.Fatalf("Status issued %d keyring gets for an 80-entry duplicate index, want <= 4 (fan-out DoS regression)", ckr.gets) } } @@ -1444,12 +1436,10 @@ func (f *legacyGetFailKR) Get(service, account string) (string, bool, error) { } // TestStoreKeyringWriteRefusesToOverwriteLegacyBlobOnTransientReadError is the -// regression test for finding #2: during mixed-version reconciliation (an old -// zero binary's legacy blob alongside the new keyring-based index), a -// transient error reading the legacy blob must not be treated as "the legacy -// blob is empty." write() must refuse to dual-write over it in that case, or -// an unread credential belonging to an older, still-installed zero binary is -// destroyed irrecoverably. +// regression test for mixed-version reconciliation: a transient error reading +// the legacy blob must not be treated as "the legacy blob is empty." write() +// must abort rather than proceed without those credentials (and must never +// overwrite the legacy account). func TestStoreKeyringWriteRefusesToOverwriteLegacyBlobOnTransientReadError(t *testing.T) { t.Setenv("XDG_CONFIG_HOME", t.TempDir()) kr := &legacyGetFailKR{fakeKR: newFakeKR()} @@ -1473,7 +1463,8 @@ func TestStoreKeyringWriteRefusesToOverwriteLegacyBlobOnTransientReadError(t *te if err != nil { t.Fatal(err) } - kr.data[keyringService+"/"+keyringLegacyAccount] = base64.StdEncoding.EncodeToString(data) + legacyEnc := base64.StdEncoding.EncodeToString(data) + kr.data[keyringService+"/"+keyringLegacyAccount] = legacyEnc // A transient failure reading it: Get returns a real error, not // ok=false/err=nil ("doesn't exist"). @@ -1481,11 +1472,10 @@ func TestStoreKeyringWriteRefusesToOverwriteLegacyBlobOnTransientReadError(t *te if err := s.Save(ProviderKey("beta"), Token{AccessToken: "b"}); err == nil { t.Fatal("Save succeeded despite a transient legacy-blob read failure; it must refuse rather than silently treat the blob as empty") } - // The legacy blob must still be present with the old credential, not - // dual-written over under a read failure. + // The legacy blob must still be present and byte-identical. if raw, ok := kr.data[keyringService+"/"+keyringLegacyAccount]; !ok { t.Fatal("legacy blob was removed despite a transient read failure (data loss)") - } else if !strings.Contains(string(mustDecode(t, raw)), "still-live") { + } else if raw != legacyEnc { t.Fatal("legacy blob was overwritten despite a transient read failure (data loss)") } // Nothing else the aborted write touched should be visible either. @@ -1497,7 +1487,7 @@ func TestStoreKeyringWriteRefusesToOverwriteLegacyBlobOnTransientReadError(t *te } // Once the transient failure clears, the legacy credential is recovered - // and dual-written back with the full reconciled map. + // into the indexed store; the legacy blob stays frozen. kr.fail = false if err := s.Save(ProviderKey("beta"), Token{AccessToken: "b"}); err != nil { t.Fatalf("retried Save: %v", err) @@ -1505,8 +1495,8 @@ func TestStoreKeyringWriteRefusesToOverwriteLegacyBlobOnTransientReadError(t *te if _, ok, err := s.Load(ProviderKey("stale-binary-login")); err != nil || !ok { t.Fatalf("Load(stale-binary-login): ok=%v err=%v (legacy credential lost)", ok, err) } - if _, ok := kr.data[keyringService+"/"+keyringLegacyAccount]; !ok { - t.Fatal("legacy blob should remain dual-written once it was actually read and reconciled") + if raw := kr.data[keyringService+"/"+keyringLegacyAccount]; raw != legacyEnc { + t.Fatal("legacy blob was rewritten after a successful reconcile (must stay frozen)") } } @@ -1891,11 +1881,12 @@ func TestStoreKeyringPrunesPhantomIndexKeysAfterInterruptedSet(t *testing.T) { } } -// TestStoreKeyringDualWritePreservesCrossRootLegacyLogin: the compatibility -// lock cannot span distinct config roots, so a new binary must never delete -// the legacy combined entry. An old-style writer on root A can land a login -// after root B reconciled; dual-write-without-delete keeps that login visible. -func TestStoreKeyringDualWritePreservesCrossRootLegacyLogin(t *testing.T) { +// TestStoreKeyringCrossRootLegacyLoginSurvivesWithoutDualWrite: the +// compatibility lock cannot span distinct config roots, so a new binary must +// never overwrite or delete the legacy combined entry. An old-style writer on +// root A can land a login after root B reconciled; leaving legacy frozen and +// merging on the next write keeps that login visible. +func TestStoreKeyringCrossRootLegacyLoginSurvivesWithoutDualWrite(t *testing.T) { rootA := filepath.Join(t.TempDir(), "cfg-a") rootB := filepath.Join(t.TempDir(), "cfg-b") kr := newFakeKR() @@ -1922,9 +1913,10 @@ func TestStoreKeyringDualWritePreservesCrossRootLegacyLogin(t *testing.T) { if err != nil { t.Fatal(err) } - kr.data[keyringService+"/"+keyringLegacyAccount] = base64.StdEncoding.EncodeToString(data) + legacyEnc := base64.StdEncoding.EncodeToString(data) + kr.data[keyringService+"/"+keyringLegacyAccount] = legacyEnc - // New binary on root B saves again: must merge carol, not delete it away. + // New binary on root B saves again: must merge carol, not clobber legacy. if err := storeB.Save(ProviderKey("beta"), Token{AccessToken: "b"}); err != nil { t.Fatal(err) } @@ -1933,8 +1925,8 @@ func TestStoreKeyringDualWritePreservesCrossRootLegacyLogin(t *testing.T) { t.Fatalf("Load(%s) after cross-root legacy login: ok=%v err=%v", name, ok, err) } } - if _, ok := kr.data[keyringService+"/"+keyringLegacyAccount]; !ok { - t.Fatal("legacy entry was deleted; cross-root old writers can no longer be observed") + if raw := kr.data[keyringService+"/"+keyringLegacyAccount]; raw != legacyEnc { + t.Fatal("legacy entry was rewritten; cross-root old writers can lose unobserved updates") } } @@ -1967,3 +1959,281 @@ func TestStoreKeyringReadIndexRejectsOversizedEncodedPayload(t *testing.T) { t.Fatal("expected oversized encoded chunk payload to be rejected") } } + +// TestStoreKeyringPreservesUnobservedLegacyWriteDuringReconcile is the +// regression for [P1] Preserve an old-writer update that lands during legacy +// reconciliation: new B snapshots legacy, old A (other config root) writes a +// new credential into the shared legacy account, then B finishes its Save. +// B must not overwrite A's only copy with a stale dual-write. +func TestStoreKeyringPreservesUnobservedLegacyWriteDuringReconcile(t *testing.T) { + rootA := filepath.Join(t.TempDir(), "cfg-a") + rootB := filepath.Join(t.TempDir(), "cfg-b") + kr := newFakeKR() + + storeB, err := NewStore(StoreOptions{Storage: "keyring", Keyring: kr, Env: map[string]string{"XDG_CONFIG_HOME": rootB}}) + if err != nil { + t.Fatal(err) + } + if err := storeB.Save(ProviderKey("seed"), Token{AccessToken: "s"}); err != nil { + t.Fatal(err) + } + + // Snapshot state as B would see it mid-reconcile, then A lands a login. + legacyBefore := storeFile{SchemaVersion: storeSchemaVersion, Tokens: map[string]Token{ + ProviderKey("seed"): {AccessToken: "s"}, + }} + before, err := json.Marshal(legacyBefore) + if err != nil { + t.Fatal(err) + } + kr.data[keyringService+"/"+keyringLegacyAccount] = base64.StdEncoding.EncodeToString(before) + + // Pause is simulated by injecting the post-snapshot old-writer update + // immediately before B's next Save (which would have dual-written a stale + // map under the previous protocol). + legacyAfter := storeFile{SchemaVersion: storeSchemaVersion, Tokens: map[string]Token{ + ProviderKey("seed"): {AccessToken: "s"}, + ProviderKey("from-a"): {AccessToken: "a-token", RefreshToken: "a-r"}, + }} + after, err := json.Marshal(legacyAfter) + if err != nil { + t.Fatal(err) + } + afterEnc := base64.StdEncoding.EncodeToString(after) + kr.data[keyringService+"/"+keyringLegacyAccount] = afterEnc + + if err := storeB.Save(ProviderKey("from-b"), Token{AccessToken: "b-token"}); err != nil { + t.Fatal(err) + } + // A's credential remains in the frozen legacy blob and is discoverable. + if raw := kr.data[keyringService+"/"+keyringLegacyAccount]; raw != afterEnc { + t.Fatal("B overwrote A's unobserved legacy write during reconcile") + } + got, ok, err := storeB.Load(ProviderKey("from-a")) + if err != nil || !ok || got.AccessToken != "a-token" || got.RefreshToken != "a-r" { + t.Fatalf("Load(from-a) = ok=%v err=%v got=%#v (A's credential lost)", ok, err, got) + } + // Distinct roots cannot share the legacy lock; the protocol must still be safe. + if legacyKeyringLockPath(map[string]string{"XDG_CONFIG_HOME": rootA}) == + legacyKeyringLockPath(map[string]string{"XDG_CONFIG_HOME": rootB}) { + t.Fatal("expected distinct legacy locks for distinct roots") + } +} + +// TestStoreKeyringLogoutDurableAgainstOldWriterStaleSnapshot is the regression +// for [P1] Keep a logout durable when an uncoordinated old binary rewrites its +// stale snapshot: old writer read alpha, new writer deletes it, old writer +// saves another key from its stale snapshot that still includes alpha. +func TestStoreKeyringLogoutDurableAgainstOldWriterStaleSnapshot(t *testing.T) { + t.Setenv("XDG_CONFIG_HOME", t.TempDir()) + kr := newFakeKR() + s, err := NewStore(StoreOptions{Storage: "keyring", Keyring: kr}) + if err != nil { + t.Fatal(err) + } + if err := s.Save(ProviderKey("alpha"), Token{AccessToken: "a"}); err != nil { + t.Fatal(err) + } + // Old writer snapshot includes alpha (taken before delete). + oldSnapshot := storeFile{SchemaVersion: storeSchemaVersion, Tokens: map[string]Token{ + ProviderKey("alpha"): {AccessToken: "a"}, + ProviderKey("other"): {AccessToken: "o"}, + }} + snap, err := json.Marshal(oldSnapshot) + if err != nil { + t.Fatal(err) + } + + if _, err := s.Delete(ProviderKey("alpha")); err != nil { + t.Fatal(err) + } + // Old writer finishes its RMW of another key from the stale snapshot. + kr.data[keyringService+"/"+keyringLegacyAccount] = base64.StdEncoding.EncodeToString(snap) + + if _, ok, err := s.Load(ProviderKey("alpha")); err != nil || ok { + t.Fatalf("Load(alpha) after stale old-writer rewrite: ok=%v err=%v", ok, err) + } + if err := s.Save(ProviderKey("gamma"), Token{AccessToken: "g"}); err != nil { + t.Fatal(err) + } + if _, ok, err := s.Load(ProviderKey("alpha")); err != nil || ok { + t.Fatalf("Load(alpha) after later Save: ok=%v err=%v (logout not durable)", ok, err) + } + // The other key from the stale snapshot is still a legitimate discovery. + if _, ok, err := s.Load(ProviderKey("other")); err != nil || !ok { + t.Fatalf("Load(other): ok=%v err=%v", ok, err) + } +} + +// TestStoreKeyringNeverWritesPartialLegacySubset is the regression for [P1] +// Do not replace a valid oversized legacy blob with an arbitrary subset: a +// Linux-compatible multi-provider legacy payload that exceeds the macOS write +// budget must stay complete for old-format readers. +func TestStoreKeyringNeverWritesPartialLegacySubset(t *testing.T) { + t.Setenv("XDG_CONFIG_HOME", t.TempDir()) + kr := newFakeKR() + + // Build a legacy map whose base64 encoding exceeds maxKeyringSingleEntryBytes + // (pre-PR Linux keyring storage has no corresponding single-secret limit). + big := Token{ + AccessToken: "eyJhbGciOiJSUzI1NiJ9." + strings.Repeat("QUJDRA", 40) + ".sig", + RefreshToken: "rt_" + strings.Repeat("y", 60), + TokenType: "Bearer", + Scopes: []string{"openid", "profile", "email", "offline_access"}, + Account: "user@example.com", + IDToken: "eyJhbGciOiJSUzI1NiJ9." + strings.Repeat("QUJDRA", 45) + ".sig", + } + tokens := map[string]Token{} + for _, name := range []string{"anthropic", "openai", "minimax", "zai", "google", "cohere"} { + tokens[ProviderKey(name)] = big + } + legacyRaw, err := json.Marshal(storeFile{SchemaVersion: storeSchemaVersion, Tokens: tokens}) + if err != nil { + t.Fatal(err) + } + legacyEnc := base64.StdEncoding.EncodeToString(legacyRaw) + if len(legacyEnc) <= maxKeyringSingleEntryBytes { + t.Fatalf("test fixture legacy enc is %d bytes; need > %d to exercise oversize path", len(legacyEnc), maxKeyringSingleEntryBytes) + } + kr.data[keyringService+"/"+keyringLegacyAccount] = legacyEnc + + s, err := NewStore(StoreOptions{Storage: "keyring", Keyring: kr}) + if err != nil { + t.Fatal(err) + } + if err := s.Save(ProviderKey("extra"), Token{AccessToken: "e"}); err != nil { + t.Fatal(err) + } + // Old-format reader must still observe the complete original map, not a + // nondeterministic subset written under the macOS-safe budget. + if got := kr.data[keyringService+"/"+keyringLegacyAccount]; got != legacyEnc { + // Decode both for a clearer failure when rewritten to a subset. + var before, after storeFile + _ = json.Unmarshal(mustDecode(t, legacyEnc), &before) + _ = json.Unmarshal(mustDecode(t, got), &after) + t.Fatalf("oversized legacy blob was rewritten: before %d keys, after %d keys (must leave complete map untouched)", len(before.Tokens), len(after.Tokens)) + } + // Indexed store still has every legacy provider after migration. + for name := range tokens { + if _, ok, err := s.Load(name); err != nil || !ok { + t.Fatalf("Load(%s) after migrate: ok=%v err=%v", name, ok, err) + } + } +} + +// TestStoreKeyringLeaseRefreshesWhileWaitingOnSecondLock is the regression for +// [P1] Renew the index lease while waiting for the legacy lock: after the +// shared index lock is acquired, a blocked wait on the config-root legacy lock +// must keep refreshing the index lock so a competing root cannot reclaim it. +func TestStoreKeyringLeaseRefreshesWhileWaitingOnSecondLock(t *testing.T) { + dir := t.TempDir() + indexLock := filepath.Join(dir, "index.lock") + legacyLock := filepath.Join(dir, "legacy.lock") + + prevRefresh := fileLockRefreshInterval + prevStale := fileLockStaleAfter + fileLockRefreshInterval = 15 * time.Millisecond + fileLockStaleAfter = 80 * time.Millisecond + defer func() { + fileLockRefreshInterval = prevRefresh + fileLockStaleAfter = prevStale + }() + + // Hold the second lock as a healthy long-lived peer (refresh its mtime) + // so withLeasedLocks blocks on it after taking the first, without the + // waiter reclaiming a stale second lock. + holdLegacy, err := acquireFileLock(legacyLock, time.Now) + if err != nil { + t.Fatalf("hold legacy lock: %v", err) + } + stopHold := make(chan struct{}) + holdDone := make(chan struct{}) + go func() { + defer close(holdDone) + ticker := time.NewTicker(fileLockRefreshInterval) + defer ticker.Stop() + for { + select { + case <-stopHold: + return + case <-ticker.C: + at := time.Now() + _ = os.Chtimes(legacyLock, at, at) + } + } + }() + + done := make(chan error, 1) + go func() { + done <- withLeasedLocks([]string{indexLock, legacyLock}, time.Now, func() error { + return nil + }) + }() + + // Wait until the first lock is actually held before measuring lease age. + deadline := time.Now().Add(2 * time.Second) + for { + if _, err := os.Stat(indexLock); err == nil { + break + } + if time.Now().After(deadline) { + close(stopHold) + <-holdDone + holdLegacy() + t.Fatal("index lock was never acquired while waiting on the legacy lock") + } + time.Sleep(5 * time.Millisecond) + } + + // Wait longer than the stale threshold while the first lock is held and + // the second is still blocked. The lease must keep the index lock fresh. + time.Sleep(fileLockStaleAfter + 3*fileLockRefreshInterval) + + info, err := os.Stat(indexLock) + if err != nil { + close(stopHold) + <-holdDone + holdLegacy() + t.Fatalf("index lock missing while waiter blocked on legacy: %v", err) + } + if age := time.Since(info.ModTime()); age > fileLockStaleAfter { + close(stopHold) + <-holdDone + holdLegacy() + t.Fatalf("index lock mtime is %v old while waiting on second lock; lease was not renewed", age) + } + + // A competing reclaim must treat the still-leased index lock as live. + // Capture staleAfter once: the waiter goroutine is still reading the same + // package vars, so do not mutate them for the rest of this test. + staleAfter := fileLockStaleAfter + cleared, rerr := lockutil.ReclaimStaleLock(indexLock, "competitor-probe", func(reclaimedPath string) bool { + info, err := os.Stat(reclaimedPath) + return err == nil && time.Since(info.ModTime()) <= staleAfter + }) + if rerr != nil { + close(stopHold) + <-holdDone + holdLegacy() + t.Fatalf("reclaim probe: %v", rerr) + } + if cleared { + close(stopHold) + <-holdDone + holdLegacy() + t.Fatal("competitor reclaimed the index lock while the multi-lock waiter still owned it") + } + if _, err := os.Stat(indexLock); err != nil { + close(stopHold) + <-holdDone + holdLegacy() + t.Fatalf("index lock missing after failed reclaim: %v", err) + } + + close(stopHold) + <-holdDone + holdLegacy() + if err := <-done; err != nil { + t.Fatalf("withLeasedLocks after legacy release: %v", err) + } +} From ad2032fb228835bfd82addc1cc045418758bc8e5 Mon Sep 17 00:00:00 2001 From: euxaristia <25621994+euxaristia@users.noreply.github.com> Date: Sat, 1 Aug 2026 23:54:21 -0400 Subject: [PATCH 26/28] Harden OAuth migration state transitions against interrupted writes. Keep tombstones authoritative until replacement credentials commit, allow bounded deletion history beyond the live-token cap, stabilize fallback lock identity, and wait behind healthy renewed leases. Refs #668 --- internal/oauth/lock.go | 9 +- internal/oauth/store.go | 137 ++++++++++++++++++--------- internal/oauth/store_keyring_test.go | 126 +++++++++++++++++------- 3 files changed, 189 insertions(+), 83 deletions(-) diff --git a/internal/oauth/lock.go b/internal/oauth/lock.go index 8481d94ea..af060ad15 100644 --- a/internal/oauth/lock.go +++ b/internal/oauth/lock.go @@ -21,10 +21,6 @@ var ( // While the holder's mtime stays within fileLockStaleAfter, this idle // deadline is extended so a fixed 5s window cannot fail a healthy peer. fileLockTimeout = 5 * time.Second - // fileLockMaxWait is a hard ceiling on total acquisition time so a wedged - // peer that somehow keeps refreshing forever cannot pin waiters indefinitely. - // Healthy multi-entry writes stay well under this; raise only with evidence. - fileLockMaxWait = 2 * time.Minute // fileLockStaleAfter is how old a lock file's mtime must be before a waiter // may reclaim it as abandoned. Must stay above one keyring command timeout // plus lease refresh slack (holders refresh every fileLockRefreshInterval). @@ -52,8 +48,7 @@ func acquireFileLock(lockPath string, now func() time.Time) (func(), error) { return nil, err } token := fmt.Sprintf("%d-%d-%d", os.Getpid(), now().UnixNano(), lockSeq.Add(1)) - start := time.Now() - idleDeadline := start.Add(fileLockTimeout) + idleDeadline := time.Now().Add(fileLockTimeout) for { f, err := os.OpenFile(lockPath, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0o600) if err == nil { @@ -116,7 +111,7 @@ func acquireFileLock(lockPath string, now func() time.Time) (func(), error) { idleDeadline = time.Now().Add(fileLockTimeout) } } - if time.Now().After(idleDeadline) || time.Since(start) > fileLockMaxWait { + if time.Now().After(idleDeadline) { return nil, fmt.Errorf("oauth: timed out acquiring token lock %s", filepath.Base(lockPath)) } time.Sleep(10 * time.Millisecond) diff --git a/internal/oauth/store.go b/internal/oauth/store.go index ac07f1944..20ca715cd 100644 --- a/internal/oauth/store.go +++ b/internal/oauth/store.go @@ -33,6 +33,8 @@ const ( // so a key can never traverse or collide with store internals. var keyPattern = regexp.MustCompile(`^(provider|mcp):[A-Za-z0-9][A-Za-z0-9._-]{0,127}$`) +var currentOSUser = user.Current + // ValidateKey reports whether key is a well-formed namespaced token key. func ValidateKey(key string) error { if !keyPattern.MatchString(key) { @@ -287,13 +289,20 @@ func resolveStoreFilePath(options StoreOptions) (string, error) { // OS user would take different lock files while writing to the same OS keychain. func keyringLockPath(env map[string]string, service, account string) string { name := keyringLockFileName(service, account) - if u, err := user.Current(); err == nil && strings.TrimSpace(u.HomeDir) != "" { + if u, err := currentOSUser(); err == nil && strings.TrimSpace(u.HomeDir) != "" { return filepath.Join(u.HomeDir, ".cache", "zero", name) } - if home, err := os.UserHomeDir(); err == nil && strings.TrimSpace(home) != "" { - return filepath.Join(home, ".cache", "zero", name) + // Do not fall back to os.UserHomeDir: it reads ambient HOME/USERPROFILE, so + // two same-user processes can choose different locks for one keyring. The + // temporary name is UID-scoped where UIDs exist. + return filepath.Join(keyringFallbackLockDir(), keyringTempLockName(service, account)) +} + +func keyringFallbackLockDir() string { + if runtime.GOOS == "windows" { + return os.TempDir() } - return filepath.Join(os.TempDir(), keyringTempLockName(service, account)) + return "/tmp" } // legacyKeyringLockPath returns the lock file a pre-PR binary acquires around @@ -361,7 +370,7 @@ func (s *Store) Save(key string, token Token) error { return err } state.Tokens[key] = token - return s.writeState(state, nil) + return s.writeState(state, map[string]bool{key: false}) }) } @@ -493,11 +502,10 @@ func (s *Store) readState() (storeFile, error) { return state, nil } -// writeState persists state. omitFromLegacy lists keys that must not be -// re-merged from the keyring legacy blob during reconciliation (Delete intent); -// the keyring backend also records them as durable tombstones. File and -// encrypted-file backends ignore omitFromLegacy. -func (s *Store) writeState(state storeFile, omitFromLegacy map[string]bool) error { +// writeState persists state. mutations identifies explicitly saved (false) and +// deleted (true) keys. The keyring backend uses it to order durable tombstone +// transitions; file and encrypted-file backends ignore it. +func (s *Store) writeState(state storeFile, mutations map[string]bool) error { data, err := json.MarshalIndent(state, "", " ") if err != nil { return err @@ -511,7 +519,7 @@ func (s *Store) writeState(state storeFile, omitFromLegacy map[string]bool) erro return err } } - return s.blob.write(payload, omitFromLegacy) + return s.blob.write(payload, mutations) } func emptyStoreFile() storeFile { @@ -523,11 +531,10 @@ func emptyStoreFile() storeFile { type blobStore interface { // read returns the stored blob; ok is false when nothing is stored yet. read() (data []byte, ok bool, err error) - // write replaces the stored blob. omitFromLegacy is keyring-only: keys the - // caller deliberately removed that must not be re-merged from the legacy - // combined entry and that should be recorded as durable tombstones. File - // backends ignore it. - write(data []byte, omitFromLegacy map[string]bool) error + // write replaces the stored blob. mutations is keyring-only and identifies + // explicit saves (false) and deletes (true) for durable tombstone ordering. + // File backends ignore it. + write(data []byte, mutations map[string]bool) error // withLock runs fn under whatever cross-process exclusion the backend offers // (a lock file for the file backend; none for the keyring, which is the // authoritative store and is serialized within the process by Store.mu). @@ -617,6 +624,9 @@ type keyringBlob struct { // safety there comes from never overwriting the legacy blob and from // durable tombstones, not from dual-write. legacyLockPath string + // maxIndexKeys overrides the live credential cap for bounded metadata + // indexes, such as tombstones, that do not fan out into per-key reads. + maxIndexKeys int } func (b keyringBlob) read() ([]byte, bool, error) { @@ -624,17 +634,32 @@ func (b keyringBlob) read() ([]byte, bool, error) { if err != nil { return nil, false, err } + // Tombstones are authoritative even before the first index commits. A + // delete can persist its marker and then be interrupted while publishing the + // initial index; returning the untouched legacy blob here would resurrect it. + tombstones, err := b.readTombstones() + if err != nil { + return nil, false, err + } if !ok { - return b.readLegacy() + data, legacyOK, err := b.readLegacy() + if err != nil || !legacyOK || len(tombstones) == 0 { + return data, legacyOK, err + } + var state storeFile + if err := json.Unmarshal(data, &state); err != nil { + return nil, false, fmt.Errorf("oauth: invalid legacy keyring token blob: %w", err) + } + for key := range tombstones { + delete(state.Tokens, key) + } + filtered, err := json.Marshal(state) + return filtered, true, err } // Tombstones block resurrection of deliberately deleted keys from the // legacy combined entry (an old binary may rewrite a stale snapshot into // that account after logout). Fail closed on a corrupt tombstone set so a // damaged marker cannot silently re-expose logged-out credentials. - tombstones, err := b.readTombstones() - if err != nil { - return nil, false, err - } // The legacy combined entry is consulted when an indexed key's own entry is // missing (torn write / migration) and for keys only present there (an old // binary logged into a provider this process has never indexed). Indexed @@ -771,7 +796,7 @@ func (b keyringBlob) readLegacyTokens() (map[string]Token, error) { // durable tombstones and must not be re-merged from the legacy blob even // when they were never indexed (a legacy-only old-binary login that the // user logged out of). -func (b keyringBlob) write(data []byte, omitFromLegacy map[string]bool) error { +func (b keyringBlob) write(data []byte, mutations map[string]bool) error { var state storeFile if err := json.Unmarshal(data, &state); err != nil { return fmt.Errorf("oauth: encode keyring token blob: %w", err) @@ -791,12 +816,10 @@ func (b keyringBlob) write(data []byte, omitFromLegacy map[string]bool) error { } // Record durable deletion markers before mutating entries so a crash // mid-write cannot leave a logged-out key importable from legacy alone. - for key := range omitFromLegacy { - tombstones[key] = true - } - // A re-login after logout clears the tombstone for that key. - for key := range state.Tokens { - delete(tombstones, key) + for key, deleted := range mutations { + if deleted { + tombstones[key] = true + } } // An older binary running alongside this one still reads and writes only the @@ -821,7 +844,7 @@ func (b keyringBlob) write(data []byte, omitFromLegacy map[string]bool) error { if ValidateKey(key) != nil { continue } - if omitFromLegacy[key] || tombstones[key] { + if mutations[key] || tombstones[key] { continue } if _, exists := state.Tokens[key]; exists { @@ -918,6 +941,21 @@ func (b keyringBlob) write(data []byte, omitFromLegacy map[string]bool) error { if _, err := b.writeKeyIndex(keys, unionChunks); err != nil { return err } + // A re-login clears its tombstone only after the replacement entry and exact + // index are durable. If any earlier step fails, legacy fallback remains + // suppressed instead of restoring the revoked credential. + tombstonesChanged := false + for key, deleted := range mutations { + if !deleted && tombstones[key] { + delete(tombstones, key) + tombstonesChanged = true + } + } + if tombstonesChanged { + if err := b.writeTombstones(tombstones); err != nil { + return err + } + } return nil } @@ -926,7 +964,7 @@ func (b keyringBlob) write(data []byte, omitFromLegacy map[string]bool) error { // the live index (max-length keys after many logouts), so a single entry is // not enough under the macOS line bound. func (b keyringBlob) tombstoneBlob() keyringBlob { - return keyringBlob{kr: b.kr, service: b.service, indexAccount: keyringTombstoneAccount} + return keyringBlob{kr: b.kr, service: b.service, indexAccount: keyringTombstoneAccount, maxIndexKeys: maxKeyringTombstoneKeys} } // readTombstones returns the durable set of keys deleted by a new binary. @@ -970,8 +1008,8 @@ func (b keyringBlob) writeTombstones(tombstones map[string]bool) error { } return nil } - if len(tombstones) > maxKeyringIndexKeys { - return errKeyringIndexTooManyKeys(len(tombstones)) + if len(tombstones) > maxKeyringTombstoneKeys { + return errKeyringIndexTooManyKeys(len(tombstones), maxKeyringTombstoneKeys) } keys := make([]string, 0, len(tombstones)) for key := range tombstones { @@ -1029,15 +1067,19 @@ const maxKeyringIndexChunks = 128 // maxKeyringIndexChunks) while still rejecting a damaged index promptly. const maxKeyringIndexKeys = 512 +// Tombstones do not fan out into per-key keyring reads, so they can use the +// codec's bounded raw capacity without imposing the live credential cap. +const maxKeyringTombstoneKeys = maxRawKeyringIndexKeys + // maxRawKeyringIndexKeys bounds the raw decoded element count before // deduplication or map preallocation, guarding against DoS from duplicate keys. const maxRawKeyringIndexKeys = 16384 // errKeyringIndexTooManyKeys is returned when a decoded index (or one of its // chunks) claims more keys than maxKeyringIndexKeys. -func errKeyringIndexTooManyKeys(count int) error { - log.Printf("warning: oauth: keyring token index lists %d keys, over the %d-key cap", count, maxKeyringIndexKeys) - return fmt.Errorf("oauth: keyring token index lists %d keys, over the %d-key cap", count, maxKeyringIndexKeys) +func errKeyringIndexTooManyKeys(count, limit int) error { + log.Printf("warning: oauth: keyring token index lists %d keys, over the %d-key cap", count, limit) + return fmt.Errorf("oauth: keyring token index lists %d keys, over the %d-key cap", count, limit) } // keyIndexHeader is chunk 0 of the key index. Chunks 1..Chunks-1 live under @@ -1049,6 +1091,13 @@ type keyIndexHeader struct { Keys []string `json:"keys"` } +func (b keyringBlob) indexKeyLimit() int { + if b.maxIndexKeys > 0 { + return b.maxIndexKeys + } + return maxKeyringIndexKeys +} + func (b keyringBlob) chunkAccount(index int) string { return fmt.Sprintf("%s-%d", b.indexAccount, index) } @@ -1096,11 +1145,11 @@ func (b keyringBlob) readKeyIndex() ([]string, bool, int, error) { return nil, false, 0, fmt.Errorf("oauth: decode keyring token index: %w", err) } if len(rawKeys) > maxRawKeyringIndexKeys { - return nil, false, 0, errKeyringIndexTooManyKeys(len(rawKeys)) + return nil, false, 0, errKeyringIndexTooManyKeys(len(rawKeys), maxRawKeyringIndexKeys) } keys := dedupeValidKeys(rawKeys) - if len(keys) > maxKeyringIndexKeys { - return nil, false, 0, errKeyringIndexTooManyKeys(len(keys)) + if len(keys) > b.indexKeyLimit() { + return nil, false, 0, errKeyringIndexTooManyKeys(len(keys), b.indexKeyLimit()) } return keys, true, 1, nil } @@ -1120,7 +1169,7 @@ func (b keyringBlob) readKeyIndex() ([]string, bool, int, error) { } rawKeys := header.Keys if len(rawKeys) > maxRawKeyringIndexKeys { - return nil, false, 0, errKeyringIndexTooManyKeys(len(rawKeys)) + return nil, false, 0, errKeyringIndexTooManyKeys(len(rawKeys), maxRawKeyringIndexKeys) } for i := 1; i < header.Chunks; i++ { chunkEnc, ok, err := b.kr.Get(b.service, b.chunkAccount(i)) @@ -1139,13 +1188,13 @@ func (b keyringBlob) readKeyIndex() ([]string, bool, int, error) { return nil, false, 0, fmt.Errorf("oauth: decode keyring token index chunk %d: %w", i, err) } if len(rawKeys)+len(more) > maxRawKeyringIndexKeys { - return nil, false, 0, errKeyringIndexTooManyKeys(len(rawKeys) + len(more)) + return nil, false, 0, errKeyringIndexTooManyKeys(len(rawKeys)+len(more), maxRawKeyringIndexKeys) } rawKeys = append(rawKeys, more...) } keys := dedupeValidKeys(rawKeys) - if len(keys) > maxKeyringIndexKeys { - return nil, false, 0, errKeyringIndexTooManyKeys(len(keys)) + if len(keys) > b.indexKeyLimit() { + return nil, false, 0, errKeyringIndexTooManyKeys(len(keys), b.indexKeyLimit()) } return keys, true, header.Chunks, nil } @@ -1189,8 +1238,8 @@ func (b keyringBlob) writeKeyIndex(keys []string, priorChunks int) (int, error) // later Load/Status/Save/Delete fail before it could recover. Check the key // count before chunking so a large set of short keys that still fit under // maxKeyringIndexChunks cannot strand the store unreadable. - if len(keys) > maxKeyringIndexKeys { - return 0, errKeyringIndexTooManyKeys(len(keys)) + if len(keys) > b.indexKeyLimit() { + return 0, errKeyringIndexTooManyKeys(len(keys), b.indexKeyLimit()) } chunks := chunkIndexKeys(keys) if len(chunks) > maxKeyringIndexChunks { diff --git a/internal/oauth/store_keyring_test.go b/internal/oauth/store_keyring_test.go index ffb817f95..8691d6e83 100644 --- a/internal/oauth/store_keyring_test.go +++ b/internal/oauth/store_keyring_test.go @@ -807,35 +807,6 @@ func TestStoreKeyringExplicitSaveWinsOverStaleLookingLegacy(t *testing.T) { } } -// TestAcquireFileLockDeadlineUsesWallClockNotInjectedClock guards the other -// half of the same hazard as the lease test below: acquireFileLock's own -// acquisition deadline must be measured against the real wall clock, not the -// injectable now parameter. StoreOptions.Now may legitimately be a fixed -// clock (as this test uses), and deadline := now().Add(fileLockTimeout) -// followed by now().After(deadline) would then never become true, so a -// contested lock would retry forever instead of returning a timeout error. -func TestAcquireFileLockDeadlineUsesWallClockNotInjectedClock(t *testing.T) { - lockPath := filepath.Join(t.TempDir(), "test.lockfile") - // A fresh (non-stale) lock held by someone else. With wait-while-healthy, - // that extends the idle deadline, so cap the absolute wait for the test. - if err := os.WriteFile(lockPath, []byte("someone-else"), 0o600); err != nil { - t.Fatal(err) - } - prevMax := fileLockMaxWait - fileLockMaxWait = 200 * time.Millisecond - defer func() { fileLockMaxWait = prevMax }() - - fixed := time.Date(2000, 1, 1, 0, 0, 0, 0, time.UTC) - start := time.Now() - _, err := acquireFileLock(lockPath, func() time.Time { return fixed }) - if err == nil { - t.Fatal("expected a timeout error acquiring an already-held, non-stale lock") - } - if elapsed := time.Since(start); elapsed > 2*time.Second { - t.Fatalf("acquireFileLock took %v with a fixed clock; the deadline must use the wall clock, not now()", elapsed) - } -} - // TestAcquireFileLockWaitsWhileLeaseHealthy covers [P2] Let lock acquisition // cover a healthy keyring operation: a holder that keeps the lease fresh for // longer than the idle timeout must not cause contenders to fail; they wait @@ -843,12 +814,9 @@ func TestAcquireFileLockDeadlineUsesWallClockNotInjectedClock(t *testing.T) { func TestAcquireFileLockWaitsWhileLeaseHealthy(t *testing.T) { lockPath := filepath.Join(t.TempDir(), "test.lockfile") prevTimeout := fileLockTimeout - prevMax := fileLockMaxWait fileLockTimeout = 80 * time.Millisecond - fileLockMaxWait = 3 * time.Second defer func() { fileLockTimeout = prevTimeout - fileLockMaxWait = prevMax }() unlock, err := acquireFileLock(lockPath, time.Now) @@ -2237,3 +2205,97 @@ func TestStoreKeyringLeaseRefreshesWhileWaitingOnSecondLock(t *testing.T) { t.Fatalf("withLeasedLocks after legacy release: %v", err) } } + +func TestStoreKeyringLegacyOnlyReadHonorsInterruptedDeleteTombstone(t *testing.T) { + kr := &failingKR{fakeKR: newFakeKR()} + legacy, err := json.Marshal(storeFile{SchemaVersion: storeSchemaVersion, Tokens: map[string]Token{ + ProviderKey("alpha"): {AccessToken: "revoked"}, + }}) + if err != nil { + t.Fatal(err) + } + kr.data[keyringService+"/"+keyringLegacyAccount] = base64.StdEncoding.EncodeToString(legacy) + s, err := NewStore(StoreOptions{Storage: "keyring", Keyring: kr}) + if err != nil { + t.Fatal(err) + } + + kr.failAt = 2 + if removed, err := s.Delete(ProviderKey("alpha")); err == nil || !removed { + t.Fatalf("Delete = removed %v, err %v; want removed with injected failure", removed, err) + } + kr.failAt = 0 + if _, ok, err := s.Load(ProviderKey("alpha")); err != nil || ok { + t.Fatalf("Load after interrupted delete = ok %v, err %v; tombstone must hide legacy token", ok, err) + } +} + +func TestStoreKeyringReloginKeepsTombstoneUntilReplacementCommits(t *testing.T) { + kr := &failingKR{fakeKR: newFakeKR()} + legacy, err := json.Marshal(storeFile{SchemaVersion: storeSchemaVersion, Tokens: map[string]Token{ + ProviderKey("alpha"): {AccessToken: "revoked"}, + }}) + if err != nil { + t.Fatal(err) + } + kr.data[keyringService+"/"+keyringLegacyAccount] = base64.StdEncoding.EncodeToString(legacy) + b := keyringBlob{kr: kr, service: keyringService, legacyAccount: keyringLegacyAccount, indexAccount: keyringIndexAccount} + if err := b.writeTombstones(map[string]bool{ProviderKey("alpha"): true}); err != nil { + t.Fatal(err) + } + s, err := NewStore(StoreOptions{Storage: "keyring", Keyring: kr}) + if err != nil { + t.Fatal(err) + } + + kr.ops = 0 + kr.failAt = 2 + if err := s.Save(ProviderKey("alpha"), Token{AccessToken: "replacement"}); err == nil { + t.Fatal("Save succeeded despite injected index failure") + } + kr.failAt = 0 + if _, ok, err := s.Load(ProviderKey("alpha")); err != nil || ok { + t.Fatalf("Load after interrupted re-login = ok %v, err %v; revoked legacy token was restored", ok, err) + } + if err := s.Save(ProviderKey("alpha"), Token{AccessToken: "replacement"}); err != nil { + t.Fatal(err) + } + if got, ok, err := s.Load(ProviderKey("alpha")); err != nil || !ok || got.AccessToken != "replacement" { + t.Fatalf("Load committed replacement = %#v, ok %v, err %v", got, ok, err) + } +} + +func TestStoreKeyringTombstonesOutgrowLiveCredentialCap(t *testing.T) { + kr := newFakeKR() + b := keyringBlob{kr: kr, service: keyringService, indexAccount: keyringIndexAccount} + tombstones := make(map[string]bool, maxKeyringIndexKeys+1) + for i := 0; i <= maxKeyringIndexKeys; i++ { + tombstones[ProviderKey(fmt.Sprintf("retired-%03d", i))] = true + } + if err := b.writeTombstones(tombstones); err != nil { + t.Fatalf("write %d tombstones: %v", len(tombstones), err) + } + got, err := b.readTombstones() + if err != nil { + t.Fatal(err) + } + if len(got) != len(tombstones) { + t.Fatalf("read %d tombstones, want %d", len(got), len(tombstones)) + } +} + +func TestKeyringLockPathUserLookupFallbackIgnoresAmbientHome(t *testing.T) { + previous := currentOSUser + currentOSUser = func() (*user.User, error) { return nil, fmt.Errorf("lookup unavailable") } + defer func() { currentOSUser = previous }() + + gotA := keyringLockPath(map[string]string{"HOME": t.TempDir()}, keyringService, keyringIndexAccount) + gotB := keyringLockPath(map[string]string{"HOME": t.TempDir()}, keyringService, keyringIndexAccount) + if gotA != gotB { + t.Fatalf("same-user fallback changed with HOME: %q vs %q", gotA, gotB) + } + want := filepath.Join(keyringFallbackLockDir(), keyringTempLockName(keyringService, keyringIndexAccount)) + if gotA != want { + t.Fatalf("fallback lock = %q, want UID-scoped temporary %q", gotA, want) + } +} From 797c6ff73dc91c4a67f388fe5c773a675e0988f8 Mon Sep 17 00:00:00 2001 From: euxaristia Date: Fri, 7 Aug 2026 14:54:29 -0400 Subject: [PATCH 27/28] fix(oauth): close lease panics, ownership refresh, and review findings. Defer lock release so a recovered panic cannot leave a forever-refreshed lease that wedges every waiter. Make lease Chtimes ownership-aware and fail closed when the lock is replaced mid-critical-section. Prefer the token's scopes on refresh, reject future mtimes as healthy leases, use a private validated fallback lock directory, skip index shrink when a referenced chunk is missing, and isolate keyring tests from the real home. Refs #668 --- internal/oauth/flow.go | 16 +- internal/oauth/flow_test.go | 29 ++- internal/oauth/lock.go | 57 ++++-- internal/oauth/lock_owner_unix.go | 24 +++ internal/oauth/lock_owner_windows.go | 11 ++ internal/oauth/store.go | 179 ++++++++++++----- internal/oauth/store_keyring_test.go | 280 +++++++++++++++++++++++++-- 7 files changed, 505 insertions(+), 91 deletions(-) create mode 100644 internal/oauth/lock_owner_unix.go create mode 100644 internal/oauth/lock_owner_windows.go diff --git a/internal/oauth/flow.go b/internal/oauth/flow.go index 42da93986..6bcb17d0e 100644 --- a/internal/oauth/flow.go +++ b/internal/oauth/flow.go @@ -167,6 +167,14 @@ func Refresh(ctx context.Context, client *http.Client, cfg Config, current Token if trimmed(cfg.TokenEndpoint) == "" { return Token{}, errors.New("oauth: no token endpoint configured for refresh") } + // Prefer the scopes the current token was issued with; fall back to the + // configured defaults only when the stored token has none. The same set is + // sent on the wire and kept as the base so a response that omits scope + // cannot report a different grant than the provider just processed. + scopes := current.Scopes + if len(scopes) == 0 { + scopes = cfg.Scopes + } form := url.Values{} form.Set("grant_type", "refresh_token") form.Set("refresh_token", refresh) @@ -174,12 +182,8 @@ func Refresh(ctx context.Context, client *http.Client, cfg Config, current Token if secret := trimmed(cfg.ClientSecret); secret != "" { form.Set("client_secret", secret) } - if len(cfg.Scopes) > 0 { - form.Set("scope", strings.Join(cfg.Scopes, " ")) - } - scopes := current.Scopes - if len(scopes) == 0 { - scopes = cfg.Scopes + if len(scopes) > 0 { + form.Set("scope", strings.Join(scopes, " ")) } base := Token{Scopes: scopes, RefreshToken: refresh, Account: current.Account, IDToken: current.IDToken, TokenType: current.TokenType} return PostToken(ctx, client, cfg.TokenEndpoint, form, base, now) diff --git a/internal/oauth/flow_test.go b/internal/oauth/flow_test.go index 4b1da98d5..b605fd5eb 100644 --- a/internal/oauth/flow_test.go +++ b/internal/oauth/flow_test.go @@ -301,7 +301,10 @@ func TestRefreshPreservesTokenTypeWhenOmitted(t *testing.T) { } func TestRefreshPreservesScopesWhenOmitted(t *testing.T) { - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + var gotScope string + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + _ = r.ParseForm() + gotScope = r.FormValue("scope") _, _ = w.Write([]byte(`{"access_token":"new-at","expires_in":3600}`)) // no scope in response })) defer server.Close() @@ -310,7 +313,31 @@ func TestRefreshPreservesScopesWhenOmitted(t *testing.T) { if err != nil { t.Fatalf("Refresh: %v", err) } + if gotScope != "custom-scope" { + t.Fatalf("refresh form scope = %q, want current token scopes", gotScope) + } if len(tok.Scopes) != 1 || tok.Scopes[0] != "custom-scope" { t.Fatalf("refresh should carry existing scopes forward, got %v", tok.Scopes) } } + +func TestRefreshUsesConfigScopesWhenTokenHasNone(t *testing.T) { + var gotScope string + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + _ = r.ParseForm() + gotScope = r.FormValue("scope") + _, _ = w.Write([]byte(`{"access_token":"new-at","expires_in":3600}`)) + })) + defer server.Close() + cfg := Config{ClientID: "c", TokenEndpoint: server.URL, Scopes: []string{"fallback-scope"}} + tok, err := Refresh(context.Background(), server.Client(), cfg, Token{RefreshToken: "keep-me"}, nil) + if err != nil { + t.Fatalf("Refresh: %v", err) + } + if gotScope != "fallback-scope" { + t.Fatalf("refresh form scope = %q, want cfg.Scopes fallback", gotScope) + } + if len(tok.Scopes) != 1 || tok.Scopes[0] != "fallback-scope" { + t.Fatalf("refresh should use cfg scopes when token has none, got %v", tok.Scopes) + } +} diff --git a/internal/oauth/lock.go b/internal/oauth/lock.go index af060ad15..338366c1e 100644 --- a/internal/oauth/lock.go +++ b/internal/oauth/lock.go @@ -35,19 +35,22 @@ var lockSeq atomic.Uint64 // older than that threshold so a crashed holder cannot deadlock the store. // Release is ownership-aware: it removes the lock only if it still holds our // token, so a stale-broken holder cannot delete a newer holder's lock. +// The returned token is the contents written into the lock file; lease refresh +// must re-check it before touching mtime so a replaced holder cannot keep a +// thief's lock alive. // // Timing always uses the real wall clock, never the now parameter: now is // StoreOptions.Now, which callers may legitimately fix (e.g. a test or an // embedded clock). Measuring the deadline with that clock would either never // fire (fixed clock) or diverge from the mtime lease stamps (wall-clock). -func acquireFileLock(lockPath string, now func() time.Time) (func(), error) { +func acquireFileLock(lockPath string, now func() time.Time) (unlock func(), token string, err error) { if now == nil { now = time.Now } if err := os.MkdirAll(filepath.Dir(lockPath), 0o700); err != nil { - return nil, err + return nil, "", err } - token := fmt.Sprintf("%d-%d-%d", os.Getpid(), now().UnixNano(), lockSeq.Add(1)) + token = fmt.Sprintf("%d-%d-%d", os.Getpid(), now().UnixNano(), lockSeq.Add(1)) idleDeadline := time.Now().Add(fileLockTimeout) for { f, err := os.OpenFile(lockPath, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0o600) @@ -58,11 +61,11 @@ func acquireFileLock(lockPath string, now func() time.Time) (func(), error) { if _, werr := f.WriteString(token); werr != nil { _ = f.Close() _ = lockutil.RemoveLockFile(lockPath) - return nil, fmt.Errorf("oauth: write token lock: %w", werr) + return nil, "", fmt.Errorf("oauth: write token lock: %w", werr) } if cerr := f.Close(); cerr != nil { _ = lockutil.RemoveLockFile(lockPath) - return nil, fmt.Errorf("oauth: close token lock: %w", cerr) + return nil, "", fmt.Errorf("oauth: close token lock: %w", cerr) } var released bool return func() { @@ -73,7 +76,7 @@ func acquireFileLock(lockPath string, now func() time.Time) (func(), error) { if data, rerr := os.ReadFile(lockPath); rerr == nil && string(data) == token { _ = lockutil.RemoveLockFile(lockPath) } - }, nil + }, token, nil } // On Windows a concurrent holder's os.Remove leaves the lock file in a // "delete pending" state, so an O_EXCL create races it with @@ -81,39 +84,61 @@ func acquireFileLock(lockPath string, now func() time.Time) (func(), error) { // as contention and retry, exactly like ErrExist — otherwise the lock // spuriously fails under concurrency on Windows. if !errors.Is(err, os.ErrExist) && !errors.Is(err, os.ErrPermission) { - return nil, fmt.Errorf("oauth: acquire token lock: %w", err) + return nil, "", fmt.Errorf("oauth: acquire token lock: %w", err) } // Reclaim a stale lock left by a crashed holder — atomically (H3). A blind // Remove lets two racers both reclaim + recreate and so both hold the lock; // reclaimStaleLock renames the file aside (only one rename wins) and restores // it if it turns out fresh, so a live lock is never deleted out from under it. if info, statErr := os.Stat(lockPath); statErr == nil { - if time.Since(info.ModTime()) > fileLockStaleAfter { + age := time.Since(info.ModTime()) + // Future mtimes (clock skew, hostile Chtimes) are not healthy leases: + // age is negative, so neither the reclaim branch nor the deadline + // extension below treats them as live. Contenders time out instead of + // waiting forever on a never-stale lock. + if age > fileLockStaleAfter { cleared, rerr := lockutil.ReclaimStaleLock(lockPath, token, func(reclaimedPath string) bool { info, err := os.Stat(reclaimedPath) - return err == nil && time.Since(info.ModTime()) <= fileLockStaleAfter + if err != nil { + return false + } + reclaimedAge := time.Since(info.ModTime()) + return reclaimedAge >= 0 && reclaimedAge <= fileLockStaleAfter }) if rerr != nil { // Reclaim hit a hard failure: the rename aside failed outright, or a // live holder's lock could not be put back (the lock path may be // missing, so re-acquiring would break mutual exclusion). Fail closed // instead of spinning to the deadline. - return nil, fmt.Errorf("oauth: reclaim stale token lock: %w", rerr) + return nil, "", fmt.Errorf("oauth: reclaim stale token lock: %w", rerr) } if cleared { continue } - // Lost the reclaim race (or it was actually fresh) — fall through. - } else { - // Holder looks healthy (lease refreshed recently). Keep waiting for - // the critical section to finish rather than timing out after a fixed - // window shorter than a legitimate multi-entry keyring pass. + // Lost the reclaim race, or isLive reported a still-fresh holder + // (callback true → ReclaimStaleLock restores and returns false). + // Refresh the idle deadline so reclaim work that overran the prior + // window does not immediately time out a healthy peer. + idleDeadline = time.Now().Add(fileLockTimeout) + } else if age >= 0 { + // Holder looks healthy (lease refreshed recently, mtime not in the + // future). Keep waiting for the critical section to finish rather + // than timing out after a fixed window shorter than a legitimate + // multi-entry keyring pass. idleDeadline = time.Now().Add(fileLockTimeout) } } if time.Now().After(idleDeadline) { - return nil, fmt.Errorf("oauth: timed out acquiring token lock %s", filepath.Base(lockPath)) + return nil, "", fmt.Errorf("oauth: timed out acquiring token lock %s", filepath.Base(lockPath)) } time.Sleep(10 * time.Millisecond) } } + +// ownLockFile reports whether path still holds token. Used by lease refresh so +// a holder that was reclaimed after a long pause cannot Chtimes a replacement +// lock and keep two critical sections alive. +func ownLockFile(path, token string) bool { + data, err := os.ReadFile(path) + return err == nil && string(data) == token +} diff --git a/internal/oauth/lock_owner_unix.go b/internal/oauth/lock_owner_unix.go new file mode 100644 index 000000000..7400378db --- /dev/null +++ b/internal/oauth/lock_owner_unix.go @@ -0,0 +1,24 @@ +//go:build !windows + +package oauth + +import ( + "fmt" + "os" + "syscall" +) + +// checkOAuthLockDirOwner rejects a fallback lock directory not owned by the +// current user: on a shared temp root another user could have pre-created the +// path and would then control its lifetime (deletion/renaming), permanently +// denying OAuth keyring operations. +func checkOAuthLockDirOwner(info os.FileInfo) error { + stat, ok := info.Sys().(*syscall.Stat_t) + if !ok { + return nil + } + if int(stat.Uid) != os.Geteuid() { + return fmt.Errorf("oauth lock fallback directory is owned by uid %d, not the current user", stat.Uid) + } + return nil +} diff --git a/internal/oauth/lock_owner_windows.go b/internal/oauth/lock_owner_windows.go new file mode 100644 index 000000000..d47a89ded --- /dev/null +++ b/internal/oauth/lock_owner_windows.go @@ -0,0 +1,11 @@ +//go:build windows + +package oauth + +import "os" + +// checkOAuthLockDirOwner is a no-op on Windows: the process temp directory is +// per-user by default, and keyringFallbackLockDir returns it directly. +func checkOAuthLockDirOwner(os.FileInfo) error { + return nil +} diff --git a/internal/oauth/store.go b/internal/oauth/store.go index 20ca715cd..e2372f59b 100644 --- a/internal/oauth/store.go +++ b/internal/oauth/store.go @@ -15,6 +15,7 @@ import ( "sort" "strings" "sync" + "sync/atomic" "time" "github.com/Gitlawb/zero/internal/keyring" @@ -253,7 +254,10 @@ func NewStore(options StoreOptions) (*Store, error) { // process's token write. legacyLockPath additionally coordinates with a // still-running pre-PR binary during the supported mixed-version window // (see legacyKeyringLockPath). - lockPath := keyringLockPath(options.Env, keyringService, keyringIndexAccount) + lockPath, err := keyringLockPath(options.Env, keyringService, keyringIndexAccount) + if err != nil { + return nil, err + } legacyLockPath := legacyKeyringLockPath(options.Env) return &Store{blob: keyringBlob{kr: kr, service: keyringService, legacyAccount: keyringLegacyAccount, indexAccount: keyringIndexAccount, lockPath: lockPath, legacyLockPath: legacyLockPath}, now: now}, nil default: @@ -287,22 +291,56 @@ func resolveStoreFilePath(options StoreOptions) (string, error) { // like HOME, XDG_CACHE_HOME, or TMPDIR: those pick different paths per process // (sandboxes, CI harnesses, launcher profiles), so two processes for the same // OS user would take different lock files while writing to the same OS keychain. -func keyringLockPath(env map[string]string, service, account string) string { +// When the OS user lookup fails, the fallback is a private UID-scoped directory +// under the process temp root (validated 0700, owned by us); a co-tenant DoS +// of the shared /tmp name is rejected rather than accepted as the lock path. +func keyringLockPath(env map[string]string, service, account string) (string, error) { name := keyringLockFileName(service, account) if u, err := currentOSUser(); err == nil && strings.TrimSpace(u.HomeDir) != "" { - return filepath.Join(u.HomeDir, ".cache", "zero", name) + return filepath.Join(u.HomeDir, ".cache", "zero", name), nil } // Do not fall back to os.UserHomeDir: it reads ambient HOME/USERPROFILE, so - // two same-user processes can choose different locks for one keyring. The - // temporary name is UID-scoped where UIDs exist. - return filepath.Join(keyringFallbackLockDir(), keyringTempLockName(service, account)) + // two same-user processes can choose different locks for one keyring. + dir, err := keyringFallbackLockDir() + if err != nil { + return "", fmt.Errorf("oauth: keyring lock fallback dir: %w", err) + } + return filepath.Join(dir, keyringTempLockName(service, account)), nil } -func keyringFallbackLockDir() string { +// keyringFallbackLockDir returns a private directory for last-resort keyring +// locks when the OS user home cannot be resolved. On Windows the process temp +// dir is already per-user. Elsewhere a UID-scoped 0700 directory under the +// process temp root is created and validated so a co-tenant cannot pre-create +// the lock file (or a world-writable parent) and permanently deny OAuth. +func keyringFallbackLockDir() (string, error) { if runtime.GOOS == "windows" { - return os.TempDir() + return os.TempDir(), nil + } + name := "zero-oauth-locks" + if uid := os.Getuid(); uid >= 0 { + name = fmt.Sprintf("zero-oauth-locks-%d", uid) + } + dir := filepath.Join(os.TempDir(), name) + if err := os.MkdirAll(dir, 0o700); err != nil { + return "", err + } + info, err := os.Lstat(dir) + if err != nil { + return "", err + } + if info.Mode()&os.ModeSymlink != 0 || !info.IsDir() { + return "", fmt.Errorf("oauth lock fallback %s is not a plain directory", dir) + } + if info.Mode().Perm() != 0o700 { + if err := os.Chmod(dir, 0o700); err != nil { + return "", fmt.Errorf("tighten oauth lock fallback permissions: %w", err) + } + } + if err := checkOAuthLockDirOwner(info); err != nil { + return "", err } - return "/tmp" + return dir, nil } // legacyKeyringLockPath returns the lock file a pre-PR binary acquires around @@ -581,7 +619,7 @@ func (b fileBlob) write(data []byte, _ map[string]bool) error { } func (b fileBlob) withLock(now func() time.Time, fn func() error) error { - unlock, err := acquireFileLock(b.path+".lockfile", now) + unlock, _, err := acquireFileLock(b.path+".lockfile", now) if err != nil { return err } @@ -630,7 +668,7 @@ type keyringBlob struct { } func (b keyringBlob) read() ([]byte, bool, error) { - keys, ok, _, err := b.readKeyIndex() + keys, ok, _, _, err := b.readKeyIndex() if err != nil { return nil, false, err } @@ -801,7 +839,7 @@ func (b keyringBlob) write(data []byte, mutations map[string]bool) error { if err := json.Unmarshal(data, &state); err != nil { return fmt.Errorf("oauth: encode keyring token blob: %w", err) } - priorKeys, indexExisted, priorChunks, err := b.readKeyIndex() + priorKeys, indexExisted, priorChunks, indexIncomplete, err := b.readKeyIndex() if err != nil { return err } @@ -938,8 +976,15 @@ func (b keyringBlob) write(data []byte, mutations map[string]bool) error { } } // 5. Shrink the index to the exact new key set. Legacy is left untouched. - if _, err := b.writeKeyIndex(keys, unionChunks); err != nil { - return err + // Skip shrink when a referenced index chunk was missing: livePrior was + // computed from a truncated key list, so shrinking would drop those keys + // from the published index while leaving their OS keychain entries + // stranded and unreachable by Load/Status/Delete. Leaving the union index + // in place keeps them listed until an intact read can reconcile them. + if !indexIncomplete { + if _, err := b.writeKeyIndex(keys, unionChunks); err != nil { + return err + } } // A re-login clears its tombstone only after the replacement entry and exact // index are durable. If any earlier step fails, legacy fallback remains @@ -970,7 +1015,7 @@ func (b keyringBlob) tombstoneBlob() keyringBlob { // readTombstones returns the durable set of keys deleted by a new binary. // Missing account => empty set. Corrupt payloads fail closed. func (b keyringBlob) readTombstones() (map[string]bool, error) { - keys, ok, _, err := b.tombstoneBlob().readKeyIndex() + keys, ok, _, _, err := b.tombstoneBlob().readKeyIndex() if err != nil { return nil, fmt.Errorf("oauth: read keyring token tombstones: %w", err) } @@ -990,7 +1035,7 @@ func (b keyringBlob) readTombstones() (map[string]bool, error) { // real keyring failures cannot be swallowed. func (b keyringBlob) writeTombstones(tombstones map[string]bool) error { tb := b.tombstoneBlob() - _, existed, priorChunks, err := tb.readKeyIndex() + _, existed, priorChunks, _, err := tb.readKeyIndex() if err != nil { return fmt.Errorf("oauth: read keyring token tombstones: %w", err) } @@ -1122,81 +1167,87 @@ func decodeKeyringIndexPayload(enc string, what string) ([]byte, error) { return raw, nil } -// readKeyIndex returns the indexed keys, whether an index exists at all, and -// how many chunk entries it currently occupies. A chunk listed by the header -// but missing from the keyring (a torn write) is skipped, mirroring how -// read() skips an indexed key whose entry is missing. -func (b keyringBlob) readKeyIndex() ([]string, bool, int, error) { +// readKeyIndex returns the indexed keys, whether an index exists at all, +// how many chunk entries it currently occupies, and whether a referenced +// continuation chunk was missing. A missing chunk (external keychain damage +// or a torn write outside this code's write order) is skipped so reads stay +// available, but incomplete is true so write() can refuse to shrink the +// index and strand the unlisted entries as undeletable orphans. +func (b keyringBlob) readKeyIndex() (keys []string, ok bool, chunks int, incomplete bool, err error) { enc, ok, err := b.kr.Get(b.service, b.indexAccount) if err != nil { - return nil, false, 0, err + return nil, false, 0, false, err } if !ok { - return nil, false, 0, nil + return nil, false, 0, false, nil } raw, err := decodeKeyringIndexPayload(enc, "keyring token index") if err != nil { - return nil, false, 0, err + return nil, false, 0, false, err } trimmed := strings.TrimSpace(string(raw)) if strings.HasPrefix(trimmed, "[") { var rawKeys []string if err := json.Unmarshal(raw, &rawKeys); err != nil { - return nil, false, 0, fmt.Errorf("oauth: decode keyring token index: %w", err) + return nil, false, 0, false, fmt.Errorf("oauth: decode keyring token index: %w", err) } if len(rawKeys) > maxRawKeyringIndexKeys { - return nil, false, 0, errKeyringIndexTooManyKeys(len(rawKeys), maxRawKeyringIndexKeys) + return nil, false, 0, false, errKeyringIndexTooManyKeys(len(rawKeys), maxRawKeyringIndexKeys) } keys := dedupeValidKeys(rawKeys) if len(keys) > b.indexKeyLimit() { - return nil, false, 0, errKeyringIndexTooManyKeys(len(keys), b.indexKeyLimit()) + return nil, false, 0, false, errKeyringIndexTooManyKeys(len(keys), b.indexKeyLimit()) } - return keys, true, 1, nil + return keys, true, 1, false, nil } var header keyIndexHeader if err := json.Unmarshal(raw, &header); err != nil { - return nil, false, 0, fmt.Errorf("oauth: decode keyring token index: %w", err) + return nil, false, 0, false, fmt.Errorf("oauth: decode keyring token index: %w", err) } // Reject an unsupported or corrupt header before looping: an out-of-range // Chunks would otherwise drive up to that many blocking keyring lookups // (each up to the 10s command timeout) while the store lock is held, wedging // every Load/Status/Save/Delete instead of failing promptly. if header.Version != 1 { - return nil, false, 0, fmt.Errorf("oauth: unsupported keyring token index version %d", header.Version) + return nil, false, 0, false, fmt.Errorf("oauth: unsupported keyring token index version %d", header.Version) } if header.Chunks < 1 || header.Chunks > maxKeyringIndexChunks { - return nil, false, 0, fmt.Errorf("oauth: keyring token index advertises %d chunks (want 1..%d)", header.Chunks, maxKeyringIndexChunks) + return nil, false, 0, false, fmt.Errorf("oauth: keyring token index advertises %d chunks (want 1..%d)", header.Chunks, maxKeyringIndexChunks) } rawKeys := header.Keys if len(rawKeys) > maxRawKeyringIndexKeys { - return nil, false, 0, errKeyringIndexTooManyKeys(len(rawKeys), maxRawKeyringIndexKeys) + return nil, false, 0, false, errKeyringIndexTooManyKeys(len(rawKeys), maxRawKeyringIndexKeys) } + incomplete = false for i := 1; i < header.Chunks; i++ { - chunkEnc, ok, err := b.kr.Get(b.service, b.chunkAccount(i)) + chunkEnc, chunkOK, err := b.kr.Get(b.service, b.chunkAccount(i)) if err != nil { - return nil, false, 0, err + return nil, false, 0, false, err } - if !ok { + if !chunkOK { + // Skip so Load/Status stay available, but remember the damage so + // write() does not shrink away the unlisted keys' entries. + incomplete = true continue } chunkRaw, err := decodeKeyringIndexPayload(chunkEnc, fmt.Sprintf("keyring token index chunk %d", i)) if err != nil { - return nil, false, 0, err + return nil, false, 0, false, err } var more []string if err := json.Unmarshal(chunkRaw, &more); err != nil { - return nil, false, 0, fmt.Errorf("oauth: decode keyring token index chunk %d: %w", i, err) + return nil, false, 0, false, fmt.Errorf("oauth: decode keyring token index chunk %d: %w", i, err) } if len(rawKeys)+len(more) > maxRawKeyringIndexKeys { - return nil, false, 0, errKeyringIndexTooManyKeys(len(rawKeys)+len(more), maxRawKeyringIndexKeys) + return nil, false, 0, false, errKeyringIndexTooManyKeys(len(rawKeys)+len(more), maxRawKeyringIndexKeys) } rawKeys = append(rawKeys, more...) } - keys := dedupeValidKeys(rawKeys) + keys = dedupeValidKeys(rawKeys) if len(keys) > b.indexKeyLimit() { - return nil, false, 0, errKeyringIndexTooManyKeys(len(keys), b.indexKeyLimit()) + return nil, false, 0, false, errKeyringIndexTooManyKeys(len(keys), b.indexKeyLimit()) } - return keys, true, header.Chunks, nil + return keys, true, header.Chunks, incomplete, nil } // dedupeValidKeys drops duplicates and malformed entries from a decoded @@ -1299,16 +1350,22 @@ var fileLockRefreshInterval = 10 * time.Second // closed. Lease ownership starts at acquisition, not after every path is // held: withLock acquires lockPath then may block on legacyLockPath, and a // peer must not be able to reclaim the first lock as stale during that wait. +// Refresh is ownership-aware: if a peer reclaims and replaces the lock while +// this holder is paused, Chtimes is skipped and lost is set so the critical +// section can fail closed instead of keeping the thief's lock forever-fresh. type leasedPath struct { path string + token string unlock func() stop chan struct{} done chan struct{} + lost atomic.Bool } -func startLease(path string, unlock func()) *leasedPath { +func startLease(path, token string, unlock func()) *leasedPath { l := &leasedPath{ path: path, + token: token, unlock: unlock, stop: make(chan struct{}), done: make(chan struct{}), @@ -1326,9 +1383,18 @@ func startLease(path string, unlock func()) *leasedPath { // judges staleness with real time.Since(mtime), so a fixed or stale // StoreOptions.Now would stamp a live lock with an old mtime that // another process would immediately reclaim, reviving the token-loss - // race these locks prevent. + // race these locks prevent. Only refresh while we still own the + // token: a post-stale reclaim can replace the file, and Chtimes on + // the replacement would keep both holders inside the critical section. + if !ownLockFile(path, token) { + l.lost.Store(true) + return + } at := time.Now() - _ = os.Chtimes(path, at, at) + if err := os.Chtimes(path, at, at); err != nil && !ownLockFile(path, token) { + l.lost.Store(true) + return + } } } }() @@ -1345,10 +1411,19 @@ func (l *leasedPath) release() { // lease starts immediately on acquisition (and keeps refreshing while later // paths are still being acquired and while fn runs), so a multi-lock wait // cannot leave an earlier lock looking abandoned. Locks are released in -// reverse order once fn returns. +// reverse order once fn returns — including when fn panics — so a recovered +// panic cannot leave a forever-refreshed lock that wedges every later waiter. +// If a lease is replaced under us mid-critical-section, the operation fails +// closed after fn returns (or with fn's error) rather than treating a dual- +// entry window as success. func withLeasedLocks(paths []string, now func() time.Time, fn func() error) error { var leases []*leasedPath + released := false releaseAll := func() { + if released { + return + } + released = true for i := len(leases) - 1; i >= 0; i-- { leases[i].release() } @@ -1357,19 +1432,27 @@ func withLeasedLocks(paths []string, now func() time.Time, fn func() error) erro if p == "" { continue } - unlock, err := acquireFileLock(p, now) + unlock, token, err := acquireFileLock(p, now) if err != nil { releaseAll() return err } // Start refreshing this lock before blocking on the next path. - leases = append(leases, startLease(p, unlock)) + leases = append(leases, startLease(p, token, unlock)) } if len(leases) == 0 { return fn() } + defer releaseAll() err := fn() - releaseAll() + for _, l := range leases { + if l.lost.Load() { + if err != nil { + return err + } + return fmt.Errorf("oauth: lost token lock lease on %s", filepath.Base(l.path)) + } + } return err } diff --git a/internal/oauth/store_keyring_test.go b/internal/oauth/store_keyring_test.go index 8691d6e83..6c72433a7 100644 --- a/internal/oauth/store_keyring_test.go +++ b/internal/oauth/store_keyring_test.go @@ -14,6 +14,37 @@ import ( "github.com/Gitlawb/zero/internal/lockutil" ) +// TestMain redirects keyring lock paths into a process-private temp home so +// the suite never creates lock files under the real user home directory +// (keyringLockPath deliberately ignores XDG_CONFIG_HOME / HOME env). Tests +// that must observe real OS identity re-stub currentOSUser themselves. +func TestMain(m *testing.M) { + dir, err := os.MkdirTemp("", "zero-oauth-test-home-*") + if err != nil { + panic(err) + } + currentOSUser = func() (*user.User, error) { + return &user.User{Uid: "0", HomeDir: dir}, nil + } + code := m.Run() + _ = os.RemoveAll(dir) + os.Exit(code) +} + +// useTempLockHome points keyringLockPath at a per-test home directory and +// restores the previous stub on cleanup. Prefer this when a test needs its +// own lock root (e.g. concurrent lock-path stability checks). +func useTempLockHome(t *testing.T) string { + t.Helper() + home := t.TempDir() + previous := currentOSUser + currentOSUser = func() (*user.User, error) { + return &user.User{Uid: "0", HomeDir: home}, nil + } + t.Cleanup(func() { currentOSUser = previous }) + return home +} + // fakeKR is an in-memory KeyringClient for exercising the keyring backend // without touching a real OS keychain. type fakeKR struct{ data map[string]string } @@ -337,7 +368,7 @@ func (e errKR) Error() string { return string(e) } func indexedKeysOf(t *testing.T, kr *fakeKR) map[string]bool { t.Helper() blob := keyringBlob{kr: kr, service: keyringService, legacyAccount: keyringLegacyAccount, indexAccount: keyringIndexAccount} - keys, _, _, err := blob.readKeyIndex() + keys, _, _, _, err := blob.readKeyIndex() if err != nil { t.Fatalf("readKeyIndex: %v", err) } @@ -628,6 +659,10 @@ func TestStoreKeyringWithLockRefreshesLease(t *testing.T) { return err } first = info.ModTime() + // The lease only needs the mtime to stay non-stale. Require a fresh + // stamp rather than strictly-newer, so coarse filesystems (HFS+ 1s, + // FAT 2s) do not flake when every refresh in a short window collapses + // to the same second. time.Sleep(150 * time.Millisecond) info, err = os.Stat(lockPath) if err != nil { @@ -639,8 +674,11 @@ func TestStoreKeyringWithLockRefreshesLease(t *testing.T) { if err != nil { t.Fatalf("withLock: %v", err) } - if !second.After(first) { - t.Fatalf("lock mtime was not refreshed during the critical section: %v then %v", first, second) + if second.Before(first) { + t.Fatalf("lock mtime went backwards during the critical section: %v then %v", first, second) + } + if age := time.Since(second); age > fileLockStaleAfter { + t.Fatalf("lock mtime is stale after lease refresh: age %v > %v", age, fileLockStaleAfter) } if _, err := os.Stat(lockPath); !os.IsNotExist(err) { t.Fatalf("lock file not released: %v", err) @@ -819,7 +857,7 @@ func TestAcquireFileLockWaitsWhileLeaseHealthy(t *testing.T) { fileLockTimeout = prevTimeout }() - unlock, err := acquireFileLock(lockPath, time.Now) + unlock, _, err := acquireFileLock(lockPath, time.Now) if err != nil { t.Fatal(err) } @@ -842,7 +880,7 @@ func TestAcquireFileLockWaitsWhileLeaseHealthy(t *testing.T) { acquired := make(chan error, 1) go func() { - u, err := acquireFileLock(lockPath, time.Now) + u, _, err := acquireFileLock(lockPath, time.Now) if err == nil { u() } @@ -925,7 +963,7 @@ func TestStoreKeyringReadIndexRejectsCorruptHeader(t *testing.T) { } ckr.data[keyringService+"/"+keyringIndexAccount] = base64.StdEncoding.EncodeToString(oversized) ckr.gets = 0 - if _, _, _, err := blob.readKeyIndex(); err == nil { + if _, _, _, _, err := blob.readKeyIndex(); err == nil { t.Fatal("expected an oversized chunk count to be rejected") } if ckr.gets != 1 { @@ -938,7 +976,7 @@ func TestStoreKeyringReadIndexRejectsCorruptHeader(t *testing.T) { } ckr.data[keyringService+"/"+keyringIndexAccount] = base64.StdEncoding.EncodeToString(unsupported) ckr.gets = 0 - if _, _, _, err := blob.readKeyIndex(); err == nil { + if _, _, _, _, err := blob.readKeyIndex(); err == nil { t.Fatal("expected an unsupported index version to be rejected") } if ckr.gets != 1 { @@ -967,7 +1005,7 @@ func TestStoreKeyringReadIndexRejectsOversizedKeyList(t *testing.T) { } ckr.data[keyringService+"/"+keyringIndexAccount] = base64.StdEncoding.EncodeToString(header) ckr.gets = 0 - if _, _, _, err := blob.readKeyIndex(); err == nil { + if _, _, _, _, err := blob.readKeyIndex(); err == nil { t.Fatal("expected an oversized key list in a chunk-0 header to be rejected") } if ckr.gets != 1 { @@ -981,7 +1019,7 @@ func TestStoreKeyringReadIndexRejectsOversizedKeyList(t *testing.T) { } ckr.data[keyringService+"/"+keyringIndexAccount] = base64.StdEncoding.EncodeToString(legacyArray) ckr.gets = 0 - if _, _, _, err := blob.readKeyIndex(); err == nil { + if _, _, _, _, err := blob.readKeyIndex(); err == nil { t.Fatal("expected an oversized legacy-format key array to be rejected") } if ckr.gets != 1 { @@ -1002,7 +1040,7 @@ func TestStoreKeyringReadIndexRejectsOversizedKeyList(t *testing.T) { ckr.data[keyringService+"/"+keyringIndexAccount] = base64.StdEncoding.EncodeToString(headerOK) ckr.data[keyringService+"/"+keyringIndexAccount+"-1"] = base64.StdEncoding.EncodeToString(chunk1) ckr.gets = 0 - if _, _, _, err := blob.readKeyIndex(); err == nil { + if _, _, _, _, err := blob.readKeyIndex(); err == nil { t.Fatal("expected an oversized key list accumulated across chunks to be rejected") } if ckr.gets != 2 { @@ -1016,7 +1054,15 @@ func TestStoreKeyringReadIndexRejectsOversizedKeyList(t *testing.T) { // and the last-resort temp name must be scoped by uid so different users // never collide on one lock file. func TestKeyringLockPathIsPerUser(t *testing.T) { - got := keyringLockPath(nil, keyringService, keyringIndexAccount) + // Observe real OS identity, not the TestMain isolation stub. + previous := currentOSUser + currentOSUser = user.Current + t.Cleanup(func() { currentOSUser = previous }) + + got, err := keyringLockPath(nil, keyringService, keyringIndexAccount) + if err != nil { + t.Fatal(err) + } name := keyringLockFileName(keyringService, keyringIndexAccount) if got == filepath.Join(os.TempDir(), "zero-"+name) { t.Fatalf("lock path is the shared temp path %q; a co-tenant could grief it", got) @@ -1125,7 +1171,7 @@ func TestStoreKeyringWriteWaitsForLegacyLockDuringReconciliation(t *testing.T) { } // Simulate an old binary holding the legacy lock for its own in-flight Save. - unlock, err := acquireFileLock(blob.legacyLockPath, s.now) + unlock, _, err := acquireFileLock(blob.legacyLockPath, s.now) if err != nil { t.Fatalf("acquire simulated legacy lock: %v", err) } @@ -1301,7 +1347,7 @@ func TestStoreKeyringReadIndexDedupesDuplicateKeys(t *testing.T) { } ckr.data[keyringService+"/"+keyringIndexAccount] = encoded - keys, ok, _, err := blob.readKeyIndex() + keys, ok, _, _, err := blob.readKeyIndex() if err != nil { t.Fatalf("readKeyIndex: %v", err) } @@ -1319,7 +1365,7 @@ func TestStoreKeyringReadIndexDedupesDuplicateKeys(t *testing.T) { t.Fatal(err) } ckr.data[keyringService+"/"+keyringIndexAccount] = base64.StdEncoding.EncodeToString(mixed) - keys, _, _, err = blob.readKeyIndex() + keys, _, _, _, err = blob.readKeyIndex() if err != nil { t.Fatalf("readKeyIndex: %v", err) } @@ -1908,7 +1954,7 @@ func TestStoreKeyringReadIndexRejectsOversizedEncodedPayload(t *testing.T) { huge := strings.Repeat("A", maxKeyringIndexEncodedBytes+1) ckr.data[keyringService+"/"+keyringIndexAccount] = huge ckr.gets = 0 - if _, _, _, err := blob.readKeyIndex(); err == nil { + if _, _, _, _, err := blob.readKeyIndex(); err == nil { t.Fatal("expected oversized encoded index payload to be rejected") } if ckr.gets != 1 { @@ -1923,7 +1969,7 @@ func TestStoreKeyringReadIndexRejectsOversizedEncodedPayload(t *testing.T) { ckr.data[keyringService+"/"+keyringIndexAccount] = base64.StdEncoding.EncodeToString(header) ckr.data[keyringService+"/"+keyringIndexAccount+"-1"] = huge ckr.gets = 0 - if _, _, _, err := blob.readKeyIndex(); err == nil { + if _, _, _, _, err := blob.readKeyIndex(); err == nil { t.Fatal("expected oversized encoded chunk payload to be rejected") } } @@ -2110,7 +2156,7 @@ func TestStoreKeyringLeaseRefreshesWhileWaitingOnSecondLock(t *testing.T) { // Hold the second lock as a healthy long-lived peer (refresh its mtime) // so withLeasedLocks blocks on it after taking the first, without the // waiter reclaiming a stale second lock. - holdLegacy, err := acquireFileLock(legacyLock, time.Now) + holdLegacy, _, err := acquireFileLock(legacyLock, time.Now) if err != nil { t.Fatalf("hold legacy lock: %v", err) } @@ -2289,13 +2335,207 @@ func TestKeyringLockPathUserLookupFallbackIgnoresAmbientHome(t *testing.T) { currentOSUser = func() (*user.User, error) { return nil, fmt.Errorf("lookup unavailable") } defer func() { currentOSUser = previous }() - gotA := keyringLockPath(map[string]string{"HOME": t.TempDir()}, keyringService, keyringIndexAccount) - gotB := keyringLockPath(map[string]string{"HOME": t.TempDir()}, keyringService, keyringIndexAccount) + gotA, err := keyringLockPath(map[string]string{"HOME": t.TempDir()}, keyringService, keyringIndexAccount) + if err != nil { + t.Fatal(err) + } + gotB, err := keyringLockPath(map[string]string{"HOME": t.TempDir()}, keyringService, keyringIndexAccount) + if err != nil { + t.Fatal(err) + } if gotA != gotB { t.Fatalf("same-user fallback changed with HOME: %q vs %q", gotA, gotB) } - want := filepath.Join(keyringFallbackLockDir(), keyringTempLockName(keyringService, keyringIndexAccount)) + fallbackDir, err := keyringFallbackLockDir() + if err != nil { + t.Fatal(err) + } + want := filepath.Join(fallbackDir, keyringTempLockName(keyringService, keyringIndexAccount)) if gotA != want { t.Fatalf("fallback lock = %q, want UID-scoped temporary %q", gotA, want) } } + +// TestWithLeasedLocksReleasesOnPanic guards the critical finding that a panic +// inside fn used to skip releaseAll: the lease goroutine kept Chtimes alive +// forever and every later waiter blocked until process exit. +func TestWithLeasedLocksReleasesOnPanic(t *testing.T) { + lockPath := filepath.Join(t.TempDir(), "panic.lock") + func() { + defer func() { + if recover() == nil { + t.Fatal("expected panic from fn") + } + }() + _ = withLeasedLocks([]string{lockPath}, time.Now, func() error { + panic("simulated critical-section panic") + }) + }() + if _, err := os.Stat(lockPath); !os.IsNotExist(err) { + t.Fatalf("lock file still present after panic recovery: %v", err) + } + start := time.Now() + if err := withLeasedLocks([]string{lockPath}, time.Now, func() error { return nil }); err != nil { + t.Fatalf("second withLeasedLocks after panic: %v", err) + } + if elapsed := time.Since(start); elapsed > time.Second { + t.Fatalf("second acquisition took %v; lock was still being leased", elapsed) + } +} + +// TestLeaseRefreshStopsWhenLockReplaced is the regression for ownership-aware +// lease refresh: if a holder pauses past fileLockStaleAfter and a peer replaces +// the lock, the original holder must not Chtimes the replacement (which would +// keep both critical sections alive) and must fail closed. +func TestLeaseRefreshStopsWhenLockReplaced(t *testing.T) { + lockPath := filepath.Join(t.TempDir(), "stolen.lock") + prevRefresh := fileLockRefreshInterval + fileLockRefreshInterval = 20 * time.Millisecond + defer func() { fileLockRefreshInterval = prevRefresh }() + + var lostErr error + err := withLeasedLocks([]string{lockPath}, time.Now, func() error { + // Replace the lock as a reclaiming peer would after a long pause. + if err := os.WriteFile(lockPath, []byte("replacement-holder"), 0o600); err != nil { + return err + } + // Wait for at least one lease tick so startLease observes the theft. + time.Sleep(80 * time.Millisecond) + // Replacement mtime must not keep being refreshed by the original lease. + info, err := os.Stat(lockPath) + if err != nil { + return err + } + first := info.ModTime() + time.Sleep(80 * time.Millisecond) + info, err = os.Stat(lockPath) + if err != nil { + return err + } + // Allow equal (coarse FS) but not strictly newer from our stolen lease. + // A healthy original lease would refresh every 20ms and push mtime forward + // on sub-second filesystems; on coarse FS we rely on lost-lease error. + _ = first + _ = info + return nil + }) + lostErr = err + if lostErr == nil { + t.Fatal("expected lost-lease error after lock replacement, got nil") + } + if !strings.Contains(lostErr.Error(), "lost token lock lease") { + t.Fatalf("error = %v, want lost token lock lease", lostErr) + } + // Replacement content must still be present: original release is ownership-aware. + data, err := os.ReadFile(lockPath) + if err == nil && string(data) == "replacement-holder" { + // Original unlock correctly left the thief's lock alone; clean up. + _ = os.Remove(lockPath) + } +} + +// TestAcquireFileLockTimesOutOnFutureMtime rejects a lock whose mtime is in +// the future as a healthy lease: without the non-negative age guard, every +// wait loop extends idleDeadline forever. +func TestAcquireFileLockTimesOutOnFutureMtime(t *testing.T) { + lockPath := filepath.Join(t.TempDir(), "future.lock") + if err := os.WriteFile(lockPath, []byte("hostile"), 0o600); err != nil { + t.Fatal(err) + } + future := time.Now().Add(time.Hour) + if err := os.Chtimes(lockPath, future, future); err != nil { + t.Fatal(err) + } + prevTimeout := fileLockTimeout + fileLockTimeout = 80 * time.Millisecond + defer func() { fileLockTimeout = prevTimeout }() + + start := time.Now() + _, _, err := acquireFileLock(lockPath, time.Now) + if err == nil { + t.Fatal("expected timeout on future-mtime lock") + } + if !strings.Contains(err.Error(), "timed out") { + t.Fatalf("error = %v, want timed out", err) + } + if elapsed := time.Since(start); elapsed > 2*time.Second { + t.Fatalf("timed out too slowly (%v); future mtime was treated as healthy", elapsed) + } +} + +// TestWriteSkipsIndexShrinkWhenChunkMissing ensures external keychain damage +// that drops a continuation chunk does not orphan the unlisted entries on the +// next Save: write keeps the union index instead of shrinking. +func TestWriteSkipsIndexShrinkWhenChunkMissing(t *testing.T) { + kr := newFakeKR() + blob := keyringBlob{kr: kr, service: keyringService, indexAccount: keyringIndexAccount} + + // Build a two-chunk index: header + chunk-1, then delete chunk-1. + keys := []string{ProviderKey("alpha"), ProviderKey("beta")} + // Force two chunks by writing via writeKeyIndex with a tiny budget: easier + // to hand-craft a header that advertises 2 chunks. + header, err := json.Marshal(keyIndexHeader{Version: 1, Chunks: 2, Keys: []string{ProviderKey("alpha")}}) + if err != nil { + t.Fatal(err) + } + chunk1, err := json.Marshal([]string{ProviderKey("beta")}) + if err != nil { + t.Fatal(err) + } + kr.data[keyringService+"/"+keyringIndexAccount] = base64.StdEncoding.EncodeToString(header) + kr.data[keyringService+"/"+keyringIndexAccount+"-1"] = base64.StdEncoding.EncodeToString(chunk1) + // Entries for both keys. + for _, k := range keys { + raw, _ := json.Marshal(Token{AccessToken: "tok-" + k}) + kr.data[keyringService+"/"+k] = base64.StdEncoding.EncodeToString(raw) + } + // Damage: drop chunk-1. + delete(kr.data, keyringService+"/"+keyringIndexAccount+"-1") + + gotKeys, ok, chunks, incomplete, err := blob.readKeyIndex() + if err != nil || !ok { + t.Fatalf("readKeyIndex: ok=%v err=%v", ok, err) + } + if !incomplete { + t.Fatal("expected incomplete=true when chunk-1 is missing") + } + if chunks != 2 { + t.Fatalf("chunks = %d, want 2", chunks) + } + if len(gotKeys) != 1 || gotKeys[0] != ProviderKey("alpha") { + t.Fatalf("keys = %v, want only header keys", gotKeys) + } + + // Save a third key; shrink must be skipped so the union still lists alpha + // (and beta cannot be re-indexed from the missing chunk, but alpha must + // not disappear and beta's entry must not be deleted as a non-livePrior). + state, err := json.Marshal(storeFile{SchemaVersion: storeSchemaVersion, Tokens: map[string]Token{ + ProviderKey("alpha"): {AccessToken: "tok-alpha"}, + ProviderKey("gamma"): {AccessToken: "tok-gamma"}, + }}) + if err != nil { + t.Fatal(err) + } + if err := blob.write(state, map[string]bool{ProviderKey("gamma"): false}); err != nil { + t.Fatalf("write: %v", err) + } + // beta entry must still exist (not deleted: it was absent from truncated livePrior). + if _, ok := kr.data[keyringService+"/"+ProviderKey("beta")]; !ok { + t.Fatal("beta entry was deleted despite missing index chunk; orphan risk path") + } + // Index after write should still be a union (not a shrink that drops everything unknown). + afterKeys, _, _, afterIncomplete, err := blob.readKeyIndex() + if err != nil { + t.Fatal(err) + } + // incomplete may clear if write rewrote a complete index; either way alpha+gamma + // must be listed, and we did not shrink away the prior union carelessly. + _ = afterIncomplete + found := map[string]bool{} + for _, k := range afterKeys { + found[k] = true + } + if !found[ProviderKey("alpha")] || !found[ProviderKey("gamma")] { + t.Fatalf("post-write keys = %v, want alpha and gamma listed", afterKeys) + } +} From 67a5ad587abac31584b8f2268c24ff1553f3c9d0 Mon Sep 17 00:00:00 2001 From: euxaristia Date: Fri, 7 Aug 2026 15:06:10 -0400 Subject: [PATCH 28/28] fix(oauth): preserve incomplete index chunks and tighten lease ownership. Skip shrinking and keep prior chunk advertisements when a keyring index continuation is missing so a restored chunk can still reconcile keys. Always re-check lock ownership after Chtimes, and replace JWT-shaped test fixtures with opaque bulk strings for secret scanners. Refs #668 --- internal/oauth/store.go | 64 +++++++++++++++++++--------- internal/oauth/store_keyring_test.go | 57 ++++++++++++++++--------- 2 files changed, 83 insertions(+), 38 deletions(-) diff --git a/internal/oauth/store.go b/internal/oauth/store.go index e2372f59b..759a5c5e9 100644 --- a/internal/oauth/store.go +++ b/internal/oauth/store.go @@ -944,6 +944,12 @@ func (b keyringBlob) write(data []byte, mutations map[string]bool) error { } // 2. Publish the union of the live prior and new key sets first, so every // entry that exists at any point during this update is indexed. + // + // When a referenced continuation chunk was missing, livePrior is truncated + // and cannot name the unlisted keys. Keep advertising the prior chunk count + // (and never delete those chunk accounts) so a later-restored chunk can + // still be reconciled; a complete rewrite to only the known keys would + // permanently orphan their OS keychain entries. union := keys if len(livePrior) > 0 { merged := make(map[string]bool, len(keys)+len(livePrior)) @@ -956,7 +962,7 @@ func (b keyringBlob) write(data []byte, mutations map[string]bool) error { } sort.Strings(union) } - unionChunks, err := b.writeKeyIndex(union, priorChunks) + unionChunks, err := b.writeKeyIndex(union, priorChunks, indexIncomplete) if err != nil { return err } @@ -968,6 +974,8 @@ func (b keyringBlob) write(data []byte, mutations map[string]bool) error { } // 4. Delete removed entries while the union index still lists them, so a // failed Delete leaves a visible (re-deletable) entry, never an orphan. + // Only walk livePrior (keys we could see): entries named only in a missing + // chunk stay put so a restored chunk can still find them. for _, key := range livePrior { if _, ok := state.Tokens[key]; !ok { if _, err := b.kr.Delete(b.service, key); err != nil { @@ -976,13 +984,10 @@ func (b keyringBlob) write(data []byte, mutations map[string]bool) error { } } // 5. Shrink the index to the exact new key set. Legacy is left untouched. - // Skip shrink when a referenced index chunk was missing: livePrior was - // computed from a truncated key list, so shrinking would drop those keys - // from the published index while leaving their OS keychain entries - // stranded and unreachable by Load/Status/Delete. Leaving the union index - // in place keeps them listed until an intact read can reconcile them. + // Skip shrink when the prior index was incomplete: a shrink to `keys` would + // drop the preserved chunk advertisements and strand unlisted entries. if !indexIncomplete { - if _, err := b.writeKeyIndex(keys, unionChunks); err != nil { + if _, err := b.writeKeyIndex(keys, unionChunks, false); err != nil { return err } } @@ -1064,7 +1069,7 @@ func (b keyringBlob) writeTombstones(tombstones map[string]bool) error { keys = append(keys, key) } sort.Strings(keys) - if _, err := tb.writeKeyIndex(keys, priorChunks); err != nil { + if _, err := tb.writeKeyIndex(keys, priorChunks, false); err != nil { return fmt.Errorf("oauth: write keyring token tombstones: %w", err) } return nil @@ -1278,12 +1283,18 @@ func dedupeValidKeys(keys []string) []string { } // writeKeyIndex persists keys as a chunked index and reports how many chunk -// entries it used. Continuation chunks are written before the header that -// references them, so the authoritative chunk 0 never advertises a chunk that -// does not exist yet; stale chunks from a previously larger index are removed -// only after the header stops referencing them (best-effort: an unreferenced -// chunk is never read). -func (b keyringBlob) writeKeyIndex(keys []string, priorChunks int) (int, error) { +// entries the published header advertises. Continuation chunks are written +// before the header that references them, so the authoritative chunk 0 never +// advertises a content chunk that does not exist yet; stale chunks from a +// previously larger index are removed only after the header stops referencing +// them (best-effort: an unreferenced chunk is never read). +// +// keepMissingChunks is set when readKeyIndex reported a missing continuation +// chunk. In that mode the header keeps advertising at least priorChunks so a +// later-restored chunk remains reachable, and chunk accounts in that range are +// not deleted (overwriting or removing them would turn recoverable damage into +// permanent orphans). +func (b keyringBlob) writeKeyIndex(keys []string, priorChunks int, keepMissingChunks bool) (int, error) { // Refuse to publish an index the reader would reject: readKeyIndex caps both // total keys and chunk count, and a header beyond either would make every // later Load/Status/Save/Delete fail before it could recover. Check the key @@ -1296,6 +1307,8 @@ func (b keyringBlob) writeKeyIndex(keys []string, priorChunks int) (int, error) if len(chunks) > maxKeyringIndexChunks { return 0, fmt.Errorf("oauth: keyring key index needs %d chunks, over the %d-chunk cap readers accept; too many stored credentials", len(chunks), maxKeyringIndexChunks) } + // Only write content chunks we produced. When preserving a damaged prior + // index, higher-numbered accounts may still hold recoverable key lists. for i := 1; i < len(chunks); i++ { chunkData, err := json.Marshal(chunks[i]) if err != nil { @@ -1305,17 +1318,26 @@ func (b keyringBlob) writeKeyIndex(keys []string, priorChunks int) (int, error) return 0, err } } - headerData, err := json.Marshal(keyIndexHeader{Version: 1, Chunks: len(chunks), Keys: chunks[0]}) + advertised := len(chunks) + if keepMissingChunks && priorChunks > advertised { + advertised = priorChunks + } + if advertised > maxKeyringIndexChunks { + return 0, fmt.Errorf("oauth: keyring key index needs %d chunks, over the %d-chunk cap readers accept; too many stored credentials", advertised, maxKeyringIndexChunks) + } + headerData, err := json.Marshal(keyIndexHeader{Version: 1, Chunks: advertised, Keys: chunks[0]}) if err != nil { return 0, err } if err := b.kr.Set(b.service, b.indexAccount, base64.StdEncoding.EncodeToString(headerData)); err != nil { return 0, err } - for i := len(chunks); i < priorChunks; i++ { - _, _ = b.kr.Delete(b.service, b.chunkAccount(i)) + if !keepMissingChunks { + for i := len(chunks); i < priorChunks; i++ { + _, _ = b.kr.Delete(b.service, b.chunkAccount(i)) + } } - return len(chunks), nil + return advertised, nil } // chunkIndexKeys packs keys into chunks whose marshaled JSON stays under @@ -1386,12 +1408,16 @@ func startLease(path, token string, unlock func()) *leasedPath { // race these locks prevent. Only refresh while we still own the // token: a post-stale reclaim can replace the file, and Chtimes on // the replacement would keep both holders inside the critical section. + // Re-check ownership after Chtimes as well: between the pre-check and + // the stamp a peer can swap the file, and a successful Chtimes on the + // thief's lock would keep both critical sections alive. if !ownLockFile(path, token) { l.lost.Store(true) return } at := time.Now() - if err := os.Chtimes(path, at, at); err != nil && !ownLockFile(path, token) { + _ = os.Chtimes(path, at, at) + if !ownLockFile(path, token) { l.lost.Store(true) return } diff --git a/internal/oauth/store_keyring_test.go b/internal/oauth/store_keyring_test.go index 6c72433a7..5244b3274 100644 --- a/internal/oauth/store_keyring_test.go +++ b/internal/oauth/store_keyring_test.go @@ -133,15 +133,15 @@ func TestStoreKeyringManyProvidersStayUnderEntryLimit(t *testing.T) { if err != nil { t.Fatal(err) } - // A realistically large single token: JWT-shaped access/ID tokens plus an - // opaque refresh token, comparable to what OIDC providers actually issue. + // A realistically large single token: opaque bulk fixtures (not JWT-shaped, + // so secret scanners do not flag test data as generic-api-key / JWT leaks). big := Token{ - AccessToken: "eyJhbGciOiJSUzI1NiJ9." + strings.Repeat("QUJDRA", 60) + ".sig", - RefreshToken: "rt_" + strings.Repeat("x", 80), + AccessToken: "test-access-" + strings.Repeat("a", 360), + RefreshToken: "test-refresh-" + strings.Repeat("x", 80), TokenType: "Bearer", Scopes: []string{"openid", "profile", "email", "offline_access"}, Account: "user@example.com", - IDToken: "eyJhbGciOiJSUzI1NiJ9." + strings.Repeat("QUJDRA", 70) + ".sig", + IDToken: "test-id-" + strings.Repeat("b", 420), } providers := []string{"anthropic", "openai", "minimax", "zai", "google"} for _, name := range providers { @@ -1294,7 +1294,7 @@ func TestStoreKeyringWriteIndexRejectsOverCapChunks(t *testing.T) { for i := range keys { keys[i] = fmt.Sprintf("%s-%d", long, i) } - if _, err := b.writeKeyIndex(keys, 0); err == nil { + if _, err := b.writeKeyIndex(keys, 0, false); err == nil { t.Fatal("writeKeyIndex published an index readKeyIndex would refuse") } if len(kr.data) != 0 { @@ -1314,7 +1314,7 @@ func TestStoreKeyringWriteIndexRejectsOverCapKeys(t *testing.T) { // would not catch this over-cap set. keys[i] = fmt.Sprintf("p%d", i) } - if _, err := b.writeKeyIndex(keys, 0); err == nil { + if _, err := b.writeKeyIndex(keys, 0, false); err == nil { t.Fatal("writeKeyIndex published a key count readKeyIndex would refuse") } if len(kr.data) != 0 { @@ -1839,7 +1839,7 @@ func TestStoreKeyringRejectsOversizedSingleTokenPayload(t *testing.T) { } huge := Token{ - AccessToken: "eyJhbGciOiJSUzI1NiJ9." + strings.Repeat("A", 6000) + ".sig", + AccessToken: "test-access-oversized-" + strings.Repeat("A", 6000), } err = s.Save(ProviderKey("huge"), huge) if err == nil { @@ -2089,13 +2089,14 @@ func TestStoreKeyringNeverWritesPartialLegacySubset(t *testing.T) { // Build a legacy map whose base64 encoding exceeds maxKeyringSingleEntryBytes // (pre-PR Linux keyring storage has no corresponding single-secret limit). + // Opaque bulk fixtures avoid secret-scanner false positives on JWT shapes. big := Token{ - AccessToken: "eyJhbGciOiJSUzI1NiJ9." + strings.Repeat("QUJDRA", 40) + ".sig", - RefreshToken: "rt_" + strings.Repeat("y", 60), + AccessToken: "test-access-" + strings.Repeat("a", 240), + RefreshToken: "test-refresh-" + strings.Repeat("y", 60), TokenType: "Bearer", Scopes: []string{"openid", "profile", "email", "offline_access"}, Account: "user@example.com", - IDToken: "eyJhbGciOiJSUzI1NiJ9." + strings.Repeat("QUJDRA", 45) + ".sig", + IDToken: "test-id-" + strings.Repeat("b", 270), } tokens := map[string]Token{} for _, name := range []string{"anthropic", "openai", "minimax", "zai", "google", "cohere"} { @@ -2506,9 +2507,8 @@ func TestWriteSkipsIndexShrinkWhenChunkMissing(t *testing.T) { t.Fatalf("keys = %v, want only header keys", gotKeys) } - // Save a third key; shrink must be skipped so the union still lists alpha - // (and beta cannot be re-indexed from the missing chunk, but alpha must - // not disappear and beta's entry must not be deleted as a non-livePrior). + // Save a third key; the union publish must keep advertising 2 chunks so a + // restored chunk-1 can still reconcile beta, and must not delete beta's entry. state, err := json.Marshal(storeFile{SchemaVersion: storeSchemaVersion, Tokens: map[string]Token{ ProviderKey("alpha"): {AccessToken: "tok-alpha"}, ProviderKey("gamma"): {AccessToken: "tok-gamma"}, @@ -2523,14 +2523,16 @@ func TestWriteSkipsIndexShrinkWhenChunkMissing(t *testing.T) { if _, ok := kr.data[keyringService+"/"+ProviderKey("beta")]; !ok { t.Fatal("beta entry was deleted despite missing index chunk; orphan risk path") } - // Index after write should still be a union (not a shrink that drops everything unknown). - afterKeys, _, _, afterIncomplete, err := blob.readKeyIndex() + afterKeys, _, afterChunks, afterIncomplete, err := blob.readKeyIndex() if err != nil { t.Fatal(err) } - // incomplete may clear if write rewrote a complete index; either way alpha+gamma - // must be listed, and we did not shrink away the prior union carelessly. - _ = afterIncomplete + if afterChunks != 2 { + t.Fatalf("post-write chunks = %d, want 2 (preserve missing-chunk advertisement)", afterChunks) + } + if !afterIncomplete { + t.Fatal("post-write index should still be incomplete until chunk-1 returns") + } found := map[string]bool{} for _, k := range afterKeys { found[k] = true @@ -2538,4 +2540,21 @@ func TestWriteSkipsIndexShrinkWhenChunkMissing(t *testing.T) { if !found[ProviderKey("alpha")] || !found[ProviderKey("gamma")] { t.Fatalf("post-write keys = %v, want alpha and gamma listed", afterKeys) } + // Restoring the missing chunk must surface beta again (the point of preserving + // the advertisement instead of shrinking to a complete 1-chunk index). + kr.data[keyringService+"/"+keyringIndexAccount+"-1"] = base64.StdEncoding.EncodeToString(chunk1) + restored, _, _, incomplete, err := blob.readKeyIndex() + if err != nil { + t.Fatal(err) + } + if incomplete { + t.Fatal("expected complete index after restoring chunk-1") + } + restoredFound := map[string]bool{} + for _, k := range restored { + restoredFound[k] = true + } + if !restoredFound[ProviderKey("beta")] { + t.Fatalf("restored keys = %v, want beta recoverable from chunk-1", restored) + } }