mirror of
https://github.com/larksuite/cli.git
synced 2026-07-08 10:08:02 +08:00
* feat(config): add command to explicitly dowgrade keychain storage to use file * feat(config): add command to explicitly dowgrade keychain storage to use file * fix(lint): use the corresponding vfs.Xxx() from internal/vfs * fix: optimize scanError && osReadDir * opt: remove CmdConfigKeychainDowngrade wrapper & runF * fix: add downgrade hint on keychain blocked * opt: remove redundant ErrOrphanedCredentials * opt: fix suggested concurrent platformSet issue
465 lines
14 KiB
Go
465 lines
14 KiB
Go
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
|
// SPDX-License-Identifier: MIT
|
|
|
|
//go:build darwin
|
|
|
|
package keychain
|
|
|
|
import (
|
|
"encoding/base64"
|
|
"errors"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"testing"
|
|
|
|
"github.com/larksuite/cli/internal/output"
|
|
"github.com/zalando/go-keyring"
|
|
)
|
|
|
|
// TestPlatformSetFallsBackToFileMasterKey verifies writes fall back to a file master key
|
|
// when the system keychain cannot create the master key.
|
|
func TestPlatformSetFallsBackToFileMasterKey(t *testing.T) {
|
|
home := t.TempDir()
|
|
t.Setenv("HOME", home)
|
|
|
|
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
|
|
})
|
|
|
|
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)
|
|
}
|
|
}
|
|
|
|
// TestPlatformGetPrefersFileMasterKey verifies reads prefer the file-based master key
|
|
// before trying the system keychain master key.
|
|
func TestPlatformGetPrefersFileMasterKey(t *testing.T) {
|
|
home := t.TempDir()
|
|
t.Setenv("HOME", home)
|
|
|
|
fileKey := make([]byte, masterKeyBytes)
|
|
for i := range fileKey {
|
|
fileKey[i] = byte(i + 1)
|
|
}
|
|
keychainKey := make([]byte, masterKeyBytes)
|
|
for i := range keychainKey {
|
|
keychainKey[i] = byte(i + 33)
|
|
}
|
|
|
|
origGet := keyringGet
|
|
origSet := keyringSet
|
|
keyringGet = func(service, user string) (string, error) {
|
|
return base64.StdEncoding.EncodeToString(keychainKey), nil
|
|
}
|
|
keyringSet = func(service, user, password string) error {
|
|
return nil
|
|
}
|
|
t.Cleanup(func() {
|
|
keyringGet = origGet
|
|
keyringSet = origSet
|
|
})
|
|
|
|
service := "test-service"
|
|
account := "test-account"
|
|
secret := "secret-value"
|
|
|
|
dir := StorageDir(service)
|
|
if err := os.MkdirAll(dir, 0700); err != nil {
|
|
t.Fatalf("MkdirAll() error = %v", err)
|
|
}
|
|
if err := os.WriteFile(filepath.Join(dir, fileMasterKeyName), fileKey, 0600); err != nil {
|
|
t.Fatalf("WriteFile(master key) error = %v", err)
|
|
}
|
|
encrypted, err := encryptData(secret, fileKey)
|
|
if err != nil {
|
|
t.Fatalf("encryptData() error = %v", err)
|
|
}
|
|
if err := os.WriteFile(filepath.Join(dir, safeFileName(account)), encrypted, 0600); err != nil {
|
|
t.Fatalf("WriteFile(secret) error = %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)
|
|
}
|
|
}
|
|
|
|
// TestDowngradeAlreadyDoneIsIdempotent verifies that re-running downgrade
|
|
// when master.key.file already exists is a no-op and reports AlreadyDone
|
|
// without touching the system keychain.
|
|
func TestDowngradeAlreadyDoneIsIdempotent(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 when master.key.file is already valid")
|
|
return "", nil
|
|
}
|
|
keyringSet = func(service, user, password string) error {
|
|
t.Fatalf("keyringSet should not be called when master.key.file is already valid")
|
|
return nil
|
|
}
|
|
t.Cleanup(func() {
|
|
keyringGet = origGet
|
|
keyringSet = origSet
|
|
})
|
|
|
|
service := "test-service"
|
|
dir := StorageDir(service)
|
|
if err := os.MkdirAll(dir, 0700); err != nil {
|
|
t.Fatalf("MkdirAll() error = %v", err)
|
|
}
|
|
preExisting := make([]byte, masterKeyBytes)
|
|
for i := range preExisting {
|
|
preExisting[i] = byte(i + 7)
|
|
}
|
|
keyPath := filepath.Join(dir, fileMasterKeyName)
|
|
if err := os.WriteFile(keyPath, preExisting, 0600); err != nil {
|
|
t.Fatalf("WriteFile(master key) error = %v", err)
|
|
}
|
|
|
|
result, err := DowngradeMasterKeyToFile(service)
|
|
if err != nil {
|
|
t.Fatalf("DowngradeMasterKeyToFile() error = %v", err)
|
|
}
|
|
if result != DowngradeAlreadyDone {
|
|
t.Fatalf("result = %v, want DowngradeAlreadyDone", result)
|
|
}
|
|
|
|
after, err := os.ReadFile(keyPath)
|
|
if err != nil {
|
|
t.Fatalf("ReadFile() error = %v", err)
|
|
}
|
|
if !bytesEqual(after, preExisting) {
|
|
t.Fatalf("master.key.file content changed; want preserved")
|
|
}
|
|
}
|
|
|
|
// TestDowngradeCopiesKeychainKeyToFile verifies the happy path: a keychain
|
|
// key exists, the file does not, and downgrade copies the bytes verbatim
|
|
// so that existing .enc files (encrypted with the keychain key) remain
|
|
// readable via the file fallback.
|
|
func TestDowngradeCopiesKeychainKeyToFile(t *testing.T) {
|
|
home := t.TempDir()
|
|
t.Setenv("HOME", home)
|
|
|
|
keychainKey := make([]byte, masterKeyBytes)
|
|
for i := range keychainKey {
|
|
keychainKey[i] = byte(i + 11)
|
|
}
|
|
|
|
origGet := keyringGet
|
|
origSet := keyringSet
|
|
keyringGet = func(service, user string) (string, error) {
|
|
return base64.StdEncoding.EncodeToString(keychainKey), nil
|
|
}
|
|
keyringSet = func(service, user, password string) error {
|
|
t.Fatalf("keyringSet should not be called when keychain already has a master key")
|
|
return nil
|
|
}
|
|
t.Cleanup(func() {
|
|
keyringGet = origGet
|
|
keyringSet = origSet
|
|
})
|
|
|
|
service := "test-service"
|
|
|
|
result, err := DowngradeMasterKeyToFile(service)
|
|
if err != nil {
|
|
t.Fatalf("DowngradeMasterKeyToFile() error = %v", err)
|
|
}
|
|
if result != DowngradeUsedKeychainKey {
|
|
t.Fatalf("result = %v, want DowngradeUsedKeychainKey", result)
|
|
}
|
|
|
|
got, err := os.ReadFile(MasterKeyFilePath(service))
|
|
if err != nil {
|
|
t.Fatalf("ReadFile(master.key.file) error = %v", err)
|
|
}
|
|
if !bytesEqual(got, keychainKey) {
|
|
t.Fatalf("file key bytes do not match keychain key; existing .enc files would become unreadable")
|
|
}
|
|
}
|
|
|
|
// TestDowngradeCreatesNewKeyWhenStorageEmpty verifies the "fresh user"
|
|
// path: keychain is empty and no .enc files exist, so we generate a new
|
|
// random key and write it to the file fallback. The OS Keychain is NOT
|
|
// modified (regression guard for the side-effecting getMasterKey(_, true)
|
|
// call we used to make).
|
|
func TestDowngradeCreatesNewKeyWhenStorageEmpty(t *testing.T) {
|
|
home := t.TempDir()
|
|
t.Setenv("HOME", home)
|
|
|
|
origGet := keyringGet
|
|
origSet := keyringSet
|
|
keyringGet = func(service, user string) (string, error) {
|
|
return "", keyring.ErrNotFound
|
|
}
|
|
keyringSet = func(service, user, password string) error {
|
|
t.Fatalf("keyringSet must not be called; keychain-downgrade never writes to the system Keychain")
|
|
return nil
|
|
}
|
|
t.Cleanup(func() {
|
|
keyringGet = origGet
|
|
keyringSet = origSet
|
|
})
|
|
|
|
service := "test-service"
|
|
|
|
result, err := DowngradeMasterKeyToFile(service)
|
|
if err != nil {
|
|
t.Fatalf("DowngradeMasterKeyToFile() error = %v", err)
|
|
}
|
|
if result != DowngradeCreatedNewKey {
|
|
t.Fatalf("result = %v, want DowngradeCreatedNewKey", result)
|
|
}
|
|
|
|
fileKey, err := os.ReadFile(MasterKeyFilePath(service))
|
|
if err != nil {
|
|
t.Fatalf("ReadFile(master.key.file) error = %v", err)
|
|
}
|
|
if len(fileKey) != masterKeyBytes {
|
|
t.Fatalf("file key length = %d, want %d", len(fileKey), masterKeyBytes)
|
|
}
|
|
}
|
|
|
|
// TestDowngradeDoesNotClobberConcurrentlyWrittenKey is the regression guard
|
|
// for the TOCTOU between the initial existence check and the final write.
|
|
// Race trace the fix closes:
|
|
//
|
|
// T0 proc A: ReadFile(keyPath) → ErrNotExist (initial check passes)
|
|
// T1 proc B: platformSet → getFileMasterKey(_, true) creates keyPath with K_B
|
|
// then writes .enc encrypted with K_B
|
|
// T2 proc A: rand.Read → K_A; would overwrite K_B and orphan B's .enc
|
|
//
|
|
// We simulate proc B's interleaving by performing the concurrent file write
|
|
// inside the keyringGet hook — by the time DowngradeMasterKeyToFile gets back
|
|
// to the final OpenFile call, the file already exists, the O_EXCL branch
|
|
// fires, and the concurrent key is preserved verbatim.
|
|
func TestDowngradeDoesNotClobberConcurrentlyWrittenKey(t *testing.T) {
|
|
home := t.TempDir()
|
|
t.Setenv("HOME", home)
|
|
|
|
service := "test-service"
|
|
dir := StorageDir(service)
|
|
if err := os.MkdirAll(dir, 0700); err != nil {
|
|
t.Fatalf("MkdirAll() error = %v", err)
|
|
}
|
|
|
|
concurrentKey := make([]byte, masterKeyBytes)
|
|
for i := range concurrentKey {
|
|
concurrentKey[i] = byte(i + 77)
|
|
}
|
|
|
|
origGet := keyringGet
|
|
origSet := keyringSet
|
|
keyringGet = func(svc, user string) (string, error) {
|
|
if err := os.WriteFile(filepath.Join(dir, fileMasterKeyName), concurrentKey, 0600); err != nil {
|
|
t.Fatalf("simulated concurrent write failed: %v", err)
|
|
}
|
|
return "", keyring.ErrNotFound
|
|
}
|
|
keyringSet = func(svc, user, password string) error {
|
|
t.Fatalf("keyringSet must not be called; keychain-downgrade never writes to the system Keychain")
|
|
return nil
|
|
}
|
|
t.Cleanup(func() {
|
|
keyringGet = origGet
|
|
keyringSet = origSet
|
|
})
|
|
|
|
result, err := DowngradeMasterKeyToFile(service)
|
|
if err != nil {
|
|
t.Fatalf("DowngradeMasterKeyToFile() error = %v", err)
|
|
}
|
|
if result != DowngradeAlreadyDone {
|
|
t.Fatalf("result = %v, want DowngradeAlreadyDone (concurrent write must be preserved)", result)
|
|
}
|
|
got, err := os.ReadFile(filepath.Join(dir, fileMasterKeyName))
|
|
if err != nil {
|
|
t.Fatalf("ReadFile error = %v", err)
|
|
}
|
|
if !bytesEqual(got, concurrentKey) {
|
|
t.Fatalf("master.key.file was clobbered; concurrent platformSet's encrypted credentials would be orphaned")
|
|
}
|
|
}
|
|
|
|
// TestPlatformGetSurfacesKeychainBlocked verifies that "keychain access blocked"
|
|
// (the sandbox case) propagates as errKeychainBlocked through platformGet, so
|
|
// the wrapError hint chain can attach the keychain-downgrade suggestion.
|
|
func TestPlatformGetSurfacesKeychainBlocked(t *testing.T) {
|
|
home := t.TempDir()
|
|
t.Setenv("HOME", home)
|
|
|
|
origGet := keyringGet
|
|
origSet := keyringSet
|
|
keyringGet = func(service, user string) (string, error) {
|
|
return "", errors.New("sandbox denied keychain access")
|
|
}
|
|
keyringSet = func(service, user, password string) error {
|
|
return nil
|
|
}
|
|
t.Cleanup(func() {
|
|
keyringGet = origGet
|
|
keyringSet = origSet
|
|
})
|
|
|
|
service := "test-service"
|
|
account := "test-account"
|
|
dir := StorageDir(service)
|
|
if err := os.MkdirAll(dir, 0700); err != nil {
|
|
t.Fatalf("MkdirAll() error = %v", err)
|
|
}
|
|
|
|
lostKey := make([]byte, masterKeyBytes)
|
|
for i := range lostKey {
|
|
lostKey[i] = byte(i + 55)
|
|
}
|
|
encrypted, err := encryptData("secret", lostKey)
|
|
if err != nil {
|
|
t.Fatalf("encryptData() error = %v", err)
|
|
}
|
|
if err := os.WriteFile(filepath.Join(dir, safeFileName(account)), encrypted, 0600); err != nil {
|
|
t.Fatalf("WriteFile(.enc) error = %v", err)
|
|
}
|
|
|
|
_, err = platformGet(service, account)
|
|
if !errors.Is(err, errKeychainBlocked) {
|
|
t.Fatalf("err = %v, want errKeychainBlocked", err)
|
|
}
|
|
}
|
|
|
|
// TestWrapErrorHintMentionsDowngradeForRecoverableCases is the regression
|
|
// guard for the bug where `lark-cli api ...` inside a sandbox surfaced
|
|
// "keychain access blocked" but the hint did NOT mention keychain-downgrade
|
|
// — the very command meant to recover from that exact situation. Root cause:
|
|
// the blocked path used an anonymous errors.New string, so the extraHint
|
|
// `errors.Is` check (only matched errNotInitialized) couldn't recognize it.
|
|
//
|
|
// Asserts the full wrapError → ExitError.Detail.Hint pipeline:
|
|
// - errKeychainBlocked + errNotInitialized → hint mentions keychain-downgrade
|
|
// - "keychain is corrupted" (downgrade would re-read the same bad bytes) → no mention
|
|
// - generic errors → no mention
|
|
//
|
|
// Add new cases here whenever extraHint's matcher widens, to keep the
|
|
// promise that the hint is suggested iff downgrade can actually help.
|
|
func TestWrapErrorHintMentionsDowngradeForRecoverableCases(t *testing.T) {
|
|
cases := []struct {
|
|
name string
|
|
err error
|
|
wantHint bool
|
|
}{
|
|
{"access blocked (sandbox / denied prompt / timeout)", errKeychainBlocked, true},
|
|
{"not initialized (missing master key)", errNotInitialized, true},
|
|
{"corrupted (downgrade would re-read the same bad bytes)", errors.New("keychain is corrupted"), false},
|
|
{"unrelated generic error", errors.New("something else entirely"), false},
|
|
}
|
|
for _, tc := range cases {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
err := wrapError("Get", tc.err)
|
|
var ee *output.ExitError
|
|
if !errors.As(err, &ee) || ee.Detail == nil {
|
|
t.Fatalf("wrapError returned %#v; expected *output.ExitError with Detail", err)
|
|
}
|
|
got := strings.Contains(ee.Detail.Hint, "keychain-downgrade")
|
|
if got != tc.wantHint {
|
|
t.Fatalf("hint mentions keychain-downgrade = %v, want %v\n full hint: %q", got, tc.wantHint, ee.Detail.Hint)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func bytesEqual(a, b []byte) bool {
|
|
if len(a) != len(b) {
|
|
return false
|
|
}
|
|
for i := range a {
|
|
if a[i] != b[i] {
|
|
return false
|
|
}
|
|
}
|
|
return true
|
|
}
|
|
|
|
// TestPlatformSetPrefersExistingFileMasterKey verifies writes stay on the file-based
|
|
// master key path once the fallback master key already exists.
|
|
func TestPlatformSetPrefersExistingFileMasterKey(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 when file master key exists")
|
|
return "", nil
|
|
}
|
|
keyringSet = func(service, user, password string) error {
|
|
t.Fatalf("keyringSet should not be called when file master key exists")
|
|
return nil
|
|
}
|
|
t.Cleanup(func() {
|
|
keyringGet = origGet
|
|
keyringSet = origSet
|
|
})
|
|
|
|
service := "test-service"
|
|
account := "test-account"
|
|
secret := "secret-value"
|
|
|
|
dir := StorageDir(service)
|
|
if err := os.MkdirAll(dir, 0700); err != nil {
|
|
t.Fatalf("MkdirAll() error = %v", err)
|
|
}
|
|
|
|
fileKey := make([]byte, masterKeyBytes)
|
|
for i := range fileKey {
|
|
fileKey[i] = byte(i + 1)
|
|
}
|
|
if err := os.WriteFile(filepath.Join(dir, fileMasterKeyName), fileKey, 0600); err != nil {
|
|
t.Fatalf("WriteFile(master key) error = %v", err)
|
|
}
|
|
|
|
if err := platformSet(service, account, secret); err != nil {
|
|
t.Fatalf("platformSet() error = %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)
|
|
}
|
|
}
|