/ internal / skills / secrets.go
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  }