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}