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