Skip to content

oauth: a stale encrypted-secret lock blocks recovery forever, and token/secret publication is not crash-durable #1163

Closed

Description

@Ayush7614

Summary

Two durability defects in the opt-in encrypted-file OAuth backend (storage: encrypted-file / Encrypted: true):

  1. A stale key-creation lock is never reclaimed. createSecretFile creates <token>.secret.lock with O_EXCL and defer os.Remove(lockPath). A crash or kill between creating and removing it leaves the file behind. Every later Save retries 500 x 2ms and then fails with timed out waiting for token secret ... file exists; there is no self-recovery. internal/securefile already has the hardened version with stale-lock reclamation and ownership verification.
  2. The AES key and the encrypted token blob are published without fsync. writeNewSecretFile writes the key to a publication temp file and renames it without Sync; fileBlob.write does the same for the encrypted token blob. A power loss after a successful save can leave a zero/partial key or blob, making existing tokens undecryptable.

Reproduction (stale lock)

Commit: cd3e0b0a.

I added this test:

s, path := newEncryptedStore(t)
lockPath := path + ".secret.lock"
os.WriteFile(lockPath, []byte("crashed-holder"), 0o600)
stale := time.Now().Add(-2 * time.Minute)
os.Chtimes(lockPath, stale, stale)
if err := s.Save(ProviderKey("demo"), Token{AccessToken: "after-crash"}); err != nil {
    t.Fatalf("Save must reclaim a stale secret lock, got: %v", err)
}

Current output:

=== RUN   TestEncryptedStoreReclaimsStaleSecretLock
    encrypt_test.go:224: Save must reclaim a stale secret lock, got: oauth: timed out waiting for token secret .../oauth-tokens.json.secret: open .../oauth-tokens.json.secret.lock: file exists
--- FAIL: TestEncryptedStoreReclaimsStaleSecretLock (1.26s)
FAIL	github.com/Twigpine/zero/internal/oauth	2.213s

User-facing consequence: after a crash during the first encrypted login, every subsequent login fails until the user finds and deletes oauth-tokens.json.secret.lock.

Root cause

  • internal/oauth/encrypt.go:102-136 (createSecretFile) has no stale-lock path; the defer at line 108 removes the lock unconditionally.
  • internal/oauth/encrypt.go:138-165 (writeNewSecretFile) writes the secret with no Sync before os.Rename.
  • internal/oauth/store.go:514-532 (fileBlob.write) writes the encrypted token blob with no Sync before os.Rename.
  • internal/oauth/encrypt.go is a stale duplicate of internal/securefile, whose package comment says it is the reusable extraction of the OAuth encrypted-file backend. The two have drifted: securefile has stale-lock reclamation, an ownership token, and file syncing; oauth does not.

Expected behavior

  • Reclaim only locks older than a stale threshold, with ownership verification before deleting, so a slow original holder cannot remove a newer holder's lock. A live lock keeps blocking.
  • Sync publication temp files before rename and sync the parent directory after rename for both the secret and the token blob.
  • Prefer using securefile.Crypter in oauth instead of maintaining a second implementation, while keeping OAuth context on wrapped errors.
  • Preserve fail-closed behavior for a missing/tampered secret and existing token data.

Scope

internal/oauth/encrypt.go (likely replace with securefile), internal/oauth/store.go (fileBlob.write), internal/oauth/encrypt_test.go, and focused internal/securefile tests for concurrent creation and wrong-sized secrets. I can implement this and run the repository's full validation.

Activity

  1. Ayush7614 commented on Oct 20, 2026

    @Ayush7614
    ContributorAuthor

    Implemented in #1165. OAuth now uses securefile.Crypter (with the sandbox-denied .secret.publish directory preserved), the encrypted token blob is fsynced before rename and its parent directory is synced, and the stale-lock regression passes. The previous code fails the stale-lock test with timed out waiting for token secret ... file exists.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions