// Package session mints and verifies the signed, stateless cookies gsg // uses to recognize a logged-in browser. There is no server-side session // store: everything needed to verify a cookie travels inside it, HMAC-signed // against a secret that lives only in this process's memory. A restart // invalidates every outstanding session -- an accepted trade-off for a // login this lightweight. // // The master secret is never used to sign anything directly. Each purpose // (today: the user session cookie; later: share-link tokens) gets its own // key derived from the secret via deriveKey, so a value signed for one // purpose can never be replayed as another -- that's enforced by // construction, not by a discriminator field a bug could skip. package session import ( "crypto/hmac" "crypto/rand" "crypto/sha256" "crypto/subtle" "encoding/base64" "encoding/json" "fmt" "net/http" "strconv" "strings" "time" ) const ( // CookieName is the browser cookie carrying the signed session. CookieName = "gsg_user" // TTL is how long a session lasts after login before it must be // re-established. Embedded inside the signed payload, not left to the // cookie's own Max-Age/Expires alone, so a copied cookie can't outlive // what the server considers valid. TTL = 30 * 24 * time.Hour // ShareCookieName is the browser cookie a redeemed share link mints. ShareCookieName = "gsg_share" purposeSession = "session" purposeShareURL = "share-url" purposeShareCookie = "share-cookie" ) // Manager issues and verifies gsg's signed cookies. Safe for concurrent // use; a nil *Manager verifies every request as anonymous, so wiring it up // is optional. type Manager struct { secret []byte } // NewManager generates a fresh in-memory secret. func NewManager() *Manager { secret := make([]byte, 32) if _, err := rand.Read(secret); err != nil { panic("session: failed to generate secret: " + err.Error()) } return &Manager{secret: secret} } // deriveKey derives a purpose-scoped signing key from the master secret. // This is a single-step HMAC-based derivation (not full RFC 5869 HKDF), // which is sufficient here: the input is already a uniformly random 32-byte // secret, so there's no entropy-extraction step to do, only labeling. func (m *Manager) deriveKey(purpose string) []byte { mac := hmac.New(sha256.New, m.secret) mac.Write([]byte(purpose)) return mac.Sum(nil) } // Issue mints a signed session cookie for username. func (m *Manager) Issue(username string) *http.Cookie { exp := time.Now().Add(TTL).Unix() payload := username + "|" + strconv.FormatInt(exp, 10) return &http.Cookie{ Name: CookieName, Value: sign(m.deriveKey(purposeSession), payload, ""), Path: "/", HttpOnly: true, Secure: true, SameSite: http.SameSiteLaxMode, Expires: time.Unix(exp, 0), } } // Verify reads and checks the session cookie on r, returning the // authenticated username, or "" if there is none, it's malformed, it's // signed for a different purpose, or it has expired. func (m *Manager) Verify(r *http.Request) string { if m == nil { return "" } c, err := r.Cookie(CookieName) if err != nil { return "" } payload, ok := verify(m.deriveKey(purposeSession), c.Value, "") if !ok { return "" } user, expStr, ok := strings.Cut(payload, "|") if !ok { return "" } exp, err := strconv.ParseInt(expStr, 10, 64) if err != nil || time.Now().Unix() > exp { return "" } return user } // Clear returns a cookie that immediately expires the session. func (m *Manager) Clear() *http.Cookie { return &http.Cookie{ Name: CookieName, Value: "", Path: "/", HttpOnly: true, Secure: true, SameSite: http.SameSiteLaxMode, MaxAge: -1, } } // ShareClaims is the payload embedded in both a share-URL token and the // gsg_share cookie minted on redemption. Slugs is the set of repo slugs the // grant unlocks; IAT/Exp are unix seconds. Pwd records only whether the // URL token requires a password -- it carries no password material itself. type ShareClaims struct { V int `json:"v"` Slugs []string `json:"slugs"` IAT int64 `json:"iat"` Exp int64 `json:"exp"` Pwd bool `json:"pwd"` } func (c ShareClaims) expired() bool { return c.Exp != 0 && time.Now().Unix() > c.Exp } // IssuedAt is c.IAT as a time.Time, for comparison against a repo's // gsg.share-revoked-after. func (c ShareClaims) IssuedAt() time.Time { return time.Unix(c.IAT, 0) } // NewShareToken signs a share-URL token. password is "" for an unprotected // link; when set, it is folded into the signature, never stored in the // token -- see the package doc. func (m *Manager) NewShareToken(slugs []string, ttl time.Duration, password string) (string, ShareClaims) { now := time.Now() claims := ShareClaims{V: 1, Slugs: slugs, IAT: now.Unix(), Exp: now.Add(ttl).Unix(), Pwd: password != ""} b, _ := json.Marshal(claims) // ShareClaims always marshals; no error path return sign(m.deriveKey(purposeShareURL), string(b), password), claims } // PeekShareToken decodes a share-URL token's claims WITHOUT verifying its // signature. It exists only so the redemption page can decide whether to // show a password prompt before it knows the password; the result must // never be used to grant access -- only VerifyShareToken's result may do // that. func PeekShareToken(tok string) (ShareClaims, bool) { encPayload, _, found := strings.Cut(tok, ".") if !found { return ShareClaims{}, false } b, err := dec(encPayload) if err != nil { return ShareClaims{}, false } var c ShareClaims if err := json.Unmarshal(b, &c); err != nil || c.V != 1 { return ShareClaims{}, false } return c, true } // VerifyShareToken checks a share-URL token against the password the // visitor supplied (pass "" for a token that doesn't require one) and // returns its claims. Wrong password, tampering, or expiry all fail the // same way -- there is nothing here for an attacker to distinguish. func (m *Manager) VerifyShareToken(tok, password string) (ShareClaims, bool) { payload, ok := verify(m.deriveKey(purposeShareURL), tok, password) if !ok { return ShareClaims{}, false } var c ShareClaims if err := json.Unmarshal([]byte(payload), &c); err != nil || c.V != 1 || c.expired() { return ShareClaims{}, false } return c, true } // IssueShareCookie mints the gsg_share cookie from already-verified claims. // IAT and Exp are copied verbatim from the URL token, not restamped to // "now": the revocation check compares a repo's gsg.share-revoked-after // against IAT, and resetting it here would let an already-redeemed browser // outlive a revocation aimed at it. func (m *Manager) IssueShareCookie(claims ShareClaims) *http.Cookie { b, _ := json.Marshal(claims) return &http.Cookie{ Name: ShareCookieName, Value: sign(m.deriveKey(purposeShareCookie), string(b), ""), Path: "/", HttpOnly: true, Secure: true, SameSite: http.SameSiteLaxMode, Expires: time.Unix(claims.Exp, 0), } } // VerifyShareCookie reads and checks the gsg_share cookie on r. func (m *Manager) VerifyShareCookie(r *http.Request) (ShareClaims, bool) { if m == nil { return ShareClaims{}, false } c, err := r.Cookie(ShareCookieName) if err != nil { return ShareClaims{}, false } payload, ok := verify(m.deriveKey(purposeShareCookie), c.Value, "") if !ok { return ShareClaims{}, false } var claims ShareClaims if err := json.Unmarshal([]byte(payload), &claims); err != nil || claims.V != 1 || claims.expired() { return ShareClaims{}, false } return claims, true } // sign produces "base64url(payload).base64url(hmac-sha256(payload||0||extra))". // Compact and self-contained like a JWT, without pulling in a JWT library or // its algorithm-confusion surface (alg:none, HS/RS key confusion) -- the key // and the hash are fixed by this package, not negotiable per token. extra is // "" for every purpose except a password-protected share-URL token, where // it's the password: folding it into the signature input (rather than // storing a hash of it in the payload) means nobody holding just the token // can crack it offline, and a guess can only be checked by asking the // server, which can rate-limit attempts. func sign(key []byte, payload, extra string) string { mac := hmac.New(sha256.New, key) mac.Write([]byte(payload)) mac.Write([]byte{0}) mac.Write([]byte(extra)) return enc(payload) + "." + enc(string(mac.Sum(nil))) } // verify checks tok against key and extra and, on success, returns the // payload. func verify(key []byte, tok, extra string) (payload string, ok bool) { encPayload, encSig, found := strings.Cut(tok, ".") if !found { return "", false } payloadBytes, err := dec(encPayload) if err != nil { return "", false } sig, err := dec(encSig) if err != nil { return "", false } mac := hmac.New(sha256.New, key) mac.Write(payloadBytes) mac.Write([]byte{0}) mac.Write([]byte(extra)) if subtle.ConstantTimeCompare(sig, mac.Sum(nil)) != 1 { return "", false } return string(payloadBytes), true } func enc(s string) string { return base64.RawURLEncoding.EncodeToString([]byte(s)) } func dec(s string) ([]byte, error) { b, err := base64.RawURLEncoding.DecodeString(s) if err != nil { return nil, fmt.Errorf("session: bad encoding: %w", err) } return b, nil }