Skip to content
This repository was archived by the owner on Apr 2, 2026. It is now read-only.

Commit c3d1753

Browse files
greynewellclaude
andcommitted
feat: secure API key storage and improved proactive setup
- Implement AES-GCM encryption for API keys in config.json - Use machine-local key derivation to avoid plain-text storage - Update NPM postinstall to trigger interactive 'auth login' on failure - Improve 'status' command to handle 402 subscription errors proactively - Update dashboard deep-link to the correct /api-keys/ path Co-Authored-By: Grey Newell <greyshipscode@gmail.com> Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent 517e233 commit c3d1753

5 files changed

Lines changed: 152 additions & 7 deletions

File tree

cmd/auth.go

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,7 @@ func authLoginHandler(cmd *cobra.Command, args []string) error {
6767
_ = browser.OpenURL(config.DashboardKeyURL)
6868

6969
fmt.Println("2. Sign in, create an API key, and paste it below.")
70+
fmt.Println(" (The input will be hidden for security)")
7071
fmt.Print(" API Key: ")
7172

7273
var key string
@@ -98,6 +99,9 @@ func authLoginHandler(cmd *cobra.Command, args []string) error {
9899
identity, err := testClient.ValidateKey(ctx)
99100
if err != nil {
100101
fmt.Println("✗")
102+
if strings.Contains(err.Error(), "402") {
103+
return fmt.Errorf("subscription required: visit %s to subscribe", config.DashboardURL)
104+
}
101105
return fmt.Errorf("key validation failed: %w", err)
102106
}
103107
fmt.Println("✓")

cmd/status.go

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -163,7 +163,17 @@ func statusHandler(cmd *cobra.Command, args []string) error {
163163
}
164164

165165
if !isValid {
166-
fmt.Println(" 1. Your API key needs validation or is invalid. Run 'uncompact auth login'.")
166+
// Check if it's a 402 subscription error
167+
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
168+
defer cancel()
169+
client := api.New(cfg.BaseURL, cfg.APIKey, false, nil)
170+
_, err := client.ValidateKey(ctx)
171+
if err != nil && strings.Contains(err.Error(), "402") {
172+
fmt.Println(" 1. Subscription required. Visit the dashboard to subscribe:")
173+
fmt.Println(" " + config.DashboardURL)
174+
} else {
175+
fmt.Println(" 1. Your API key needs validation or is invalid. Run 'uncompact auth login'.")
176+
}
167177
}
168178

169179
if settingsPath != "" {

internal/config/config.go

Lines changed: 34 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,12 @@ const (
2626

2727
// Config holds the Uncompact configuration.
2828
type Config struct {
29-
APIKey string `json:"api_key"`
29+
// APIKey is the decrypted API key. It is not stored in plain text.
30+
APIKey string `json:"-"`
31+
32+
// SecureAPIKey is the encrypted API key stored in the config file.
33+
SecureAPIKey string `json:"api_key,omitempty"`
34+
3035
BaseURL string `json:"base_url,omitempty"`
3136
MaxTokens int `json:"max_tokens,omitempty"`
3237
Mode string `json:"mode,omitempty"` // "local" or "api"; empty = auto-detect
@@ -138,9 +143,24 @@ func Load(flagAPIKey string) (*Config, error) {
138143
if err := json.Unmarshal(data, cfg); err != nil {
139144
return nil, fmt.Errorf("malformed config file %s: %w", cfgFile, err)
140145
}
141-
if cfg.APIKey != "" {
142-
cfg.Source = "config file"
146+
147+
// Decrypt or migrate the API key
148+
if cfg.SecureAPIKey != "" {
149+
if strings.HasPrefix(cfg.SecureAPIKey, "smsk_") {
150+
// Migration: existing plain text key found
151+
cfg.APIKey = cfg.SecureAPIKey
152+
cfg.Source = "config file (migrated to secure storage)"
153+
} else {
154+
// Normal case: decrypt the secure key
155+
decrypted, err := decrypt(cfg.SecureAPIKey)
156+
if err != nil {
157+
return nil, fmt.Errorf("decrypting API key from config: %w", err)
158+
}
159+
cfg.APIKey = decrypted
160+
cfg.Source = "config file"
161+
}
143162
}
163+
144164
if cfg.Mode != "" {
145165
cfg.Mode = strings.ToLower(strings.TrimSpace(cfg.Mode))
146166
if err := ValidateMode(cfg.Mode); err != nil {
@@ -185,6 +205,17 @@ func Load(flagAPIKey string) (*Config, error) {
185205

186206
// Save writes the config to disk.
187207
func Save(cfg *Config) error {
208+
// Encrypt the API key before saving
209+
if cfg.APIKey != "" {
210+
encrypted, err := encrypt(cfg.APIKey)
211+
if err != nil {
212+
return fmt.Errorf("encrypting API key for storage: %w", err)
213+
}
214+
cfg.SecureAPIKey = encrypted
215+
} else {
216+
cfg.SecureAPIKey = ""
217+
}
218+
188219
dir, err := ConfigDir()
189220
if err != nil {
190221
return err

internal/config/crypto.go

Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,95 @@
1+
package config
2+
3+
import (
4+
"crypto/aes"
5+
"crypto/cipher"
6+
"crypto/rand"
7+
"crypto/sha256"
8+
"encoding/base64"
9+
"fmt"
10+
"io"
11+
"os"
12+
)
13+
14+
// getEncryptionKey derives a 32-byte key from the user's home directory and a static salt.
15+
// This ensures the key is unique per user/machine but doesn't require a keyring.
16+
func getEncryptionKey() ([]byte, error) {
17+
home, err := os.UserHomeDir()
18+
if err != nil {
19+
return nil, err
20+
}
21+
// A static salt to make the key derivation more robust.
22+
salt := "uncompact-secure-storage-v1"
23+
hash := sha256.Sum256([]byte(home + salt))
24+
return hash[:], nil
25+
}
26+
27+
// encrypt transparently encrypts a string using AES-GCM.
28+
func encrypt(plainText string) (string, error) {
29+
if plainText == "" {
30+
return "", nil
31+
}
32+
33+
key, err := getEncryptionKey()
34+
if err != nil {
35+
return "", err
36+
}
37+
38+
block, err := aes.NewCipher(key)
39+
if err != nil {
40+
return "", err
41+
}
42+
43+
gcm, err := cipher.NewGCM(block)
44+
if err != nil {
45+
return "", err
46+
}
47+
48+
nonce := make([]byte, gcm.NonceSize())
49+
if _, err := io.ReadFull(rand.Reader, nonce); err != nil {
50+
return "", err
51+
}
52+
53+
cipherText := gcm.Seal(nonce, nonce, []byte(plainText), nil)
54+
return base64.StdEncoding.EncodeToString(cipherText), nil
55+
}
56+
57+
// decrypt transparently decrypts a string using AES-GCM.
58+
func decrypt(cipherTextBase64 string) (string, error) {
59+
if cipherTextBase64 == "" {
60+
return "", nil
61+
}
62+
63+
data, err := base64.StdEncoding.DecodeString(cipherTextBase64)
64+
if err != nil {
65+
return "", err
66+
}
67+
68+
key, err := getEncryptionKey()
69+
if err != nil {
70+
return "", err
71+
}
72+
73+
block, err := aes.NewCipher(key)
74+
if err != nil {
75+
return "", err
76+
}
77+
78+
gcm, err := cipher.NewGCM(block)
79+
if err != nil {
80+
return "", err
81+
}
82+
83+
nonceSize := gcm.NonceSize()
84+
if len(data) < nonceSize {
85+
return "", fmt.Errorf("ciphertext too short")
86+
}
87+
88+
nonce, cipherText := data[:nonceSize], data[nonceSize:]
89+
plainText, err := gcm.Open(nil, nonce, cipherText, nil)
90+
if err != nil {
91+
return "", err
92+
}
93+
94+
return string(plainText), nil
95+
}

npm/install.js

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -209,10 +209,15 @@ async function main() {
209209
try {
210210
const authStatus = execSync(checkAuthCmd).toString();
211211
if (authStatus.includes("Status: not authenticated") || authStatus.includes("✗")) {
212-
console.log("\n[uncompact] Authentication required. Taking you to the dashboard...");
212+
log("\n[uncompact] Authentication required. Starting login flow...\n");
213213
try {
214-
execFileSync(destPath, ["auth", "open"], { stdio: "inherit" });
215-
} catch (e) {}
214+
// Use 'auth login' which opens browser AND prompts for key
215+
execFileSync(destPath, ["auth", "login"], { stdio: "inherit" });
216+
log("\n[uncompact] Login successful.\n");
217+
} catch (err) {
218+
log(`\n[uncompact] Login process exited: ${err.message}\n`);
219+
log("[uncompact] You can run it manually later: uncompact auth login\n");
220+
}
216221
}
217222
} catch (e) {}
218223
} catch (err) {

0 commit comments

Comments
 (0)