* 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
439 lines
13 KiB
Go
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)
|
|
}
|
|
}
|
|
}
|