package auth import ( "crypto/sha256" "crypto/subtle" "encoding/hex" "strings" "sync" "time" "gitweb/internal/store" ) // PATVerifier checks personal access tokens for the non-interactive (CI) auth // path. A user stores the SHA-256 hashes of their tokens, one per line, in their // own upspin tree at /gitweb/tokens, and grants read on that file to the // service. Ownership of the tree is the identity proof; possession of a token // whose hash appears there authenticates as that user. // // File format, one entry per line: // // # a comment // <64-hex sha256-of-token> optional label words type PATVerifier struct { st store.Store ttl time.Duration mu sync.Mutex cache map[string]patEntry // user -> parsed token hashes } type patEntry struct { hashes [][32]byte expires time.Time } // NewPATVerifier returns a PATVerifier reading token files through st. func NewPATVerifier(st store.Store) *PATVerifier { return &PATVerifier{st: st, ttl: 60 * time.Second, cache: map[string]patEntry{}} } // Verify reports whether token is a valid PAT for user. A missing or unreadable // tokens file is not an error: it simply yields false (fail closed, no leak). func (v *PATVerifier) Verify(user, token string) (bool, error) { if user == "" || token == "" { return false, nil } hashes, err := v.hashesFor(user) if err != nil { return false, err } want := sha256.Sum256([]byte(token)) match := false // Compare against every entry without early exit, to keep the work // independent of which (if any) token matched. for i := range hashes { if subtle.ConstantTimeCompare(want[:], hashes[i][:]) == 1 { match = true } } return match, nil } func (v *PATVerifier) hashesFor(user string) ([][32]byte, error) { now := time.Now() v.mu.Lock() if e, ok := v.cache[user]; ok && now.Before(e.expires) { v.mu.Unlock() return e.hashes, nil } v.mu.Unlock() var hashes [][32]byte data, err := v.st.Get(user + "/gitweb/tokens") switch err { case nil: hashes = parseTokenFile(data) case store.ErrNotFound: // No tokens file: cache the empty result so repeated failed logins do // not hammer the store. default: return nil, err } v.mu.Lock() v.cache[user] = patEntry{hashes: hashes, expires: now.Add(v.ttl)} v.mu.Unlock() return hashes, nil } // parseTokenFile extracts the 32-byte token hashes from a tokens file, ignoring // blank lines, comments, and malformed entries. func parseTokenFile(data []byte) [][32]byte { var out [][32]byte for _, line := range strings.Split(string(data), "\n") { line = strings.TrimSpace(line) if line == "" || strings.HasPrefix(line, "#") { continue } field := line if i := strings.IndexAny(line, " \t"); i >= 0 { field = line[:i] } raw, err := hex.DecodeString(field) if err != nil || len(raw) != 32 { continue } var h [32]byte copy(h[:], raw) out = append(out, h) } return out }