tenseleyflow/shithub / ce07729

Browse files

Add PAT package: shithub_pat_ minting, sha256 hash, scope set + scope implication, in-memory last-used debouncer

Authored by mfwolffe <wolffemf@dukes.jmu.edu>
SHA
ce0772998c361059c280a5c2dc9dad8fd41b6222
Parents
c9afea9
Tree
588905f

4 changed files

StatusFile+-
A internal/auth/pat/debouncer.go 74 0
A internal/auth/pat/pat.go 128 0
A internal/auth/pat/pat_test.go 177 0
A internal/auth/pat/scopes.go 76 0
internal/auth/pat/debouncer.goadded
@@ -0,0 +1,74 @@
1
+// SPDX-License-Identifier: AGPL-3.0-or-later
2
+
3
+package pat
4
+
5
+import (
6
+	"sync"
7
+	"time"
8
+)
9
+
10
+// Debouncer suppresses repeat last-used updates for a given token within
11
+// a time window. The auth middleware calls ShouldTouch before issuing
12
+// the DB write so 100 requests/sec hitting the same token don't burn
13
+// 100 UPDATEs on user_tokens.
14
+//
15
+// Eviction is a simple cap-based sweep: once the map exceeds maxEntries,
16
+// we drop entries older than the window. Acceptable for our scale —
17
+// bounded by the active-PAT count, not the request rate.
18
+type Debouncer struct {
19
+	mu         sync.Mutex
20
+	last       map[int64]time.Time
21
+	window     time.Duration
22
+	maxEntries int
23
+	now        func() time.Time
24
+}
25
+
26
+// NewDebouncer returns a Debouncer with a 60-second default window and a
27
+// 4096-entry cap. The defaults are right for our launch scale; expose as
28
+// fields (NOT as constructor args) if a test needs to override them.
29
+func NewDebouncer(window time.Duration) *Debouncer {
30
+	if window <= 0 {
31
+		window = 60 * time.Second
32
+	}
33
+	return &Debouncer{
34
+		last:       make(map[int64]time.Time),
35
+		window:     window,
36
+		maxEntries: 4096,
37
+		now:        time.Now,
38
+	}
39
+}
40
+
41
+// ShouldTouch reports whether the auth middleware should issue a
42
+// last-used DB write for tokenID right now. Returns true at most once per
43
+// (tokenID, window) pair — subsequent calls within the window return
44
+// false. Always thread-safe.
45
+func (d *Debouncer) ShouldTouch(tokenID int64) bool {
46
+	now := d.now()
47
+	d.mu.Lock()
48
+	defer d.mu.Unlock()
49
+	if last, ok := d.last[tokenID]; ok && now.Sub(last) < d.window {
50
+		return false
51
+	}
52
+	d.last[tokenID] = now
53
+	if len(d.last) > d.maxEntries {
54
+		d.evictStaleLocked(now)
55
+	}
56
+	return true
57
+}
58
+
59
+func (d *Debouncer) evictStaleLocked(now time.Time) {
60
+	for k, t := range d.last {
61
+		if now.Sub(t) >= d.window {
62
+			delete(d.last, k)
63
+		}
64
+	}
65
+}
66
+
67
+// Forget removes tokenID from the debouncer's memory — useful when a
68
+// token is revoked, so a subsequent (unauthenticated) lookup with the
69
+// same id can't accidentally inherit a stale "recently touched" state.
70
+func (d *Debouncer) Forget(tokenID int64) {
71
+	d.mu.Lock()
72
+	defer d.mu.Unlock()
73
+	delete(d.last, tokenID)
74
+}
internal/auth/pat/pat.goadded
@@ -0,0 +1,128 @@
1
+// SPDX-License-Identifier: AGPL-3.0-or-later
2
+
3
+// Package pat owns personal-access-token minting, hashing, scope
4
+// constants, and the auth-middleware verification path.
5
+//
6
+// Format: shithub_pat_<32-char-base62>. The fixed `shithub_pat_` prefix is
7
+// recognized by secret-scanning tooling and by our own log redactor — so
8
+// even if someone accidentally pastes a raw token into a log line or a
9
+// public PR, downstream tooling has a chance to spot it.
10
+package pat
11
+
12
+import (
13
+	"crypto/rand"
14
+	"crypto/sha256"
15
+	"crypto/subtle"
16
+	"errors"
17
+	"fmt"
18
+	"strings"
19
+)
20
+
21
+// Prefix is the fixed marker prepended to every minted token.
22
+const Prefix = "shithub_pat_"
23
+
24
+// PayloadLen is the length of the random payload (chars), not bytes.
25
+// 32 base62 characters carry log2(62)*32 ≈ 190 bits of entropy — well
26
+// beyond any plausible brute-force budget.
27
+const PayloadLen = 32
28
+
29
+// DisplayPrefixLen is how much of the raw token we keep for display
30
+// (e.g. on the listing page). It includes the literal `shithub_pat_`
31
+// plus four characters of payload, enough to disambiguate at a glance.
32
+const DisplayPrefixLen = len("shithub_pat_") + 4
33
+
34
+// MaxTokensPerUser bounds the listing page and the auth-lookup table size.
35
+const MaxTokensPerUser = 50
36
+
37
+// alphabet is the base62 character set used for the random payload.
38
+const alphabet = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789"
39
+
40
+// ErrMalformed is returned when the supplied string isn't a well-formed
41
+// shithub PAT. Distinct from "valid format but unknown" so the auth
42
+// middleware can short-circuit without touching the DB.
43
+var ErrMalformed = errors.New("pat: malformed token")
44
+
45
+// Mint generates a fresh raw token, its sha256 hash (for storage), and
46
+// its display prefix. The raw token MUST be shown to the user exactly
47
+// once and never stored.
48
+func Mint() (raw string, hash []byte, prefix string, err error) {
49
+	payload, err := randomBase62(PayloadLen)
50
+	if err != nil {
51
+		return "", nil, "", fmt.Errorf("pat: mint payload: %w", err)
52
+	}
53
+	raw = Prefix + payload
54
+	sum := sha256.Sum256([]byte(raw))
55
+	prefix = raw[:DisplayPrefixLen]
56
+	return raw, sum[:], prefix, nil
57
+}
58
+
59
+// HashOf computes the canonical lookup hash for raw, after verifying
60
+// the prefix and length. Use this on every middleware lookup so a
61
+// malformed input never reaches the DB index.
62
+func HashOf(raw string) ([]byte, error) {
63
+	if !strings.HasPrefix(raw, Prefix) {
64
+		return nil, ErrMalformed
65
+	}
66
+	if len(raw) != len(Prefix)+PayloadLen {
67
+		return nil, ErrMalformed
68
+	}
69
+	for _, r := range raw[len(Prefix):] {
70
+		if !isBase62(r) {
71
+			return nil, ErrMalformed
72
+		}
73
+	}
74
+	sum := sha256.Sum256([]byte(raw))
75
+	return sum[:], nil
76
+}
77
+
78
+// EqualHash compares two stored hashes in constant time. The auth path
79
+// already keys by hash via UNIQUE-index lookup, but we also expose this
80
+// for any future code that compares hashes outside a DB lookup.
81
+func EqualHash(a, b []byte) bool {
82
+	return subtle.ConstantTimeCompare(a, b) == 1
83
+}
84
+
85
+// LooksLike reports whether s resembles a PAT (cheap structural check).
86
+// Used by URL-credential redaction in the log layer.
87
+func LooksLike(s string) bool {
88
+	if !strings.HasPrefix(s, Prefix) {
89
+		return false
90
+	}
91
+	if len(s) != len(Prefix)+PayloadLen {
92
+		return false
93
+	}
94
+	for _, r := range s[len(Prefix):] {
95
+		if !isBase62(r) {
96
+			return false
97
+		}
98
+	}
99
+	return true
100
+}
101
+
102
+func isBase62(r rune) bool {
103
+	return (r >= 'A' && r <= 'Z') || (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9')
104
+}
105
+
106
+// randomBase62 returns n base62 characters from crypto/rand. Reads more
107
+// bytes than strictly needed and rejection-samples to keep the
108
+// distribution uniform.
109
+func randomBase62(n int) (string, error) {
110
+	out := make([]byte, 0, n)
111
+	buf := make([]byte, n*2)
112
+	for len(out) < n {
113
+		if _, err := rand.Read(buf); err != nil {
114
+			return "", err
115
+		}
116
+		for _, b := range buf {
117
+			// 256 mod 62 == 8; reject the top range to avoid bias.
118
+			if b >= 248 {
119
+				continue
120
+			}
121
+			out = append(out, alphabet[b%62])
122
+			if len(out) == n {
123
+				break
124
+			}
125
+		}
126
+	}
127
+	return string(out), nil
128
+}
internal/auth/pat/pat_test.goadded
@@ -0,0 +1,177 @@
1
+// SPDX-License-Identifier: AGPL-3.0-or-later
2
+
3
+package pat
4
+
5
+import (
6
+	"crypto/sha256"
7
+	"strings"
8
+	"testing"
9
+	"time"
10
+)
11
+
12
+func TestMint_FormatAndUniqueness(t *testing.T) {
13
+	t.Parallel()
14
+	seen := map[string]bool{}
15
+	for i := 0; i < 100; i++ {
16
+		raw, hash, prefix, err := Mint()
17
+		if err != nil {
18
+			t.Fatalf("Mint: %v", err)
19
+		}
20
+		if !strings.HasPrefix(raw, Prefix) {
21
+			t.Fatalf("missing prefix: %s", raw)
22
+		}
23
+		if len(raw) != len(Prefix)+PayloadLen {
24
+			t.Fatalf("wrong length: %d", len(raw))
25
+		}
26
+		if !strings.HasPrefix(prefix, Prefix) {
27
+			t.Fatalf("display prefix wrong: %s", prefix)
28
+		}
29
+		if len(prefix) != DisplayPrefixLen {
30
+			t.Fatalf("display prefix length: %d", len(prefix))
31
+		}
32
+		if seen[raw] {
33
+			t.Fatalf("duplicate raw token: %s", raw)
34
+		}
35
+		seen[raw] = true
36
+		want := sha256.Sum256([]byte(raw))
37
+		if !EqualHash(want[:], hash) {
38
+			t.Fatalf("hash mismatch")
39
+		}
40
+	}
41
+}
42
+
43
+func TestHashOf_RoundTrip(t *testing.T) {
44
+	t.Parallel()
45
+	raw, hash, _, _ := Mint()
46
+	got, err := HashOf(raw)
47
+	if err != nil {
48
+		t.Fatalf("HashOf: %v", err)
49
+	}
50
+	if !EqualHash(got, hash) {
51
+		t.Fatalf("hash round-trip mismatch")
52
+	}
53
+}
54
+
55
+func TestHashOf_RejectsMalformed(t *testing.T) {
56
+	t.Parallel()
57
+	cases := []string{
58
+		"",
59
+		"not-a-pat",
60
+		Prefix + "tooshort",
61
+		Prefix + strings.Repeat("a", PayloadLen-1),
62
+		Prefix + strings.Repeat("a", PayloadLen+1),
63
+		Prefix + strings.Repeat("!", PayloadLen),
64
+	}
65
+	for _, c := range cases {
66
+		if _, err := HashOf(c); err == nil {
67
+			t.Errorf("expected error for %q", c)
68
+		}
69
+	}
70
+}
71
+
72
+func TestLooksLike(t *testing.T) {
73
+	t.Parallel()
74
+	raw, _, _, _ := Mint()
75
+	if !LooksLike(raw) {
76
+		t.Fatal("LooksLike rejected its own output")
77
+	}
78
+	if LooksLike("not-a-pat") {
79
+		t.Fatal("LooksLike accepted nonsense")
80
+	}
81
+}
82
+
83
+// ----- scopes -----
84
+
85
+func TestHasScope(t *testing.T) {
86
+	t.Parallel()
87
+	if !HasScope([]string{"repo:read"}, ScopeRepoRead) {
88
+		t.Fatal("repo:read should grant repo:read")
89
+	}
90
+	if !HasScope([]string{"repo:write"}, ScopeRepoRead) {
91
+		t.Fatal("repo:write should imply repo:read")
92
+	}
93
+	if HasScope([]string{"repo:read"}, ScopeRepoWrite) {
94
+		t.Fatal("repo:read should NOT imply repo:write")
95
+	}
96
+	if !HasScope([]string{"user:write"}, ScopeUserRead) {
97
+		t.Fatal("user:write should imply user:read")
98
+	}
99
+	if HasScope(nil, ScopeRepoRead) {
100
+		t.Fatal("empty held should grant nothing")
101
+	}
102
+}
103
+
104
+func TestNormalizeScopes(t *testing.T) {
105
+	t.Parallel()
106
+	in := []string{"user:read", "bogus", "repo:read", "repo:read", "user:read"}
107
+	got := NormalizeScopes(in)
108
+	want := []string{"repo:read", "user:read"}
109
+	if len(got) != len(want) {
110
+		t.Fatalf("got %v, want %v", got, want)
111
+	}
112
+	for i := range got {
113
+		if got[i] != want[i] {
114
+			t.Fatalf("got %v, want %v", got, want)
115
+		}
116
+	}
117
+}
118
+
119
+// ----- debouncer -----
120
+
121
+func TestDebouncer_FirstCallTouchesAndSubsequentSuppresses(t *testing.T) {
122
+	t.Parallel()
123
+	d := NewDebouncer(60 * time.Second)
124
+	if !d.ShouldTouch(1) {
125
+		t.Fatal("first call must touch")
126
+	}
127
+	if d.ShouldTouch(1) {
128
+		t.Fatal("second call within window must suppress")
129
+	}
130
+}
131
+
132
+func TestDebouncer_DifferentTokensIndependent(t *testing.T) {
133
+	t.Parallel()
134
+	d := NewDebouncer(60 * time.Second)
135
+	if !d.ShouldTouch(1) {
136
+		t.Fatal("first call for 1")
137
+	}
138
+	if !d.ShouldTouch(2) {
139
+		t.Fatal("first call for 2")
140
+	}
141
+}
142
+
143
+func TestDebouncer_WindowReset(t *testing.T) {
144
+	t.Parallel()
145
+	d := NewDebouncer(50 * time.Millisecond)
146
+	if !d.ShouldTouch(1) {
147
+		t.Fatal("first")
148
+	}
149
+	time.Sleep(80 * time.Millisecond)
150
+	if !d.ShouldTouch(1) {
151
+		t.Fatal("after window")
152
+	}
153
+}
154
+
155
+func TestDebouncer_HighRate(t *testing.T) {
156
+	t.Parallel()
157
+	d := NewDebouncer(60 * time.Second)
158
+	touches := 0
159
+	for i := 0; i < 100; i++ {
160
+		if d.ShouldTouch(42) {
161
+			touches++
162
+		}
163
+	}
164
+	if touches != 1 {
165
+		t.Fatalf("got %d touches over 100 calls, want 1", touches)
166
+	}
167
+}
168
+
169
+func TestDebouncer_Forget(t *testing.T) {
170
+	t.Parallel()
171
+	d := NewDebouncer(60 * time.Second)
172
+	d.ShouldTouch(1)
173
+	d.Forget(1)
174
+	if !d.ShouldTouch(1) {
175
+		t.Fatal("after Forget, ShouldTouch must return true again")
176
+	}
177
+}
internal/auth/pat/scopes.goadded
@@ -0,0 +1,76 @@
1
+// SPDX-License-Identifier: AGPL-3.0-or-later
2
+
3
+package pat
4
+
5
+// Scope is a typed scope constant. The set is intentionally coarse —
6
+// matches the GitHub classic-PAT model. Fine-grained per-repo scoping
7
+// is a post-MVP project.
8
+type Scope string
9
+
10
+const (
11
+	ScopeRepoRead  Scope = "repo:read"
12
+	ScopeRepoWrite Scope = "repo:write"
13
+	ScopeUserRead  Scope = "user:read"
14
+	ScopeUserWrite Scope = "user:write"
15
+	ScopeAdminRead Scope = "admin:read"
16
+)
17
+
18
+// AllScopes is the canonical ordered list — drives the create-form UI
19
+// and the validation list in ValidScope.
20
+var AllScopes = []Scope{
21
+	ScopeRepoRead,
22
+	ScopeRepoWrite,
23
+	ScopeUserRead,
24
+	ScopeUserWrite,
25
+	ScopeAdminRead,
26
+}
27
+
28
+// ValidScope reports whether s is a known scope name.
29
+func ValidScope(s string) bool {
30
+	for _, sc := range AllScopes {
31
+		if string(sc) == s {
32
+			return true
33
+		}
34
+	}
35
+	return false
36
+}
37
+
38
+// HasScope reports whether held grants required. Operations that don't
39
+// require a scope (browser-session callers) skip this check entirely.
40
+//
41
+// repo:write implicitly grants repo:read; user:write implicitly grants
42
+// user:read. We expand the implication here so callers don't have to.
43
+func HasScope(held []string, required Scope) bool {
44
+	want := string(required)
45
+	for _, h := range held {
46
+		if h == want {
47
+			return true
48
+		}
49
+		if required == ScopeRepoRead && h == string(ScopeRepoWrite) {
50
+			return true
51
+		}
52
+		if required == ScopeUserRead && h == string(ScopeUserWrite) {
53
+			return true
54
+		}
55
+	}
56
+	return false
57
+}
58
+
59
+// NormalizeScopes filters input to known scopes, deduplicates, and
60
+// returns them in AllScopes order. Used by the create handler to
61
+// canonicalize whatever the form submitted before it lands in the DB.
62
+func NormalizeScopes(in []string) []string {
63
+	seen := map[string]struct{}{}
64
+	for _, s := range in {
65
+		if ValidScope(s) {
66
+			seen[s] = struct{}{}
67
+		}
68
+	}
69
+	out := make([]string, 0, len(seen))
70
+	for _, sc := range AllScopes {
71
+		if _, ok := seen[string(sc)]; ok {
72
+			out = append(out, string(sc))
73
+		}
74
+	}
75
+	return out
76
+}