Sign in

Search issue titles and bodies, change requests and comments. Private repositories you cannot read are not included.

vole
Clone

Read only, no credentials:

https://rodent.software/git/vole.git

Write over SSH with a registered key:

git clone ssh://git@0.0.0.0:2222/vole.git

A free and open-source forge that lives in your Git repo.

Phase 1: a working git remote with access control

653e0cfc227c992e5b26ea10057aa0b424b6ce4f · Keenan Nemetz · 18d ago · parent c5626f1b

Phase 1: a working git remote with access control

Vole is now a functioning forge foundation: one CGO-free binary that serves
real git over smart HTTP and enforces per-repo policy in pre-receive.

What is in:

- internal/transport drives `git http-backend` as a CGI subprocess. Chunked
  uploads are spooled to learn CONTENT_LENGTH (git's default client config
  produces them), spool files are cleaned on error and abort, and a janitor
  sweeps anything a crash left behind. Pushes are capped by size and
  serialised behind a semaphore, because Phase 0 measured 1.13 GB of git RSS
  for a single 1 GiB push.
- internal/acl stores roles on refs/vole/acl inside each repository, so
  access rules travel with a clone. One evaluation function is shared by the
  hook and the web layer.
- internal/hook enforces policy in pre-receive: authentication, write role,
  protected refs, per-blob size limits, and a refusal to accept pushes to
  refs/vole/* - without that last check a writer could push their own
  acl.json and grant themselves admin.
- internal/auth holds users, personal access tokens and web sessions in
  SQLite. Passwords use PBKDF2-SHA256; tokens are 256-bit random values and
  are stored as SHA-256 hashes, since there is no low-entropy secret to grind.
- internal/index is the derived read model, rebuildable from disk and refs.
- internal/repo owns the bare-repo store and the metadata ref CAS helper.
- internal/web is the browse surface: repo list, tree, blob, raw, history,
  login, and per-repo settings for access rules, rename and delete.
- internal/signing creates the instance ed25519 key at first boot for the
  Phase 7 work.

Every Phase 1 exit criterion has a test that would fail if it regressed:
clone/push against a real listener, read-only and anonymous rejection with the
ref proven unmoved, rebuild-from-refs compared against the actual acl ref, a
spool file proven absent after an aborted chunked upload, and two pushes
completing through a single-slot semaphore. scripts/ci.sh runs gofmt, vet,
build, a markdown link check and the race-enabled suite.

Verified by hand as well: a clean `vole init` -> user -> token -> push ->
clone cycle, web login and browse, and a 256 MiB chunked push killed
mid-flight leaving no ref, no spool file and no orphaned git processes.

internal/auth/auth.go +433

@@ -0,0 +1,433 @@
1 // Package auth stores users, personal access tokens and web sessions.
2 //
3 // Tokens are hashed with SHA-256 rather than a slow KDF on purpose: they are
4 // 256-bit random values we generate, so there is no low-entropy secret to grind
5 // and a fast hash keeps git-over-HTTP authentication cheap. Passwords are the
6 // opposite and use PBKDF2-SHA256.
7 package auth
8
9 import (
10 "crypto/pbkdf2"
11 "crypto/rand"
12 "crypto/sha256"
13 "crypto/subtle"
14 "database/sql"
15 "encoding/base64"
16 "encoding/hex"
17 "errors"
18 "fmt"
19 "strings"
20 "time"
21
22 _ "modernc.org/sqlite"
23 )
24
25 const (
26 pwIterations = 600_000
27 tokenPrefix = "vole_"
28 )
29
30 var (
31 ErrNotFound = errors.New("auth: not found")
32 ErrBadCreds = errors.New("auth: invalid credentials")
33 ErrDisabled = errors.New("auth: account disabled")
34 ErrDuplicate = errors.New("auth: already exists")
35 )
36
37 type User struct {
38 ID string
39 Username string
40 Created time.Time
41 Disabled bool
42 }
43
44 type Token struct {
45 ID string
46 UserID string
47 Name string
48 Scope string
49 Created time.Time
50 Expires *time.Time
51 LastUsed *time.Time
52 }
53
54 type Store struct {
55 db *sql.DB
56 }
57
58 func Open(path string) (*Store, error) {
59 dsn := path + "?_pragma=busy_timeout(5000)&_pragma=journal_mode(WAL)&_pragma=foreign_keys(1)"
60 db, err := sql.Open("sqlite", dsn)
61 if err != nil {
62 return nil, err
63 }
64 db.SetMaxOpenConns(4)
65 s := &Store{db: db}
66 if err := s.migrate(); err != nil {
67 db.Close()
68 return nil, err
69 }
70 return s, nil
71 }
72
73 func (s *Store) Close() error { return s.db.Close() }
74
75 func (s *Store) migrate() error {
76 _, err := s.db.Exec(`
77 CREATE TABLE IF NOT EXISTS users (
78 id TEXT PRIMARY KEY,
79 username TEXT NOT NULL UNIQUE COLLATE NOCASE,
80 pw_salt BLOB NOT NULL,
81 pw_hash BLOB NOT NULL,
82 pw_iter INTEGER NOT NULL,
83 created INTEGER NOT NULL,
84 disabled INTEGER NOT NULL DEFAULT 0
85 );
86 CREATE TABLE IF NOT EXISTS tokens (
87 id TEXT PRIMARY KEY,
88 user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
89 name TEXT NOT NULL,
90 hash BLOB NOT NULL UNIQUE,
91 scope TEXT NOT NULL,
92 created INTEGER NOT NULL,
93 expires INTEGER,
94 last_used INTEGER
95 );
96 CREATE TABLE IF NOT EXISTS sessions (
97 id TEXT PRIMARY KEY,
98 user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
99 created INTEGER NOT NULL,
100 expires INTEGER NOT NULL
101 );
102 CREATE INDEX IF NOT EXISTS sessions_expires ON sessions(expires);
103 `)
104 return err
105 }
106
107 // ---------- users ----------
108
109 func ValidateUsername(u string) error {
110 if len(u) < 2 || len(u) > 32 {
111 return fmt.Errorf("username must be 2-32 characters")
112 }
113 for _, r := range u {
114 if (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9') || r == '-' || r == '_' {
115 continue
116 }
117 return fmt.Errorf("username may only contain lowercase letters, digits, dash and underscore")
118 }
119 return nil
120 }
121
122 func hashPassword(pw string) (salt, hash []byte, iter int, err error) {
123 salt = make([]byte, 16)
124 if _, err = rand.Read(salt); err != nil {
125 return nil, nil, 0, err
126 }
127 hash, err = pbkdf2.Key(sha256.New, pw, salt, pwIterations, 32)
128 if err != nil {
129 return nil, nil, 0, err
130 }
131 return salt, hash, pwIterations, nil
132 }
133
134 func (s *Store) CreateUser(username, password string) (*User, error) {
135 if err := ValidateUsername(username); err != nil {
136 return nil, err
137 }
138 if len(password) < 8 {
139 return nil, fmt.Errorf("password must be at least 8 characters")
140 }
141 salt, hash, iter, err := hashPassword(password)
142 if err != nil {
143 return nil, err
144 }
145 id, err := newID()
146 if err != nil {
147 return nil, err
148 }
149 now := time.Now().Unix()
150 if _, err := s.db.Exec(
151 `INSERT INTO users(id, username, pw_salt, pw_hash, pw_iter, created) VALUES(?,?,?,?,?,?)`,
152 id, username, salt, hash, iter, now); err != nil {
153 if strings.Contains(err.Error(), "UNIQUE") {
154 return nil, fmt.Errorf("%w: user %q", ErrDuplicate, username)
155 }
156 return nil, err
157 }
158 return &User{ID: id, Username: username, Created: time.Unix(now, 0).UTC()}, nil
159 }
160
161 func (s *Store) SetPassword(userID, password string) error {
162 if len(password) < 8 {
163 return fmt.Errorf("password must be at least 8 characters")
164 }
165 salt, hash, iter, err := hashPassword(password)
166 if err != nil {
167 return err
168 }
169 res, err := s.db.Exec(`UPDATE users SET pw_salt=?, pw_hash=?, pw_iter=? WHERE id=?`, salt, hash, iter, userID)
170 if err != nil {
171 return err
172 }
173 if n, _ := res.RowsAffected(); n == 0 {
174 return ErrNotFound
175 }
176 return nil
177 }
178
179 // Authenticate verifies a username and password. It always runs the KDF, so a
180 // missing user and a wrong password take the same time.
181 func (s *Store) Authenticate(username, password string) (*User, error) {
182 var u User
183 var salt, hash []byte
184 var iter int
185 var created int64
186 var disabled int
187 err := s.db.QueryRow(`SELECT id, username, pw_salt, pw_hash, pw_iter, created, disabled FROM users WHERE username=?`,
188 username).Scan(&u.ID, &u.Username, &salt, &hash, &iter, &created, &disabled)
189 if errors.Is(err, sql.ErrNoRows) {
190 // Burn equivalent CPU so absence is not distinguishable by timing.
191 _, _, _, _ = hashPassword(password)
192 return nil, ErrBadCreds
193 }
194 if err != nil {
195 return nil, err
196 }
197 got, err := pbkdf2.Key(sha256.New, password, salt, iter, 32)
198 if err != nil {
199 return nil, err
200 }
201 if subtle.ConstantTimeCompare(got, hash) != 1 {
202 return nil, ErrBadCreds
203 }
204 if disabled != 0 {
205 return nil, ErrDisabled
206 }
207 u.Created = time.Unix(created, 0).UTC()
208 return &u, nil
209 }
210
211 func (s *Store) UserByName(username string) (*User, error) {
212 return s.userWhere(`WHERE username=?`, username)
213 }
214
215 func (s *Store) UserByID(id string) (*User, error) {
216 return s.userWhere(`WHERE id=?`, id)
217 }
218
219 func (s *Store) userWhere(where string, arg any) (*User, error) {
220 var u User
221 var created int64
222 var disabled int
223 err := s.db.QueryRow(`SELECT id, username, created, disabled FROM users `+where, arg).
224 Scan(&u.ID, &u.Username, &created, &disabled)
225 if errors.Is(err, sql.ErrNoRows) {
226 return nil, ErrNotFound
227 }
228 if err != nil {
229 return nil, err
230 }
231 u.Created = time.Unix(created, 0).UTC()
232 u.Disabled = disabled != 0
233 return &u, nil
234 }
235
236 func (s *Store) ListUsers() ([]*User, error) {
237 rows, err := s.db.Query(`SELECT id, username, created, disabled FROM users ORDER BY username`)
238 if err != nil {
239 return nil, err
240 }
241 defer rows.Close()
242 var out []*User
243 for rows.Next() {
244 var u User
245 var created int64
246 var disabled int
247 if err := rows.Scan(&u.ID, &u.Username, &created, &disabled); err != nil {
248 return nil, err
249 }
250 u.Created = time.Unix(created, 0).UTC()
251 u.Disabled = disabled != 0
252 out = append(out, &u)
253 }
254 return out, rows.Err()
255 }
256
257 func (s *Store) CountUsers() (int, error) {
258 var n int
259 err := s.db.QueryRow(`SELECT COUNT(*) FROM users`).Scan(&n)
260 return n, err
261 }
262
263 // ---------- tokens ----------
264
265 func hashToken(t string) []byte {
266 sum := sha256.Sum256([]byte(t))
267 return sum[:]
268 }
269
270 // CreateToken returns the plaintext token exactly once; only its hash is stored.
271 func (s *Store) CreateToken(userID, name, scope string, ttl time.Duration) (string, *Token, error) {
272 if scope != "read" && scope != "write" {
273 return "", nil, fmt.Errorf("token scope must be read or write")
274 }
275 raw := make([]byte, 32)
276 if _, err := rand.Read(raw); err != nil {
277 return "", nil, err
278 }
279 plain := tokenPrefix + base64.RawURLEncoding.EncodeToString(raw)
280 id, err := newID()
281 if err != nil {
282 return "", nil, err
283 }
284 now := time.Now().UTC()
285 var expires any
286 var expPtr *time.Time
287 if ttl > 0 {
288 e := now.Add(ttl)
289 expires, expPtr = e.Unix(), &e
290 }
291 if _, err := s.db.Exec(
292 `INSERT INTO tokens(id, user_id, name, hash, scope, created, expires) VALUES(?,?,?,?,?,?,?)`,
293 id, userID, name, hashToken(plain), scope, now.Unix(), expires); err != nil {
294 return "", nil, err
295 }
296 return plain, &Token{ID: id, UserID: userID, Name: name, Scope: scope, Created: now, Expires: expPtr}, nil
297 }
298
299 // AuthenticateToken resolves a bearer token to its user. Expired tokens are
300 // rejected rather than deleted on read.
301 func (s *Store) AuthenticateToken(plain string) (*User, *Token, error) {
302 if !strings.HasPrefix(plain, tokenPrefix) {
303 return nil, nil, ErrBadCreds
304 }
305 var t Token
306 var created int64
307 var expires, lastUsed sql.NullInt64
308 err := s.db.QueryRow(
309 `SELECT id, user_id, name, scope, created, expires, last_used FROM tokens WHERE hash=?`,
310 hashToken(plain)).Scan(&t.ID, &t.UserID, &t.Name, &t.Scope, &created, &expires, &lastUsed)
311 if errors.Is(err, sql.ErrNoRows) {
312 return nil, nil, ErrBadCreds
313 }
314 if err != nil {
315 return nil, nil, err
316 }
317 t.Created = time.Unix(created, 0).UTC()
318 if expires.Valid {
319 e := time.Unix(expires.Int64, 0).UTC()
320 t.Expires = &e
321 if time.Now().UTC().After(e) {
322 return nil, nil, ErrBadCreds
323 }
324 }
325 if lastUsed.Valid {
326 l := time.Unix(lastUsed.Int64, 0).UTC()
327 t.LastUsed = &l
328 }
329 u, err := s.UserByID(t.UserID)
330 if err != nil {
331 return nil, nil, ErrBadCreds
332 }
333 if u.Disabled {
334 return nil, nil, ErrDisabled
335 }
336 // Best-effort touch; failure here must not fail authentication.
337 _, _ = s.db.Exec(`UPDATE tokens SET last_used=? WHERE id=?`, time.Now().Unix(), t.ID)
338 return u, &t, nil
339 }
340
341 func (s *Store) ListTokens(userID string) ([]*Token, error) {
342 rows, err := s.db.Query(`SELECT id, user_id, name, scope, created, expires, last_used FROM tokens WHERE user_id=? ORDER BY created DESC`, userID)
343 if err != nil {
344 return nil, err
345 }
346 defer rows.Close()
347 var out []*Token
348 for rows.Next() {
349 var t Token
350 var created int64
351 var expires, lastUsed sql.NullInt64
352 if err := rows.Scan(&t.ID, &t.UserID, &t.Name, &t.Scope, &created, &expires, &lastUsed); err != nil {
353 return nil, err
354 }
355 t.Created = time.Unix(created, 0).UTC()
356 if expires.Valid {
357 e := time.Unix(expires.Int64, 0).UTC()
358 t.Expires = &e
359 }
360 if lastUsed.Valid {
361 l := time.Unix(lastUsed.Int64, 0).UTC()
362 t.LastUsed = &l
363 }
364 out = append(out, &t)
365 }
366 return out, rows.Err()
367 }
368
369 func (s *Store) RevokeToken(userID, tokenID string) error {
370 res, err := s.db.Exec(`DELETE FROM tokens WHERE id=? AND user_id=?`, tokenID, userID)
371 if err != nil {
372 return err
373 }
374 if n, _ := res.RowsAffected(); n == 0 {
375 return ErrNotFound
376 }
377 return nil
378 }
379
380 // ---------- sessions ----------
381
382 func (s *Store) CreateSession(userID string, ttl time.Duration) (string, error) {
383 raw := make([]byte, 32)
384 if _, err := rand.Read(raw); err != nil {
385 return "", err
386 }
387 sid := hex.EncodeToString(raw)
388 now := time.Now().UTC()
389 if _, err := s.db.Exec(`INSERT INTO sessions(id, user_id, created, expires) VALUES(?,?,?,?)`,
390 sid, userID, now.Unix(), now.Add(ttl).Unix()); err != nil {
391 return "", err
392 }
393 return sid, nil
394 }
395
396 func (s *Store) SessionUser(sid string) (*User, error) {
397 if sid == "" {
398 return nil, ErrNotFound
399 }
400 var userID string
401 var expires int64
402 err := s.db.QueryRow(`SELECT user_id, expires FROM sessions WHERE id=?`, sid).Scan(&userID, &expires)
403 if errors.Is(err, sql.ErrNoRows) {
404 return nil, ErrNotFound
405 }
406 if err != nil {
407 return nil, err
408 }
409 if time.Now().UTC().Unix() > expires {
410 _, _ = s.db.Exec(`DELETE FROM sessions WHERE id=?`, sid)
411 return nil, ErrNotFound
412 }
413 return s.UserByID(userID)
414 }
415
416 func (s *Store) DeleteSession(sid string) error {
417 _, err := s.db.Exec(`DELETE FROM sessions WHERE id=?`, sid)
418 return err
419 }
420
421 // PruneSessions drops expired rows; call at startup.
422 func (s *Store) PruneSessions() error {
423 _, err := s.db.Exec(`DELETE FROM sessions WHERE expires < ?`, time.Now().Unix())
424 return err
425 }
426
427 func newID() (string, error) {
428 b := make([]byte, 12)
429 if _, err := rand.Read(b); err != nil {
430 return "", err
431 }
432 return hex.EncodeToString(b), nil
433 }