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.
Move writes to SSH, drop web authentication, keep identity in git
a5f19b3c19861e080953096f934df25dc38beaf7 · Keenan Nemetz · 18d ago · parent 653e0cfc
Move writes to SSH, drop web authentication, keep identity in git The authenticated-HTTP design is gone rather than extended. Writes now require an SSH key, the web is read-only, and the user roster lives in git. What replaced what: - internal/sshserver is the only write transport. An embedded SSH server (golang.org/x/crypto/ssh) authenticates by public key and hands the principal to pre-receive through the environment, so the hook's policy code is unchanged. The exec surface is whitelisted to git-upload-pack and git-receive-pack, the repository argument is resolved through the store rather than concatenated, and everything else -- including `sh -c` -- is refused and asserted refused in a test. - internal/identity stores users on refs/heads/main in data/system.git. The directory listing is the table: users/<name>/keys/<sha>.pub. Writes are CAS commits, reads come from an in-memory snapshot so the SSH auth callback is a map lookup. Nothing stored is a secret, so a clone of system.git is a complete backup and git log on it is the audit trail. - internal/transport is now anonymous read-only: upload-pack behind a visibility check, 404 for private repositories, and no push path at all. - internal/web has no login, no sessions and no mutations. Deleted outright: internal/auth, the Basic-auth path, login/logout, web settings/rename/delete, chunked body spooling, the spool janitor, the push semaphore and the hand-rolled byte cap. The cap is now git's receive.maxInputSize per repo, which enforces it on every transport without buffering. Three things worth recording: - The CAS helper read the tree outside the retry loop and compared against a freshly-read old value, so a concurrent writer's commit could be silently discarded while the CAS "succeeded". A test with two stores adding eight keys at once found it losing half of them; read, mutate and compare now all happen inside the loop. - The roster moved from refs/vole/users to refs/heads/main because `git clone` only mirrors refs/heads/*: the documented backup command produced an instance with zero users. Storing on a branch also means git itself refuses to point the ref at a non-commit. - Exec refusals are answered on the channel rather than by rejecting the request. Rejecting made the client print "exec request failed on channel 0", so the reason was unreadable. Verified by hand as well as by test: init -> user -> add-key -> repo -> serve, push over SSH, anonymous HTTP clone, web browse, a private repo answering 404 on both anonymous surfaces while still cloning over SSH, a refused `sh -c` that prints why and executes nothing, and a plain clone of system.git yielding the whole roster with no side database present.
internal/auth/auth.go −433
| @@ -1,433 +0,0 @@ | |||
| 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 | } | ||