1package tokenstore
2
3import (
4 "context"
5 "crypto/sha256"
6 "encoding/hex"
7 "encoding/json"
8 "fmt"
9 "os"
10 "path/filepath"
11 "strings"
12 "sync"
13
14 "github.com/bnema/zerowrap"
15
16 "github.com/bnema/gordon/internal/domain"
17)
18
19// sanitizeSubject creates a safe filename from a subject by hashing it.
20// This prevents path traversal attacks where subject contains "../" sequences.
21func sanitizeSubject(subject string) string {
22 hash := sha256.Sum256([]byte(subject))
23 return hex.EncodeToString(hash[:])
24}
25
26const (
27 // unsafeTokenDir is the subdirectory for tokens in the data directory.
28 unsafeTokenDir = "secrets/gordon/registry/tokens"
29 // unsafeRevokedFile is the filename for the revocation list.
30 unsafeRevokedFile = "secrets/gordon/registry/revoked.json"
31)
32
33// UnsafeStore implements TokenStore using plain text files.
34// WARNING: This store does not encrypt secrets. Only use when pass/sops are unavailable.
35type UnsafeStore struct {
36 mu sync.RWMutex
37 dataDir string
38 log zerowrap.Logger
39}
40
41// NewUnsafeStore creates a new file-based token store.
42// dataDir is the base directory for storing secrets (typically gordon's data_dir).
43//
44// WARNING: Secrets are stored in plain text. The user has explicitly chosen
45// secrets_backend = "unsafe" in config, so they accept this tradeoff.
46func NewUnsafeStore(dataDir string, log zerowrap.Logger) (*UnsafeStore, error) {
47 store := &UnsafeStore{
48 dataDir: dataDir,
49 log: log,
50 }
51
52 log.Warn().
53 Str(zerowrap.FieldLayer, "adapter").
54 Str(zerowrap.FieldAdapter, "tokenstore").
55 Str("provider", "unsafe").
56 Str("data_dir", dataDir).
57 Msg("using unsafe secrets backend - secrets are stored in plain text")
58
59 return store, nil
60}
61
62// unsafeTokenData holds both JWT and metadata in a single file.
63type unsafeTokenData struct {
64 JWT string `json:"jwt"`
65 Metadata tokenMetadata `json:"metadata"`
66}
67
68// SaveToken stores a token JWT and metadata as a JSON file.
69func (s *UnsafeStore) SaveToken(_ context.Context, token *domain.Token, jwt string) error {
70 tokenDir := filepath.Join(s.dataDir, unsafeTokenDir)
71 if err := os.MkdirAll(tokenDir, 0700); err != nil {
72 return fmt.Errorf("failed to create token directory: %w", err)
73 }
74
75 data := unsafeTokenData{
76 JWT: jwt,
77 Metadata: tokenMetadata{
78 ID: token.ID,
79 Subject: token.Subject, // Store original subject in metadata
80 Scopes: token.Scopes,
81 IssuedAt: token.IssuedAt,
82 ExpiresAt: token.ExpiresAt,
83 Revoked: token.Revoked,
84 LastExtendedAt: token.LastExtendedAt,
85 },
86 }
87
88 dataJSON, err := json.MarshalIndent(data, "", " ")
89 if err != nil {
90 return fmt.Errorf("failed to marshal token data: %w", err)
91 }
92
93 // SECURITY: Use sanitized filename to prevent path traversal
94 safeFilename := sanitizeSubject(token.Subject)
95 tokenFile := filepath.Join(tokenDir, safeFilename+".json")
96 if err := os.WriteFile(tokenFile, dataJSON, 0600); err != nil {
97 return fmt.Errorf("failed to write token file: %w", err)
98 }
99
100 s.log.Debug().
101 Str(zerowrap.FieldLayer, "adapter").
102 Str(zerowrap.FieldAdapter, "tokenstore").
103 Str("provider", "unsafe").
104 Str("subject", token.Subject).
105 Msg("token stored in file")
106
107 return nil
108}
109
110// GetToken retrieves token JWT by subject from file.
111func (s *UnsafeStore) GetToken(_ context.Context, subject string) (string, *domain.Token, error) {
112 // SECURITY: Use sanitized filename to prevent path traversal
113 safeFilename := sanitizeSubject(subject)
114 tokenFile := filepath.Join(s.dataDir, unsafeTokenDir, safeFilename+".json")
115
116 dataJSON, err := os.ReadFile(tokenFile)
117 if err != nil {
118 if os.IsNotExist(err) {
119 return "", nil, domain.ErrTokenNotFound
120 }
121 return "", nil, fmt.Errorf("failed to read token file: %w", err)
122 }
123
124 var data unsafeTokenData
125 if err := json.Unmarshal(dataJSON, &data); err != nil {
126 return "", nil, fmt.Errorf("failed to unmarshal token data: %w", err)
127 }
128
129 token := &domain.Token{
130 ID: data.Metadata.ID,
131 Subject: data.Metadata.Subject,
132 Scopes: data.Metadata.Scopes,
133 IssuedAt: data.Metadata.IssuedAt,
134 ExpiresAt: data.Metadata.ExpiresAt,
135 Revoked: data.Metadata.Revoked,
136 LastExtendedAt: data.Metadata.LastExtendedAt,
137 }
138
139 return data.JWT, token, nil
140}
141
142// ListTokens returns all stored tokens from files.
143func (s *UnsafeStore) ListTokens(_ context.Context) ([]domain.Token, error) {
144 tokenDir := filepath.Join(s.dataDir, unsafeTokenDir)
145
146 entries, err := os.ReadDir(tokenDir)
147 if err != nil {
148 if os.IsNotExist(err) {
149 return []domain.Token{}, nil
150 }
151 return nil, fmt.Errorf("failed to read token directory: %w", err)
152 }
153
154 var tokens []domain.Token
155 for _, entry := range entries {
156 if entry.IsDir() || !strings.HasSuffix(entry.Name(), ".json") {
157 continue
158 }
159
160 // Read file directly since filenames are now hashed
161 tokenFile := filepath.Join(tokenDir, entry.Name())
162 dataJSON, err := os.ReadFile(tokenFile)
163 if err != nil {
164 s.log.Warn().Err(err).Str("file", entry.Name()).Msg("failed to read token file")
165 continue
166 }
167
168 var data unsafeTokenData
169 if err := json.Unmarshal(dataJSON, &data); err != nil {
170 s.log.Warn().Err(err).Str("file", entry.Name()).Msg("failed to unmarshal token data")
171 continue
172 }
173
174 token := domain.Token{
175 ID: data.Metadata.ID,
176 Subject: data.Metadata.Subject,
177 Scopes: data.Metadata.Scopes,
178 IssuedAt: data.Metadata.IssuedAt,
179 ExpiresAt: data.Metadata.ExpiresAt,
180 Revoked: data.Metadata.Revoked,
181 LastExtendedAt: data.Metadata.LastExtendedAt,
182 }
183
184 tokens = append(tokens, token)
185 }
186
187 return tokens, nil
188}
189
190// Revoke adds token ID to revocation list file.
191func (s *UnsafeStore) Revoke(_ context.Context, tokenID string) error {
192 s.mu.Lock()
193 defer s.mu.Unlock()
194
195 revokedFile := filepath.Join(s.dataDir, unsafeRevokedFile)
196
197 // Ensure parent directory exists
198 if err := os.MkdirAll(filepath.Dir(revokedFile), 0700); err != nil {
199 return fmt.Errorf("failed to create revoked directory: %w", err)
200 }
201
202 // Get current list
203 revokedList, err := s.getRevokedList()
204 if err != nil {
205 return err
206 }
207
208 // Add to list if not already present
209 for _, id := range revokedList {
210 if id == tokenID {
211 return nil // Already revoked
212 }
213 }
214 revokedList = append(revokedList, tokenID)
215
216 // Store updated list
217 listJSON, err := json.MarshalIndent(revokedList, "", " ")
218 if err != nil {
219 return fmt.Errorf("failed to marshal revocation list: %w", err)
220 }
221
222 if err := os.WriteFile(revokedFile, listJSON, 0600); err != nil {
223 return fmt.Errorf("failed to write revocation list: %w", err)
224 }
225
226 s.log.Info().
227 Str(zerowrap.FieldLayer, "adapter").
228 Str(zerowrap.FieldAdapter, "tokenstore").
229 Str("token_id", tokenID).
230 Msg("token revoked")
231
232 return nil
233}
234
235// IsRevoked checks if token ID is in revocation list.
236func (s *UnsafeStore) IsRevoked(_ context.Context, tokenID string) (bool, error) {
237 s.mu.RLock()
238 defer s.mu.RUnlock()
239
240 revokedList, err := s.getRevokedList()
241 if err != nil {
242 return false, err
243 }
244
245 for _, id := range revokedList {
246 if id == tokenID {
247 return true, nil
248 }
249 }
250
251 return false, nil
252}
253
254// UpdateTokenExpiry updates the JWT and expiry metadata for an existing token.
255// LastExtendedAt is also updated to track debounce timing.
256func (s *UnsafeStore) UpdateTokenExpiry(ctx context.Context, token *domain.Token, newJWT string) error {
257 return s.SaveToken(ctx, token, newJWT)
258}
259
260// DeleteToken removes token file.
261func (s *UnsafeStore) DeleteToken(_ context.Context, subject string) error {
262 // SECURITY: Use sanitized filename to prevent path traversal
263 safeFilename := sanitizeSubject(subject)
264 tokenFile := filepath.Join(s.dataDir, unsafeTokenDir, safeFilename+".json")
265
266 if err := os.Remove(tokenFile); err != nil {
267 if os.IsNotExist(err) {
268 return domain.ErrTokenNotFound
269 }
270 return fmt.Errorf("failed to delete token file: %w", err)
271 }
272
273 s.log.Debug().
274 Str(zerowrap.FieldLayer, "adapter").
275 Str(zerowrap.FieldAdapter, "tokenstore").
276 Str("subject", subject).
277 Msg("token deleted from file")
278
279 return nil
280}
281
282// getRevokedList retrieves the current revocation list from file.
283func (s *UnsafeStore) getRevokedList() ([]string, error) {
284 revokedFile := filepath.Join(s.dataDir, unsafeRevokedFile)
285
286 listJSON, err := os.ReadFile(revokedFile)
287 if err != nil {
288 if os.IsNotExist(err) {
289 return []string{}, nil
290 }
291 return nil, fmt.Errorf("failed to read revocation list: %w", err)
292 }
293
294 var revokedList []string
295 if err := json.Unmarshal(listJSON, &revokedList); err != nil {
296 return nil, fmt.Errorf("failed to unmarshal revocation list: %w", err)
297 }
298
299 return revokedList, nil
300}
301
302// IsAvailable always returns true for file-based storage.
303func (s *UnsafeStore) IsAvailable() bool {
304 return true
305}