forked from tangled.org/core
this repo has no description
1package secrets 2 3import ( 4 "context" 5 "fmt" 6 "log/slog" 7 "path" 8 "strings" 9 "time" 10 11 "github.com/bluesky-social/indigo/atproto/syntax" 12 vault "github.com/openbao/openbao/api/v2" 13) 14 15type OpenBaoManager struct { 16 client *vault.Client 17 mountPath string 18 logger *slog.Logger 19} 20 21type OpenBaoManagerOpt func(*OpenBaoManager) 22 23func WithMountPath(mountPath string) OpenBaoManagerOpt { 24 return func(v *OpenBaoManager) { 25 v.mountPath = mountPath 26 } 27} 28 29// NewOpenBaoManager creates a new OpenBao manager that connects to a Bao Proxy 30// The proxyAddress should point to the local Bao Proxy (e.g., "http://127.0.0.1:8200") 31// The proxy handles all authentication automatically via Auto-Auth 32func NewOpenBaoManager(proxyAddress string, logger *slog.Logger, opts ...OpenBaoManagerOpt) (*OpenBaoManager, error) { 33 if proxyAddress == "" { 34 return nil, fmt.Errorf("proxy address cannot be empty") 35 } 36 37 config := vault.DefaultConfig() 38 config.Address = proxyAddress 39 40 client, err := vault.NewClient(config) 41 if err != nil { 42 return nil, fmt.Errorf("failed to create openbao client: %w", err) 43 } 44 45 manager := &OpenBaoManager{ 46 client: client, 47 mountPath: "spindle", // default KV v2 mount path 48 logger: logger, 49 } 50 51 for _, opt := range opts { 52 opt(manager) 53 } 54 55 if err := manager.testConnection(); err != nil { 56 return nil, fmt.Errorf("failed to connect to bao proxy: %w", err) 57 } 58 59 logger.Info("successfully connected to bao proxy", "address", proxyAddress) 60 return manager, nil 61} 62 63// testConnection verifies that we can connect to the proxy 64func (v *OpenBaoManager) testConnection() error { 65 ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) 66 defer cancel() 67 68 // try token self-lookup as a quick way to verify proxy works 69 // and is authenticated 70 _, err := v.client.Auth().Token().LookupSelfWithContext(ctx) 71 if err != nil { 72 return fmt.Errorf("proxy connection test failed: %w", err) 73 } 74 75 return nil 76} 77 78func (v *OpenBaoManager) AddSecret(ctx context.Context, secret UnlockedSecret) error { 79 if err := ValidateKey(secret.Key); err != nil { 80 return err 81 } 82 83 secretPath := v.buildSecretPath(secret.Repo, secret.Key) 84 v.logger.Debug("adding secret", "repo", secret.Repo, "key", secret.Key, "path", secretPath) 85 86 // Check if secret already exists 87 existing, err := v.client.KVv2(v.mountPath).Get(ctx, secretPath) 88 if err == nil && existing != nil { 89 v.logger.Debug("secret already exists", "path", secretPath) 90 return ErrKeyAlreadyPresent 91 } 92 93 secretData := map[string]interface{}{ 94 "value": secret.Value, 95 "repo": string(secret.Repo), 96 "key": secret.Key, 97 "created_at": secret.CreatedAt.Format(time.RFC3339), 98 "created_by": secret.CreatedBy.String(), 99 } 100 101 v.logger.Debug("writing secret to openbao", "path", secretPath, "mount", v.mountPath) 102 resp, err := v.client.KVv2(v.mountPath).Put(ctx, secretPath, secretData) 103 if err != nil { 104 v.logger.Error("failed to write secret", "path", secretPath, "error", err) 105 return fmt.Errorf("failed to store secret in openbao: %w", err) 106 } 107 108 v.logger.Debug("secret write response", "version", resp.VersionMetadata.Version, "created_time", resp.VersionMetadata.CreatedTime) 109 110 v.logger.Debug("verifying secret was written", "path", secretPath) 111 readBack, err := v.client.KVv2(v.mountPath).Get(ctx, secretPath) 112 if err != nil { 113 v.logger.Error("failed to verify secret after write", "path", secretPath, "error", err) 114 return fmt.Errorf("secret not found after writing to %s/%s: %w", v.mountPath, secretPath, err) 115 } 116 117 if readBack == nil || readBack.Data == nil { 118 v.logger.Error("secret verification returned empty data", "path", secretPath) 119 return fmt.Errorf("secret verification failed: empty data returned for %s/%s", v.mountPath, secretPath) 120 } 121 122 v.logger.Info("secret added and verified successfully", "repo", secret.Repo, "key", secret.Key, "version", readBack.VersionMetadata.Version) 123 return nil 124} 125 126func (v *OpenBaoManager) RemoveSecret(ctx context.Context, secret Secret[any]) error { 127 secretPath := v.buildSecretPath(secret.Repo, secret.Key) 128 129 // check if secret exists 130 existing, err := v.client.KVv2(v.mountPath).Get(ctx, secretPath) 131 if err != nil || existing == nil { 132 return ErrKeyNotFound 133 } 134 135 err = v.client.KVv2(v.mountPath).DeleteMetadata(ctx, secretPath) 136 if err != nil { 137 return fmt.Errorf("failed to delete secret from openbao: %w", err) 138 } 139 140 v.logger.Debug("secret removed successfully", "repo", secret.Repo, "key", secret.Key) 141 return nil 142} 143 144func (v *OpenBaoManager) GetSecretsLocked(ctx context.Context, repo DidSlashRepo) ([]LockedSecret, error) { 145 repoPath := v.buildRepoPath(repo) 146 147 secretsList, err := v.client.Logical().ListWithContext(ctx, fmt.Sprintf("%s/metadata/%s", v.mountPath, repoPath)) 148 if err != nil { 149 if strings.Contains(err.Error(), "no secret found") || strings.Contains(err.Error(), "no handler for route") { 150 return []LockedSecret{}, nil 151 } 152 return nil, fmt.Errorf("failed to list secrets: %w", err) 153 } 154 155 if secretsList == nil || secretsList.Data == nil { 156 return []LockedSecret{}, nil 157 } 158 159 keys, ok := secretsList.Data["keys"].([]interface{}) 160 if !ok { 161 return []LockedSecret{}, nil 162 } 163 164 var secrets []LockedSecret 165 166 for _, keyInterface := range keys { 167 key, ok := keyInterface.(string) 168 if !ok { 169 continue 170 } 171 172 secretPath := fmt.Sprintf("%s/%s", repoPath, key) 173 secretData, err := v.client.KVv2(v.mountPath).Get(ctx, secretPath) 174 if err != nil { 175 v.logger.Warn("failed to read secret metadata", "path", secretPath, "error", err) 176 continue 177 } 178 179 if secretData == nil || secretData.Data == nil { 180 continue 181 } 182 183 data := secretData.Data 184 185 createdAtStr, ok := data["created_at"].(string) 186 if !ok { 187 createdAtStr = time.Now().Format(time.RFC3339) 188 } 189 190 createdAt, err := time.Parse(time.RFC3339, createdAtStr) 191 if err != nil { 192 createdAt = time.Now() 193 } 194 195 createdByStr, ok := data["created_by"].(string) 196 if !ok { 197 createdByStr = "" 198 } 199 200 keyStr, ok := data["key"].(string) 201 if !ok { 202 keyStr = key 203 } 204 205 secret := LockedSecret{ 206 Key: keyStr, 207 Repo: repo, 208 CreatedAt: createdAt, 209 CreatedBy: syntax.DID(createdByStr), 210 } 211 212 secrets = append(secrets, secret) 213 } 214 215 v.logger.Debug("retrieved locked secrets", "repo", repo, "count", len(secrets)) 216 return secrets, nil 217} 218 219func (v *OpenBaoManager) GetSecretsUnlocked(ctx context.Context, repo DidSlashRepo) ([]UnlockedSecret, error) { 220 repoPath := v.buildRepoPath(repo) 221 222 secretsList, err := v.client.Logical().ListWithContext(ctx, fmt.Sprintf("%s/metadata/%s", v.mountPath, repoPath)) 223 if err != nil { 224 if strings.Contains(err.Error(), "no secret found") || strings.Contains(err.Error(), "no handler for route") { 225 return []UnlockedSecret{}, nil 226 } 227 return nil, fmt.Errorf("failed to list secrets: %w", err) 228 } 229 230 if secretsList == nil || secretsList.Data == nil { 231 return []UnlockedSecret{}, nil 232 } 233 234 keys, ok := secretsList.Data["keys"].([]interface{}) 235 if !ok { 236 return []UnlockedSecret{}, nil 237 } 238 239 var secrets []UnlockedSecret 240 241 for _, keyInterface := range keys { 242 key, ok := keyInterface.(string) 243 if !ok { 244 continue 245 } 246 247 secretPath := fmt.Sprintf("%s/%s", repoPath, key) 248 secretData, err := v.client.KVv2(v.mountPath).Get(ctx, secretPath) 249 if err != nil { 250 v.logger.Warn("failed to read secret", "path", secretPath, "error", err) 251 continue 252 } 253 254 if secretData == nil || secretData.Data == nil { 255 continue 256 } 257 258 data := secretData.Data 259 260 valueStr, ok := data["value"].(string) 261 if !ok { 262 v.logger.Warn("secret missing value", "path", secretPath) 263 continue 264 } 265 266 createdAtStr, ok := data["created_at"].(string) 267 if !ok { 268 createdAtStr = time.Now().Format(time.RFC3339) 269 } 270 271 createdAt, err := time.Parse(time.RFC3339, createdAtStr) 272 if err != nil { 273 createdAt = time.Now() 274 } 275 276 createdByStr, ok := data["created_by"].(string) 277 if !ok { 278 createdByStr = "" 279 } 280 281 keyStr, ok := data["key"].(string) 282 if !ok { 283 keyStr = key 284 } 285 286 secret := UnlockedSecret{ 287 Key: keyStr, 288 Value: valueStr, 289 Repo: repo, 290 CreatedAt: createdAt, 291 CreatedBy: syntax.DID(createdByStr), 292 } 293 294 secrets = append(secrets, secret) 295 } 296 297 v.logger.Debug("retrieved unlocked secrets", "repo", repo, "count", len(secrets)) 298 return secrets, nil 299} 300 301// buildRepoPath creates a safe path for a repository 302func (v *OpenBaoManager) buildRepoPath(repo DidSlashRepo) string { 303 // convert DidSlashRepo to a safe path by replacing special characters 304 repoPath := strings.ReplaceAll(string(repo), "/", "_") 305 repoPath = strings.ReplaceAll(repoPath, ":", "_") 306 repoPath = strings.ReplaceAll(repoPath, ".", "_") 307 return fmt.Sprintf("repos/%s", repoPath) 308} 309 310// buildSecretPath creates a path for a specific secret 311func (v *OpenBaoManager) buildSecretPath(repo DidSlashRepo, key string) string { 312 return path.Join(v.buildRepoPath(repo), key) 313}