Skip to content

feat: add AES-GCM encrypted file fallback for keychain storage#146

Open
sunxiaoguang wants to merge 4 commits intolarksuite:mainfrom
sunxiaoguang:fallback_to_file_without_keychain_on_macosx
Open

feat: add AES-GCM encrypted file fallback for keychain storage#146
sunxiaoguang wants to merge 4 commits intolarksuite:mainfrom
sunxiaoguang:fallback_to_file_without_keychain_on_macosx

Conversation

@sunxiaoguang
Copy link
Copy Markdown

@sunxiaoguang sunxiaoguang commented Mar 31, 2026

Overview

This PR implements a fallback mechanism for credential storage. It ensures that lark-cli remains fully functional even when the macOS native keychain is inaccessible due to sandbox restrictions or environment limitations.

Closes: #147

Core Changes

1. Encrypted Storage Layer (internal/keychain)

  • file_encrypted_store.go: Centralizes AES-GCM encryption logic. It manages a master.key and stores encrypted secrets in ~/.lark-cli/keychain/.
  • Error Handling: Introduced ErrUnavailable and ShouldUseFallback to strictly define when the CLI should move from primary to secondary storage.
  • Platform Refactor: Cleaned up keychain_darwin.go and keychain_other.go by moving duplicated file I/O logic into the new shared encrypted store.

2. Enhanced Configuration Workflow (cmd/config)

  • Storage Pipeline: Refactored config init to use a centralized storage helper (storeAndSaveOnlyApp) that handles the store -> save -> cleanup sequence.
  • Rollback Mechanism: If saving the configuration file fails, the CLI now attempts to roll back (delete) any newly stored keychain/fallback entries to prevent "orphaned" credentials.
  • Validation: Added validateSecretReuse to ensure users must re-enter secrets if they change an App ID, preventing credential mismatches.

3. Architecture & Cleanup

  • internal/configdir: Created a dedicated package for resolving the CLI's home directory, replacing scattered os.Getenv and os.UserHomeDir calls.
  • Token Store Migration: Updated internal/auth to support reading/writing tokens to the new fallback store while maintaining backward compatibility with legacy unencrypted token files.

Security Considerations

  • Fallback files and the master key are created with strict 0600 permissions (read/write for the owner only).
  • Data is encrypted using AES-GCM, providing both confidentiality and integrity (authenticity) checks.

Testing Performed

  • init_storage_test.go: Verified fallback triggers when the keychain returns "denied."
  • file_encrypted_store_test.go: Confirmed encryption/decryption works as expected and storage artifacts are cleaned up on removal.
  • Manual Testing: Simulated keychain unavailability on macOS to verify the warning message:

    warning: keychain unavailable, app secret stored in a local encrypted fallback managed by lark-cli

Checklist

  • Comprehensive unit tests added for new storage logic.
  • Manual verification of the upgrade path for existing tokens.

Summary by CodeRabbit

  • Reliability Improvements

    • Credentials and tokens now prefer the system secret store and automatically fall back to an encrypted local file when unavailable; fallbacks are rolled back on save failures and cleaned up on removal or upgrades.
  • New Features

    • Clear user-facing warnings when credentials or refreshed tokens are persisted to local encrypted files (mentions filesystem permissions).
  • Bug Fixes

    • Rejects reuse of credential references across different apps and removes replaced/stale secrets to avoid orphaned stores.

This commit adds a locally managed encrypted store to serve as a
fallback when the system keychain is unreachable.

- Centralizes AES-GCM encryption logic into internal/keychain.
- Updates auth token and app secret resolution to support fallback files.
- Adds user warnings when falling back to local encrypted storage.
- Improves 'config init' reliability with better validation and rollbacks.
- Refactors config directory handling into a dedicated internal package.

Signed-off-by: Xiaoguang Sun <sunxiaoguang@gmail.com>
Copilot AI review requested due to automatic review settings March 31, 2026 09:39
@CLAassistant
Copy link
Copy Markdown

CLAassistant commented Mar 31, 2026

CLA assistant check
All committers have signed the CLA.

@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented Mar 31, 2026

📝 Walkthrough

Walkthrough

Centralizes config-dir resolution; adds AES‑GCM encrypted-file fallback for keychain unavailability; extends secret/token storage to prefer system keychain then fall back to encrypted files with validation, rollback, and user warnings; refactors config-init to use new storage/validation helpers. (50 words)

Changes

Cohort / File(s) Summary
Keychain fallback policy & errors
internal/keychain/errors.go, internal/keychain/fallback_policy.go, internal/keychain/fallback_policy_test.go
Introduce ErrUnavailable sentinel, WrapUnavailable(err) and ShouldUseFallback(err) to mark/wrap keychain-unavailable errors and gate fallback behavior.
Encrypted-file fallback store
internal/keychain/file_encrypted_store.go, internal/keychain/file_encrypted_store_test.go
New file-backed AES‑GCM fallback under config dir with per-service master.key (0600), atomic writes, and APIs: GetFallback, GetFallbackWithError, SetFallback, RemoveFallback.
Platform keychain refactor
internal/keychain/keychain_darwin.go, internal/keychain/keychain_other.go, internal/keychain/keychain_darwin_test.go
Remove duplicated crypto/filename logic from platform files; delegate master-key and encrypted-file ops to shared helpers and wrap platform errors with WrapUnavailable.
Core secret resolution & storage
internal/core/secret.go, internal/core/secret_resolve.go, internal/core/secret_resolve_test.go
Add encrypted_file secret source, implement ForStorageWithEncryptedFallback (keychain → encrypted fallback), update ResolveSecretInput and RemoveSecretStore to handle encrypted-file fallbacks.
Config init refactor & validation
cmd/config/init.go, cmd/config/init_command_test.go, cmd/config/init_storage_test.go
Add storeAndSaveOnlyApp, validateSecretReuse, cleanupReplacedCurrentAppSecret, warning helpers; replace prior inline ForStorage/save/cleanup flows with unified storage/rollback/cleanup sequencing; add storage/rollback/reuse tests.
Token persistence with fallback
internal/auth/token_store.go, internal/auth/token_store_test.go, internal/auth/uat_client.go, cmd/auth/login.go, cmd/auth/login_test.go
Switch token persistence to use tokenKeychain primarily; SetStoredToken returns (usedFallback,bool); write encrypted fallback when allowed; remove legacy managed token file; emit filesystem-permissions warnings when fallback used; tests added.
Config directory centralization
internal/configdir/config_dir.go, internal/core/config.go
Add configdir.Get() (env var → home → warn) and delegate GetConfigDir() to it.
Doctor message & tests docs
cmd/doctor/doctor.go, cmd/config/init_command_test.go
Add warn check-result helper and widen doctor status comment; add or adjust test doc comments.

Sequence Diagram

sequenceDiagram
    participant User
    participant CLI as CLI App
    participant SysKC as System Keychain
    participant EncFS as Encrypted File Fallback
    participant Config as Config Store

    User->>CLI: config init / login (store secret/token)
    CLI->>SysKC: Attempt Set (keychain)
    alt SysKC Unavailable (ShouldUseFallback)
        SysKC-->>CLI: ErrUnavailable
        CLI->>EncFS: SetFallback (AES‑GCM file + master.key)
        EncFS-->>CLI: OK (encrypted file written)
        CLI->>Config: Save config with SecretRef {source:"encrypted_file"}
        Config-->>CLI: OK
        CLI->>User: Emit encrypted-fallback warning (stderr)
    else SysKC OK
        SysKC-->>CLI: OK (keychain stored)
        CLI->>Config: Save config with SecretRef {source:"keychain"}
        Config-->>CLI: OK
    end
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~60 minutes

Poem

🐰 I hopped through code with tiny feet,
AES‑GCM kept secrets neat.
When keychains nap and locks are tight,
I stitch a file to guard the night.
Warned and tidy — hop, all right!

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 69.47% 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 directly describes the main feature implemented: adding an AES-GCM encrypted file fallback mechanism for keychain storage when the system keychain is unavailable.
Linked Issues check ✅ Passed The pull request comprehensively implements all coding requirements from issue #147: AES-GCM encrypted file fallback with master.key (0600 permissions), user warnings on fallback use, secret reuse validation across app ID changes, centralized config directory handling, and token/secret storage migration.
Out of Scope Changes check ✅ Passed All changes directly support the encrypted file fallback feature: new keychain fallback infrastructure, config init refactoring to use the fallback, config directory centralization, token store migration, and comprehensive test coverage.

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

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

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

Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull request overview

This PR adds an AES-GCM encrypted, CLI-managed file store as a fallback for credentials when the macOS native keychain is unavailable (e.g., sandbox restrictions), and updates config/token workflows to use it safely.

Changes:

  • Introduces a shared encrypted-file storage layer with a file-based fallback directory and typed “keychain unavailable” error signaling.
  • Refactors secret storage/resolution to support a new encrypted_file SecretRef source, plus rollback + validation in config init.
  • Migrates token storage to use the fallback store while retaining read-compatibility with legacy unencrypted token files.

Reviewed changes

Copilot reviewed 18 out of 18 changed files in this pull request and generated 3 comments.

Show a summary per file
File Description
internal/keychain/keychain_other.go Removes duplicated crypto/file I/O and delegates to shared encrypted-file helpers on Linux.
internal/keychain/keychain_darwin.go Wraps keychain master-key failures as “unavailable” to enable fallback; delegates file I/O to shared helpers.
internal/keychain/file_encrypted_store.go Adds centralized AES-GCM + master.key handling + fallback Get/Set/Remove APIs.
internal/keychain/file_encrypted_store_test.go Tests encryption, permissions, and cleanup behavior for fallback store.
internal/keychain/fallback_policy.go Adds typed policy (ShouldUseFallback) for deciding when to degrade.
internal/keychain/fallback_policy_test.go Tests fallback policy only triggers on typed unavailable errors.
internal/keychain/errors.go Introduces ErrUnavailable + WrapUnavailable to standardize fallback-eligible failures.
internal/core/secret.go Extends SecretRef schema to allow encrypted_file source.
internal/core/secret_resolve.go Adds encrypted fallback secret resolution + new storage helper with fallback; updates removal logic.
internal/core/secret_resolve_test.go Adds tests for secret storage behavior and file-based secret resolution.
internal/core/config.go Centralizes config dir resolution via new internal/configdir package.
internal/configdir/config_dir.go New package for resolving CLI config dir (env override + home fallback).
internal/auth/token_store.go Adds encrypted fallback + legacy token file migration and cleanup.
internal/auth/token_store_test.go Tests fallback token persistence, removal, legacy reads, and non-fallback on generic errors.
cmd/doctor/doctor.go Updates doctor messaging to reflect “local credential store” rather than keychain only.
cmd/config/init.go Refactors init storage pipeline, adds rollback on save failure, adds secret-ref reuse validation, and warns on fallback usage.
cmd/config/init_storage_test.go Adds tests for fallback triggering, rollback behavior, and secret reuse validation.
cmd/config/init_command_test.go Moves/clarifies test responsibilities between command wiring vs storage behavior.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines +57 to +66
key = make([]byte, masterKeyBytes)
if _, err := rand.Read(key); err != nil {
return nil, err
}
if err := validate.AtomicWrite(keyPath, key, 0600); err != nil {
if existingKey, readErr := loadMasterKeyFile(dir); readErr == nil {
return existingKey, nil
}
return nil, err
}
Copy link

Copilot AI Mar 31, 2026

Choose a reason for hiding this comment

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

loadOrCreateMasterKeyFile uses validate.AtomicWrite to create master.key after a read miss. AtomicWrite renames over the target path unconditionally (see internal/validate/atomicwrite.go), so concurrent processes can race and overwrite an already-created master.key with a different value. That can permanently orphan any secrets written with the previous key. Consider creating master.key with an exclusive create (O_CREATE|O_EXCL) or a “rename-no-replace” strategy (or a filesystem lock) so the key is never replaced once created; if creation fails because the file exists, re-read and use the existing key.

Copilot uses AI. Check for mistakes.
Comment on lines +22 to +26
var safeFileNameRe = regexp.MustCompile(`[^a-zA-Z0-9._-]`)

func safeFileName(account string) string {
return safeFileNameRe.ReplaceAllString(account, "_") + ".enc"
}
Copy link

Copilot AI Mar 31, 2026

Choose a reason for hiding this comment

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

safeFileName() replaces all non-[a-zA-Z0-9.-] characters with "", which can cause different account IDs to collide to the same filename (e.g., ":" vs "_" or other replaced chars), leading to silent overwrites and wrong secret/token retrieval. To make filenames collision-free, consider encoding the full account string (e.g., base64.RawURLEncoding or hex(SHA-256(account))) instead of lossy character replacement.

Copilot uses AI. Check for mistakes.
Comment on lines 62 to +70

select {
case res := <-resCh:
return res.key, res.err
if res.err != nil {
return nil, WrapUnavailable(res.err)
}
return res.key, nil
case <-ctx.Done():
return nil, ctx.Err()
}
}

func encryptData(plaintext string, key []byte) ([]byte, error) {
block, err := aes.NewCipher(key)
if err != nil {
return nil, err
}
aesGCM, err := cipher.NewGCM(block)
if err != nil {
return nil, err
}

iv := make([]byte, ivBytes)
if _, err := rand.Read(iv); err != nil {
return nil, err
return nil, WrapUnavailable(ctx.Err())
Copy link

Copilot AI Mar 31, 2026

Choose a reason for hiding this comment

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

The new WrapUnavailable handling makes keychain failures fallback-eligible, but getMasterKey() still generates a brand-new master key whenever keyring.Get(service, "master.key") returns any error (not just “not found”). If keyring.Get fails transiently (locked/denied read) but keyring.Set succeeds, this rotates master.key and permanently breaks decryption of existing encrypted files. Consider only generating a new master key when the error is a definite not-found (e.g., errors.Is(err, keyring.ErrNotFound)) and otherwise returning WrapUnavailable(err) so callers can fall back without destroying existing data.

Copilot uses AI. Check for mistakes.
@greptile-apps
Copy link
Copy Markdown

greptile-apps Bot commented Mar 31, 2026

Greptile Summary

This PR adds an AES-GCM encrypted file fallback for credential storage so lark-cli remains functional when the platform keychain is inaccessible (e.g., macOS sandbox restrictions). It also refactors the duplicated AES/file-I/O code that previously lived separately in keychain_darwin.go and keychain_other.go into a shared file_encrypted_store.go, introduces a typed ErrUnavailable sentinel for clean fallback gating, centralises config-dir resolution, and hardens the config init pipeline with rollback-on-failure and stale-secret cleanup.

Key changes:

  • internal/keychain/file_encrypted_store.go — new AES-GCM encrypted fallback store; master.key created with O_EXCL to avoid races; reads distinguish "file absent" from "decrypt failure".
  • internal/keychain/errors.go + fallback_policy.go — typed ErrUnavailable contract; only WrapUnavailable errors trigger fallback, not generic keychain errors.
  • internal/auth/token_store.go — three-tier read path (primary keychain → encrypted fallback → legacy plaintext); writes to primary keychain clean up fallback and legacy files atomically.
  • cmd/config/init.gostoreAndSaveOnlyApp helper enforces store→save→cleanup→warn ordering with rollback on config-file save failure.
  • internal/configdir/config_dir.go — new package eliminating scattered os.Getenv/os.UserHomeDir calls.

One logic gap found: In GetStoredToken, the os.IsNotExist guard used to route between "try legacy" and "hard error" also matches a wrapped *PathError produced when master.key itself is missing. In practice the legacy file is already removed by SetStoredToken, so there is no observable impact in the normal flow — but the intent and the implementation are not perfectly aligned (see inline comment).

Confidence Score: 4/5

Safe to merge with one minor logic gap in the legacy-fallback routing that is unlikely to surface in practice.

The PR is well-structured with strong test coverage across all new code paths. The single remaining finding is a P2 logical inconsistency in GetStoredToken where a missing master.key is indistinguishable from a missing encrypted-data file via os.IsNotExist, potentially routing to the legacy token file. Because SetStoredToken always removes the legacy file after writing the encrypted fallback, the real-world impact is nil → re-auth rather than any data exposure. Score is 4 (not 5) to prompt the author to consider tightening this invariant before merging.

internal/auth/token_store.go (lines 95–102) and internal/keychain/file_encrypted_store.go (GetFallbackWithError) for the master-key-missing case discussed in the inline comment.

Important Files Changed

Filename Overview
internal/keychain/file_encrypted_store.go New AES-GCM encrypted fallback store; well-structured with separate error-preserving variants, atomic writes, and no-replace master key semantics. Minor logical gap: GetFallbackWithError returns a wrapped os.ErrNotExist for a missing master.key (not just a missing .enc file), making the two "not found" cases indistinguishable to callers.
internal/auth/token_store.go Token read/write refactored to try primary keychain → encrypted fallback → legacy file. The os.IsNotExist check used to distinguish "file absent" from "decrypt/key error" can be fooled by a missing master.key, potentially causing a spurious legacy-file read; see inline comment.
cmd/config/init.go Centralised secret storage pipeline into storeAndSaveOnlyApp with correct rollback-on-save-failure and old-secret cleanup ordering.
internal/core/secret_resolve.go Added encrypted_file source to ResolveSecretInput and new ForStorageWithEncryptedFallback helper; fallback is correctly skipped for non-ErrUnavailable keychain errors.
internal/keychain/keychain_darwin.go Removed duplicated AES/file-I/O code and replaced with shared helpers; resolveMasterKey now correctly wraps non-ErrNotFound errors as ErrUnavailable instead of silently regenerating the master key.
internal/keychain/keychain_other.go Linux backend trimmed to thin wrappers over shared helpers in file_encrypted_store.go; no functional regressions.
internal/keychain/errors.go Clean typed-error contract for fallback eligibility via ErrUnavailable / WrapUnavailable; multi-unwrap support is correctly implemented.
internal/configdir/config_dir.go New thin package centralising config-dir resolution; correctly prefers LARKSUITE_CLI_CONFIG_DIR env var.
cmd/config/init_storage_test.go Comprehensive new test file covering fallback trigger, rollback-on-failure, secret-ref reuse validation, and old-backend cleanup; all paths exercised correctly.
internal/auth/uat_client.go Minor: doRefreshToken now emits a fallback warning via the token's ErrOut stream when token refresh falls back to the encrypted file store.

Flowchart

%%{init: {'theme': 'neutral'}}%%
flowchart TD
    A[SetStoredToken / ForStorageWithEncryptedFallback] --> B{Primary keychain Set succeeds?}
    B -- yes --> C[Remove encrypted fallback file\nRemove legacy file\nreturn usedFallback=false]
    B -- no --> D{ShouldUseFallback?\nerrors.Is ErrUnavailable}
    D -- no --> E[Return keychain error]
    D -- yes --> F[SetFallback — AES-GCM\n~/.lark-cli/keychain/svc/]
    F --> G[Remove legacy file\nreturn usedFallback=true]
    H[GetStoredToken] --> I{Primary keychain Get non-empty?}
    I -- yes, valid JSON --> J[Return token]
    I -- no / error --> K[GetFallbackWithError]
    K -- success --> L[Parse JSON and return]
    K -- error --> M{os.IsNotExist?}
    M -- yes --> N[readLegacyManagedToken\nlegacy .json file]
    M -- no --> O[Return nil / re-auth required]
    style F fill:#ffe0b2
    style G fill:#ffe0b2
    style N fill:#fff9c4
Loading

Reviews (4): Last reviewed commit: "Merge remote-tracking branch 'origin/mai..." | Re-trigger Greptile

Comment thread cmd/config/init.go Outdated
Comment thread internal/keychain/file_encrypted_store.go
Comment thread internal/keychain/file_encrypted_store.go
Comment thread internal/auth/token_store.go Outdated
Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 5

🧹 Nitpick comments (1)
cmd/config/init_storage_test.go (1)

140-145: Add platform guard or use platform-neutral failure injection.

The test at line 142 uses os.Chmod(configDir, 0500) to simulate a write failure. On Windows, os.Chmod does not enforce POSIX write semantics on directories, making this mechanism unreliable across platforms. While Windows is not currently in the CI test matrix, the test should either be guarded with //go:build !windows to make the platform limitation explicit, or refactored to use a platform-neutral failure injection (e.g., mocking the underlying save operation directly rather than relying on filesystem permissions).

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@cmd/config/init_storage_test.go` around lines 140 - 145, The test uses
os.Chmod(configDir, 0500) inside the trackingKeychain.setFunc to induce a write
failure, which is unreliable on Windows; either add a platform build constraint
(e.g., add //go:build !windows to this test file) to skip it on Windows, or
refactor the test to inject a platform-neutral failure by mocking the save path
instead of changing filesystem permissions — for example modify the test harness
around trackingKeychain and its setFunc to return a synthetic error (e.g.,
return fmt.Errorf("simulated write error")) when invoked, ensuring the test
asserts that the code path in the init/storage logic handles that error without
relying on os.Chmod.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@internal/core/secret_resolve.go`:
- Around line 61-77: ForStorageWithEncryptedFallback currently leaves the other
backend's secret when switching backends; update it so after a successful kc.Set
(in ForStorageWithEncryptedFallback) you also remove the encrypted-file fallback
entry for secretAccountKey(appId) (call the inverse of keychain.SetFallback,
e.g., keychain.DeleteFallback(keychain.LarkCliService, key) or
keychain.RemoveFallback) and likewise after a successful keychain.SetFallback
call remove any existing keychain entry for the same key via the KeychainAccess
delete method (kc.Delete or similar); perform deletions best-effort (ignore
non-fatal errors but return the primary store error if that failed) so the ref
source no longer leaves a stale secret in the other backend.

In `@internal/keychain/errors.go`:
- Around line 16-20: WrapUnavailable currently loses the original error from the
unwrap chain; change its return to join ErrUnavailable with the original err so
callers can inspect both (use errors.Join(ErrUnavailable, err) or otherwise
combine errors so both are unwrappable) — update the WrapUnavailable function to
return errors.Join(ErrUnavailable, err) when err is non-nil and not already
ErrUnavailable, referring to the WrapUnavailable function and ErrUnavailable
symbol.

In `@internal/keychain/file_encrypted_store.go`:
- Around line 45-62: loadOrCreateMasterKeyFile currently writes the generated
master.key into the same fallback directory as the ciphertext (via
validate.AtomicWrite and loadMasterKeyFile), which defeats encryption if the
directory is readable; change the design so the AES master key is not stored
next to the blobs: either integrate with the OS secure key store / keyring API
(replace loadMasterKeyFile calls with a keyring-backed getter), or derive the
master key from a user secret (e.g., scrypt/Argon2 with a stored salt) and only
store non-secret salt next to blobs; update loadOrCreateMasterKeyFile,
loadMasterKeyFile and any validate.AtomicWrite usage to instead read/write the
key from the secure location or perform derivation, and ensure fallback/write
permissions and error handling remain intact.
- Around line 45-67: loadOrCreateMasterKeyFile must only create a new master.key
when the file truly does not exist; currently it generates a key on any read
error and uses validate.AtomicWrite (which renames over the destination) causing
races and unintended rotations. Change loadOrCreateMasterKeyFile to first call
loadMasterKeyFile and if it returns a key return it; if it returns an error,
check if the error is os.IsNotExist (only then proceed to create dir and
generate a new key); create the file using an exclusive-create strategy (e.g.,
write to a temp file and use os.OpenFile with os.O_CREATE|os.O_EXCL or attempt
to write and on EEXIST re-read via loadMasterKeyFile) so concurrent writers
cannot silently overwrite each other; if any write fails because the file now
exists, reload with loadMasterKeyFile and return that key. Ensure references:
loadOrCreateMasterKeyFile, loadMasterKeyFile, validate.AtomicWrite, master.key,
masterKeyBytes.
- Around line 22-25: The current safeFileName function and safeFileNameRe
produce many-to-one collisions by replacing characters with "_"—replace this
with a deterministic, collision-resistant encoder: compute a safe digest (e.g.,
SHA-256) of the full account string and encode it as hex or URL-safe base64 (no
padding), then append ".enc"; keep the original replacement approach only as a
human-readable prefix if desired (e.g., prefix + "-" + digest) so filenames
remain filesystem-safe and unique; update safeFileName and remove reliance on
safeFileNameRe for uniqueness while ensuring the output uses only
filesystem-safe characters.

---

Nitpick comments:
In `@cmd/config/init_storage_test.go`:
- Around line 140-145: The test uses os.Chmod(configDir, 0500) inside the
trackingKeychain.setFunc to induce a write failure, which is unreliable on
Windows; either add a platform build constraint (e.g., add //go:build !windows
to this test file) to skip it on Windows, or refactor the test to inject a
platform-neutral failure by mocking the save path instead of changing filesystem
permissions — for example modify the test harness around trackingKeychain and
its setFunc to return a synthetic error (e.g., return fmt.Errorf("simulated
write error")) when invoked, ensuring the test asserts that the code path in the
init/storage logic handles that error without relying on os.Chmod.
🪄 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: defaults

Review profile: CHILL

Plan: Pro

Run ID: 210a05c8-dd69-4246-9db9-53d533ac04a4

📥 Commits

Reviewing files that changed from the base of the PR and between c8341bb and 631eb84.

📒 Files selected for processing (18)
  • cmd/config/init.go
  • cmd/config/init_command_test.go
  • cmd/config/init_storage_test.go
  • cmd/doctor/doctor.go
  • internal/auth/token_store.go
  • internal/auth/token_store_test.go
  • internal/configdir/config_dir.go
  • internal/core/config.go
  • internal/core/secret.go
  • internal/core/secret_resolve.go
  • internal/core/secret_resolve_test.go
  • internal/keychain/errors.go
  • internal/keychain/fallback_policy.go
  • internal/keychain/fallback_policy_test.go
  • internal/keychain/file_encrypted_store.go
  • internal/keychain/file_encrypted_store_test.go
  • internal/keychain/keychain_darwin.go
  • internal/keychain/keychain_other.go

Comment on lines +61 to +77
// ForStorageWithEncryptedFallback stores a plain secret in keychain when available,
// or falls back to the shared encrypted file store.
func ForStorageWithEncryptedFallback(appId string, input SecretInput, kc keychain.KeychainAccess) (SecretInput, error) {
if !input.IsPlain() {
return input, nil
}
key := secretAccountKey(appId)
if err := kc.Set(keychain.LarkCliService, key, input.Plain); err == nil {
return SecretInput{Ref: &SecretRef{Source: "keychain", ID: key}}, nil
} else if !keychain.ShouldUseFallback(err) {
return SecretInput{}, fmt.Errorf("store secret in keychain: %w", err)
}
if err := keychain.SetFallback(keychain.LarkCliService, key, input.Plain); err != nil {
return SecretInput{}, fmt.Errorf("store secret encrypted fallback: %w", err)
}
return SecretInput{Ref: &SecretRef{Source: "encrypted_file", ID: key}}, nil
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

Same-app reinit can leave a stale encrypted_file secret on disk.

Once this helper starts returning a "keychain" ref again, the old fallback entry for the same appsecret:<appId> is never removed. cleanupOldConfig skips the current app in cmd/config/init.go:71-84, so rerunning config init after keychain access is restored leaves the secret in both backends. Please add a post-save cleanup path for the previous backend when the app ID stays the same and the ref source changes.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@internal/core/secret_resolve.go` around lines 61 - 77,
ForStorageWithEncryptedFallback currently leaves the other backend's secret when
switching backends; update it so after a successful kc.Set (in
ForStorageWithEncryptedFallback) you also remove the encrypted-file fallback
entry for secretAccountKey(appId) (call the inverse of keychain.SetFallback,
e.g., keychain.DeleteFallback(keychain.LarkCliService, key) or
keychain.RemoveFallback) and likewise after a successful keychain.SetFallback
call remove any existing keychain entry for the same key via the KeychainAccess
delete method (kc.Delete or similar); perform deletions best-effort (ignore
non-fatal errors but return the primary store error if that failed) so the ref
source no longer leaves a stale secret in the other backend.

Comment thread internal/keychain/errors.go Outdated
Comment on lines +16 to +20
func WrapUnavailable(err error) error {
if err == nil || errors.Is(err, ErrUnavailable) {
return err
}
return fmt.Errorf("%w: %v", ErrUnavailable, err)
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

🧩 Analysis chain

🏁 Script executed:

# First, let's examine the errors.go file to understand the current implementation
cat -n internal/keychain/errors.go

Repository: larksuite/cli

Length of output: 777


🏁 Script executed:

# Check the keychain_darwin.go file to see how WrapUnavailable is used
fd -t f "keychain_darwin.go" | head -5 | xargs -I {} sh -c 'echo "File: {}"; cat -n {} | sed -n "60,75p"'

Repository: larksuite/cli

Length of output: 531


🏁 Script executed:

# Let's verify the exact error wrapping semantics by checking Go documentation
# and testing fmt.Errorf behavior with %w
python3 << 'EOF'
# Document Go fmt.Errorf error wrapping semantics
# %w wraps the error (puts it in the error chain for errors.Unwrap)
# %v converts to string (does NOT wrap)

# In fmt.Errorf("%w: %v", ErrUnavailable, err):
# - ErrUnavailable is wrapped (position of %w)
# - err is formatted as string only (position of %v)

# This means:
# - errors.Is(result, ErrUnavailable) == true
# - errors.Is(result, originalErr) == false  <-- This is the issue!
# - errors.Unwrap(result) returns ErrUnavailable, not originalErr

print("Go fmt.Errorf wrapping semantics:")
print("- %w: wraps the error (becomes part of error chain)")
print("- %v: formats as string only (not wrapped)")
print("")
print("Current implementation: fmt.Errorf(\"%w: %v\", ErrUnavailable, err)")
print("Result: Only ErrUnavailable is in the error chain")
print("Impact: errors.Is(err, originalError) will be false")
EOF

Repository: larksuite/cli

Length of output: 354


Make underlying errors inspectable with errors.Is and errors.As.

The current implementation fmt.Errorf("%w: %v", ErrUnavailable, err) only wraps ErrUnavailable in the error chain. The original error (including context timeouts/cancellations at line 70) is lost from error chain inspection—callers cannot use errors.Is or errors.As to detect them. Use a pattern that wraps both the lower-level error and ErrUnavailable so the original cause remains unwrap-able.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@internal/keychain/errors.go` around lines 16 - 20, WrapUnavailable currently
loses the original error from the unwrap chain; change its return to join
ErrUnavailable with the original err so callers can inspect both (use
errors.Join(ErrUnavailable, err) or otherwise combine errors so both are
unwrappable) — update the WrapUnavailable function to return
errors.Join(ErrUnavailable, err) when err is non-nil and not already
ErrUnavailable, referring to the WrapUnavailable function and ErrUnavailable
symbol.

Comment thread internal/keychain/file_encrypted_store.go Outdated
Comment on lines +45 to +62
func loadOrCreateMasterKeyFile(dir string) ([]byte, error) {
keyPath := filepath.Join(dir, "master.key")

key, err := loadMasterKeyFile(dir)
if err == nil {
return key, nil
}

if err := os.MkdirAll(dir, 0700); err != nil {
return nil, err
}

key = make([]byte, masterKeyBytes)
if _, err := rand.Read(key); err != nil {
return nil, err
}
if err := validate.AtomicWrite(keyPath, key, 0600); err != nil {
if existingKey, readErr := loadMasterKeyFile(dir); readErr == nil {
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

The fallback key is stored beside the ciphertext.

Writing master.key into the same fallback directory means any process or user that can read ~/.lark-cli/keychain/<service> can decrypt every entry. In practice the protection comes from the 0600 file mode, not the AES-GCM layer. If this downgrade is intentional, the warning/docs should say it is weaker than native keychain storage; otherwise the key has to come from something not stored next to the blobs.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@internal/keychain/file_encrypted_store.go` around lines 45 - 62,
loadOrCreateMasterKeyFile currently writes the generated master.key into the
same fallback directory as the ciphertext (via validate.AtomicWrite and
loadMasterKeyFile), which defeats encryption if the directory is readable;
change the design so the AES master key is not stored next to the blobs: either
integrate with the OS secure key store / keyring API (replace loadMasterKeyFile
calls with a keyring-backed getter), or derive the master key from a user secret
(e.g., scrypt/Argon2 with a stored salt) and only store non-secret salt next to
blobs; update loadOrCreateMasterKeyFile, loadMasterKeyFile and any
validate.AtomicWrite usage to instead read/write the key from the secure
location or perform derivation, and ensure fallback/write permissions and error
handling remain intact.

Comment thread internal/keychain/file_encrypted_store.go
@sunxiaoguang
Copy link
Copy Markdown
Author

I will work on the comments later

Signed-off-by: Xiaoguang Sun <sunxiaoguang@gmail.com>
Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 2

Caution

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

⚠️ Outside diff range comments (2)
internal/auth/token_store.go (1)

69-89: ⚠️ Potential issue | 🔴 Critical

Handle the “primary + fallback both exist” case explicitly.

A user can already have a token in the native keychain, then refresh/login while the keychain is unavailable and write a newer token only to fallback. Once the keychain is reachable again, GetStoredToken returns the older primary copy first and ignores the newer fallback one, so auth can roll back to stale credentials. Please read both stores and resolve conflicts with a freshness signal that changes on refresh (for example later expiry metadata or a dedicated persisted write timestamp), then clean up the older copy. A regression test with stale-primary/newer-fallback coexistence would catch this.

Also applies to: 92-109

cmd/auth/login.go (1)

294-312: ⚠️ Potential issue | 🟠 Major

Roll back token mutations if saving multi fails.

Both login paths persist the new token and delete other users' tokens before core.SaveMultiAppConfig. If that save fails while switching users, the config still points at the old user set but some of those old tokens may already be gone, while the new token is orphaned. Please use the same store→save→rollback pattern here, or at least postpone old-token cleanup until after the config write succeeds.

Also applies to: 376-393

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@cmd/auth/login.go` around lines 294 - 312, When switching the active user you
must avoid deleting other users' tokens before the config write succeeds:
instead, capture the list of old userOpenIds from core.LoadMultiAppConfig, call
core.SaveMultiAppConfig with the new app.Users first, and only after
SaveMultiAppConfig returns nil iterate and call larkauth.RemoveStoredToken for
the oldUser.UserOpenId values; if SaveMultiAppConfig fails, roll back the newly
stored token by calling larkauth.RemoveStoredToken(config.AppID, openId) (the
code paths using larkauth.SetStoredToken, larkauth.RemoveStoredToken,
core.LoadMultiAppConfig and core.SaveMultiAppConfig should be updated
accordingly).
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@internal/keychain/keychain_darwin.go`:
- Around line 100-118: The Darwin read/remove paths now only use the new
safeFileName/raw-url-base64 filenames and will miss pre-upgrade items; update
the platform read function (the one that currently returns
readEncryptedFile(StorageDir(service), account, key)) and platformRemove to fall
back to the legacy Darwin filename mapping: after computing key via
getMasterKey(service) and attempting the normal
readEncryptedFile(StorageDir(service), account, key) (or removeEncryptedFile),
if that returns “not found”, try the legacy regexp-based filename variants used
by the old Darwin implementation (reconstruct the old filename mapping logic and
attempt readEncryptedFile/removeEncryptedFile for those legacy names under
StorageDir(service)); keep using readEncryptedFile, removeEncryptedFile,
safeFileName, getMasterKey and StorageDir so only lookup/migration logic
changes.
- Around line 37-58: The current logic in the getFn success branch incorrectly
wraps base64 decode and master-key length failures with WrapUnavailable, causing
corrupted key entries to be treated as "unavailable" and trigger fallbacks;
change the behavior in the branch where err == nil so that decode errors (from
base64.StdEncoding.DecodeString) and invalid length checks against
masterKeyBytes are returned as direct hard errors (e.g., fmt.Errorf or a
domain-specific error) instead of WrapUnavailable, while preserving
WrapUnavailable only for true keychain access failures (cases where
errors.Is(err, keyring.ErrNotFound) or other getFn errors); keep generation via
generateFn and storage via setFn unchanged.

---

Outside diff comments:
In `@cmd/auth/login.go`:
- Around line 294-312: When switching the active user you must avoid deleting
other users' tokens before the config write succeeds: instead, capture the list
of old userOpenIds from core.LoadMultiAppConfig, call core.SaveMultiAppConfig
with the new app.Users first, and only after SaveMultiAppConfig returns nil
iterate and call larkauth.RemoveStoredToken for the oldUser.UserOpenId values;
if SaveMultiAppConfig fails, roll back the newly stored token by calling
larkauth.RemoveStoredToken(config.AppID, openId) (the code paths using
larkauth.SetStoredToken, larkauth.RemoveStoredToken, core.LoadMultiAppConfig and
core.SaveMultiAppConfig should be updated accordingly).
🪄 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: defaults

Review profile: CHILL

Plan: Pro

Run ID: 04b5fb62-cd6b-489e-bc56-c37cce15775f

📥 Commits

Reviewing files that changed from the base of the PR and between 631eb84 and c1cadff.

📒 Files selected for processing (15)
  • cmd/auth/login.go
  • cmd/auth/login_test.go
  • cmd/config/init.go
  • cmd/config/init_storage_test.go
  • internal/auth/token_store.go
  • internal/auth/token_store_test.go
  • internal/auth/uat_client.go
  • internal/core/secret_resolve.go
  • internal/core/secret_resolve_test.go
  • internal/keychain/errors.go
  • internal/keychain/fallback_policy_test.go
  • internal/keychain/file_encrypted_store.go
  • internal/keychain/file_encrypted_store_test.go
  • internal/keychain/keychain_darwin.go
  • internal/keychain/keychain_darwin_test.go
✅ Files skipped from review due to trivial changes (1)
  • cmd/config/init_storage_test.go
🚧 Files skipped from review as they are similar to previous changes (6)
  • internal/keychain/fallback_policy_test.go
  • internal/keychain/errors.go
  • cmd/config/init.go
  • internal/keychain/file_encrypted_store_test.go
  • internal/core/secret_resolve_test.go
  • internal/core/secret_resolve.go

Comment on lines +37 to +58
encodedKey, err := getFn()
switch {
case err == nil:
key, decodeErr := base64.StdEncoding.DecodeString(encodedKey)
if decodeErr != nil {
return nil, WrapUnavailable(fmt.Errorf("decode master key: %w", decodeErr))
}
if len(key) != masterKeyBytes {
return nil, WrapUnavailable(fmt.Errorf("invalid master key length: %d", len(key)))
}
return key, nil
case errors.Is(err, keyring.ErrNotFound):
key, genErr := generateFn()
if genErr != nil {
return nil, genErr
}
if setErr := setFn(base64.StdEncoding.EncodeToString(key)); setErr != nil {
return nil, WrapUnavailable(setErr)
}
return key, nil
default:
return nil, WrapUnavailable(err)
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

Don't classify malformed master-key data as keychain unavailability.

ShouldUseFallback is supposed to trigger only for real keychain-unavailable failures. Wrapping base64/length validation errors with ErrUnavailable means a corrupted keyring entry will silently divert new writes into the fallback store, while the existing Darwin ciphertext remains unreadable. This should be a hard error instead of a fallback signal.

Possible fix
 	case err == nil:
 		key, decodeErr := base64.StdEncoding.DecodeString(encodedKey)
 		if decodeErr != nil {
-			return nil, WrapUnavailable(fmt.Errorf("decode master key: %w", decodeErr))
+			return nil, fmt.Errorf("decode master key: %w", decodeErr)
 		}
 		if len(key) != masterKeyBytes {
-			return nil, WrapUnavailable(fmt.Errorf("invalid master key length: %d", len(key)))
+			return nil, fmt.Errorf("invalid master key length: %d", len(key))
 		}
 		return key, nil
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
encodedKey, err := getFn()
switch {
case err == nil:
key, decodeErr := base64.StdEncoding.DecodeString(encodedKey)
if decodeErr != nil {
return nil, WrapUnavailable(fmt.Errorf("decode master key: %w", decodeErr))
}
if len(key) != masterKeyBytes {
return nil, WrapUnavailable(fmt.Errorf("invalid master key length: %d", len(key)))
}
return key, nil
case errors.Is(err, keyring.ErrNotFound):
key, genErr := generateFn()
if genErr != nil {
return nil, genErr
}
if setErr := setFn(base64.StdEncoding.EncodeToString(key)); setErr != nil {
return nil, WrapUnavailable(setErr)
}
return key, nil
default:
return nil, WrapUnavailable(err)
encodedKey, err := getFn()
switch {
case err == nil:
key, decodeErr := base64.StdEncoding.DecodeString(encodedKey)
if decodeErr != nil {
return nil, fmt.Errorf("decode master key: %w", decodeErr)
}
if len(key) != masterKeyBytes {
return nil, fmt.Errorf("invalid master key length: %d", len(key))
}
return key, nil
case errors.Is(err, keyring.ErrNotFound):
key, genErr := generateFn()
if genErr != nil {
return nil, genErr
}
if setErr := setFn(base64.StdEncoding.EncodeToString(key)); setErr != nil {
return nil, WrapUnavailable(setErr)
}
return key, nil
default:
return nil, WrapUnavailable(err)
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@internal/keychain/keychain_darwin.go` around lines 37 - 58, The current logic
in the getFn success branch incorrectly wraps base64 decode and master-key
length failures with WrapUnavailable, causing corrupted key entries to be
treated as "unavailable" and trigger fallbacks; change the behavior in the
branch where err == nil so that decode errors (from
base64.StdEncoding.DecodeString) and invalid length checks against
masterKeyBytes are returned as direct hard errors (e.g., fmt.Errorf or a
domain-specific error) instead of WrapUnavailable, while preserving
WrapUnavailable only for true keychain access failures (cases where
errors.Is(err, keyring.ErrNotFound) or other getFn errors); keep generation via
generateFn and storage via setFn unchanged.

Comment on lines +100 to +118
// Shared encrypted-file read semantics live in file_encrypted_store.go.
// New code should reuse that helper layer instead of reimplementing file I/O here.
return readEncryptedFile(StorageDir(service), account, key)
}

func platformSet(service, account, data string) error {
key, err := getMasterKey(service)
if err != nil {
return err
}
dir := StorageDir(service)
if err := os.MkdirAll(dir, 0700); err != nil {
return err
}
encrypted, err := encryptData(data, key)
if err != nil {
return err
}

targetPath := filepath.Join(dir, safeFileName(account))
tmpPath := filepath.Join(dir, safeFileName(account)+"."+uuid.New().String()+".tmp")
defer os.Remove(tmpPath)

if err := os.WriteFile(tmpPath, encrypted, 0600); err != nil {
return err
}

// Atomic rename to prevent file corruption during multi-process writes
if err := os.Rename(tmpPath, targetPath); err != nil {
return err
}
return nil
// Shared encrypted-file write semantics live in file_encrypted_store.go.
// New code should reuse that helper layer instead of reimplementing file I/O here.
return writeEncryptedFile(StorageDir(service), account, data, key)
}

func platformRemove(service, account string) error {
err := os.Remove(filepath.Join(StorageDir(service), safeFileName(account)))
if err != nil && !os.IsNotExist(err) {
return err
}
return nil
// Shared encrypted-file cleanup semantics live in file_encrypted_store.go.
// New code should reuse that helper layer instead of reimplementing file I/O here.
return removeEncryptedFile(StorageDir(service), account)
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🔴 Critical

Preserve the legacy Darwin filename mapping on read/remove.

These calls now resolve primary macOS ciphertext paths through the shared safeFileName implementation, which uses raw-url-base64 filenames. The previous Darwin implementation used a different regexp-based filename mapping, so existing secrets/tokens written before this change will stop being found after upgrade unless you keep a legacy lookup/migration path.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@internal/keychain/keychain_darwin.go` around lines 100 - 118, The Darwin
read/remove paths now only use the new safeFileName/raw-url-base64 filenames and
will miss pre-upgrade items; update the platform read function (the one that
currently returns readEncryptedFile(StorageDir(service), account, key)) and
platformRemove to fall back to the legacy Darwin filename mapping: after
computing key via getMasterKey(service) and attempting the normal
readEncryptedFile(StorageDir(service), account, key) (or removeEncryptedFile),
if that returns “not found”, try the legacy regexp-based filename variants used
by the old Darwin implementation (reconstruct the old filename mapping logic and
attempt readEncryptedFile/removeEncryptedFile for those legacy names under
StorageDir(service)); keep using readEncryptedFile, removeEncryptedFile,
safeFileName, getMasterKey and StorageDir so only lookup/migration logic
changes.

Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

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 (2)
cmd/config/init.go (1)

249-259: ⚠️ Potential issue | 🟠 Major

This unchanged-secret branch still bypasses the new cleanup path.

Unlike the plain-secret branches, this one writes existing back directly and never calls cleanupOldConfig(). If a legacy multi-app config is loaded, extra app entries and their stored tokens survive after config init even though the rest of the command now normalizes through a single save/cleanup flow. Please reuse storeAndSaveOnlyApp, or add an equivalent single-app save + cleanup path here if you need to preserve current users.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@cmd/config/init.go` around lines 249 - 259, The unchanged-secret branch
updates existing.Apps[0] and calls core.SaveMultiAppConfig, which bypasses the
new single-app cleanup path (cleanupOldConfig/storeAndSaveOnlyApp) and leaves
legacy extra entries/tokens; modify this branch to reuse the single-app
save+cleanup flow by calling the existing helper storeAndSaveOnlyApp (or
implement the equivalent: build a single-app config from existing.Apps[0] with
updated AppId/Brand/Lang, then call cleanupOldConfig and the single-app save
routine) instead of calling core.SaveMultiAppConfig directly after
validateSecretReuse.
internal/auth/token_store.go (1)

74-92: ⚠️ Potential issue | 🟠 Major

Reconcile keychain and fallback reads instead of trusting keychain first.

After the fallback write on Lines 109-113, an older keychain entry can still remain because the keychain was unavailable. This read path returns that stale value as soon as keychain access recovers and never reaches the newer fallback copy. Please compare both stores and choose the newer token (for example by GrantedAt), or otherwise make the fallback authoritative until a later primary write succeeds.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@internal/auth/token_store.go` around lines 74 - 92, The current read flow in
token_store.go calls tokenKeychain.Get(...) then
keychain.GetFallbackWithError(...), but returns the first non-empty keychain
result which can be stale; modify the logic in the function that reads tokens
(uses tokenKeychain.Get, keychain.GetFallbackWithError, accountKey, appId,
userOpenId and unmarshals into StoredUAToken) to read both sources first,
unmarshal both into StoredUAToken, and then choose the most recent token by
comparing the GrantedAt timestamps (or else treat the fallback as authoritative
until a subsequent primary write succeeds); ensure you still call
readLegacyManagedToken(appId, userOpenId) when both fail appropriately and only
return nil on real errors.
♻️ Duplicate comments (1)
internal/keychain/file_encrypted_store.go (1)

23-25: ⚠️ Potential issue | 🟠 Major

Use a lowercase-only filename derivation here.

base64.RawURLEncoding is only collision-free on case-sensitive filesystems. On the default case-insensitive macOS volumes this fallback targets, two different account keys whose encodings differ only by letter case can alias the same .enc path and overwrite each other. Please switch to a lowercase-only deterministic mapping such as hex-encoded SHA-256.

Possible fix
 import (
 	"crypto/aes"
 	"crypto/cipher"
 	"crypto/rand"
-	"encoding/base64"
+	"crypto/sha256"
+	"encoding/hex"
 	"fmt"
 	"os"
 	"path/filepath"
@@
 func safeFileName(account string) string {
-	return base64.RawURLEncoding.EncodeToString([]byte(account)) + ".enc"
+	sum := sha256.Sum256([]byte(account))
+	return hex.EncodeToString(sum[:]) + ".enc"
 }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@internal/keychain/file_encrypted_store.go` around lines 23 - 25, The
safeFileName function currently uses base64.RawURLEncoding which can produce
mixed-case filenames and collide on case-insensitive filesystems; replace its
implementation so it deterministically computes a lowercase-only hex SHA-256
digest of the account string and appends ".enc" (e.g., compute SHA-256(account)
and hex-encode the sum in lowercase), updating any imports as needed; keep the
function name safeFileName and the returned suffix ".enc".
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Outside diff comments:
In `@cmd/config/init.go`:
- Around line 249-259: The unchanged-secret branch updates existing.Apps[0] and
calls core.SaveMultiAppConfig, which bypasses the new single-app cleanup path
(cleanupOldConfig/storeAndSaveOnlyApp) and leaves legacy extra entries/tokens;
modify this branch to reuse the single-app save+cleanup flow by calling the
existing helper storeAndSaveOnlyApp (or implement the equivalent: build a
single-app config from existing.Apps[0] with updated AppId/Brand/Lang, then call
cleanupOldConfig and the single-app save routine) instead of calling
core.SaveMultiAppConfig directly after validateSecretReuse.

In `@internal/auth/token_store.go`:
- Around line 74-92: The current read flow in token_store.go calls
tokenKeychain.Get(...) then keychain.GetFallbackWithError(...), but returns the
first non-empty keychain result which can be stale; modify the logic in the
function that reads tokens (uses tokenKeychain.Get,
keychain.GetFallbackWithError, accountKey, appId, userOpenId and unmarshals into
StoredUAToken) to read both sources first, unmarshal both into StoredUAToken,
and then choose the most recent token by comparing the GrantedAt timestamps (or
else treat the fallback as authoritative until a subsequent primary write
succeeds); ensure you still call readLegacyManagedToken(appId, userOpenId) when
both fail appropriately and only return nil on real errors.

---

Duplicate comments:
In `@internal/keychain/file_encrypted_store.go`:
- Around line 23-25: The safeFileName function currently uses
base64.RawURLEncoding which can produce mixed-case filenames and collide on
case-insensitive filesystems; replace its implementation so it deterministically
computes a lowercase-only hex SHA-256 digest of the account string and appends
".enc" (e.g., compute SHA-256(account) and hex-encode the sum in lowercase),
updating any imports as needed; keep the function name safeFileName and the
returned suffix ".enc".

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 9aa94aee-4710-4c30-8672-1c387a6fd295

📥 Commits

Reviewing files that changed from the base of the PR and between c1cadff and cc78be3.

📒 Files selected for processing (19)
  • cmd/auth/login.go
  • cmd/auth/login_test.go
  • cmd/config/init.go
  • cmd/config/init_command_test.go
  • cmd/config/init_storage_test.go
  • cmd/doctor/doctor.go
  • internal/auth/token_store.go
  • internal/auth/token_store_test.go
  • internal/auth/uat_client.go
  • internal/core/secret.go
  • internal/core/secret_resolve.go
  • internal/core/secret_resolve_test.go
  • internal/keychain/errors.go
  • internal/keychain/fallback_policy_test.go
  • internal/keychain/file_encrypted_store.go
  • internal/keychain/file_encrypted_store_test.go
  • internal/keychain/keychain_darwin.go
  • internal/keychain/keychain_darwin_test.go
  • internal/keychain/keychain_other.go
✅ Files skipped from review due to trivial changes (9)
  • cmd/config/init_command_test.go
  • internal/keychain/fallback_policy_test.go
  • cmd/doctor/doctor.go
  • internal/auth/uat_client.go
  • internal/auth/token_store_test.go
  • internal/keychain/file_encrypted_store_test.go
  • internal/core/secret_resolve_test.go
  • internal/keychain/keychain_darwin.go
  • cmd/config/init_storage_test.go
🚧 Files skipped from review as they are similar to previous changes (5)
  • cmd/auth/login_test.go
  • internal/keychain/keychain_darwin_test.go
  • internal/core/secret.go
  • internal/keychain/keychain_other.go
  • internal/core/secret_resolve.go

Comment on lines +12 to +14
"os"
"path/filepath"

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Filename scheme change silently breaks existing stored credentials

The safeFileName implementation was changed from regex-sanitization (replacing special characters with underscores) to raw URL-encoding. These two schemes produce completely different on-disk names for the same account key, so any credentials written by the old format will never be found by the new code.

Both keychain_darwin.go and keychain_other.go now delegate their platformGet/Set/Remove implementations to the shared helpers in this file, meaning every pre-existing encrypted credential file on macOS under ~/Library/Application Support/<service>/ and on Linux under ~/.local/share/<service>/ will be silently ignored after this upgrade. Users will lose access to their stored app secrets and auth tokens on first run.

The PR correctly adds readLegacyManagedToken to migrate old plaintext token files, but there is no equivalent migration for the old encrypted-file naming convention. A migration path such as falling back to the old sanitized name when the new-format file is absent, or renaming existing files on first access, is needed to avoid silent credential loss on upgrade.

…out_keychain_on_macosx

# Conflicts:
#	cmd/auth/login.go
#	cmd/doctor/doctor.go
Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

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

🧹 Nitpick comments (1)
cmd/doctor/doctor.go (1)

67-70: Test coverage missing for the new "warn" status.

The relevant test file (doctor_test.go) only covers "pass", "fail", and "skip" statuses. There's no test verifying that "warn" status is treated as non-failing (i.e., allOK remains true). Consider adding a test case that includes warn() results to confirm the expected behavior.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@cmd/doctor/doctor.go` around lines 67 - 70, Add a unit test in doctor_test.go
that exercises the new warn status by calling warn(name,msg,hint) and including
its result in the checks list used to compute allOK; assert that the warn check
produces Status "warn" and that allOK remains true (i.e., warn is non-failing).
Reference the warn function and the code path that computes allOK (the function
that iterates checkResult values and sets allOK) so the test verifies both the
Status string and that warn does not flip allOK to false.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Nitpick comments:
In `@cmd/doctor/doctor.go`:
- Around line 67-70: Add a unit test in doctor_test.go that exercises the new
warn status by calling warn(name,msg,hint) and including its result in the
checks list used to compute allOK; assert that the warn check produces Status
"warn" and that allOK remains true (i.e., warn is non-failing). Reference the
warn function and the code path that computes allOK (the function that iterates
checkResult values and sets allOK) so the test verifies both the Status string
and that warn does not flip allOK to false.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 2bda8a15-9050-47b8-9ecf-ea88a62d4182

📥 Commits

Reviewing files that changed from the base of the PR and between cc78be3 and b87804c.

📒 Files selected for processing (2)
  • cmd/auth/login.go
  • cmd/doctor/doctor.go
🚧 Files skipped from review as they are similar to previous changes (1)
  • cmd/auth/login.go

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.

[Feature] Implement Encrypted File Fallback for Keychain Storage

3 participants