Migration This is my new public git source viewer. Source coming soon. Please contact with concerns/bugs. Contact me

internal/session/session.go

554bcfc63721b22faf8bca78d5518d76e24a4d20 · 9.1 KB · 280 lines raw

1 // Package session mints and verifies the signed, stateless cookies gsg
2 // uses to recognize a logged-in browser. There is no server-side session
3 // store: everything needed to verify a cookie travels inside it, HMAC-signed
4 // against a secret that lives only in this process's memory. A restart
5 // invalidates every outstanding session -- an accepted trade-off for a
6 // login this lightweight.
7 //
8 // The master secret is never used to sign anything directly. Each purpose
9 // (today: the user session cookie; later: share-link tokens) gets its own
10 // key derived from the secret via deriveKey, so a value signed for one
11 // purpose can never be replayed as another -- that's enforced by
12 // construction, not by a discriminator field a bug could skip.
13 package session
14
15 import (
16 "crypto/hmac"
17 "crypto/rand"
18 "crypto/sha256"
19 "crypto/subtle"
20 "encoding/base64"
21 "encoding/json"
22 "fmt"
23 "net/http"
24 "strconv"
25 "strings"
26 "time"
27 )
28
29 const (
30 // CookieName is the browser cookie carrying the signed session.
31 CookieName = "gsg_user"
32
33 // TTL is how long a session lasts after login before it must be
34 // re-established. Embedded inside the signed payload, not left to the
35 // cookie's own Max-Age/Expires alone, so a copied cookie can't outlive
36 // what the server considers valid.
37 TTL = 30 * 24 * time.Hour
38
39 // ShareCookieName is the browser cookie a redeemed share link mints.
40 ShareCookieName = "gsg_share"
41
42 purposeSession = "session"
43 purposeShareURL = "share-url"
44 purposeShareCookie = "share-cookie"
45 )
46
47 // Manager issues and verifies gsg's signed cookies. Safe for concurrent
48 // use; a nil *Manager verifies every request as anonymous, so wiring it up
49 // is optional.
50 type Manager struct {
51 secret []byte
52 }
53
54 // NewManager generates a fresh in-memory secret.
55 func NewManager() *Manager {
56 secret := make([]byte, 32)
57 if _, err := rand.Read(secret); err != nil {
58 panic("session: failed to generate secret: " + err.Error())
59 }
60 return &Manager{secret: secret}
61 }
62
63 // deriveKey derives a purpose-scoped signing key from the master secret.
64 // This is a single-step HMAC-based derivation (not full RFC 5869 HKDF),
65 // which is sufficient here: the input is already a uniformly random 32-byte
66 // secret, so there's no entropy-extraction step to do, only labeling.
67 func (m *Manager) deriveKey(purpose string) []byte {
68 mac := hmac.New(sha256.New, m.secret)
69 mac.Write([]byte(purpose))
70 return mac.Sum(nil)
71 }
72
73 // Issue mints a signed session cookie for username.
74 func (m *Manager) Issue(username string) *http.Cookie {
75 exp := time.Now().Add(TTL).Unix()
76 payload := username + "|" + strconv.FormatInt(exp, 10)
77 return &http.Cookie{
78 Name: CookieName,
79 Value: sign(m.deriveKey(purposeSession), payload, ""),
80 Path: "/",
81 HttpOnly: true,
82 Secure: true,
83 SameSite: http.SameSiteLaxMode,
84 Expires: time.Unix(exp, 0),
85 }
86 }
87
88 // Verify reads and checks the session cookie on r, returning the
89 // authenticated username, or "" if there is none, it's malformed, it's
90 // signed for a different purpose, or it has expired.
91 func (m *Manager) Verify(r *http.Request) string {
92 if m == nil {
93 return ""
94 }
95 c, err := r.Cookie(CookieName)
96 if err != nil {
97 return ""
98 }
99 payload, ok := verify(m.deriveKey(purposeSession), c.Value, "")
100 if !ok {
101 return ""
102 }
103 user, expStr, ok := strings.Cut(payload, "|")
104 if !ok {
105 return ""
106 }
107 exp, err := strconv.ParseInt(expStr, 10, 64)
108 if err != nil || time.Now().Unix() > exp {
109 return ""
110 }
111 return user
112 }
113
114 // Clear returns a cookie that immediately expires the session.
115 func (m *Manager) Clear() *http.Cookie {
116 return &http.Cookie{
117 Name: CookieName,
118 Value: "",
119 Path: "/",
120 HttpOnly: true,
121 Secure: true,
122 SameSite: http.SameSiteLaxMode,
123 MaxAge: -1,
124 }
125 }
126
127 // ShareClaims is the payload embedded in both a share-URL token and the
128 // gsg_share cookie minted on redemption. Slugs is the set of repo slugs the
129 // grant unlocks; IAT/Exp are unix seconds. Pwd records only whether the
130 // URL token requires a password -- it carries no password material itself.
131 type ShareClaims struct {
132 V int `json:"v"`
133 Slugs []string `json:"slugs"`
134 IAT int64 `json:"iat"`
135 Exp int64 `json:"exp"`
136 Pwd bool `json:"pwd"`
137 }
138
139 func (c ShareClaims) expired() bool { return c.Exp != 0 && time.Now().Unix() > c.Exp }
140
141 // IssuedAt is c.IAT as a time.Time, for comparison against a repo's
142 // gsg.share-revoked-after.
143 func (c ShareClaims) IssuedAt() time.Time { return time.Unix(c.IAT, 0) }
144
145 // NewShareToken signs a share-URL token. password is "" for an unprotected
146 // link; when set, it is folded into the signature, never stored in the
147 // token -- see the package doc.
148 func (m *Manager) NewShareToken(slugs []string, ttl time.Duration, password string) (string, ShareClaims) {
149 now := time.Now()
150 claims := ShareClaims{V: 1, Slugs: slugs, IAT: now.Unix(), Exp: now.Add(ttl).Unix(), Pwd: password != ""}
151 b, _ := json.Marshal(claims) // ShareClaims always marshals; no error path
152 return sign(m.deriveKey(purposeShareURL), string(b), password), claims
153 }
154
155 // PeekShareToken decodes a share-URL token's claims WITHOUT verifying its
156 // signature. It exists only so the redemption page can decide whether to
157 // show a password prompt before it knows the password; the result must
158 // never be used to grant access -- only VerifyShareToken's result may do
159 // that.
160 func PeekShareToken(tok string) (ShareClaims, bool) {
161 encPayload, _, found := strings.Cut(tok, ".")
162 if !found {
163 return ShareClaims{}, false
164 }
165 b, err := dec(encPayload)
166 if err != nil {
167 return ShareClaims{}, false
168 }
169 var c ShareClaims
170 if err := json.Unmarshal(b, &c); err != nil || c.V != 1 {
171 return ShareClaims{}, false
172 }
173 return c, true
174 }
175
176 // VerifyShareToken checks a share-URL token against the password the
177 // visitor supplied (pass "" for a token that doesn't require one) and
178 // returns its claims. Wrong password, tampering, or expiry all fail the
179 // same way -- there is nothing here for an attacker to distinguish.
180 func (m *Manager) VerifyShareToken(tok, password string) (ShareClaims, bool) {
181 payload, ok := verify(m.deriveKey(purposeShareURL), tok, password)
182 if !ok {
183 return ShareClaims{}, false
184 }
185 var c ShareClaims
186 if err := json.Unmarshal([]byte(payload), &c); err != nil || c.V != 1 || c.expired() {
187 return ShareClaims{}, false
188 }
189 return c, true
190 }
191
192 // IssueShareCookie mints the gsg_share cookie from already-verified claims.
193 // IAT and Exp are copied verbatim from the URL token, not restamped to
194 // "now": the revocation check compares a repo's gsg.share-revoked-after
195 // against IAT, and resetting it here would let an already-redeemed browser
196 // outlive a revocation aimed at it.
197 func (m *Manager) IssueShareCookie(claims ShareClaims) *http.Cookie {
198 b, _ := json.Marshal(claims)
199 return &http.Cookie{
200 Name: ShareCookieName,
201 Value: sign(m.deriveKey(purposeShareCookie), string(b), ""),
202 Path: "/",
203 HttpOnly: true,
204 Secure: true,
205 SameSite: http.SameSiteLaxMode,
206 Expires: time.Unix(claims.Exp, 0),
207 }
208 }
209
210 // VerifyShareCookie reads and checks the gsg_share cookie on r.
211 func (m *Manager) VerifyShareCookie(r *http.Request) (ShareClaims, bool) {
212 if m == nil {
213 return ShareClaims{}, false
214 }
215 c, err := r.Cookie(ShareCookieName)
216 if err != nil {
217 return ShareClaims{}, false
218 }
219 payload, ok := verify(m.deriveKey(purposeShareCookie), c.Value, "")
220 if !ok {
221 return ShareClaims{}, false
222 }
223 var claims ShareClaims
224 if err := json.Unmarshal([]byte(payload), &claims); err != nil || claims.V != 1 || claims.expired() {
225 return ShareClaims{}, false
226 }
227 return claims, true
228 }
229
230 // sign produces "base64url(payload).base64url(hmac-sha256(payload||0||extra))".
231 // Compact and self-contained like a JWT, without pulling in a JWT library or
232 // its algorithm-confusion surface (alg:none, HS/RS key confusion) -- the key
233 // and the hash are fixed by this package, not negotiable per token. extra is
234 // "" for every purpose except a password-protected share-URL token, where
235 // it's the password: folding it into the signature input (rather than
236 // storing a hash of it in the payload) means nobody holding just the token
237 // can crack it offline, and a guess can only be checked by asking the
238 // server, which can rate-limit attempts.
239 func sign(key []byte, payload, extra string) string {
240 mac := hmac.New(sha256.New, key)
241 mac.Write([]byte(payload))
242 mac.Write([]byte{0})
243 mac.Write([]byte(extra))
244 return enc(payload) + "." + enc(string(mac.Sum(nil)))
245 }
246
247 // verify checks tok against key and extra and, on success, returns the
248 // payload.
249 func verify(key []byte, tok, extra string) (payload string, ok bool) {
250 encPayload, encSig, found := strings.Cut(tok, ".")
251 if !found {
252 return "", false
253 }
254 payloadBytes, err := dec(encPayload)
255 if err != nil {
256 return "", false
257 }
258 sig, err := dec(encSig)
259 if err != nil {
260 return "", false
261 }
262 mac := hmac.New(sha256.New, key)
263 mac.Write(payloadBytes)
264 mac.Write([]byte{0})
265 mac.Write([]byte(extra))
266 if subtle.ConstantTimeCompare(sig, mac.Sum(nil)) != 1 {
267 return "", false
268 }
269 return string(payloadBytes), true
270 }
271
272 func enc(s string) string { return base64.RawURLEncoding.EncodeToString([]byte(s)) }
273
274 func dec(s string) ([]byte, error) {
275 b, err := base64.RawURLEncoding.DecodeString(s)
276 if err != nil {
277 return nil, fmt.Errorf("session: bad encoding: %w", err)
278 }
279 return b, nil
280 }