v2.14.0 5e11a00

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
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}

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 8

Tags 106

↑↓ moveenter openesc close