Go · 3987 bytes Raw Blame History
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 }
129