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 "unicode/utf8"
14
15 "github.com/bnema/zerowrap"
16
17 "github.com/bnema/gordon/internal/domain"
18)
19
20// ansiRegex matches ANSI escape sequences for stripping from pass output.
21var ansiRegex = regexp.MustCompile(`\x1b\[[0-9;]*m`)
22
23// subjectRegex validates token subjects to prevent path injection.
24// Allows alphanumeric characters, forward slashes, underscores, dots, hyphens, and @.
25// This prevents path traversal (../) and command injection attacks.
26var subjectRegex = regexp.MustCompile(`^[a-zA-Z0-9/_.@-]+$`)
27
28const (
29 // passTokenPath is the base path for tokens in pass.
30 passTokenPath = "gordon/registry/tokens" //nolint:gosec // Not a credential, this is a pass store path
31 // passRevokedPath is the path for the revocation list in pass.
32 passRevokedPath = "gordon/registry/revoked" //nolint:gosec // Not a credential, this is a pass store path
33)
34
35// cachedToken holds a token and its JWT in memory.
36type cachedToken struct {
37 jwt string
38 token *domain.Token
39}
40
41// validateSubject validates a token subject to prevent path injection and command injection.
42// Returns an error if the subject is invalid.
43func validateSubject(subject string) error {
44 if subject == "" {
45 return fmt.Errorf("subject cannot be empty")
46 }
47
48 if !subjectRegex.MatchString(subject) {
49 return fmt.Errorf("invalid subject: must contain only alphanumeric characters, /, _, ., @, -")
50 }
51
52 if strings.Contains(subject, "..") {
53 return fmt.Errorf("invalid subject: cannot contain '..' to prevent path traversal")
54 }
55
56 if strings.HasSuffix(subject, ".meta") {
57 return fmt.Errorf("invalid subject: cannot end with '.meta' (reserved for token metadata files)")
58 }
59
60 return nil
61}
62
63// PassStore implements TokenStore using the pass password manager.
64type PassStore struct {
65 timeout time.Duration
66 log zerowrap.Logger
67
68 // In-memory cache to avoid repeated pass calls
69 cacheMu sync.RWMutex
70 tokenCache map[string]*cachedToken // keyed by subject
71 revokedList []string // cached revocation list
72 revokedSet map[string]struct{} // for O(1) lookup
73}
74
75// NewPassStore creates a new pass-based token store.
76func NewPassStore(log zerowrap.Logger) *PassStore {
77 return &PassStore{
78 timeout: 10 * time.Second,
79 log: log,
80 tokenCache: make(map[string]*cachedToken),
81 revokedSet: make(map[string]struct{}),
82 }
83}
84
85// tokenMetadata holds the non-sensitive token information.
86type tokenMetadata struct {
87 ID string `json:"id"`
88 Subject string `json:"subject"`
89 Scopes []string `json:"scopes"`
90 IssuedAt time.Time `json:"issued_at"`
91 ExpiresAt time.Time `json:"expires_at,omitempty"`
92 Revoked bool `json:"revoked"`
93 LastExtendedAt time.Time `json:"last_extended_at"`
94}
95
96// SaveToken stores a token JWT and metadata in pass.
97func (s *PassStore) SaveToken(ctx context.Context, token *domain.Token, jwt string) error {
98 if err := validateSubject(token.Subject); err != nil {
99 return err
100 }
101
102 ctx, cancel := context.WithTimeout(ctx, s.timeout)
103 defer cancel()
104
105 // Store JWT
106 jwtPath := fmt.Sprintf("%s/%s", passTokenPath, token.Subject)
107 if err := s.passInsert(ctx, jwtPath, jwt); err != nil {
108 return fmt.Errorf("failed to store token: %w", err)
109 }
110
111 // Store metadata
112 meta := tokenMetadata{
113 ID: token.ID,
114 Subject: token.Subject,
115 Scopes: token.Scopes,
116 IssuedAt: token.IssuedAt,
117 ExpiresAt: token.ExpiresAt,
118 Revoked: token.Revoked,
119 LastExtendedAt: token.LastExtendedAt,
120 }
121 metaJSON, err := json.Marshal(meta)
122 if err != nil {
123 return fmt.Errorf("failed to marshal token metadata: %w", err)
124 }
125
126 metaPath := fmt.Sprintf("%s/%s.meta", passTokenPath, token.Subject)
127 if err := s.passInsert(ctx, metaPath, string(metaJSON)); err != nil {
128 return fmt.Errorf("failed to store token metadata: %w", err)
129 }
130
131 // Update cache
132 s.cacheMu.Lock()
133 s.tokenCache[token.Subject] = &cachedToken{jwt: jwt, token: token}
134 s.cacheMu.Unlock()
135
136 s.log.Debug().
137 Str(zerowrap.FieldLayer, "adapter").
138 Str(zerowrap.FieldAdapter, "tokenstore").
139 Str("provider", "pass").
140 Str("subject", token.Subject).
141 Msg("token stored in pass")
142
143 return nil
144}
145
146// GetToken retrieves token JWT by subject from pass.
147func (s *PassStore) GetToken(ctx context.Context, subject string) (string, *domain.Token, error) {
148 if err := validateSubject(subject); err != nil {
149 return "", nil, err
150 }
151
152 // Check cache first
153 s.cacheMu.RLock()
154 if cached, ok := s.tokenCache[subject]; ok {
155 s.cacheMu.RUnlock()
156 return cached.jwt, cached.token, nil
157 }
158 s.cacheMu.RUnlock()
159
160 ctx, cancel := context.WithTimeout(ctx, s.timeout)
161 defer cancel()
162
163 // Get JWT
164 jwtPath := fmt.Sprintf("%s/%s", passTokenPath, subject)
165 jwt, err := s.passShow(ctx, jwtPath)
166 if err != nil {
167 return "", nil, domain.ErrTokenNotFound
168 }
169
170 // Get metadata
171 metaPath := fmt.Sprintf("%s/%s.meta", passTokenPath, subject)
172 metaJSON, err := s.passShow(ctx, metaPath)
173 if err != nil {
174 return "", nil, fmt.Errorf("failed to get token metadata: %w", err)
175 }
176
177 var meta tokenMetadata
178 if err := json.Unmarshal([]byte(metaJSON), &meta); err != nil {
179 return "", nil, fmt.Errorf("failed to unmarshal token metadata: %w", err)
180 }
181
182 token := &domain.Token{
183 ID: meta.ID,
184 Subject: meta.Subject,
185 Scopes: meta.Scopes,
186 IssuedAt: meta.IssuedAt,
187 ExpiresAt: meta.ExpiresAt,
188 Revoked: meta.Revoked,
189 LastExtendedAt: meta.LastExtendedAt,
190 }
191
192 // Cache the token
193 s.cacheMu.Lock()
194 s.tokenCache[subject] = &cachedToken{jwt: jwt, token: token}
195 s.cacheMu.Unlock()
196
197 return jwt, token, nil
198}
199
200// treeEntry holds a single parsed entry from pass ls tree output.
201type treeEntry struct {
202 depth int
203 name string
204}
205
206// parsePassLsEntries parses raw pass ls output into a slice of tree entries.
207// Each entry carries its nesting depth (0 = direct child of the listed root)
208// and the bare file/directory name on that line.
209func parsePassLsEntries(output string) []treeEntry {
210 var entries []treeEntry
211
212 lines := strings.Split(output, "\n")
213 // Skip the first line (the directory header, e.g. "gordon/registry/tokens").
214 for _, line := range lines[1:] {
215 // Strip ANSI escape sequences (pass may output coloured text on a TTY).
216 line = ansiRegex.ReplaceAllString(line, "")
217
218 if strings.TrimSpace(line) == "" {
219 continue
220 }
221
222 // Locate the branch marker. pass uses Unicode box-drawing characters:
223 // ├── or └── (U+251C / U+2514, then two U+2500 dashes, then a space)
224 // Some terminals may fall back to ASCII equivalents:
225 // |-- or `-- (1-byte ASCII chars)
226 //
227 // Regardless of Unicode vs ASCII, each indent level is exactly 4 characters
228 // wide (e.g. "│ " = pipe + 3 spaces, " " = 4 spaces, "| " = pipe + 3).
229 // We count runes before the branch character and divide by 4 to get depth.
230
231 var nameOffset int // byte offset where the entry name begins
232 var branchRune int // rune count before the branch character (├ └ | `)
233
234 if idx := strings.Index(line, "── "); idx != -1 {
235 // Unicode variant: the two '─' dashes (U+2500) start at byte idx.
236 // The branch character (├ or └, 3 bytes each) immediately precedes them.
237 branchByteIdx := idx - 3
238 if branchByteIdx < 0 {
239 // Marker is too close to the start — malformed line, skip.
240 continue
241 }
242 // Count runes in the indentation prefix before the branch char.
243 branchRune = utf8.RuneCountInString(line[:branchByteIdx])
244 // Name starts after: branchChar(3B) + ─(3B) + ─(3B) + SP(1B) = 10B from branch.
245 nameOffset = branchByteIdx + 10
246 } else if idx := strings.Index(line, "-- "); idx != -1 {
247 // ASCII variant: branch char (| or `, 1 byte) is immediately before the dashes.
248 branchByteIdx := idx - 1
249 if branchByteIdx < 0 {
250 // Marker is too close to the start — malformed line, skip.
251 continue
252 }
253 branchRune = utf8.RuneCountInString(line[:branchByteIdx])
254 // Name starts after: branchChar(1B) + -(1B) + -(1B) + SP(1B) = 4B from branch.
255 nameOffset = branchByteIdx + 4
256 } else {
257 // No recognisable tree marker on this line — skip it.
258 continue
259 }
260
261 if nameOffset > len(line) {
262 continue
263 }
264 name := strings.TrimSpace(line[nameOffset:])
265 if name == "" {
266 continue
267 }
268
269 // Each indent level is 4 characters wide.
270 depth := branchRune / 4
271 entries = append(entries, treeEntry{depth: depth, name: name})
272 }
273
274 return entries
275}
276
277// parsePassLsOutput parses the tree-formatted output of `pass ls` and returns
278// a flat list of full subject paths. It reconstructs slash-separated paths for
279// nested entries (e.g. "team/alice") by tracking the indentation depth.
280// Only leaf nodes (actual pass entries, not intermediate directories) are returned.
281//
282// pass ls outputs a tree like:
283//
284// gordon/registry/tokens
285// ├── admin
286// └── team
287// ├── alice
288// └── bob
289//
290// This function returns ["admin", "team/alice", "team/bob"].
291func parsePassLsOutput(output string) []string {
292 type frame struct {
293 depth int
294 name string
295 }
296
297 entries := parsePassLsEntries(output)
298
299 var subjects []string
300 var stack []frame
301
302 for i, entry := range entries {
303 // Pop ancestors that are at the same depth or deeper than the current entry.
304 for len(stack) > 0 && stack[len(stack)-1].depth >= entry.depth {
305 stack = stack[:len(stack)-1]
306 }
307
308 // Build the full path for this entry by joining ancestors with this name.
309 parts := make([]string, 0, len(stack)+1)
310 for _, f := range stack {
311 parts = append(parts, f.name)
312 }
313 parts = append(parts, entry.name)
314 subject := strings.Join(parts, "/")
315
316 // Determine whether this entry is an intermediate directory node.
317 // A node is a directory if the immediately following entry is deeper.
318 isDir := i+1 < len(entries) && entries[i+1].depth > entry.depth
319
320 // Always push so deeper entries can use this as ancestor context.
321 stack = append(stack, frame(entry))
322
323 if !isDir {
324 subjects = append(subjects, subject)
325 }
326 }
327
328 if subjects == nil {
329 return []string{}
330 }
331 return subjects
332}
333
334// ListTokens returns all stored tokens from pass.
335func (s *PassStore) ListTokens(ctx context.Context) ([]domain.Token, error) {
336 ctx, cancel := context.WithTimeout(ctx, s.timeout)
337 defer cancel()
338
339 // List all entries under the token path
340 cmd := exec.CommandContext(ctx, "pass", "ls", passTokenPath) //nolint:gosec // passTokenPath is a fixed constant
341 output, err := cmd.Output()
342 if err != nil {
343 // If the path doesn't exist, return empty list
344 return []domain.Token{}, nil
345 }
346
347 subjects := parsePassLsOutput(string(output))
348
349 var tokens []domain.Token
350 for _, subject := range subjects {
351 // Skip .meta files — they are metadata companions, not tokens themselves.
352 if strings.HasSuffix(subject, ".meta") {
353 continue
354 }
355
356 // Try to get the token metadata.
357 // Use a fresh per-call context so each GetToken gets the full timeout,
358 // not the shared (potentially exhausted) deadline from the outer ctx.
359 callCtx, callCancel := context.WithTimeout(context.Background(), s.timeout)
360 _, token, err := s.GetToken(callCtx, subject)
361 callCancel()
362 if err != nil {
363 s.log.Warn().Err(err).Str("subject", subject).Msg("failed to get token")
364 continue
365 }
366
367 tokens = append(tokens, *token)
368 }
369
370 return tokens, nil
371}
372
373// Revoke adds token ID to revocation list in pass.
374func (s *PassStore) Revoke(ctx context.Context, tokenID string) error {
375 s.cacheMu.Lock()
376 defer s.cacheMu.Unlock()
377
378 ctx, cancel := context.WithTimeout(ctx, s.timeout)
379 defer cancel()
380
381 // Get current revocation list
382 revokedList, err := s.getRevokedList(ctx)
383 if err != nil {
384 return err
385 }
386
387 // Add to list if not already present
388 for _, id := range revokedList {
389 if id == tokenID {
390 return nil // Already revoked
391 }
392 }
393 revokedList = append(revokedList, tokenID)
394
395 // Store updated list
396 listJSON, err := json.Marshal(revokedList)
397 if err != nil {
398 return fmt.Errorf("failed to marshal revocation list: %w", err)
399 }
400
401 if err := s.passInsert(ctx, passRevokedPath, string(listJSON)); err != nil {
402 return fmt.Errorf("failed to store revocation list: %w", err)
403 }
404
405 // Update cache
406 s.revokedList = revokedList
407 s.revokedSet = make(map[string]struct{}, len(revokedList))
408 for _, id := range revokedList {
409 s.revokedSet[id] = struct{}{}
410 }
411
412 s.log.Info().
413 Str(zerowrap.FieldLayer, "adapter").
414 Str(zerowrap.FieldAdapter, "tokenstore").
415 Str("token_id", tokenID).
416 Msg("token revoked")
417
418 return nil
419}
420
421// IsRevoked checks if token ID is in revocation list.
422func (s *PassStore) IsRevoked(ctx context.Context, tokenID string) (bool, error) {
423 // Check cache first
424 s.cacheMu.RLock()
425 if s.revokedList != nil {
426 _, revoked := s.revokedSet[tokenID]
427 s.cacheMu.RUnlock()
428 return revoked, nil
429 }
430 s.cacheMu.RUnlock()
431
432 ctx, cancel := context.WithTimeout(ctx, s.timeout)
433 defer cancel()
434
435 revokedList, err := s.getRevokedList(ctx)
436 if err != nil {
437 return false, err
438 }
439
440 // Cache the revocation list
441 s.cacheMu.Lock()
442 s.revokedList = revokedList
443 s.revokedSet = make(map[string]struct{}, len(revokedList))
444 for _, id := range revokedList {
445 s.revokedSet[id] = struct{}{}
446 }
447 _, revoked := s.revokedSet[tokenID]
448 s.cacheMu.Unlock()
449
450 return revoked, nil
451}
452
453// UpdateTokenExpiry updates the JWT and expiry metadata for an existing token.
454// LastExtendedAt is also updated to track debounce timing.
455// UpdateTokenExpiry enforces update-only semantics: it returns an error if
456// token is nil or if no existing record is found for token.Subject.
457func (s *PassStore) UpdateTokenExpiry(ctx context.Context, token *domain.Token, newJWT string) error {
458 if token == nil {
459 return fmt.Errorf("UpdateTokenExpiry: token must not be nil")
460 }
461 // Confirm the token exists before overwriting to avoid silent creation.
462 _, existing, err := s.GetToken(ctx, token.Subject)
463 if err != nil {
464 return fmt.Errorf("UpdateTokenExpiry: token not found for subject %q: %w", token.Subject, err)
465 }
466 if existing == nil {
467 return fmt.Errorf("UpdateTokenExpiry: no existing token for subject %q", token.Subject)
468 }
469 return s.SaveToken(ctx, token, newJWT)
470}
471
472// DeleteToken removes token from pass.
473func (s *PassStore) DeleteToken(ctx context.Context, subject string) error {
474 if err := validateSubject(subject); err != nil {
475 return err
476 }
477
478 ctx, cancel := context.WithTimeout(ctx, s.timeout)
479 defer cancel()
480
481 jwtPath := fmt.Sprintf("%s/%s", passTokenPath, subject)
482 metaPath := fmt.Sprintf("%s/%s.meta", passTokenPath, subject)
483
484 // Remove JWT
485 // #nosec G204 -- command and args are fixed; subject is treated as data only.
486 cmd := exec.CommandContext(ctx, "pass", "rm", "-f", jwtPath)
487 if err := cmd.Run(); err != nil {
488 return fmt.Errorf("failed to delete token: %w", err)
489 }
490
491 // Remove metadata
492 // #nosec G204 -- command and args are fixed; subject is treated as data only.
493 cmd = exec.CommandContext(ctx, "pass", "rm", "-f", metaPath)
494 if err := cmd.Run(); err != nil {
495 s.log.Warn().Err(err).Msg("failed to delete token metadata")
496 }
497
498 // Clear from cache
499 s.cacheMu.Lock()
500 delete(s.tokenCache, subject)
501 s.cacheMu.Unlock()
502
503 s.log.Debug().
504 Str(zerowrap.FieldLayer, "adapter").
505 Str(zerowrap.FieldAdapter, "tokenstore").
506 Str("subject", subject).
507 Msg("token deleted from pass")
508
509 return nil
510}
511
512// passInsert inserts a value into pass.
513func (s *PassStore) passInsert(ctx context.Context, path, value string) error {
514 cmd := exec.CommandContext(ctx, "pass", "insert", "-m", "-f", path) //nolint:gosec // binary is constant ("pass"); path arguments are sanitized token subjects
515 cmd.Stdin = strings.NewReader(value)
516 output, err := cmd.CombinedOutput()
517 if err != nil {
518 return fmt.Errorf("pass insert failed: %s: %w", string(output), err)
519 }
520 return nil
521}
522
523// passShow retrieves a value from pass.
524func (s *PassStore) passShow(ctx context.Context, path string) (string, error) {
525 cmd := exec.CommandContext(ctx, "pass", "show", path) //nolint:gosec // binary is constant ("pass"); path arguments are sanitized token subjects
526 output, err := cmd.Output()
527 if err != nil {
528 return "", fmt.Errorf("pass show failed: %w", err)
529 }
530 return strings.TrimSpace(string(output)), nil
531}
532
533// getRevokedList retrieves the current revocation list.
534func (s *PassStore) getRevokedList(ctx context.Context) ([]string, error) {
535 listJSON, err := s.passShow(ctx, passRevokedPath)
536 if err != nil {
537 // If the list doesn't exist, return empty list
538 return []string{}, nil
539 }
540
541 var revokedList []string
542 if err := json.Unmarshal([]byte(listJSON), &revokedList); err != nil {
543 return nil, fmt.Errorf("failed to unmarshal revocation list: %w", err)
544 }
545
546 return revokedList, nil
547}
548
549// IsAvailable checks if pass is available in the system.
550func (s *PassStore) IsAvailable() bool {
551 cmd := exec.Command("pass", "version")
552 return cmd.Run() == nil
553}