v2.8.0 6aced72

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
22const (
23	// passTokenPath is the base path for tokens in pass.
24	passTokenPath = "gordon/registry/tokens" //nolint:gosec // Not a credential, this is a pass store path
25	// passRevokedPath is the path for the revocation list in pass.
26	passRevokedPath = "gordon/registry/revoked" //nolint:gosec // Not a credential, this is a pass store path
27)
28
29// cachedToken holds a token and its JWT in memory.
30type cachedToken struct {
31	jwt   string
32	token *domain.Token
33}
34
35// PassStore implements TokenStore using the pass password manager.
36type PassStore struct {
37	timeout time.Duration
38	log     zerowrap.Logger
39
40	// In-memory cache to avoid repeated pass calls
41	cacheMu     sync.RWMutex
42	tokenCache  map[string]*cachedToken // keyed by subject
43	revokedList []string                // cached revocation list
44	revokedSet  map[string]struct{}     // for O(1) lookup
45}
46
47// NewPassStore creates a new pass-based token store.
48func NewPassStore(log zerowrap.Logger) *PassStore {
49	return &PassStore{
50		timeout:    10 * time.Second,
51		log:        log,
52		tokenCache: make(map[string]*cachedToken),
53		revokedSet: make(map[string]struct{}),
54	}
55}
56
57// tokenMetadata holds the non-sensitive token information.
58type tokenMetadata struct {
59	ID        string    `json:"id"`
60	Subject   string    `json:"subject"`
61	Scopes    []string  `json:"scopes"`
62	IssuedAt  time.Time `json:"issued_at"`
63	ExpiresAt time.Time `json:"expires_at,omitempty"`
64	Revoked   bool      `json:"revoked"`
65}
66
67// SaveToken stores a token JWT and metadata in pass.
68func (s *PassStore) SaveToken(ctx context.Context, token *domain.Token, jwt string) error {
69	ctx, cancel := context.WithTimeout(ctx, s.timeout)
70	defer cancel()
71
72	// Store JWT
73	jwtPath := fmt.Sprintf("%s/%s", passTokenPath, token.Subject)
74	if err := s.passInsert(ctx, jwtPath, jwt); err != nil {
75		return fmt.Errorf("failed to store token: %w", err)
76	}
77
78	// Store metadata
79	meta := tokenMetadata{
80		ID:        token.ID,
81		Subject:   token.Subject,
82		Scopes:    token.Scopes,
83		IssuedAt:  token.IssuedAt,
84		ExpiresAt: token.ExpiresAt,
85		Revoked:   token.Revoked,
86	}
87	metaJSON, err := json.Marshal(meta)
88	if err != nil {
89		return fmt.Errorf("failed to marshal token metadata: %w", err)
90	}
91
92	metaPath := fmt.Sprintf("%s/%s.meta", passTokenPath, token.Subject)
93	if err := s.passInsert(ctx, metaPath, string(metaJSON)); err != nil {
94		return fmt.Errorf("failed to store token metadata: %w", err)
95	}
96
97	// Update cache
98	s.cacheMu.Lock()
99	s.tokenCache[token.Subject] = &cachedToken{jwt: jwt, token: token}
100	s.cacheMu.Unlock()
101
102	s.log.Debug().
103		Str(zerowrap.FieldLayer, "adapter").
104		Str(zerowrap.FieldAdapter, "tokenstore").
105		Str("provider", "pass").
106		Str("subject", token.Subject).
107		Msg("token stored in pass")
108
109	return nil
110}
111
112// GetToken retrieves token JWT by subject from pass.
113func (s *PassStore) GetToken(ctx context.Context, subject string) (string, *domain.Token, error) {
114	// Check cache first
115	s.cacheMu.RLock()
116	if cached, ok := s.tokenCache[subject]; ok {
117		s.cacheMu.RUnlock()
118		return cached.jwt, cached.token, nil
119	}
120	s.cacheMu.RUnlock()
121
122	ctx, cancel := context.WithTimeout(ctx, s.timeout)
123	defer cancel()
124
125	// Get JWT
126	jwtPath := fmt.Sprintf("%s/%s", passTokenPath, subject)
127	jwt, err := s.passShow(ctx, jwtPath)
128	if err != nil {
129		return "", nil, domain.ErrTokenNotFound
130	}
131
132	// Get metadata
133	metaPath := fmt.Sprintf("%s/%s.meta", passTokenPath, subject)
134	metaJSON, err := s.passShow(ctx, metaPath)
135	if err != nil {
136		return "", nil, fmt.Errorf("failed to get token metadata: %w", err)
137	}
138
139	var meta tokenMetadata
140	if err := json.Unmarshal([]byte(metaJSON), &meta); err != nil {
141		return "", nil, fmt.Errorf("failed to unmarshal token metadata: %w", err)
142	}
143
144	token := &domain.Token{
145		ID:        meta.ID,
146		Subject:   meta.Subject,
147		Scopes:    meta.Scopes,
148		IssuedAt:  meta.IssuedAt,
149		ExpiresAt: meta.ExpiresAt,
150		Revoked:   meta.Revoked,
151	}
152
153	// Cache the token
154	s.cacheMu.Lock()
155	s.tokenCache[subject] = &cachedToken{jwt: jwt, token: token}
156	s.cacheMu.Unlock()
157
158	return jwt, token, nil
159}
160
161// ListTokens returns all stored tokens from pass.
162func (s *PassStore) ListTokens(ctx context.Context) ([]domain.Token, error) {
163	ctx, cancel := context.WithTimeout(ctx, s.timeout)
164	defer cancel()
165
166	// List all entries under the token path
167	cmd := exec.CommandContext(ctx, "pass", "ls", passTokenPath)
168	output, err := cmd.Output()
169	if err != nil {
170		// If the path doesn't exist, return empty list
171		return []domain.Token{}, nil
172	}
173
174	var tokens []domain.Token
175	lines := strings.Split(string(output), "\n")
176
177	for _, line := range lines {
178		line = strings.TrimSpace(line)
179
180		// Strip ANSI escape sequences (pass outputs colored text on TTY)
181		line = ansiRegex.ReplaceAllString(line, "")
182
183		// Skip tree formatting characters (both Unicode and ASCII variants)
184		// Unicode: ├── └── │
185		line = strings.TrimPrefix(line, "├── ")
186		line = strings.TrimPrefix(line, "└── ")
187		line = strings.TrimPrefix(line, "│   ")
188		// ASCII: |-- `-- |
189		line = strings.TrimPrefix(line, "|-- ")
190		line = strings.TrimPrefix(line, "`-- ")
191		line = strings.TrimPrefix(line, "|   ")
192
193		line = strings.TrimSpace(line)
194
195		// Skip .meta files, empty lines, and the header line
196		if line == "" || strings.HasSuffix(line, ".meta") || line == passTokenPath {
197			continue
198		}
199
200		// Try to get the token metadata
201		_, token, err := s.GetToken(ctx, line)
202		if err != nil {
203			s.log.Warn().Err(err).Str("subject", line).Msg("failed to get token")
204			continue
205		}
206
207		tokens = append(tokens, *token)
208	}
209
210	return tokens, nil
211}
212
213// Revoke adds token ID to revocation list in pass.
214func (s *PassStore) Revoke(ctx context.Context, tokenID string) error {
215	ctx, cancel := context.WithTimeout(ctx, s.timeout)
216	defer cancel()
217
218	// Get current revocation list
219	revokedList, err := s.getRevokedList(ctx)
220	if err != nil {
221		return err
222	}
223
224	// Add to list if not already present
225	for _, id := range revokedList {
226		if id == tokenID {
227			return nil // Already revoked
228		}
229	}
230	revokedList = append(revokedList, tokenID)
231
232	// Store updated list
233	listJSON, err := json.Marshal(revokedList)
234	if err != nil {
235		return fmt.Errorf("failed to marshal revocation list: %w", err)
236	}
237
238	if err := s.passInsert(ctx, passRevokedPath, string(listJSON)); err != nil {
239		return fmt.Errorf("failed to store revocation list: %w", err)
240	}
241
242	// Update cache
243	s.cacheMu.Lock()
244	s.revokedList = revokedList
245	s.revokedSet = make(map[string]struct{}, len(revokedList))
246	for _, id := range revokedList {
247		s.revokedSet[id] = struct{}{}
248	}
249	s.cacheMu.Unlock()
250
251	s.log.Info().
252		Str(zerowrap.FieldLayer, "adapter").
253		Str(zerowrap.FieldAdapter, "tokenstore").
254		Str("token_id", tokenID).
255		Msg("token revoked")
256
257	return nil
258}
259
260// IsRevoked checks if token ID is in revocation list.
261func (s *PassStore) IsRevoked(ctx context.Context, tokenID string) (bool, error) {
262	// Check cache first
263	s.cacheMu.RLock()
264	if s.revokedList != nil {
265		_, revoked := s.revokedSet[tokenID]
266		s.cacheMu.RUnlock()
267		return revoked, nil
268	}
269	s.cacheMu.RUnlock()
270
271	ctx, cancel := context.WithTimeout(ctx, s.timeout)
272	defer cancel()
273
274	revokedList, err := s.getRevokedList(ctx)
275	if err != nil {
276		return false, err
277	}
278
279	// Cache the revocation list
280	s.cacheMu.Lock()
281	s.revokedList = revokedList
282	s.revokedSet = make(map[string]struct{}, len(revokedList))
283	for _, id := range revokedList {
284		s.revokedSet[id] = struct{}{}
285	}
286	_, revoked := s.revokedSet[tokenID]
287	s.cacheMu.Unlock()
288
289	return revoked, nil
290}
291
292// DeleteToken removes token from pass.
293func (s *PassStore) DeleteToken(ctx context.Context, subject string) error {
294	ctx, cancel := context.WithTimeout(ctx, s.timeout)
295	defer cancel()
296
297	jwtPath := fmt.Sprintf("%s/%s", passTokenPath, subject)
298	metaPath := fmt.Sprintf("%s/%s.meta", passTokenPath, subject)
299
300	// Remove JWT
301	// #nosec G204 -- command and args are fixed; subject is treated as data only.
302	cmd := exec.CommandContext(ctx, "pass", "rm", "-f", jwtPath)
303	if err := cmd.Run(); err != nil {
304		return fmt.Errorf("failed to delete token: %w", err)
305	}
306
307	// Remove metadata
308	// #nosec G204 -- command and args are fixed; subject is treated as data only.
309	cmd = exec.CommandContext(ctx, "pass", "rm", "-f", metaPath)
310	if err := cmd.Run(); err != nil {
311		s.log.Warn().Err(err).Msg("failed to delete token metadata")
312	}
313
314	// Clear from cache
315	s.cacheMu.Lock()
316	delete(s.tokenCache, subject)
317	s.cacheMu.Unlock()
318
319	s.log.Debug().
320		Str(zerowrap.FieldLayer, "adapter").
321		Str(zerowrap.FieldAdapter, "tokenstore").
322		Str("subject", subject).
323		Msg("token deleted from pass")
324
325	return nil
326}
327
328// passInsert inserts a value into pass.
329func (s *PassStore) passInsert(ctx context.Context, path, value string) error {
330	cmd := exec.CommandContext(ctx, "pass", "insert", "-m", "-f", path)
331	cmd.Stdin = strings.NewReader(value)
332	output, err := cmd.CombinedOutput()
333	if err != nil {
334		return fmt.Errorf("pass insert failed: %s: %w", string(output), err)
335	}
336	return nil
337}
338
339// passShow retrieves a value from pass.
340func (s *PassStore) passShow(ctx context.Context, path string) (string, error) {
341	cmd := exec.CommandContext(ctx, "pass", "show", path)
342	output, err := cmd.Output()
343	if err != nil {
344		return "", fmt.Errorf("pass show failed: %w", err)
345	}
346	return strings.TrimSpace(string(output)), nil
347}
348
349// getRevokedList retrieves the current revocation list.
350func (s *PassStore) getRevokedList(ctx context.Context) ([]string, error) {
351	listJSON, err := s.passShow(ctx, passRevokedPath)
352	if err != nil {
353		// If the list doesn't exist, return empty list
354		return []string{}, nil
355	}
356
357	var revokedList []string
358	if err := json.Unmarshal([]byte(listJSON), &revokedList); err != nil {
359		return nil, fmt.Errorf("failed to unmarshal revocation list: %w", err)
360	}
361
362	return revokedList, nil
363}
364
365// IsAvailable checks if pass is available in the system.
366func (s *PassStore) IsAvailable() bool {
367	cmd := exec.Command("pass", "version")
368	return cmd.Run() == nil
369}

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