v2.10.3 f7e7684

unsafe.go RawHistoryFolder

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	"github.com/bnema/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}

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