this repo has no description
1package secrets
2
3import (
4 "context"
5 "fmt"
6 "log/slog"
7 "path"
8 "strings"
9 "sync"
10 "time"
11
12 "github.com/bluesky-social/indigo/atproto/syntax"
13 vault "github.com/openbao/openbao/api/v2"
14)
15
16type OpenBaoManager struct {
17 client *vault.Client
18 mountPath string
19 roleID string
20 secretID string
21 stopCh chan struct{}
22 tokenMu sync.RWMutex
23 logger *slog.Logger
24}
25
26type OpenBaoManagerOpt func(*OpenBaoManager)
27
28func WithMountPath(mountPath string) OpenBaoManagerOpt {
29 return func(v *OpenBaoManager) {
30 v.mountPath = mountPath
31 }
32}
33
34func NewOpenBaoManager(address, roleID, secretID string, logger *slog.Logger, opts ...OpenBaoManagerOpt) (*OpenBaoManager, error) {
35 if address == "" {
36 return nil, fmt.Errorf("address cannot be empty")
37 }
38 if roleID == "" {
39 return nil, fmt.Errorf("role_id cannot be empty")
40 }
41 if secretID == "" {
42 return nil, fmt.Errorf("secret_id cannot be empty")
43 }
44
45 config := vault.DefaultConfig()
46 config.Address = address
47
48 client, err := vault.NewClient(config)
49 if err != nil {
50 return nil, fmt.Errorf("failed to create openbao client: %w", err)
51 }
52
53 // Authenticate using AppRole
54 err = authenticateAppRole(client, roleID, secretID)
55 if err != nil {
56 return nil, fmt.Errorf("failed to authenticate with AppRole: %w", err)
57 }
58
59 manager := &OpenBaoManager{
60 client: client,
61 mountPath: "spindle", // default KV v2 mount path
62 roleID: roleID,
63 secretID: secretID,
64 stopCh: make(chan struct{}),
65 logger: logger,
66 }
67
68 for _, opt := range opts {
69 opt(manager)
70 }
71
72 go manager.tokenRenewalLoop()
73
74 return manager, nil
75}
76
77// authenticateAppRole authenticates the client using AppRole method
78func authenticateAppRole(client *vault.Client, roleID, secretID string) error {
79 authData := map[string]interface{}{
80 "role_id": roleID,
81 "secret_id": secretID,
82 }
83
84 resp, err := client.Logical().Write("auth/approle/login", authData)
85 if err != nil {
86 return fmt.Errorf("failed to login with AppRole: %w", err)
87 }
88
89 if resp == nil || resp.Auth == nil {
90 return fmt.Errorf("no auth info returned from AppRole login")
91 }
92
93 client.SetToken(resp.Auth.ClientToken)
94 return nil
95}
96
97// stop stops the token renewal goroutine
98func (v *OpenBaoManager) Stop() {
99 close(v.stopCh)
100}
101
102// tokenRenewalLoop runs in a background goroutine to automatically renew or re-authenticate tokens
103func (v *OpenBaoManager) tokenRenewalLoop() {
104 ticker := time.NewTicker(30 * time.Second) // Check every 30 seconds
105 defer ticker.Stop()
106
107 for {
108 select {
109 case <-v.stopCh:
110 return
111 case <-ticker.C:
112 ctx := context.Background()
113 if err := v.ensureValidToken(ctx); err != nil {
114 v.logger.Error("openbao token renewal failed", "error", err)
115 }
116 }
117 }
118}
119
120// ensureValidToken checks if the current token is valid and renews or re-authenticates if needed
121func (v *OpenBaoManager) ensureValidToken(ctx context.Context) error {
122 v.tokenMu.Lock()
123 defer v.tokenMu.Unlock()
124
125 // check current token info
126 tokenInfo, err := v.client.Auth().Token().LookupSelf()
127 if err != nil {
128 // token is invalid, need to re-authenticate
129 v.logger.Warn("token lookup failed, re-authenticating", "error", err)
130 return v.reAuthenticate()
131 }
132
133 if tokenInfo == nil || tokenInfo.Data == nil {
134 return v.reAuthenticate()
135 }
136
137 // check TTL
138 ttlRaw, ok := tokenInfo.Data["ttl"]
139 if !ok {
140 return v.reAuthenticate()
141 }
142
143 var ttl int64
144 switch t := ttlRaw.(type) {
145 case int64:
146 ttl = t
147 case float64:
148 ttl = int64(t)
149 case int:
150 ttl = int64(t)
151 default:
152 return v.reAuthenticate()
153 }
154
155 // if TTL is less than 5 minutes, try to renew
156 if ttl < 300 {
157 v.logger.Info("token ttl low, attempting renewal", "ttl_seconds", ttl)
158
159 renewResp, err := v.client.Auth().Token().RenewSelf(3600) // 1h
160 if err != nil {
161 v.logger.Warn("token renewal failed, re-authenticating", "error", err)
162 return v.reAuthenticate()
163 }
164
165 if renewResp == nil || renewResp.Auth == nil {
166 v.logger.Warn("token renewal returned no auth info, re-authenticating")
167 return v.reAuthenticate()
168 }
169
170 v.logger.Info("token renewed successfully", "new_ttl_seconds", renewResp.Auth.LeaseDuration)
171 }
172
173 return nil
174}
175
176// reAuthenticate performs a fresh authentication using AppRole
177func (v *OpenBaoManager) reAuthenticate() error {
178 v.logger.Info("re-authenticating with approle")
179
180 err := authenticateAppRole(v.client, v.roleID, v.secretID)
181 if err != nil {
182 return fmt.Errorf("re-authentication failed: %w", err)
183 }
184
185 v.logger.Info("re-authentication successful")
186 return nil
187}
188
189func (v *OpenBaoManager) AddSecret(ctx context.Context, secret UnlockedSecret) error {
190 v.tokenMu.RLock()
191 defer v.tokenMu.RUnlock()
192 if err := ValidateKey(secret.Key); err != nil {
193 return err
194 }
195
196 secretPath := v.buildSecretPath(secret.Repo, secret.Key)
197
198 fmt.Println(v.mountPath, secretPath)
199
200 existing, err := v.client.KVv2(v.mountPath).Get(ctx, secretPath)
201 if err == nil && existing != nil {
202 return ErrKeyAlreadyPresent
203 }
204
205 secretData := map[string]interface{}{
206 "value": secret.Value,
207 "repo": string(secret.Repo),
208 "key": secret.Key,
209 "created_at": secret.CreatedAt.Format(time.RFC3339),
210 "created_by": secret.CreatedBy.String(),
211 }
212
213 _, err = v.client.KVv2(v.mountPath).Put(ctx, secretPath, secretData)
214 if err != nil {
215 return fmt.Errorf("failed to store secret in openbao: %w", err)
216 }
217
218 return nil
219}
220
221func (v *OpenBaoManager) RemoveSecret(ctx context.Context, secret Secret[any]) error {
222 v.tokenMu.RLock()
223 defer v.tokenMu.RUnlock()
224 secretPath := v.buildSecretPath(secret.Repo, secret.Key)
225
226 existing, err := v.client.KVv2(v.mountPath).Get(ctx, secretPath)
227 if err != nil || existing == nil {
228 return ErrKeyNotFound
229 }
230
231 err = v.client.KVv2(v.mountPath).Delete(ctx, secretPath)
232 if err != nil {
233 return fmt.Errorf("failed to delete secret from openbao: %w", err)
234 }
235
236 return nil
237}
238
239func (v *OpenBaoManager) GetSecretsLocked(ctx context.Context, repo DidSlashRepo) ([]LockedSecret, error) {
240 v.tokenMu.RLock()
241 defer v.tokenMu.RUnlock()
242 repoPath := v.buildRepoPath(repo)
243
244 secretsList, err := v.client.Logical().List(fmt.Sprintf("%s/metadata/%s", v.mountPath, repoPath))
245 if err != nil {
246 if strings.Contains(err.Error(), "no secret found") || strings.Contains(err.Error(), "no handler for route") {
247 return []LockedSecret{}, nil
248 }
249 return nil, fmt.Errorf("failed to list secrets: %w", err)
250 }
251
252 if secretsList == nil || secretsList.Data == nil {
253 return []LockedSecret{}, nil
254 }
255
256 keys, ok := secretsList.Data["keys"].([]interface{})
257 if !ok {
258 return []LockedSecret{}, nil
259 }
260
261 var secrets []LockedSecret
262
263 for _, keyInterface := range keys {
264 key, ok := keyInterface.(string)
265 if !ok {
266 continue
267 }
268
269 secretPath := path.Join(repoPath, key)
270 secretData, err := v.client.KVv2(v.mountPath).Get(ctx, secretPath)
271 if err != nil {
272 continue // Skip secrets we can't read
273 }
274
275 if secretData == nil || secretData.Data == nil {
276 continue
277 }
278
279 data := secretData.Data
280
281 createdAtStr, ok := data["created_at"].(string)
282 if !ok {
283 createdAtStr = time.Now().Format(time.RFC3339)
284 }
285
286 createdAt, err := time.Parse(time.RFC3339, createdAtStr)
287 if err != nil {
288 createdAt = time.Now()
289 }
290
291 createdByStr, ok := data["created_by"].(string)
292 if !ok {
293 createdByStr = ""
294 }
295
296 keyStr, ok := data["key"].(string)
297 if !ok {
298 keyStr = key
299 }
300
301 secret := LockedSecret{
302 Key: keyStr,
303 Repo: repo,
304 CreatedAt: createdAt,
305 CreatedBy: syntax.DID(createdByStr),
306 }
307
308 secrets = append(secrets, secret)
309 }
310
311 return secrets, nil
312}
313
314func (v *OpenBaoManager) GetSecretsUnlocked(ctx context.Context, repo DidSlashRepo) ([]UnlockedSecret, error) {
315 v.tokenMu.RLock()
316 defer v.tokenMu.RUnlock()
317 repoPath := v.buildRepoPath(repo)
318
319 secretsList, err := v.client.Logical().List(fmt.Sprintf("%s/metadata/%s", v.mountPath, repoPath))
320 if err != nil {
321 if strings.Contains(err.Error(), "no secret found") || strings.Contains(err.Error(), "no handler for route") {
322 return []UnlockedSecret{}, nil
323 }
324 return nil, fmt.Errorf("failed to list secrets: %w", err)
325 }
326
327 if secretsList == nil || secretsList.Data == nil {
328 return []UnlockedSecret{}, nil
329 }
330
331 keys, ok := secretsList.Data["keys"].([]interface{})
332 if !ok {
333 return []UnlockedSecret{}, nil
334 }
335
336 var secrets []UnlockedSecret
337
338 for _, keyInterface := range keys {
339 key, ok := keyInterface.(string)
340 if !ok {
341 continue
342 }
343
344 secretPath := path.Join(repoPath, key)
345 secretData, err := v.client.KVv2(v.mountPath).Get(ctx, secretPath)
346 if err != nil {
347 continue
348 }
349
350 if secretData == nil || secretData.Data == nil {
351 continue
352 }
353
354 data := secretData.Data
355
356 valueStr, ok := data["value"].(string)
357 if !ok {
358 continue // skip secrets without values
359 }
360
361 createdAtStr, ok := data["created_at"].(string)
362 if !ok {
363 createdAtStr = time.Now().Format(time.RFC3339)
364 }
365
366 createdAt, err := time.Parse(time.RFC3339, createdAtStr)
367 if err != nil {
368 createdAt = time.Now()
369 }
370
371 createdByStr, ok := data["created_by"].(string)
372 if !ok {
373 createdByStr = ""
374 }
375
376 keyStr, ok := data["key"].(string)
377 if !ok {
378 keyStr = key
379 }
380
381 secret := UnlockedSecret{
382 Key: keyStr,
383 Value: valueStr,
384 Repo: repo,
385 CreatedAt: createdAt,
386 CreatedBy: syntax.DID(createdByStr),
387 }
388
389 secrets = append(secrets, secret)
390 }
391
392 return secrets, nil
393}
394
395// buildRepoPath creates an OpenBao path for a repository
396func (v *OpenBaoManager) buildRepoPath(repo DidSlashRepo) string {
397 // convert DidSlashRepo to a safe path by replacing special characters
398 repoPath := strings.ReplaceAll(string(repo), "/", "_")
399 repoPath = strings.ReplaceAll(repoPath, ":", "_")
400 repoPath = strings.ReplaceAll(repoPath, ".", "_")
401 return fmt.Sprintf("repos/%s", repoPath)
402}
403
404// buildSecretPath creates an OpenBao path for a specific secret
405func (v *OpenBaoManager) buildSecretPath(repo DidSlashRepo, key string) string {
406 return path.Join(v.buildRepoPath(repo), key)
407}