v2.31.0-dev22 8d342c4

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	"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}

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