v2.2.0 64d39bf

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

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