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