main 0857610

pass.go RawHistoryFolder

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}

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 7

Tags 106

↑↓ moveenter openesc close