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