Skip to content

oauth: opt-in AES-256-GCM encrypted-at-rest token storage#213

Merged
gnanam1990 merged 3 commits into
mainfrom
feat/oauth-encrypted-store
Jun 16, 2026
Merged

oauth: opt-in AES-256-GCM encrypted-at-rest token storage#213
gnanam1990 merged 3 commits into
mainfrom
feat/oauth-encrypted-store

Conversation

@gnanam1990

@gnanam1990 gnanam1990 commented Jun 15, 2026

Copy link
Copy Markdown
Collaborator

Summary

OAuth Phase 2 — adds an opt-in encrypted-at-rest token store. The default backend is unchanged (0600 plaintext JSON); setting ZERO_OAUTH_STORAGE=encrypted-file encrypts the token file with AES-256-GCM under a per-user random 32-byte secret persisted 0600 beside it (<tokens>.secret). Pure stdlib — no new dependency.

Builds on the merged internal/oauth engine (#210); branches off main.

How it works

  • internal/oauth/encrypt.goaesGCMCrypter: seal = random-nonce ‖ ciphertext; GCM gives confidentiality and tamper detection. Secret is load-or-create with an atomic 0600 write.
  • Store gains an optional crypter: readState decrypts before unmarshal, writeState encrypts after marshal. A missing secret, short blob, or failed auth tag all fail closed — never a silent empty/plaintext fallback.
  • CLI: newAuthManager selects the backend from ZERO_OAUTH_STORAGE; zero auth help documents it.

Security

Confidentiality + integrity at rest (AES-256-GCM), per-user random secret, 0600 on both files, fail-closed on tamper/missing-secret. Token material is still never logged (status output unchanged).

Verification

Tests: crypter round-trip + tamper; secret create/persist/0600/wrong-size fail-closed; store-through-encrypted (ciphertext on disk, round-trip, Status/Delete, tamper + missing-secret fail-closed).

Gates: gofmt · vet · build (host + linux + windows) · go test ./... incl. -race · staticcheck (no new) · govulncheck (0) · deadcode (no new vs main).

Local run check (built binary): a --device login under ZERO_OAUTH_STORAGE=encrypted-file against a local fake OAuth server:

  • Logged in to demo.
  • on-disk token file is ciphertext — 0 plaintext markers (no access token / no schemaVersion), binary hexdump;
  • <tokens>.secret present at mode 600;
  • zero auth status decrypts and reads it back (demo: logged in (refreshable), expires …), leaking no secret material.

Follow-ups (unchanged from before)

OS-keyring backend (needs a third-party dep) and unifying the MCP token store onto internal/oauth's namespaced store (transparent migration) remain separate follow-ups.

Summary by CodeRabbit

  • New Features

    • Added optional encrypted-at-rest OAuth token storage via ZERO_OAUTH_STORAGE=encrypted-file (AES-256-GCM), storing tokens on disk as ciphertext with a companion secret file.
  • Documentation

    • Expanded auth help with a new “Storage” section covering default token location, ZERO_OAUTH_TOKENS_PATH override, 0600 permissions, and encryption behavior.
  • Tests

    • Added coverage for encryption/decryption correctness, ciphertext not exposing plaintext, tamper/missing-secret failure-closed behavior, concurrent secret creation convergence, and CLI rejection of invalid ZERO_OAUTH_STORAGE values.

OAuth Phase 2: add an encrypted token-store backend. The default backend still
writes the 0600 plaintext JSON unchanged; setting ZERO_OAUTH_STORAGE=encrypted-file
encrypts the token file with AES-256-GCM under a per-user random 32-byte secret
persisted 0600 beside the token file (<tokens>.secret).

- internal/oauth/encrypt.go: aesGCMCrypter (nonce||ciphertext, GCM tamper
  detection) + load-or-create secret (atomic 0600 write). Pure stdlib, no dep.
- Store gains an optional crypter: readState decrypts before unmarshal, writeState
  encrypts after marshal. A missing secret, a short blob, or a failed auth tag all
  fail closed (no silent empty/plaintext fallback).
- CLI: newAuthManager selects the backend from ZERO_OAUTH_STORAGE; `zero auth`
  help documents it.

Tests: crypter round-trip + tamper, secret create/persist/0600/wrong-size,
store-through-encrypted (ciphertext on disk, round-trip, Status/Delete, tamper +
missing-secret fail-closed). Gates: gofmt/vet/build(host+linux+windows)/test
-race/staticcheck(no new)/govulncheck(0)/deadcode(no new) all pass. Local run
check: device login under ZERO_OAUTH_STORAGE=encrypted-file writes a ciphertext
file (no plaintext token), .secret is 0600, and `zero auth status` reads it back.
@github-actions

github-actions Bot commented Jun 15, 2026

Copy link
Copy Markdown
Contributor

Zero automated PR review

Verdict: No blockers found

Blockers

  • None found.

Validation

  • [pass] Diff hygiene: git diff --check
  • [pass] Tests: go test ./...
  • [pass] Build: go run ./cmd/zero-release build
  • [pass] Smoke build: go run ./cmd/zero-release smoke

Scope

Head: 3ad7b4f702e2
Changed files (5): internal/cli/auth.go, internal/cli/auth_test.go, internal/oauth/encrypt.go, internal/oauth/encrypt_test.go, internal/oauth/store.go

This deterministic review checks validation status and basic diff hygiene. A human reviewer still owns product judgment and design quality.

@coderabbitai

coderabbitai Bot commented Jun 15, 2026

Copy link
Copy Markdown

Review Change Stack

Walkthrough

Adds opt-in AES-256-GCM encrypted-at-rest storage for OAuth tokens. A new aesGCMCrypter type in encrypt.go handles nonce-prefixed encryption and atomic secret-file management. StoreOptions gains an Encrypted flag that wires the crypter into readState/writeState. The CLI reads ZERO_OAUTH_STORAGE=encrypted-file to activate encryption and documents it in help text.

Changes

Encrypted OAuth Token Storage

Layer / File(s) Summary
AES-GCM crypter and secret management
internal/oauth/encrypt.go
Defines aesGCMCrypter with seal/open methods (nonce-prefixed blobs) and loadOrCreateSecret with atomic exclusive file creation, strict 32-byte length validation, and fail-closed errors for missing or corrupt secrets.
Store encrypted read/write paths
internal/oauth/store.go
StoreOptions.Encrypted bool and unexported Store.crypter field added; NewStore initializes the crypter; readState decrypts before JSON unmarshalling; writeState encrypts after marshalling before atomic rename.
Encryption and store tests
internal/oauth/encrypt_test.go
Covers AES-GCM round-trip and tamper rejection, loadOrCreateSecret creation/reuse/0600 permissions/corruption, encrypted store round-trip confirming ciphertext on disk, and failure-closed behavior for tampered blobs and missing secret files.
CLI env-var wiring and help text
internal/cli/auth.go, internal/cli/auth_test.go
Imports os, reads ZERO_OAUTH_STORAGE (case-insensitive, trimmed) to set StoreOptions.Encrypted, extends auth help text to document storage path and encrypted-file AES-256-GCM option, and includes a test validating rejection of invalid storage modes.

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~25 minutes

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 31.25% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title accurately summarizes the main change: introducing opt-in AES-256-GCM encrypted-at-rest token storage for OAuth, which is the primary feature across all modified files.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/oauth-encrypted-store

Comment @coderabbitai help to get the list of available commands and usage tips.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@internal/oauth/encrypt_test.go`:
- Around line 57-59: The code ignores errors from file I/O operations before
dereferencing or using their results, which can cause panics. At the os.Stat
call around line 58, check the error return value before calling info.Mode() on
the result. Similarly, at the os.ReadFile call around line 124, check the error
return value before indexing into the raw slice at line 125. Add proper error
handling (either return early or skip the check) for both locations to prevent
panics when these file operations fail.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: b5c8b442-f1b8-4728-93cb-7fbc1d9e94f4

📥 Commits

Reviewing files that changed from the base of the PR and between f857c4a and 563f0a3.

📒 Files selected for processing (4)
  • internal/cli/auth.go
  • internal/oauth/encrypt.go
  • internal/oauth/encrypt_test.go
  • internal/oauth/store.go

Comment thread internal/oauth/encrypt_test.go

@Vasanthdev2004 Vasanthdev2004 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Verdict: APPROVED ✅

Reviewed locally in worktree review-pr213 at 563f0a3. The actual PR diff (1 commit on top of main) is 382 lines across internal/cli/auth.go (12 +), internal/oauth/encrypt.go (new, 116), internal/oauth/encrypt_test.go (new, 145), and internal/oauth/store.go (28 +). gofmt/vet/build clean; go test ./internal/oauth/... passes (9.2s). Full suite shows only the pre-existing TestRunExecUsesProjectConfigAndOpenAICompatibleProvider failure (reproduces on clean main, not this PR).

What it does: adds an opt-in Encrypted: true flag to oauth.StoreOptions. When set, the JSON token file is replaced by an AES-256-GCM ciphertext blob (nonce || ciphertext), with a separate 32-byte random secret persisted in <filePath>.secret (0600). Activated at the CLI by ZERO_OAUTH_STORAGE=encrypted-file. Default is unchanged (0600 plaintext JSON).

Security invariants verified by reading the diff:

  • AES-256-GCM with random nonces via crypto/rand. 32-byte secret generated on first use, persisted atomically (tmp + rename), 0600 permissions, dir 0700.
  • Fail-closed on missing secret: open passes create=false, so a missing secret is a hard error rather than silently minting a new key that could never decrypt the existing file.
  • Tamper detection: GCM auth tag is verified; a flipped byte breaks gcm.Open and surfaces as "wrong secret or tampered".
  • Short-blob guard: len(blob) < gcm.NonceSize() is rejected before attempting decryption.
  • No key material in error messages: the only error that mentions the secret is "token secret %s is missing" (path only, not the secret value).
  • Schema version unchanged: the unified store's schemaVersion: 1 is still validated, so a corrupted/foreign file fails closed.

Tests (encrypt_test.go): round-trip + tamper + missing-secret + short-blob + wrong-sized secret + atomic-rename + dir-creation, all green.

One small observation (not a blocker):

The CLI change wires ZERO_OAUTH_STORAGE=encrypted-file to Encrypted: true and documents it in auth --help. It would be nice to also plumb the keyring backend (from #216) through the same env var as a third option — that way the env var becomes a single switch: plain (default) | encrypted-file | keyring. The PR as-is is consistent with the "encrypted file" addition, and #216's keyring is the natural follow-up to make them siblings.

Merging.

🤖 Generated with Claude Code

@gnanam1990

Copy link
Copy Markdown
Collaborator Author

@CodeRabbit rereview

@coderabbitai

coderabbitai Bot commented Jun 16, 2026

Copy link
Copy Markdown
🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "=== PR Info ==="
gh pr view 213 --repo Gitlawb/zero --json title,state,body,files

echo ""
echo "=== PR Diff ==="
gh pr diff 213 --repo Gitlawb/zero

Length of output: 16695



@gnanam1990 — here's a focused re-review of PR #213. The implementation is solid overall (AES-256-GCM, fail-closed, atomic writes, 0600 permissions, good test coverage). Below are the actionable issues from most to least severe.


🔴 Medium — TOCTOU race in loadOrCreateSecret

Two concurrent CLI invocations can both find the secret file absent, each generate a different 32-byte secret, write to differently-named temp files, then race on os.Rename. The last rename wins, but the losing process retains its generated secret in memory. Any token file it then encrypts with seal() will be permanently unreadable by every subsequent process (including itself after restart), because the on-disk secret no longer matches.

Fix: after a failed rename, re-read the file that won the race and return that secret:

if err := os.Rename(tmp, path); err != nil {
    _ = os.Remove(tmp)
    // Another process won the race — use their secret.
    data, readErr := os.ReadFile(path)
    if readErr == nil && len(data) == secretBytes {
        return data, nil
    }
    return nil, fmt.Errorf("oauth: commit token secret: %w", err)
}

🟡 Low — MkdirAll error missing context wrapping (encrypt.go)

Every other error in loadOrCreateSecret is wrapped with fmt.Errorf("oauth: ...: %w", err), but the MkdirAll return is bare:

// current
if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
    return nil, err   // ← no context
}

// fix
if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
    return nil, fmt.Errorf("oauth: create secret dir: %w", err)
}

🟡 Low — Mode-switch produces a confusing error with no hint

If a user removes ZERO_OAUTH_STORAGE=encrypted-file after first login (or adds it to an existing plaintext store), they get either "invalid JSON" or "encrypted token file is too short" — neither hints at the actual problem. A one-line detection would help:

// In readState, before json.Unmarshal, when crypter == nil:
if len(data) > 0 && !json.Valid(data) {
    return storeFile{}, fmt.Errorf("oauth: token file at %s is not valid JSON; "+
        "if it was written with ZERO_OAUTH_STORAGE=encrypted-file, set that env var to read it", s.filePath)
}

🟡 Low — writeState inconsistency: encrypted seals data, plaintext writes data+\n

payload := append(data, '\n')          // plaintext uses data+newline
if s.crypter != nil {
    payload, err = s.crypter.seal(data) // encrypted seals data (no newline)

The sealed blob contains JSON without a trailing newline. This is harmless (both json.Unmarshal and GCM decryption work fine), but it is inconsistent. Consider sealing payload (with the newline) for uniformity:

payload := append(data, '\n')
if s.crypter != nil {
    payload, err = s.crypter.seal(payload) // seal the same bytes regardless of backend
    ...
}

🔵 Nit — Redundant nil check for now in newAESGCMCrypter

NewStore already guards now:

if now == nil {
    now = time.Now
}
store.crypter = newAESGCMCrypter(store.filePath+".secret", now)

The identical guard inside newAESGCMCrypter is dead code. It's harmless but can be removed for clarity.


✅ What's good

  • AES-256-GCM (confidentiality + integrity), random nonce per write — correct.
  • open(create=false) for missing secret fails closed instead of silently minting a new key — exactly right.
  • Atomic rename for both secret and token file writes.
  • 0o600 on both files, including the secret, enforced in tests on non-Windows.
  • Test coverage for tamper, missing secret, ciphertext-on-disk, and round-trip — all the important failure modes are exercised.
  • No new external dependencies.

The TOCTOU race is the only issue that could cause silent, permanent data loss under concurrent use. The rest are polish items.

✅ Action performed

Full review finished.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🧹 Nitpick comments (3)
internal/oauth/encrypt.go (2)

104-106: ⚡ Quick win

Missing error context wrapping on MkdirAll.

All other errors in loadOrCreateSecret are wrapped with fmt.Errorf("oauth: ..."), but this one is returned bare. Wrap it for consistency and debuggability.

 	if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
-		return nil, err
+		return nil, fmt.Errorf("oauth: create token secret directory: %w", err)
 	}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@internal/oauth/encrypt.go` around lines 104 - 106, The error returned from
the os.MkdirAll call in the loadOrCreateSecret function is not wrapped with
fmt.Errorf, while all other errors in this function are consistently wrapped
with the "oauth: " prefix for context and debuggability. Wrap the error using
fmt.Errorf with an appropriate message like "oauth: " followed by a descriptive
message and the error variable to maintain consistency with the rest of the
function's error handling.

29-34: 💤 Low value

Redundant nil check for now.

NewStore already defaults options.Now to time.Now before passing it to newAESGCMCrypter. This guard is dead code. Remove it or, if you want defense-in-depth, add a brief comment explaining why.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@internal/oauth/encrypt.go` around lines 29 - 34, The nil check for the now
parameter in the newAESGCMCrypter function is redundant because NewStore already
ensures options.Now defaults to time.Now before calling this function. Either
remove the entire if block that checks if now is nil and defaults it to
time.Now, or if you prefer defense-in-depth protection, replace it with a brief
comment explaining why the check is retained despite the earlier default
assignment. The choice depends on whether you want to maintain defensive guard
clauses or rely solely on the upstream default.
internal/oauth/store.go (1)

270-277: ⚡ Quick win

Inconsistent newline handling between encrypted and plaintext paths.

Line 270 appends a trailing newline to data, storing it in payload. But line 273 seals data (without the newline) rather than payload. The plaintext backend writes payload (JSON + newline), while the encrypted backend seals only data (JSON, no newline).

This inconsistency means decrypting and re-encrypting the file changes its content hash, and if you ever need to compare or diff the underlying JSON, the formats diverge. For uniformity, seal the same content:

 	payload := append(data, '\n')
 	if s.crypter != nil {
 		// Encrypted backend: the on-disk file is opaque ciphertext, not JSON.
-		payload, err = s.crypter.seal(data)
+		payload, err = s.crypter.seal(payload)
 		if err != nil {
 			return err
 		}
 	}

Alternatively, skip the newline entirely for both paths if it's not meaningful, but consistency is the key point.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@internal/oauth/store.go` around lines 270 - 277, The newline is appended to
data and stored in payload on line 270, but the encrypted backend only seals the
original data without the newline on line 273, while the plaintext backend
writes the full payload. To ensure consistency between encrypted and plaintext
backends, change the seal call in the if block to seal payload instead of data,
so both paths handle the same content with the trailing newline included.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@internal/cli/auth.go`:
- Around line 132-135: The oauth.NewStore call in the auth initialization
currently silently defaults to plaintext encryption (Encrypted: false) for any
unrecognized ZERO_OAUTH_STORAGE value, which can leave tokens unencrypted
without user awareness. Before creating the store, validate the
ZERO_OAUTH_STORAGE environment variable by retrieving and trimming its value,
then explicitly check if it is either empty (for plaintext) or equals
"encrypted-file" (for encryption). If the value is non-empty but not
"encrypted-file", return an error immediately to fail fast and alert the user of
the invalid configuration, rather than silently downgrading security.

In `@internal/oauth/encrypt.go`:
- Around line 111-114: The code has a TOCTOU race condition in the token secret
file creation where concurrent invocations can generate different secrets and
race on os.Rename, causing the losing process to use an orphaned secret that no
longer matches the on-disk file. After the successful os.Rename in the rename
error handling block, add logic to re-read the secret file from disk and use
that authoritative version instead of the locally generated secret. This ensures
the in-memory secret always matches what is actually stored on disk, preventing
token encryption/decryption mismatches when multiple processes compete to create
the secret file.

---

Nitpick comments:
In `@internal/oauth/encrypt.go`:
- Around line 104-106: The error returned from the os.MkdirAll call in the
loadOrCreateSecret function is not wrapped with fmt.Errorf, while all other
errors in this function are consistently wrapped with the "oauth: " prefix for
context and debuggability. Wrap the error using fmt.Errorf with an appropriate
message like "oauth: " followed by a descriptive message and the error variable
to maintain consistency with the rest of the function's error handling.
- Around line 29-34: The nil check for the now parameter in the newAESGCMCrypter
function is redundant because NewStore already ensures options.Now defaults to
time.Now before calling this function. Either remove the entire if block that
checks if now is nil and defaults it to time.Now, or if you prefer
defense-in-depth protection, replace it with a brief comment explaining why the
check is retained despite the earlier default assignment. The choice depends on
whether you want to maintain defensive guard clauses or rely solely on the
upstream default.

In `@internal/oauth/store.go`:
- Around line 270-277: The newline is appended to data and stored in payload on
line 270, but the encrypted backend only seals the original data without the
newline on line 273, while the plaintext backend writes the full payload. To
ensure consistency between encrypted and plaintext backends, change the seal
call in the if block to seal payload instead of data, so both paths handle the
same content with the trailing newline included.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 5067943d-635d-4911-afc9-76fddd80a66f

📥 Commits

Reviewing files that changed from the base of the PR and between f857c4a and 563f0a3.

📒 Files selected for processing (4)
  • internal/cli/auth.go
  • internal/oauth/encrypt.go
  • internal/oauth/encrypt_test.go
  • internal/oauth/store.go

Comment thread internal/cli/auth.go
Comment thread internal/oauth/encrypt.go Outdated
Resolve CodeRabbit review comments on this PR:

- newAuthManager: reject an unsupported ZERO_OAUTH_STORAGE value instead of
  silently downgrading to plaintext. A mistyped non-empty value now fails fast
  ("invalid ZERO_OAUTH_STORAGE ... (supported: encrypted-file)") so a user who
  believes encryption is on is never left writing plaintext.
- loadOrCreateSecret: fix the first-run TOCTOU race. os.Rename publishes by
  clobbering on POSIX, so two concurrent processes could each generate a secret
  and the loser would silently orphan every token it then encrypts. Create the
  secret exclusively (O_CREATE|O_EXCL); the loser adopts the winner's on-disk
  secret. Factored the read+size-validate into readSecretFile and dropped the
  now-unused clock plumbing from the crypter.
- encrypt_test: handle os.Stat / os.ReadFile errors before dereferencing the
  result (mode/index) so a real I/O failure surfaces instead of a panic.

Adds a test asserting an invalid ZERO_OAUTH_STORAGE fails fast.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@internal/oauth/encrypt.go`:
- Around line 103-105: When encountering os.ErrExist on the file creation
attempt, the current code immediately calls readSecretFile(path) which can fail
if the winning writer hasn't finished writing and closing the file yet. Add a
bounded retry loop around the readSecretFile(path) call in the errors.Is(err,
os.ErrExist) branch to allow time for the concurrent writer to complete. Include
a small backoff delay between retries to avoid spinning, and ensure the retry
count is reasonable (e.g., 3-5 attempts with small millisecond delays) so that
legitimate failures still propagate rather than hanging indefinitely.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 6819520d-9d7e-42c9-93f6-ed6cf3f242ce

📥 Commits

Reviewing files that changed from the base of the PR and between 563f0a3 and 0b040a3.

📒 Files selected for processing (5)
  • internal/cli/auth.go
  • internal/cli/auth_test.go
  • internal/oauth/encrypt.go
  • internal/oauth/encrypt_test.go
  • internal/oauth/store.go
🚧 Files skipped from review as they are similar to previous changes (3)
  • internal/oauth/encrypt_test.go
  • internal/cli/auth.go
  • internal/oauth/store.go

Comment thread internal/oauth/encrypt.go
@gnanam1990

Copy link
Copy Markdown
Collaborator Author

Pushed 0b040a3 addressing CodeRabbit's review:

  • newAuthManager rejects an unsupported ZERO_OAUTH_STORAGE value instead of silently downgrading to plaintext (fails fast with "supported: encrypted-file").
  • loadOrCreateSecret fixes the first-run TOCTOU race: os.Rename clobbers on POSIX, so concurrent processes could each mint a secret and the loser would orphan its tokens. Now creates the secret exclusively (O_CREATE|O_EXCL) and the loser adopts the winner's on-disk secret. Factored read+validate into readSecretFile; dropped the now-unused clock plumbing.
  • encrypt_test handles os.Stat / os.ReadFile errors before dereferencing (no panic masking the real failure).

Added a test that an invalid ZERO_OAUTH_STORAGE fails fast. gofmt / vet / build / go test -race green.

@coderabbitai review

@coderabbitai

coderabbitai Bot commented Jun 16, 2026

Copy link
Copy Markdown

Tip

For best results, initiate chat on the files or code changes.

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "=== Latest PR Diff ==="
gh pr diff 213 --repo Gitlawb/zero

echo ""
echo "=== PR Commits ==="
gh pr view 213 --repo Gitlawb/zero --json commits --jq '.commits[-3:]'

Length of output: 18780



@gnanam1990 — the key fixes look correct. Here's what landed and what's still open.


✅ Fixed

Issue Status
TOCTOU race (O_EXCL + adopt winner's secret on ErrExist) Fixed correctly
Invalid ZERO_OAUTH_STORAGE fails fast with clear error Fixed correctly
Redundant now nil guard in newAESGCMCrypter Dropped entirely (clock plumbing removed)
encrypt_test panic on I/O error before deref Fixed correctly

The TOCTOU fix is the right approach: O_CREATE|O_EXCL wins atomically on every POSIX filesystem, and the losing process adopts the winner's key via readSecretFile(path) — all concurrent first-run processes converge on the same persisted secret.


🟡 Still open (low severity)

1. MkdirAll error still returned bare (encrypt.go, loadOrCreateSecret):

// current — no context
if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
    return nil, err
}

// fix
if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
    return nil, fmt.Errorf("oauth: create secret dir: %w", err)
}

2. writeState seals data instead of payload (store.go):

payload := append(data, '\n')
if s.crypter != nil {
    payload, err = s.crypter.seal(data)  // ← still sealing data, not payload

The sealed blob omits the trailing newline that plaintext gets. Harmless (GCM decryption and json.Unmarshal both work), but inconsistent with the plaintext backend. Sealing payload makes both backends byte-for-byte equivalent before their respective transformations.


🔵 Not addressed (UX polish, lowest priority)

The mode-switch confusing error — when a user toggles ZERO_OAUTH_STORAGE after first login they get raw "invalid JSON" or "encrypted token file is too short" with no hint about the env var mismatch. Happy to file a follow-up issue for this if you'd like.


The two remaining items are both minor. The core correctness and security properties are solid — approve when ready.

CodeRabbit follow-up: the O_EXCL create path read the on-disk secret
immediately after EEXIST, but the winner may not have finished writing it
yet, so a loser could observe a short file and fail transiently. Retry the
read briefly (bounded) so concurrent first-run invocations converge on the
winner's secret. Adds a -race concurrency test asserting convergence.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
internal/oauth/encrypt.go (1)

95-96: ⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Wrap os.MkdirAll error with context for consistency.

Every other error path in this function wraps errors with fmt.Errorf("oauth: ..."), but this one returns the raw error. Minor inconsistency that makes debugging harder if directory creation fails.

Proposed fix
 	if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
-		return nil, err
+		return nil, fmt.Errorf("oauth: create token secret directory: %w", err)
 	}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@internal/oauth/encrypt.go` around lines 95 - 96, The error from os.MkdirAll
is returned raw without wrapping, while all other error paths in this function
use fmt.Errorf with an "oauth: " prefix for consistency. Modify the return
statement to wrap the error from os.MkdirAll using fmt.Errorf with a descriptive
message like "oauth: failed to create directory" to maintain consistency with
the error handling pattern used throughout the rest of the function.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Outside diff comments:
In `@internal/oauth/encrypt.go`:
- Around line 95-96: The error from os.MkdirAll is returned raw without
wrapping, while all other error paths in this function use fmt.Errorf with an
"oauth: " prefix for consistency. Modify the return statement to wrap the error
from os.MkdirAll using fmt.Errorf with a descriptive message like "oauth: failed
to create directory" to maintain consistency with the error handling pattern
used throughout the rest of the function.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: ad9e3dd7-5dcf-4484-a2e8-8619414eb13f

📥 Commits

Reviewing files that changed from the base of the PR and between 0b040a3 and 3ad7b4f.

📒 Files selected for processing (2)
  • internal/oauth/encrypt.go
  • internal/oauth/encrypt_test.go

@gnanam1990
gnanam1990 merged commit 75ecf8e into main Jun 16, 2026
7 checks passed
gnanam1990 added a commit that referenced this pull request Jun 16, 2026
Resolve the conflicts from #213 (AES-256-GCM encrypted token store) landing on
main, which overlapped #216's keyring/store-unification work in the same files:

- internal/oauth/store.go: unify the two backends. Keep #216's blobStore
  architecture (file vs keyring) and graft #213's optional crypter onto the file
  path, so the three backends compose: "" / "file" (plaintext), "encrypted-file"
  (AES-256-GCM, crypter set), and "keyring". StoreOptions keeps Encrypted as a
  legacy alias for Storage=="encrypted-file"; readState decrypts and writeState
  seals when crypter != nil.
- internal/cli/auth.go: newAuthManager validates ZERO_OAUTH_STORAGE against
  {file, encrypted-file, keyring} and passes it through as Storage (the
  auto-merge had kept #213's encrypted-only validation, which rejected keyring);
  merge the help text to document both encrypted-file and keyring.

Both feature suites pass (encrypt + keyring store, the invalid-storage CLI
guard, mcp/swarm/keyring); adds a test for the explicit Storage="encrypted-file"
selector. The only remaining cli failures are the pre-existing env-dependent
doctor/exec tests that pass in CI.
gnanam1990 added a commit that referenced this pull request Jun 16, 2026
Bring the branch current with main (which now carries #213's AES-256-GCM
encrypted token store, #218, #219, #204). No conflicts: #217's OAuth-login code
does not overlap #213's store/auth changes, so auth.go and store.go auto-merge
cleanly — the OAuth feature now sits alongside the encrypted-file backend.
Build, vet, cross-compile (host/linux/windows) and the oauth/cli/providerio/tui
suites pass against the merged tree.
@Vasanthdev2004
Vasanthdev2004 deleted the feat/oauth-encrypted-store branch June 28, 2026 08:27
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants