Skip to content

Commit d6ca974

Browse files
committed
feat(context): make keychain migration opt-in via context fix
Stop migrating plaintext credentials on config load to preserve existing workflows. New logins still store secrets in the keychain when available, and users can run tcloud context fix --migrate-credentials to migrate.
1 parent 66ef732 commit d6ca974

11 files changed

Lines changed: 180 additions & 23 deletions

File tree

‎README.md‎

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,30 @@ users:
4646
token: <PAT>
4747
```
4848
49+
## Config file permissions
50+
51+
The CLI stores settings in `~/.tcloud`. On load, the CLI warns if that file is world-readable. Fix permissions with:
52+
53+
```bash
54+
tcloud context fix
55+
```
56+
57+
This restricts `~/.tcloud` to owner-only access (`0600`).
58+
59+
### Migrating credentials to the keychain
60+
61+
New logins store secrets in the OS credential store (macOS Keychain, Windows Credential Manager, or Linux Secret Service) when available. Existing configs with plaintext tokens in `~/.tcloud` are left unchanged until manually running the `fix` command.
62+
63+
To move existing plaintext credentials into the keychain:
64+
65+
```bash
66+
tcloud context fix --migrate-credentials
67+
```
68+
69+
Use `tcloud context view` to inspect your config with secrets redacted.
70+
71+
Control credential storage with `THALASSA_CREDENTIAL_STORE` (`auto`, `keychain`, or `file`).
72+
4973
## Development
5074

5175
### Prerequisites

‎cmd/context/fix.go‎

Lines changed: 16 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -9,13 +9,23 @@ import (
99
"github.com/thalassa-cloud/cli/internal/config/securefile"
1010
)
1111

12+
var migrateCredentials bool
13+
1214
var fixCmd = &cobra.Command{
13-
Use: "fix",
14-
Short: "Fix config file security issues",
15-
Long: "Fix security issues in the CLI config file, such as overly permissive file permissions.",
16-
Example: ` tcloud context fix`,
17-
Args: cobra.NoArgs,
15+
Use: "fix",
16+
Short: "Fix config file security issues",
17+
Long: "Fix security issues in the CLI config file, such as overly permissive file permissions or migrating credentials to the keychain.",
18+
Example: ` tcloud context fix
19+
tcloud context fix --migrate-credentials`,
20+
Args: cobra.NoArgs,
1821
RunE: func(cmd *cobra.Command, args []string) error {
22+
if migrateCredentials {
23+
if err := contextstate.MigrateCredentialsToKeychain(); err != nil {
24+
return err
25+
}
26+
fmt.Println("Migrated credentials from the config file to the keychain.")
27+
}
28+
1929
filename := contextstate.ConfigFilename()
2030
mode, permissive, err := securefile.CheckPermissions(filename)
2131
if err != nil {
@@ -36,4 +46,5 @@ var fixCmd = &cobra.Command{
3646

3747
func init() {
3848
ContextCmd.AddCommand(fixCmd)
49+
fixCmd.Flags().BoolVar(&migrateCredentials, "migrate-credentials", false, "move plaintext credentials from the config file into the keychain")
3950
}

‎internal/config/contextstate/config.go‎

Lines changed: 38 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -92,6 +92,17 @@ func (c *configFileContextManager) Load() error {
9292
return nil
9393
}
9494

95+
func (c *configFileContextManager) MigrateCredentialsToKeychain() error {
96+
migrated, err := c.migratePlaintextCredentialsToKeychain()
97+
if err != nil {
98+
return err
99+
}
100+
if !migrated {
101+
return nil
102+
}
103+
return c.Save()
104+
}
105+
95106
// AddOrMergeContext adds or merges the given context.
96107
func (c *configFileContextManager) AddOrMergeContext(context Context) error {
97108
c.setUser(context.Users)
@@ -173,24 +184,13 @@ func (c *configFileContextManager) RemoveContextServer(name string) error {
173184
return c.Save()
174185
}
175186

176-
func (c *configFileContextManager) syncCredentialsAfterLoad() (bool, error) {
187+
func (c *configFileContextManager) syncCredentialsAfterLoad() (bool, error) { //nolint:unparam
177188
preference := credentials.PreferredStore()
178-
migrated := false
179189

180190
for i := range c.config.Users {
181191
user := &c.config.Users[i]
182-
if user.User.HasSecrets() && credentials.UseKeychain(preference, user.CredentialStore) {
183-
store := credentials.ResolveStore(credentials.StoreKeychain)
184-
if err := store.Set(user.Name, user.User.Secrets()); err != nil {
185-
if preference == credentials.StoreKeychain {
186-
return false, fmt.Errorf("store credentials in keychain: %w", err)
187-
}
188-
user.CredentialStore = credentials.StoreFile
189-
continue
190-
}
191-
user.CredentialStore = credentials.StoreKeychain
192-
migrated = true
193-
continue
192+
if user.CredentialStore == "" && user.User.HasSecrets() {
193+
user.CredentialStore = credentials.StoreFile
194194
}
195195

196196
if user.CredentialStore != credentials.StoreKeychain {
@@ -211,6 +211,30 @@ func (c *configFileContextManager) syncCredentialsAfterLoad() (bool, error) {
211211
user.User.ApplySecrets(secrets)
212212
}
213213

214+
return false, nil
215+
}
216+
217+
func (c *configFileContextManager) migratePlaintextCredentialsToKeychain() (bool, error) {
218+
store := credentials.ResolveStore(credentials.StoreKeychain)
219+
if !store.Available() {
220+
return false, fmt.Errorf("keychain credential store is not available")
221+
}
222+
223+
migrated := false
224+
for i := range c.config.Users {
225+
user := &c.config.Users[i]
226+
if user.CredentialStore == credentials.StoreKeychain {
227+
continue
228+
}
229+
if !user.User.HasSecrets() {
230+
continue
231+
}
232+
if err := store.Set(user.Name, user.User.Secrets()); err != nil {
233+
return false, fmt.Errorf("store credentials for user %q in keychain: %w", user.Name, err)
234+
}
235+
user.CredentialStore = credentials.StoreKeychain
236+
migrated = true
237+
}
214238
return migrated, nil
215239
}
216240

‎internal/config/contextstate/config_test.go‎

Lines changed: 39 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -14,10 +14,9 @@ import (
1414
"github.com/thalassa-cloud/cli/internal/credentials"
1515
)
1616

17-
func TestConfigManagerMigratesPlaintextCredentialsToKeychain(t *testing.T) {
17+
func TestConfigManagerMigrateCredentialsToKeychain(t *testing.T) {
1818
keyring.MockInit()
1919
t.Cleanup(credentials.ResetKeyring)
20-
t.Setenv(credentials.ThalassaCredentialStoreEnvVar, credentials.StoreKeychain)
2120

2221
dir := t.TempDir()
2322
path := filepath.Join(dir, ".tcloud")
@@ -42,11 +41,17 @@ users:
4241
manager := contextstate.NewConfigFileContextManager(path)
4342
require.NoError(t, manager.Load())
4443

44+
onDisk, err := os.ReadFile(path)
45+
require.NoError(t, err)
46+
assert.Contains(t, string(onDisk), "tc_pat_secret")
47+
48+
require.NoError(t, manager.MigrateCredentialsToKeychain())
49+
4550
ctx, err := manager.Get()
4651
require.NoError(t, err)
4752
assert.Equal(t, "tc_pat_secret", ctx.Users.User.Token)
4853

49-
onDisk, err := os.ReadFile(path)
54+
onDisk, err = os.ReadFile(path)
5055
require.NoError(t, err)
5156
assert.NotContains(t, string(onDisk), "tc_pat_secret")
5257

@@ -74,6 +79,37 @@ users:
7479
assert.Equal(t, "tc_pat_secret", got.Token)
7580
}
7681

82+
func TestConfigManagerLoadDoesNotMigratePlaintextCredentials(t *testing.T) {
83+
keyring.MockInit()
84+
t.Cleanup(credentials.ResetKeyring)
85+
86+
dir := t.TempDir()
87+
path := filepath.Join(dir, ".tcloud")
88+
require.NoError(t, os.WriteFile(path, []byte(`configVersion: v1
89+
current-context: default
90+
contexts:
91+
- name: default
92+
context:
93+
api: api.thalassa.cloud
94+
user: default
95+
servers:
96+
- name: api.thalassa.cloud
97+
api:
98+
server: https://api.thalassa.cloud
99+
users:
100+
- name: default
101+
user:
102+
token: tc_pat_secret
103+
`), 0o644))
104+
105+
manager := contextstate.NewConfigFileContextManager(path)
106+
require.NoError(t, manager.Load())
107+
108+
onDisk, err := os.ReadFile(path)
109+
require.NoError(t, err)
110+
assert.Contains(t, string(onDisk), "tc_pat_secret")
111+
}
112+
77113
func TestConfigManagerSanitizedConfigRedactsSecrets(t *testing.T) {
78114
manager := contextstate.NewConfigFileContextManager(filepath.Join(t.TempDir(), ".tcloud"))
79115
require.NoError(t, manager.AddOrMergeContext(contextstate.Context{
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
package contextstate
2+
3+
import "github.com/thalassa-cloud/cli/internal/credentials"
4+
5+
// ApplyPreferredCredentialStoreForNewLogin marks where freshly authenticated credentials should be stored.
6+
func ApplyPreferredCredentialStoreForNewLogin(user *Users) {
7+
switch credentials.PreferredStore() {
8+
case credentials.StoreKeychain:
9+
user.CredentialStore = credentials.StoreKeychain
10+
case credentials.StoreFile:
11+
user.CredentialStore = credentials.StoreFile
12+
default:
13+
if credentials.ResolveStore(credentials.StoreKeychain).Available() {
14+
user.CredentialStore = credentials.StoreKeychain
15+
} else {
16+
user.CredentialStore = credentials.StoreFile
17+
}
18+
}
19+
}
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
package contextstate
2+
3+
import (
4+
"testing"
5+
6+
"github.com/stretchr/testify/assert"
7+
"github.com/zalando/go-keyring"
8+
9+
"github.com/thalassa-cloud/cli/internal/credentials"
10+
)
11+
12+
func TestApplyPreferredCredentialStoreForNewLoginUsesKeychainWhenAvailable(t *testing.T) {
13+
keyring.MockInit()
14+
t.Cleanup(credentials.ResetKeyring)
15+
16+
user := Users{Name: "default"}
17+
ApplyPreferredCredentialStoreForNewLogin(&user)
18+
assert.Equal(t, credentials.StoreKeychain, user.CredentialStore)
19+
}
20+
21+
func TestApplyPreferredCredentialStoreForNewLoginRespectsFilePreference(t *testing.T) {
22+
keyring.MockInit()
23+
t.Cleanup(credentials.ResetKeyring)
24+
t.Setenv(credentials.ThalassaCredentialStoreEnvVar, credentials.StoreFile)
25+
26+
user := Users{Name: "default"}
27+
ApplyPreferredCredentialStoreForNewLogin(&user)
28+
assert.Equal(t, credentials.StoreFile, user.CredentialStore)
29+
}

‎internal/config/contextstate/login.go‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ func Login(ctx context.Context, token string) error {
1313
return err
1414
}
1515
context.Users.User.Token = token
16+
ApplyPreferredCredentialStoreForNewLogin(&context.Users)
1617
if err := CombineConfigContext(context); err != nil {
1718
return err
1819
}
@@ -36,6 +37,7 @@ func LoginWithAPIEndpointOidc(ctx context.Context, clientID, clientSecret, apiEn
3637
context.Users.User.AccessToken = ""
3738

3839
context.Servers.API.Server = u.String()
40+
ApplyPreferredCredentialStoreForNewLogin(&context.Users)
3941
if err := CombineConfigContext(context); err != nil {
4042
return err
4143
}
@@ -58,6 +60,7 @@ func LoginWithAccessToken(ctx context.Context, accessToken, apiEndpoint string)
5860
context.Users.User.ClientID = ""
5961
context.Users.User.ClientSecret = ""
6062
context.Servers.API.Server = u.String()
63+
ApplyPreferredCredentialStoreForNewLogin(&context.Users)
6164
if err := CombineConfigContext(context); err != nil {
6265
return err
6366
}
@@ -81,6 +84,7 @@ func LoginWithAPIEndpoint(ctx context.Context, token, apiEndpoint string) error
8184
context.Users.User.AccessToken = ""
8285

8386
context.Servers.API.Server = u.String()
87+
ApplyPreferredCredentialStoreForNewLogin(&context.Users)
8488
if err := CombineConfigContext(context); err != nil {
8589
return err
8690
}

‎internal/config/contextstate/manager.go‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -93,6 +93,9 @@ type ConfigManager interface {
9393

9494
// FixPermissions restricts the config file to owner-only access.
9595
FixPermissions() error
96+
97+
// MigrateCredentialsToKeychain moves plaintext credentials from the config file into the keychain.
98+
MigrateCredentialsToKeychain() error
9699
}
97100

98101
func Init() {
@@ -138,6 +141,10 @@ func FixConfigPermissions() error {
138141
return globalConfigManager.FixPermissions()
139142
}
140143

144+
func MigrateCredentialsToKeychain() error {
145+
return globalConfigManager.MigrateCredentialsToKeychain()
146+
}
147+
141148
func GetContextConfiguration() (Context, error) {
142149
return globalConfigManager.Get()
143150
}

‎internal/config/contextstate/oidc_browser.go‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,7 @@ func loginWithBrowserTokens(tokens browseroidc.TokenPair, apiEndpoint string) er
6969

7070
currentContext.Users.User.SetBrowserTokens(tokens.AccessToken, tokens.RefreshToken, tokens.Expiry)
7171
currentContext.Servers.API.Server = u.String()
72+
ApplyPreferredCredentialStoreForNewLogin(&currentContext.Users)
7273
if err := CombineConfigContext(currentContext); err != nil {
7374
return err
7475
}

‎internal/credentials/credentials.go‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -84,7 +84,7 @@ func UseKeychain(preference, userStore string) bool {
8484
if userStore == StoreFile {
8585
return false
8686
}
87-
return keyringStore{}.Available()
87+
return false
8888
}
8989
}
9090

0 commit comments

Comments
 (0)