1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
|
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 <user>/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
}
|