1// Package tokenstore implements token storage adapters.
2package tokenstore
3
4import (
5 "context"
6 "encoding/json"
7 "fmt"
8 "os/exec"
9 "regexp"
10 "strings"
11 "sync"
12 "time"
13
14 "github.com/bnema/zerowrap"
15
16 "github.com/bnema/gordon/internal/domain"
17)
18
19// ansiRegex matches ANSI escape sequences for stripping from pass output.
20var ansiRegex = regexp.MustCompile(`\x1b\[[0-9;]*m`)
21
22// subjectRegex validates token subjects to prevent path injection.
23// Allows alphanumeric characters, forward slashes, underscores, dots, hyphens, and @.
24// This prevents path traversal (../) and command injection attacks.
25var subjectRegex = regexp.MustCompile(`^[a-zA-Z0-9/_.@-]+$`)
26
27const (
28 // passTokenPath is the base path for tokens in pass.
29 passTokenPath = "gordon/registry/tokens" //nolint:gosec // Not a credential, this is a pass store path
30 // passRevokedPath is the path for the revocation list in pass.
31 passRevokedPath = "gordon/registry/revoked" //nolint:gosec // Not a credential, this is a pass store path
32)
33
34// cachedToken holds a token and its JWT in memory.
35type cachedToken struct {
36 jwt string
37 token *domain.Token
38}
39
40// validateSubject validates a token subject to prevent path injection and command injection.
41// Returns an error if the subject is invalid.
42func validateSubject(subject string) error {
43 if subject == "" {
44 return fmt.Errorf("subject cannot be empty")
45 }
46
47 if !subjectRegex.MatchString(subject) {
48 return fmt.Errorf("invalid subject: must contain only alphanumeric characters, /, _, ., @, -")
49 }
50
51 if strings.Contains(subject, "..") {
52 return fmt.Errorf("invalid subject: cannot contain '..' to prevent path traversal")
53 }
54
55 return nil
56}
57
58// PassStore implements TokenStore using the pass password manager.
59type PassStore struct {
60 timeout time.Duration
61 log zerowrap.Logger
62
63 // In-memory cache to avoid repeated pass calls
64 cacheMu sync.RWMutex
65 tokenCache map[string]*cachedToken // keyed by subject
66 revokedList []string // cached revocation list
67 revokedSet map[string]struct{} // for O(1) lookup
68}
69
70// NewPassStore creates a new pass-based token store.
71func NewPassStore(log zerowrap.Logger) *PassStore {
72 return &PassStore{
73 timeout: 10 * time.Second,
74 log: log,
75 tokenCache: make(map[string]*cachedToken),
76 revokedSet: make(map[string]struct{}),
77 }
78}
79
80// tokenMetadata holds the non-sensitive token information.
81type tokenMetadata struct {
82 ID string `json:"id"`
83 Subject string `json:"subject"`
84 Scopes []string `json:"scopes"`
85 IssuedAt time.Time `json:"issued_at"`
86 ExpiresAt time.Time `json:"expires_at,omitempty"`
87 Revoked bool `json:"revoked"`
88}
89
90// SaveToken stores a token JWT and metadata in pass.
91func (s *PassStore) SaveToken(ctx context.Context, token *domain.Token, jwt string) error {
92 if err := validateSubject(token.Subject); err != nil {
93 return err
94 }
95
96 ctx, cancel := context.WithTimeout(ctx, s.timeout)
97 defer cancel()
98
99 // Store JWT
100 jwtPath := fmt.Sprintf("%s/%s", passTokenPath, token.Subject)
101 if err := s.passInsert(ctx, jwtPath, jwt); err != nil {
102 return fmt.Errorf("failed to store token: %w", err)
103 }
104
105 // Store metadata
106 meta := tokenMetadata{
107 ID: token.ID,
108 Subject: token.Subject,
109 Scopes: token.Scopes,
110 IssuedAt: token.IssuedAt,
111 ExpiresAt: token.ExpiresAt,
112 Revoked: token.Revoked,
113 }
114 metaJSON, err := json.Marshal(meta)
115 if err != nil {
116 return fmt.Errorf("failed to marshal token metadata: %w", err)
117 }
118
119 metaPath := fmt.Sprintf("%s/%s.meta", passTokenPath, token.Subject)
120 if err := s.passInsert(ctx, metaPath, string(metaJSON)); err != nil {
121 return fmt.Errorf("failed to store token metadata: %w", err)
122 }
123
124 // Update cache
125 s.cacheMu.Lock()
126 s.tokenCache[token.Subject] = &cachedToken{jwt: jwt, token: token}
127 s.cacheMu.Unlock()
128
129 s.log.Debug().
130 Str(zerowrap.FieldLayer, "adapter").
131 Str(zerowrap.FieldAdapter, "tokenstore").
132 Str("provider", "pass").
133 Str("subject", token.Subject).
134 Msg("token stored in pass")
135
136 return nil
137}
138
139// GetToken retrieves token JWT by subject from pass.
140func (s *PassStore) GetToken(ctx context.Context, subject string) (string, *domain.Token, error) {
141 if err := validateSubject(subject); err != nil {
142 return "", nil, err
143 }
144
145 // Check cache first
146 s.cacheMu.RLock()
147 if cached, ok := s.tokenCache[subject]; ok {
148 s.cacheMu.RUnlock()
149 return cached.jwt, cached.token, nil
150 }
151 s.cacheMu.RUnlock()
152
153 ctx, cancel := context.WithTimeout(ctx, s.timeout)
154 defer cancel()
155
156 // Get JWT
157 jwtPath := fmt.Sprintf("%s/%s", passTokenPath, subject)
158 jwt, err := s.passShow(ctx, jwtPath)
159 if err != nil {
160 return "", nil, domain.ErrTokenNotFound
161 }
162
163 // Get metadata
164 metaPath := fmt.Sprintf("%s/%s.meta", passTokenPath, subject)
165 metaJSON, err := s.passShow(ctx, metaPath)
166 if err != nil {
167 return "", nil, fmt.Errorf("failed to get token metadata: %w", err)
168 }
169
170 var meta tokenMetadata
171 if err := json.Unmarshal([]byte(metaJSON), &meta); err != nil {
172 return "", nil, fmt.Errorf("failed to unmarshal token metadata: %w", err)
173 }
174
175 token := &domain.Token{
176 ID: meta.ID,
177 Subject: meta.Subject,
178 Scopes: meta.Scopes,
179 IssuedAt: meta.IssuedAt,
180 ExpiresAt: meta.ExpiresAt,
181 Revoked: meta.Revoked,
182 }
183
184 // Cache the token
185 s.cacheMu.Lock()
186 s.tokenCache[subject] = &cachedToken{jwt: jwt, token: token}
187 s.cacheMu.Unlock()
188
189 return jwt, token, nil
190}
191
192// ListTokens returns all stored tokens from pass.
193func (s *PassStore) ListTokens(ctx context.Context) ([]domain.Token, error) {
194 ctx, cancel := context.WithTimeout(ctx, s.timeout)
195 defer cancel()
196
197 // List all entries under the token path
198 cmd := exec.CommandContext(ctx, "pass", "ls", passTokenPath)
199 output, err := cmd.Output()
200 if err != nil {
201 // If the path doesn't exist, return empty list
202 return []domain.Token{}, nil
203 }
204
205 var tokens []domain.Token
206 lines := strings.Split(string(output), "\n")
207
208 for _, line := range lines {
209 line = strings.TrimSpace(line)
210
211 // Strip ANSI escape sequences (pass outputs colored text on TTY)
212 line = ansiRegex.ReplaceAllString(line, "")
213
214 // Skip tree formatting characters (both Unicode and ASCII variants)
215 // Unicode: ├── └── │
216 line = strings.TrimPrefix(line, "├── ")
217 line = strings.TrimPrefix(line, "└── ")
218 line = strings.TrimPrefix(line, "│ ")
219 // ASCII: |-- `-- |
220 line = strings.TrimPrefix(line, "|-- ")
221 line = strings.TrimPrefix(line, "`-- ")
222 line = strings.TrimPrefix(line, "| ")
223
224 line = strings.TrimSpace(line)
225
226 // Skip .meta files, empty lines, and the header line
227 if line == "" || strings.HasSuffix(line, ".meta") || line == passTokenPath {
228 continue
229 }
230
231 // Try to get the token metadata
232 _, token, err := s.GetToken(ctx, line)
233 if err != nil {
234 s.log.Warn().Err(err).Str("subject", line).Msg("failed to get token")
235 continue
236 }
237
238 tokens = append(tokens, *token)
239 }
240
241 return tokens, nil
242}
243
244// Revoke adds token ID to revocation list in pass.
245func (s *PassStore) Revoke(ctx context.Context, tokenID string) error {
246 s.cacheMu.Lock()
247 defer s.cacheMu.Unlock()
248
249 ctx, cancel := context.WithTimeout(ctx, s.timeout)
250 defer cancel()
251
252 // Get current revocation list
253 revokedList, err := s.getRevokedList(ctx)
254 if err != nil {
255 return err
256 }
257
258 // Add to list if not already present
259 for _, id := range revokedList {
260 if id == tokenID {
261 return nil // Already revoked
262 }
263 }
264 revokedList = append(revokedList, tokenID)
265
266 // Store updated list
267 listJSON, err := json.Marshal(revokedList)
268 if err != nil {
269 return fmt.Errorf("failed to marshal revocation list: %w", err)
270 }
271
272 if err := s.passInsert(ctx, passRevokedPath, string(listJSON)); err != nil {
273 return fmt.Errorf("failed to store revocation list: %w", err)
274 }
275
276 // Update cache
277 s.revokedList = revokedList
278 s.revokedSet = make(map[string]struct{}, len(revokedList))
279 for _, id := range revokedList {
280 s.revokedSet[id] = struct{}{}
281 }
282
283 s.log.Info().
284 Str(zerowrap.FieldLayer, "adapter").
285 Str(zerowrap.FieldAdapter, "tokenstore").
286 Str("token_id", tokenID).
287 Msg("token revoked")
288
289 return nil
290}
291
292// IsRevoked checks if token ID is in revocation list.
293func (s *PassStore) IsRevoked(ctx context.Context, tokenID string) (bool, error) {
294 // Check cache first
295 s.cacheMu.RLock()
296 if s.revokedList != nil {
297 _, revoked := s.revokedSet[tokenID]
298 s.cacheMu.RUnlock()
299 return revoked, nil
300 }
301 s.cacheMu.RUnlock()
302
303 ctx, cancel := context.WithTimeout(ctx, s.timeout)
304 defer cancel()
305
306 revokedList, err := s.getRevokedList(ctx)
307 if err != nil {
308 return false, err
309 }
310
311 // Cache the revocation list
312 s.cacheMu.Lock()
313 s.revokedList = revokedList
314 s.revokedSet = make(map[string]struct{}, len(revokedList))
315 for _, id := range revokedList {
316 s.revokedSet[id] = struct{}{}
317 }
318 _, revoked := s.revokedSet[tokenID]
319 s.cacheMu.Unlock()
320
321 return revoked, nil
322}
323
324// DeleteToken removes token from pass.
325func (s *PassStore) DeleteToken(ctx context.Context, subject string) error {
326 if err := validateSubject(subject); err != nil {
327 return err
328 }
329
330 ctx, cancel := context.WithTimeout(ctx, s.timeout)
331 defer cancel()
332
333 jwtPath := fmt.Sprintf("%s/%s", passTokenPath, subject)
334 metaPath := fmt.Sprintf("%s/%s.meta", passTokenPath, subject)
335
336 // Remove JWT
337 // #nosec G204 -- command and args are fixed; subject is treated as data only.
338 cmd := exec.CommandContext(ctx, "pass", "rm", "-f", jwtPath)
339 if err := cmd.Run(); err != nil {
340 return fmt.Errorf("failed to delete token: %w", err)
341 }
342
343 // Remove metadata
344 // #nosec G204 -- command and args are fixed; subject is treated as data only.
345 cmd = exec.CommandContext(ctx, "pass", "rm", "-f", metaPath)
346 if err := cmd.Run(); err != nil {
347 s.log.Warn().Err(err).Msg("failed to delete token metadata")
348 }
349
350 // Clear from cache
351 s.cacheMu.Lock()
352 delete(s.tokenCache, subject)
353 s.cacheMu.Unlock()
354
355 s.log.Debug().
356 Str(zerowrap.FieldLayer, "adapter").
357 Str(zerowrap.FieldAdapter, "tokenstore").
358 Str("subject", subject).
359 Msg("token deleted from pass")
360
361 return nil
362}
363
364// passInsert inserts a value into pass.
365func (s *PassStore) passInsert(ctx context.Context, path, value string) error {
366 cmd := exec.CommandContext(ctx, "pass", "insert", "-m", "-f", path)
367 cmd.Stdin = strings.NewReader(value)
368 output, err := cmd.CombinedOutput()
369 if err != nil {
370 return fmt.Errorf("pass insert failed: %s: %w", string(output), err)
371 }
372 return nil
373}
374
375// passShow retrieves a value from pass.
376func (s *PassStore) passShow(ctx context.Context, path string) (string, error) {
377 cmd := exec.CommandContext(ctx, "pass", "show", path)
378 output, err := cmd.Output()
379 if err != nil {
380 return "", fmt.Errorf("pass show failed: %w", err)
381 }
382 return strings.TrimSpace(string(output)), nil
383}
384
385// getRevokedList retrieves the current revocation list.
386func (s *PassStore) getRevokedList(ctx context.Context) ([]string, error) {
387 listJSON, err := s.passShow(ctx, passRevokedPath)
388 if err != nil {
389 // If the list doesn't exist, return empty list
390 return []string{}, nil
391 }
392
393 var revokedList []string
394 if err := json.Unmarshal([]byte(listJSON), &revokedList); err != nil {
395 return nil, fmt.Errorf("failed to unmarshal revocation list: %w", err)
396 }
397
398 return revokedList, nil
399}
400
401// IsAvailable checks if pass is available in the system.
402func (s *PassStore) IsAvailable() bool {
403 cmd := exec.Command("pass", "version")
404 return cmd.Run() == nil
405}