* 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
178 lines
6.3 KiB
Go
178 lines
6.3 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"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"testing"
|
|
)
|
|
|
|
func TestMigrateToFileDEKReencryptsSystemEntries(t *testing.T) {
|
|
root := t.TempDir()
|
|
t.Setenv(StorageDirEnv, root)
|
|
t.Setenv(DisableKeychainEnv, "")
|
|
stubMacOSSystemDEK(t, bytes.Repeat([]byte{0x42}, dekBytes), nil)
|
|
|
|
service := "test-migrate-system-to-file"
|
|
values := map[string]string{
|
|
AccountToken: "legacy-token",
|
|
AccountToken + ":corp-a": "profile-token",
|
|
}
|
|
if err := Set(service, "appsecret_demo", "app-secret"); err != nil {
|
|
t.Fatalf("Set(unrelated secret) error = %v", err)
|
|
}
|
|
unrelatedPath := filepath.Join(StorageDir(service), safeFileName("appsecret_demo"))
|
|
unrelatedCiphertext := []byte("unreadable unrelated ciphertext")
|
|
if err := os.WriteFile(unrelatedPath, unrelatedCiphertext, 0600); err != nil {
|
|
t.Fatalf("WriteFile(unrelated secret) error = %v", err)
|
|
}
|
|
for account, value := range values {
|
|
if err := Set(service, account, value); err != nil {
|
|
t.Fatalf("Set(%q) error = %v", account, err)
|
|
}
|
|
}
|
|
|
|
count, err := MigrateToFileDEK(service, true)
|
|
if err != nil {
|
|
t.Fatalf("MigrateToFileDEK(dry-run) error = %v", err)
|
|
}
|
|
if count != len(values) {
|
|
t.Fatalf("dry-run count = %d, want %d", count, len(values))
|
|
}
|
|
dekPath := filepath.Join(StorageDir(service), "dek")
|
|
if _, err := os.Stat(dekPath); !os.IsNotExist(err) {
|
|
t.Fatalf("dry-run created file DEK; stat error = %v", err)
|
|
}
|
|
|
|
count, err = MigrateToFileDEK(service, false)
|
|
if err != nil {
|
|
t.Fatalf("MigrateToFileDEK() error = %v", err)
|
|
}
|
|
if count != len(values) {
|
|
t.Fatalf("migration count = %d, want %d", count, len(values))
|
|
}
|
|
if info, err := os.Stat(dekPath); err != nil || info.Mode().Perm() != 0600 {
|
|
t.Fatalf("file DEK stat = %v, %v; want mode 0600", info, err)
|
|
}
|
|
|
|
for _, disableKeychain := range []string{"1", ""} {
|
|
t.Setenv(DisableKeychainEnv, disableKeychain)
|
|
for account, want := range values {
|
|
got, err := Get(service, account)
|
|
if err != nil || got != want {
|
|
t.Fatalf("Get(%q) with %s=%q = %q, %v; want %q", account, DisableKeychainEnv, disableKeychain, got, err, want)
|
|
}
|
|
}
|
|
}
|
|
if count, err := MigrateToFileDEK(service, true); err != nil || count != len(values) {
|
|
t.Fatalf("repeat dry-run = %d, %v; want %d auth entries", count, err, len(values))
|
|
}
|
|
if got, err := os.ReadFile(unrelatedPath); err != nil || !bytes.Equal(got, unrelatedCiphertext) {
|
|
t.Fatalf("unrelated secret after migration = %q, %v; want byte-for-byte preserved", got, err)
|
|
}
|
|
}
|
|
|
|
func TestMigrateToFileDEKKeepsNewNonAuthSecretsOnSystemKeychain(t *testing.T) {
|
|
root := t.TempDir()
|
|
t.Setenv(StorageDirEnv, root)
|
|
t.Setenv(DisableKeychainEnv, "")
|
|
systemDEK := bytes.Repeat([]byte{0x52}, dekBytes)
|
|
stubMacOSSystemDEK(t, systemDEK, nil)
|
|
|
|
service := "test-migrate-secret-isolation"
|
|
if err := Set(service, AccountToken, "system-token"); err != nil {
|
|
t.Fatalf("Set(auth token) error = %v", err)
|
|
}
|
|
if _, err := MigrateToFileDEK(service, false); err != nil {
|
|
t.Fatalf("MigrateToFileDEK() error = %v", err)
|
|
}
|
|
|
|
nonAuthEntries := map[string]string{
|
|
"client-secret:demo": "client-secret",
|
|
"app-token:demo": "app-token",
|
|
"appsecret:demo": "stored-secret",
|
|
}
|
|
for account, value := range nonAuthEntries {
|
|
if err := Set(service, account, value); err != nil {
|
|
t.Fatalf("Set(%q) after migration error = %v", account, err)
|
|
}
|
|
ciphertext, err := os.ReadFile(filepath.Join(StorageDir(service), safeFileName(account)))
|
|
if err != nil {
|
|
t.Fatalf("ReadFile(%q) error = %v", account, err)
|
|
}
|
|
got, err := decryptData(ciphertext, systemDEK)
|
|
if err != nil || got != value {
|
|
t.Fatalf("system Keychain DEK decrypt %q = %q, %v; want %q", account, got, err, value)
|
|
}
|
|
}
|
|
|
|
t.Setenv(DisableKeychainEnv, "1")
|
|
if got, err := Get(service, AccountToken); err != nil || got != "system-token" {
|
|
t.Fatalf("file-DEK Get(auth token) = %q, %v; want migrated token", got, err)
|
|
}
|
|
for account := range nonAuthEntries {
|
|
if got, err := Get(service, account); !IsCiphertextKeyMismatch(err) {
|
|
t.Fatalf("file-DEK Get(%q) = %q, %v; want ciphertext key mismatch", account, got, err)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestMigrateToFileDEKAbortsBeforeWritingWhenAnyEntryIsUnreadable(t *testing.T) {
|
|
root := t.TempDir()
|
|
t.Setenv(StorageDirEnv, root)
|
|
t.Setenv(DisableKeychainEnv, "")
|
|
stubMacOSSystemDEK(t, bytes.Repeat([]byte{0x24}, dekBytes), nil)
|
|
|
|
service := "test-migrate-preflight"
|
|
if err := Set(service, AccountToken, "preserve-me"); err != nil {
|
|
t.Fatalf("Set() error = %v", err)
|
|
}
|
|
goodPath := filepath.Join(StorageDir(service), safeFileName(AccountToken))
|
|
goodBefore, err := os.ReadFile(goodPath)
|
|
if err != nil {
|
|
t.Fatalf("ReadFile(good entry) error = %v", err)
|
|
}
|
|
badPath := filepath.Join(StorageDir(service), safeFileName(AccountToken+":bad"))
|
|
if err := os.WriteFile(badPath, []byte("corrupt ciphertext"), 0600); err != nil {
|
|
t.Fatalf("WriteFile(bad entry) error = %v", err)
|
|
}
|
|
|
|
if _, err := MigrateToFileDEK(service, false); err == nil || !strings.Contains(err.Error(), "validate keychain entry") {
|
|
t.Fatalf("MigrateToFileDEK() error = %v, want preflight failure", err)
|
|
}
|
|
goodAfter, err := os.ReadFile(goodPath)
|
|
if err != nil {
|
|
t.Fatalf("ReadFile(good entry after failure) error = %v", err)
|
|
}
|
|
if !bytes.Equal(goodAfter, goodBefore) {
|
|
t.Fatal("failed migration modified a readable entry")
|
|
}
|
|
if _, err := os.Stat(filepath.Join(StorageDir(service), "dek")); !os.IsNotExist(err) {
|
|
t.Fatalf("failed migration created file DEK; stat error = %v", err)
|
|
}
|
|
}
|
|
|
|
func TestMigrateToFileDEKRequiresSystemKeychainMode(t *testing.T) {
|
|
t.Setenv(StorageDirEnv, t.TempDir())
|
|
t.Setenv(DisableKeychainEnv, "1")
|
|
if _, err := MigrateToFileDEK("test-migrate-mode", true); err == nil || !strings.Contains(err.Error(), DisableKeychainEnv) {
|
|
t.Fatalf("MigrateToFileDEK() error = %v, want mode guidance", err)
|
|
}
|
|
}
|