Repository navigation
oauth: a stale encrypted-secret lock blocks recovery forever, and token/secret publication is not crash-durable #1163
Copy link
Copy link
Closed
Description
Summary
Two durability defects in the opt-in encrypted-file OAuth backend (storage: encrypted-file / Encrypted: true):
- A stale key-creation lock is never reclaimed.
createSecretFilecreates<token>.secret.lockwithO_EXCLanddefer os.Remove(lockPath). A crash or kill between creating and removing it leaves the file behind. Every laterSaveretries 500 x 2ms and then fails withtimed out waiting for token secret ... file exists; there is no self-recovery.internal/securefilealready has the hardened version with stale-lock reclamation and ownership verification. - The AES key and the encrypted token blob are published without fsync.
writeNewSecretFilewrites the key to a publication temp file and renames it withoutSync;fileBlob.writedoes 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 noSyncbeforeos.Rename.internal/oauth/store.go:514-532(fileBlob.write) writes the encrypted token blob with noSyncbeforeos.Rename.internal/oauth/encrypt.gois a stale duplicate ofinternal/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.Crypterinoauthinstead 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.
Reactions are currently unavailable
Metadata
Metadata
Assignees
Labels
No labels
Activity
Ayush7614 commentedon Oct 20, 2026
Implemented in #1165. OAuth now uses
securefile.Crypter(with the sandbox-denied.secret.publishdirectory 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 withtimed out waiting for token secret ... file exists.