package auth import ( "crypto/rand" "crypto/sha256" "encoding/base64" "encoding/hex" "errors" "fmt" "regexp" ) // ErrEmptyToken is returned when a token is required and none was supplied. // It is deliberately distinct from "no such session": an absent cookie is a // different situation from a cookie that no longer matches a row. var ErrEmptyToken = errors.New("auth: session token is empty") // TokenBytes is the entropy behind a session token. // // 32 bytes — 256 bits — is the size of the SHA-256 output it is hashed to, so // nothing is wasted at either end, and it puts guessing a live session far // beyond reach: an attacker who could test a billion candidates a second would // still need on the order of 10^60 years. // // This is a raw byte count, not a character count. The encoded token is 43 // characters of base64url. const TokenBytes = 32 // tokenHashPattern is the exact shape stored in sessions.token_hash, and the // same pattern the sessions_token_hash_sha256 CHECK constraint enforces in // migration 000004. Validating here turns a database constraint violation into // a clear Go error at the point the mistake was made. var tokenHashPattern = regexp.MustCompile(`^[0-9a-f]{64}$`) // GenerateToken returns a new, cryptographically random session token. // // The returned string is the secret itself. It is what goes into the HttpOnly // cookie and it must never be written to the database, to a log, or to an // error message. Only its hash is persisted — see HashToken. // // base64url without padding, so the value is safe in a cookie, a header and a // URL without escaping, and contains no '=' to be mangled by a cookie parser. func GenerateToken() (string, error) { buf := make([]byte, TokenBytes) if _, err := rand.Read(buf); err != nil { // There is no fallback. math/rand here would produce tokens an // attacker can predict from a handful of observed sessions. return "", fmt.Errorf("auth: read random bytes: %w", err) } return base64.RawURLEncoding.EncodeToString(buf), nil } // HashToken returns the lowercase hex SHA-256 of a session token. // // This is what the database stores. A plain hash — not argon2 — is the right // choice here and the wrong one for a password, and the difference is entropy: // a session token is 256 uniformly random bits, so there is no dictionary to // run against it and no work factor worth paying on every single request. A // password is chosen by a human and needs argon2id precisely because it is not. // // The function is pure and deterministic: the same token always hashes to the // same string, which is what makes lookup by hash possible at all. func HashToken(token string) string { sum := sha256.Sum256([]byte(token)) return hex.EncodeToString(sum[:]) } // IsTokenHash reports whether s has the shape HashToken produces. // // Used to catch the one mistake that would be catastrophic and silent: passing // a raw token where a hash is expected, and storing the secret in plaintext. // A raw token is base64url and contains characters outside [0-9a-f], or is the // wrong length, so it always fails this test. func IsTokenHash(s string) bool { return tokenHashPattern.MatchString(s) }