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}