v2.7.0 fac7705

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

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