Files
dingtalk-workspace-cli/internal/keychain/keychain_darwin_test.go
修雨 bfd48b6a71 fix: preserve macOS auth across keychain mode changes (#597)
* fix: preserve auth across macOS keychain modes

* docs(auth): clarify per-profile recovery

* fix(auth): add safe macOS keychain migration

* ci: add native Windows auth coverage

* ci: scope Windows checks to auth paths

* fix(auth): address keychain review boundaries
2026-07-13 15:27:39 +08:00

439 lines
13 KiB
Go

// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//go:build darwin
package keychain
import (
"bytes"
"encoding/base64"
"errors"
"os"
"path/filepath"
"strings"
"testing"
keyringpkg "github.com/zalando/go-keyring"
)
func TestDefaultKeychainPathFromSecurityOutput(t *testing.T) {
got := defaultKeychainPathFromSecurityOutput([]byte("\"/Users/me/Library/Keychains/login.keychain-db\"\n"))
if got != "/Users/me/Library/Keychains/login.keychain-db" {
t.Fatalf("path = %q", got)
}
}
func TestCheckDefaultKeychainAvailableReportsMissingPath(t *testing.T) {
missingPath := filepath.Join(t.TempDir(), "missing.keychain-db")
prev := readDefaultKeychain
readDefaultKeychain = func() ([]byte, error) {
return []byte("\"" + missingPath + "\"\n"), nil
}
t.Cleanup(func() {
readDefaultKeychain = prev
})
err := checkDefaultKeychainAvailable()
if !IsUnavailable(err) {
t.Fatalf("error = %v, want unavailable", err)
}
if !strings.Contains(err.Error(), missingPath) {
t.Fatalf("error = %v, want missing keychain path", err)
}
}
func TestGetMissingAccountDoesNotReadMacOSDEK(t *testing.T) {
t.Setenv(DisableKeychainEnv, "")
keychainPath := filepath.Join(t.TempDir(), "login.keychain-db")
if err := os.WriteFile(keychainPath, nil, 0600); err != nil {
t.Fatalf("WriteFile(default keychain) error = %v", err)
}
prevReadDefault := readDefaultKeychain
prevGet := keyringGet
prevSet := keyringSet
readDefaultKeychain = func() ([]byte, error) {
return []byte("\"" + keychainPath + "\"\n"), nil
}
keyringGet = func(service, account string) (string, error) {
t.Fatalf("keyring.Get(%q, %q) called for missing account", service, account)
return "", nil
}
keyringSet = func(service, account, value string) error {
t.Fatalf("keyring.Set(%q, %q) called for missing account", service, account)
return nil
}
t.Cleanup(func() {
readDefaultKeychain = prevReadDefault
keyringGet = prevGet
keyringSet = prevSet
})
got, err := Get("test-missing-account", "auth-token")
if err != nil {
t.Fatalf("Get() error = %v, want nil", err)
}
if got != "" {
t.Fatalf("Get() = %q, want empty string", got)
}
}
func TestGetWithMissingMacOSDEKDoesNotCreateDEK(t *testing.T) {
t.Setenv(DisableKeychainEnv, "")
keychainPath := filepath.Join(t.TempDir(), "login.keychain-db")
if err := os.WriteFile(keychainPath, nil, 0600); err != nil {
t.Fatalf("WriteFile(default keychain) error = %v", err)
}
prevReadDefault := readDefaultKeychain
prevGet := keyringGet
prevSet := keyringSet
readDefaultKeychain = func() ([]byte, error) {
return []byte("\"" + keychainPath + "\"\n"), nil
}
keyringGet = func(service, account string) (string, error) {
if account != "dek" {
t.Fatalf("keyring.Get account = %q, want dek", account)
}
return "", keyringpkg.ErrNotFound
}
setCalls := 0
keyringSet = func(service, account, value string) error {
setCalls++
return errors.New("keyring.Set should not be called by Get")
}
t.Cleanup(func() {
readDefaultKeychain = prevReadDefault
keyringGet = prevGet
keyringSet = prevSet
})
service := "test-missing-dek"
account := "auth-token"
dir := StorageDir(service)
if err := os.MkdirAll(dir, 0700); err != nil {
t.Fatalf("MkdirAll() error = %v", err)
}
if err := os.WriteFile(filepath.Join(dir, safeFileName(account)), []byte("ciphertext"), 0600); err != nil {
t.Fatalf("WriteFile(ciphertext) error = %v", err)
}
_, err := Get(service, account)
if !IsDEKMissing(err) {
t.Fatalf("Get() error = %v, want dek missing", err)
}
if setCalls != 0 {
t.Fatalf("keyring.Set calls = %d, want 0", setCalls)
}
}
// TestDisableKeychainFallback verifies that setting DWS_DISABLE_KEYCHAIN
// routes the DEK to a local file (same scheme as Linux) and the full
// Set/Get/Remove cycle works without touching the system Keychain.
// This is the support path for sandboxed runtimes such as Codex App.
func TestDisableKeychainFallback(t *testing.T) {
tmp := t.TempDir()
t.Setenv(StorageDirEnv, tmp)
t.Setenv(DisableKeychainEnv, "1")
service := "test-disable-keychain"
account := "auth-token"
payload := `{"access_token":"abc","refresh_token":"def"}`
if err := Set(service, account, payload); err != nil {
t.Fatalf("Set() error = %v", err)
}
// File DEK must materialize on disk.
dekPath := filepath.Join(tmp, service, "dek")
info, err := os.Stat(dekPath)
if err != nil {
t.Fatalf("file DEK not created at %s: %v", dekPath, err)
}
if mode := info.Mode().Perm(); mode != 0600 {
t.Fatalf("DEK file perm = %o, want 0600", mode)
}
got, err := Get(service, account)
if err != nil {
t.Fatalf("Get() error = %v", err)
}
if got != payload {
t.Fatalf("Get() = %q, want %q", got, payload)
}
// A second Get must reuse the same DEK (no regeneration).
dek1, err := os.ReadFile(dekPath)
if err != nil {
t.Fatalf("ReadFile(dek) error = %v", err)
}
if _, err := Get(service, account); err != nil {
t.Fatalf("second Get() error = %v", err)
}
dek2, err := os.ReadFile(dekPath)
if err != nil {
t.Fatalf("ReadFile(dek) second error = %v", err)
}
if string(dek1) != string(dek2) {
t.Fatal("DEK rotated between calls; want stable")
}
if err := Remove(service, account); err != nil {
t.Fatalf("Remove() error = %v", err)
}
if Exists(service, account) {
t.Fatal("Exists() = true after Remove(), want false")
}
}
// TestDisableKeychainOverwrite verifies the fallback path supports
// overwriting an existing token entry.
func TestDisableKeychainOverwrite(t *testing.T) {
tmp := t.TempDir()
t.Setenv(StorageDirEnv, tmp)
t.Setenv(DisableKeychainEnv, "1")
service := "test-disable-keychain-overwrite"
account := "auth-token"
if err := Set(service, account, "initial"); err != nil {
t.Fatalf("Set() initial error = %v", err)
}
if err := Set(service, account, "overwritten"); err != nil {
t.Fatalf("Set() overwrite error = %v", err)
}
got, err := Get(service, account)
if err != nil {
t.Fatalf("Get() error = %v", err)
}
if got != "overwritten" {
t.Fatalf("Get() = %q, want %q", got, "overwritten")
}
}
func stubMacOSSystemDEK(t *testing.T, systemDEK []byte, getErr error) *int {
t.Helper()
defaultKeychainPath := filepath.Join(t.TempDir(), "login.keychain-db")
if err := os.WriteFile(defaultKeychainPath, nil, 0600); err != nil {
t.Fatalf("WriteFile(default keychain) error = %v", err)
}
prevReadDefault := readDefaultKeychain
prevGet := keyringGet
prevSet := keyringSet
readDefaultKeychain = func() ([]byte, error) {
return []byte("\"" + defaultKeychainPath + "\"\n"), nil
}
keyringGet = func(service, account string) (string, error) {
if account != "dek" {
return "", errors.New("unexpected keyring account")
}
if getErr != nil {
return "", getErr
}
return base64.StdEncoding.EncodeToString(systemDEK), nil
}
setCalls := 0
keyringSet = func(service, account, value string) error {
setCalls++
return errors.New("unexpected keyring.Set")
}
t.Cleanup(func() {
readDefaultKeychain = prevReadDefault
keyringGet = prevGet
keyringSet = prevSet
})
return &setCalls
}
func TestSystemKeychainModePreservesFileDEKCredentials(t *testing.T) {
t.Setenv(StorageDirEnv, t.TempDir())
systemDEK := bytes.Repeat([]byte{0x22}, dekBytes)
setCalls := stubMacOSSystemDEK(t, systemDEK, nil)
service := "test-dek-mode-switch"
account := "auth-token"
t.Setenv(DisableKeychainEnv, "1")
if err := Set(service, account, "file-dek-token"); err != nil {
t.Fatalf("Set() in file-DEK mode error = %v", err)
}
t.Setenv(DisableKeychainEnv, "")
got, err := Get(service, account)
if err != nil {
t.Fatalf("Get() after switching to system Keychain mode error = %v", err)
}
if got != "file-dek-token" {
t.Fatalf("Get() after switching modes = %q, want file-dek-token", got)
}
if err := Set(service, account, "updated-token"); err != nil {
t.Fatalf("Set() after switching to system Keychain mode error = %v", err)
}
profileAccount := account + ":corp"
if err := Set(service, profileAccount, "profile-token"); err != nil {
t.Fatalf("Set() new profile account in system Keychain mode error = %v", err)
}
t.Setenv(DisableKeychainEnv, "1")
got, err = Get(service, account)
if err != nil {
t.Fatalf("Get() after switching back to file-DEK mode error = %v", err)
}
if got != "updated-token" {
t.Fatalf("Get() after switching back = %q, want updated-token", got)
}
got, err = Get(service, profileAccount)
if err != nil {
t.Fatalf("Get() new profile account in file-DEK mode error = %v", err)
}
if got != "profile-token" {
t.Fatalf("Get() new profile account = %q, want profile-token", got)
}
if *setCalls != 0 {
t.Fatalf("keyring.Set calls = %d, want 0", *setCalls)
}
}
func TestSystemKeychainModeFallsBackWhenSystemDEKIsMissing(t *testing.T) {
t.Setenv(StorageDirEnv, t.TempDir())
setCalls := stubMacOSSystemDEK(t, nil, keyringpkg.ErrNotFound)
service := "test-missing-system-dek-fallback"
account := "auth-token"
t.Setenv(DisableKeychainEnv, "1")
if err := Set(service, account, "file-dek-token"); err != nil {
t.Fatalf("Set() in file-DEK mode error = %v", err)
}
t.Setenv(DisableKeychainEnv, "")
got, err := Get(service, account)
if err != nil {
t.Fatalf("Get() with missing system DEK error = %v", err)
}
if got != "file-dek-token" {
t.Fatalf("Get() with missing system DEK = %q, want file-dek-token", got)
}
if *setCalls != 0 {
t.Fatalf("keyring.Set calls = %d, want 0", *setCalls)
}
}
func TestFileDEKModeDoesNotOverwriteSystemKeychainCredentials(t *testing.T) {
root := t.TempDir()
t.Setenv(StorageDirEnv, root)
systemDEK := bytes.Repeat([]byte{0x22}, dekBytes)
stubMacOSSystemDEK(t, systemDEK, nil)
service := "test-system-ciphertext-preserved"
account := "auth-token"
t.Setenv(DisableKeychainEnv, "")
if err := Set(service, account, "system-token"); err != nil {
t.Fatalf("Set() in system Keychain mode error = %v", err)
}
if _, err := os.Stat(filepath.Join(StorageDir(service), "dek")); !os.IsNotExist(err) {
t.Fatalf("system Keychain Set created file DEK; stat error = %v", err)
}
targetPath := filepath.Join(StorageDir(service), safeFileName(account))
before, err := os.ReadFile(targetPath)
if err != nil {
t.Fatalf("ReadFile(ciphertext) error = %v", err)
}
fileDEK := bytes.Repeat([]byte{0x11}, dekBytes)
if err := os.WriteFile(filepath.Join(StorageDir(service), "dek"), fileDEK, 0600); err != nil {
t.Fatalf("WriteFile(file DEK) error = %v", err)
}
t.Setenv(DisableKeychainEnv, "1")
if err := Set(service, account, "file-token"); !IsCiphertextKeyMismatch(err) {
t.Fatalf("Set() error = %v, want ciphertext key mismatch", err)
}
after, err := os.ReadFile(targetPath)
if err != nil {
t.Fatalf("ReadFile(ciphertext after rejected Set) error = %v", err)
}
if !bytes.Equal(after, before) {
t.Fatal("rejected file-DEK Set overwrote system Keychain ciphertext")
}
t.Setenv(DisableKeychainEnv, "")
got, err := Get(service, account)
if err != nil {
t.Fatalf("Get() in system Keychain mode error = %v", err)
}
if got != "system-token" {
t.Fatalf("Get() after rejected overwrite = %q, want system-token", got)
}
profileAccount := account + ":corp"
if err := Set(service, profileAccount, "system-profile-token"); err != nil {
t.Fatalf("Set() new profile beside system-backed root error = %v", err)
}
got, err = Get(service, profileAccount)
if err != nil || got != "system-profile-token" {
t.Fatalf("Get() new system-backed profile = %q, %v", got, err)
}
t.Setenv(DisableKeychainEnv, "1")
if _, err := Get(service, profileAccount); !IsCiphertextKeyMismatch(err) {
t.Fatalf("file-DEK Get() of system-backed profile error = %v, want ciphertext key mismatch", err)
}
}
func TestSetDoesNotOverwriteCiphertextWithUnknownDEK(t *testing.T) {
root := t.TempDir()
t.Setenv(StorageDirEnv, root)
systemDEK := bytes.Repeat([]byte{0x22}, dekBytes)
stubMacOSSystemDEK(t, systemDEK, nil)
service := "test-unknown-ciphertext-key"
account := "auth-token"
dir := StorageDir(service)
if err := os.MkdirAll(dir, 0700); err != nil {
t.Fatalf("MkdirAll() error = %v", err)
}
fileDEK := bytes.Repeat([]byte{0x11}, dekBytes)
if err := os.WriteFile(filepath.Join(dir, "dek"), fileDEK, 0600); err != nil {
t.Fatalf("WriteFile(file DEK) error = %v", err)
}
unknownDEK := bytes.Repeat([]byte{0x33}, dekBytes)
ciphertext, err := encryptData("unknown-token", unknownDEK)
if err != nil {
t.Fatalf("encryptData() error = %v", err)
}
targetPath := filepath.Join(dir, safeFileName(account))
if err := os.WriteFile(targetPath, ciphertext, 0600); err != nil {
t.Fatalf("WriteFile(ciphertext) error = %v", err)
}
for _, disableKeychain := range []string{"", "1"} {
t.Setenv(DisableKeychainEnv, disableKeychain)
if err := Set(service, account, "replacement"); !IsCiphertextKeyMismatch(err) {
t.Fatalf("Set() with %s=%q error = %v, want ciphertext key mismatch", DisableKeychainEnv, disableKeychain, err)
}
after, err := os.ReadFile(targetPath)
if err != nil {
t.Fatalf("ReadFile(ciphertext after rejected Set) error = %v", err)
}
if !bytes.Equal(after, ciphertext) {
t.Fatalf("rejected Set with %s=%q overwrote ciphertext", DisableKeychainEnv, disableKeychain)
}
}
}