secrets.go
1 package skills 2 3 import ( 4 "encoding/json" 5 "errors" 6 "fmt" 7 "os" 8 "path/filepath" 9 "regexp" 10 "sort" 11 "syscall" 12 13 "github.com/zalando/go-keyring" 14 ) 15 16 var validEnvKey = regexp.MustCompile(`^[A-Z][A-Z0-9_]*$`) 17 18 // IsValidEnvKey checks if a key name is a valid environment variable name. 19 func IsValidEnvKey(key string) bool { 20 return validEnvKey.MatchString(key) 21 } 22 23 // SecretsStore manages per-skill secret values (API keys). 24 // Values are stored in the macOS Keychain (encrypted); a plaintext index 25 // file (<shannonDir>/secrets-index.json) records which key names are 26 // configured per skill so ConfiguredKeys() can answer without triggering 27 // Keychain access prompts. 28 type SecretsStore struct { 29 indexPath string 30 lockPath string 31 } 32 33 type secretsIndex struct { 34 Skills map[string][]string `json:"skills"` 35 } 36 37 // keychainServiceName returns the macOS Keychain service identifier for a skill. 38 func keychainServiceName(skillName string) string { 39 return "com.shannon.skill." + skillName 40 } 41 42 // NewSecretsStore returns a store rooted at <shannonDir>. Returns nil for 43 // empty shannonDir so callers can safely use it in test contexts. 44 func NewSecretsStore(shannonDir string) *SecretsStore { 45 if shannonDir == "" { 46 return nil 47 } 48 return &SecretsStore{ 49 indexPath: filepath.Join(shannonDir, "secrets-index.json"), 50 lockPath: filepath.Join(shannonDir, "secrets-index.json.lock"), 51 } 52 } 53 54 // Get returns the secrets for a skill by reading values from the Keychain 55 // for each key listed in the index. Returns nil if no secrets are stored. 56 func (s *SecretsStore) Get(skillName string) map[string]string { 57 if s == nil { 58 return nil 59 } 60 keys := s.ConfiguredKeys(skillName) 61 if len(keys) == 0 { 62 return nil 63 } 64 result := make(map[string]string, len(keys)) 65 for _, k := range keys { 66 val, err := keychainRead(keychainServiceName(skillName), k) 67 if err != nil { 68 // Skip keys we can't read (e.g. user denied Keychain access 69 // for a single item). The index lists the key but the value 70 // is unavailable — treat as absent. 71 continue 72 } 73 result[k] = val 74 } 75 return result 76 } 77 78 // Set writes secrets to the Keychain and updates the index. 79 // Existing keys are overwritten, new keys are added, unmentioned keys are preserved. 80 func (s *SecretsStore) Set(skillName string, secrets map[string]string) error { 81 if s == nil { 82 return nil 83 } 84 if len(secrets) == 0 { 85 return nil 86 } 87 service := keychainServiceName(skillName) 88 for k, v := range secrets { 89 if err := keychainWrite(service, k, v); err != nil { 90 return fmt.Errorf("keychain write %s/%s: %w", skillName, k, err) 91 } 92 } 93 // Merge key names into the index. 94 return s.modifyIndex(func(data *secretsIndex) { 95 if data.Skills == nil { 96 data.Skills = make(map[string][]string) 97 } 98 seen := map[string]bool{} 99 for _, k := range data.Skills[skillName] { 100 seen[k] = true 101 } 102 merged := append([]string(nil), data.Skills[skillName]...) 103 for k := range secrets { 104 if !seen[k] { 105 merged = append(merged, k) 106 seen[k] = true 107 } 108 } 109 sort.Strings(merged) 110 data.Skills[skillName] = merged 111 }) 112 } 113 114 // Delete removes all secrets for a skill. 115 func (s *SecretsStore) Delete(skillName string) error { 116 if s == nil { 117 return nil 118 } 119 service := keychainServiceName(skillName) 120 keys := s.ConfiguredKeys(skillName) 121 for _, k := range keys { 122 // Best-effort delete: ignore "not found" errors. 123 _ = keychainDelete(service, k) 124 } 125 return s.modifyIndex(func(data *secretsIndex) { 126 delete(data.Skills, skillName) 127 }) 128 } 129 130 // DeleteKey removes a single secret key for a skill. 131 func (s *SecretsStore) DeleteKey(skillName, key string) error { 132 if s == nil { 133 return nil 134 } 135 _ = keychainDelete(keychainServiceName(skillName), key) 136 return s.modifyIndex(func(data *secretsIndex) { 137 if keys, ok := data.Skills[skillName]; ok { 138 filtered := keys[:0] 139 for _, k := range keys { 140 if k != key { 141 filtered = append(filtered, k) 142 } 143 } 144 if len(filtered) == 0 { 145 delete(data.Skills, skillName) 146 } else { 147 data.Skills[skillName] = filtered 148 } 149 } 150 }) 151 } 152 153 // ConfiguredKeys returns the sorted list of key names configured for a skill. 154 // Reads only the index file — does not access the Keychain. 155 func (s *SecretsStore) ConfiguredKeys(skillName string) []string { 156 if s == nil { 157 return nil 158 } 159 data := s.loadIndex() 160 keys, ok := data.Skills[skillName] 161 if !ok || len(keys) == 0 { 162 return nil 163 } 164 out := append([]string(nil), keys...) 165 sort.Strings(out) 166 return out 167 } 168 169 // loadIndex reads the index file. Returns an empty struct if file doesn't exist. 170 func (s *SecretsStore) loadIndex() secretsIndex { 171 data, err := os.ReadFile(s.indexPath) 172 if err != nil { 173 return secretsIndex{} 174 } 175 var idx secretsIndex 176 if err := json.Unmarshal(data, &idx); err != nil { 177 return secretsIndex{} 178 } 179 return idx 180 } 181 182 // modifyIndex performs a read-modify-write cycle under an exclusive flock. 183 func (s *SecretsStore) modifyIndex(fn func(*secretsIndex)) error { 184 lockFile, err := os.OpenFile(s.lockPath, os.O_CREATE|os.O_RDWR, 0600) 185 if err != nil { 186 return err 187 } 188 defer lockFile.Close() 189 if err := syscall.Flock(int(lockFile.Fd()), syscall.LOCK_EX); err != nil { 190 return err 191 } 192 defer syscall.Flock(int(lockFile.Fd()), syscall.LOCK_UN) 193 // Do NOT os.Remove the lock file — concurrent goroutines may flock 194 // on different inodes if the file is deleted and recreated. 195 196 data := s.loadIndex() 197 fn(&data) 198 199 jsonBytes, err := json.MarshalIndent(data, "", " ") 200 if err != nil { 201 return err 202 } 203 204 tmp := s.indexPath + ".tmp" 205 if err := os.WriteFile(tmp, jsonBytes, 0600); err != nil { 206 return err 207 } 208 return os.Rename(tmp, s.indexPath) 209 } 210 211 // keychainWrite stores a secret in the OS keychain. On macOS this uses 212 // `security` interactive mode internally (via zalando/go-keyring) so the 213 // password travels over stdin, never argv — process listings (`ps`, 214 // ProcessInformation) cannot observe the value. 215 func keychainWrite(service, account, password string) error { 216 if err := keyring.Set(service, account, password); err != nil { 217 return fmt.Errorf("keyring set %s/%s: %w", service, account, err) 218 } 219 return nil 220 } 221 222 // keychainRead retrieves a secret. Returns an error if the item is not found. 223 func keychainRead(service, account string) (string, error) { 224 val, err := keyring.Get(service, account) 225 if err != nil { 226 return "", fmt.Errorf("keyring get %s/%s: %w", service, account, err) 227 } 228 return val, nil 229 } 230 231 // keychainDelete removes a secret. Returns nil if the item does not exist 232 // (idempotent delete). 233 func keychainDelete(service, account string) error { 234 err := keyring.Delete(service, account) 235 if err == nil || errors.Is(err, keyring.ErrNotFound) { 236 return nil 237 } 238 return fmt.Errorf("keyring delete %s/%s: %w", service, account, err) 239 }