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}