v2.31.0-dev23 58a92f3

pass.go RawHistoryFolder

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}

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