Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 21 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -222,6 +222,27 @@ lark-cli api POST /open-apis/im/v1/messages --params '{"receive_id_type":"chat_i

## Advanced Usage

### macOS Credential Backend

On macOS, credentials use a local encrypted file backend by default so local
automation, headless sessions, and AI agent workflows are not interrupted by
Keychain prompts:

```bash
lark-cli config init
lark-cli auth login --recommend
```

Set `LARKSUITE_CLI_KEYCHAIN_BACKEND` only when you need a different backend:

Supported values are:

| Value | Behavior |
| ---------- | ------------------------------------------------------------------------ |
| `file` | Default: store the encryption master key in a local `0600` file and never prompt Keychain |
| `auto` | Prefer an existing file master key, otherwise use Keychain, with file fallback on write failure |
| `keychain` | Force macOS Keychain and fail if it is unavailable |

### Output Formats

```bash
Expand Down
20 changes: 20 additions & 0 deletions README.zh.md
Original file line number Diff line number Diff line change
Expand Up @@ -223,6 +223,26 @@ lark-cli api POST /open-apis/im/v1/messages --params '{"receive_id_type":"chat_i

## 进阶用法

### macOS 凭证后端

macOS 默认使用本地加密文件后端保存凭证,避免本机自动化、无头会话或 AI Agent
工作流被 Keychain 密码/指纹弹窗打断:

```bash
lark-cli config init
lark-cli auth login --recommend
```

只有需要切换后端时,才需要设置 `LARKSUITE_CLI_KEYCHAIN_BACKEND`:

支持的取值:

| 取值 | 行为 |
| ---------- | ------------------------------------------------------------------------ |
| `file` | 默认:将加密 master key 存在本地 `0600` 文件中,并且不会触发 Keychain 弹窗 |
| `auto` | 优先使用已有文件 master key,否则使用 Keychain,写入失败时 fallback 到文件 |
| `keychain` | 强制使用 macOS Keychain;不可用时直接失败 |

### 输出格式

```bash
Expand Down
72 changes: 66 additions & 6 deletions internal/keychain/keychain_darwin.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,9 +12,11 @@ import (
"crypto/rand"
"encoding/base64"
"errors"
"fmt"
"os"
"path/filepath"
"regexp"
"strings"
"time"

"github.com/google/uuid"
Expand All @@ -37,12 +39,39 @@ const tagBytes = 16
// fileMasterKeyName is the local fallback master key file name.
const fileMasterKeyName = "master.key.file"

// keychainBackendEnv selects the macOS secret master-key backend.
const keychainBackendEnv = "LARKSUITE_CLI_KEYCHAIN_BACKEND"

type keychainBackendMode string

const (
keychainBackendAuto keychainBackendMode = "auto"
keychainBackendKeychain keychainBackendMode = "keychain"
keychainBackendFile keychainBackendMode = "file"
)

// keyringGet is overridden in tests to simulate system keychain reads.
var keyringGet = keyring.Get

// keyringSet is overridden in tests to simulate system keychain writes.
var keyringSet = keyring.Set

func resolveKeychainBackendMode() (keychainBackendMode, error) {
value := strings.TrimSpace(strings.ToLower(os.Getenv(keychainBackendEnv)))
switch value {
case "":
return keychainBackendFile, nil
case string(keychainBackendAuto):
return keychainBackendAuto, nil
case string(keychainBackendKeychain):
return keychainBackendKeychain, nil
case string(keychainBackendFile):
return keychainBackendFile, nil
default:
return "", fmt.Errorf("invalid %s %q (want auto, keychain, or file)", keychainBackendEnv, value)
}
}

// StorageDir returns the storage directory for a given service name on macOS.
func StorageDir(service string) string {
home, err := vfs.UserHomeDir()
Expand Down Expand Up @@ -256,11 +285,28 @@ func platformGet(service, account string) (string, error) {
if err != nil {
return "", err
}
if key, ferr := getFileMasterKey(service, false); ferr == nil {
if plaintext, derr := decryptData(data, key); derr == nil {
return plaintext, nil

mode, err := resolveKeychainBackendMode()
if err != nil {
return "", err
}

if mode == keychainBackendFile {
key, err := getFileMasterKey(service, false)
if err != nil {
return "", err
}
return decryptData(data, key)
}

if mode == keychainBackendAuto {
if key, ferr := getFileMasterKey(service, false); ferr == nil {
if plaintext, derr := decryptData(data, key); derr == nil {
return plaintext, nil
}
}
}

key, err := getMasterKey(service, false)
if err != nil {
return "", err
Expand All @@ -274,16 +320,30 @@ func platformGet(service, account string) (string, error) {

// platformSet stores a value in the macOS keychain.
func platformSet(service, account, data string) error {
key, err := getFileMasterKey(service, false)
mode, err := resolveKeychainBackendMode()
if err != nil {
return err
}

var key []byte
switch mode {
case keychainBackendFile:
key, err = getFileMasterKey(service, true)
case keychainBackendKeychain:
key, err = getMasterKey(service, true)
default:
key, err = getFileMasterKey(service, false)
if err != nil {
key, err = getFileMasterKey(service, true)
key, err = getMasterKey(service, true)
if err != nil {
return err
key, err = getFileMasterKey(service, true)
}
}
}
if err != nil {
return err
}

dir := StorageDir(service)
if err := vfs.MkdirAll(dir, 0700); err != nil {
return err
Expand Down
88 changes: 88 additions & 0 deletions internal/keychain/keychain_darwin_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -161,3 +161,91 @@ func TestPlatformSetPrefersExistingFileMasterKey(t *testing.T) {
t.Fatalf("platformGet() = %q, want %q", got, secret)
}
}

// TestPlatformSetDefaultFileBackendSkipsSystemKeychain verifies the default
// file backend never touches the system keychain.
func TestPlatformSetDefaultFileBackendSkipsSystemKeychain(t *testing.T) {
home := t.TempDir()
t.Setenv("HOME", home)

origGet := keyringGet
origSet := keyringSet
keyringGet = func(service, user string) (string, error) {
t.Fatalf("keyringGet should not be called by the default file backend")
return "", nil
}
keyringSet = func(service, user, password string) error {
t.Fatalf("keyringSet should not be called by the default file backend")
return nil
}
t.Cleanup(func() {
keyringGet = origGet
keyringSet = origSet
})

service := "test-service"
account := "test-account"
secret := "secret-value"

if err := platformSet(service, account, secret); err != nil {
t.Fatalf("platformSet() error = %v", err)
}
if _, err := os.Stat(filepath.Join(StorageDir(service), fileMasterKeyName)); err != nil {
t.Fatalf("file master key not created: %v", err)
}

got, err := platformGet(service, account)
if err != nil {
t.Fatalf("platformGet() error = %v", err)
}
if got != secret {
t.Fatalf("platformGet() = %q, want %q", got, secret)
}
}

func TestResolveKeychainBackendModeDefaultsToFile(t *testing.T) {
mode, err := resolveKeychainBackendMode()
if err != nil {
t.Fatalf("resolveKeychainBackendMode() error = %v", err)
}
if mode != keychainBackendFile {
t.Fatalf("resolveKeychainBackendMode() = %q, want %q", mode, keychainBackendFile)
}
}

// TestPlatformSetKeychainBackendSkipsFileFallback verifies the explicit keychain
// backend fails instead of silently falling back to local file storage.
func TestPlatformSetKeychainBackendSkipsFileFallback(t *testing.T) {
home := t.TempDir()
t.Setenv("HOME", home)
t.Setenv(keychainBackendEnv, string(keychainBackendKeychain))

origGet := keyringGet
origSet := keyringSet
keyringGet = func(service, user string) (string, error) {
return "", keyring.ErrNotFound
}
keyringSet = func(service, user, password string) error {
return errors.New("blocked")
}
t.Cleanup(func() {
keyringGet = origGet
keyringSet = origSet
})

err := platformSet("test-service", "test-account", "secret-value")
if err == nil {
t.Fatal("platformSet() error = nil, want keychain error")
}
if _, statErr := os.Stat(filepath.Join(StorageDir("test-service"), fileMasterKeyName)); !errors.Is(statErr, os.ErrNotExist) {
t.Fatalf("file master key should not be created in keychain mode, stat error = %v", statErr)
}
}

func TestResolveKeychainBackendModeRejectsInvalidValue(t *testing.T) {
t.Setenv(keychainBackendEnv, "invalid")

if _, err := resolveKeychainBackendMode(); err == nil {
t.Fatal("resolveKeychainBackendMode() error = nil, want validation error")
}
}
Loading