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 "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"
25 // passRevokedPath is the path for the revocation list in pass.
26 passRevokedPath = "gordon/registry/revoked"
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}