mirror of
https://github.com/larksuite/cli.git
synced 2026-09-14 18:42:53 +08:00
7a6a4dbdf5
* fix: preserve credential and config read failures Report corrupt or inaccessible stored credentials as typed errors instead of treating them as missing. Preserve config load failures across auth diagnostics and keep trusted request metadata available when host signal collection is unavailable. Fixes #1925 * fix(auth): attach re-authorization recovery to corrupt stored token errors A stored token that fails to decode or fails semantic validation is now reported as internal/storage but carried no recovery hint, so `api --as user`, `auth check`, `auth status`, and `doctor` all ended in a dead end. A new login overwrites the damaged entry, so attach the canonical user-authorization recovery on the two read-side corruption branches. The write-side validator stays hint-free: a rejected write leaves nothing on disk to re-authorize. `auth status` keeps the storage failure in `note` and appends the recovery instead of replacing it. Tests pin the hint on GetStoredToken, GetValidAccessToken, identitydiag (including reduced-distribution projection when auth login is concealed), auth check, and auth status, and pin that SetStoredToken replaces a corrupt entry. ---------
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/errs"
|
|
"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 → typed storage error 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 storageErr *errs.InternalError
|
|
if !errors.As(err, &storageErr) {
|
|
t.Fatalf("wrapError returned %#v; expected *errs.InternalError", err)
|
|
}
|
|
got := strings.Contains(storageErr.Hint, "keychain-downgrade")
|
|
if got != tc.wantHint {
|
|
t.Fatalf("hint mentions keychain-downgrade = %v, want %v\n full hint: %q", got, tc.wantHint, storageErr.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)
|
|
}
|
|
}
|