Files
pages/internal/auth/token.go
T
2026-08-15 07:13:00 +00:00

135 lines
4.7 KiB
Go

// Package auth mints and verifies API tokens.
//
// A token looks like pgs_<keyid>_<secret>. The key id is the public half: it is
// stored in the clear, indexed, printed by `pages key list` and safe to log.
// The secret is 256 bits of crypto/rand, shown to the operator exactly once at
// creation and never stored — only its SHA-256.
//
// Splitting the two is what keeps verification a single indexed lookup instead
// of a table scan comparing every hash, and it gives the CLI something
// displayable that reveals nothing.
package auth
import (
"crypto/rand"
"crypto/sha256"
"crypto/subtle"
"encoding/base32"
"encoding/base64"
"errors"
"strings"
)
const (
// Prefix marks a pages token. Its main job is to be greppable: secret
// scanners and humans can both spot a leaked credential by shape alone.
Prefix = "pgs"
keyIDBytes = 10 // 80 bits -> exactly 16 base32 characters, no padding
secretBytes = 32 // 256 bits -> exactly 43 base64url characters, no padding
// KeyIDLen and secretLen are the encoded lengths. Parse checks them exactly
// so a truncated or padded token is rejected before any lookup happens.
KeyIDLen = 16
secretLen = 43
)
// keyIDEncoding is lowercase base32 so a key id can be typed, double-clicked
// and pasted without case confusion. It is not standard base32; do not swap it
// for base32.StdEncoding without a migration, because existing ids would stop
// decoding.
var keyIDEncoding = base32.NewEncoding("abcdefghijklmnopqrstuvwxyz234567").WithPadding(base32.NoPadding)
// ErrMalformedToken is returned for anything that is not shaped like a token.
//
// It deliberately carries no detail about which check failed and never embeds
// the offending token: these errors reach logs, and a log line quoting a
// near-miss credential is a credential leak.
var ErrMalformedToken = errors.New("auth: malformed token")
// Mint generates a new token. The caller stores keyID and secretHash and hands
// token to the operator; there is no way to recover token afterwards.
func Mint() (token, keyID string, secretHash [32]byte, err error) {
idRaw := make([]byte, keyIDBytes)
if _, err := rand.Read(idRaw); err != nil {
return "", "", [32]byte{}, err
}
secretRaw := make([]byte, secretBytes)
if _, err := rand.Read(secretRaw); err != nil {
return "", "", [32]byte{}, err
}
keyID = keyIDEncoding.EncodeToString(idRaw)
secret := base64.RawURLEncoding.EncodeToString(secretRaw)
return Prefix + "_" + keyID + "_" + secret, keyID, sha256.Sum256([]byte(secret)), nil
}
// Parse splits a token into its two halves, validating shape and alphabet.
//
// This runs before any database work, so it is also the cheap filter that keeps
// junk from reaching the store: an unauthenticated flood of garbage tokens
// costs a few string comparisons each, not a query.
func Parse(token string) (keyID, secret string, err error) {
rest, ok := strings.CutPrefix(token, Prefix+"_")
if !ok {
return "", "", ErrMalformedToken
}
keyID, secret, ok = strings.Cut(rest, "_")
if !ok {
return "", "", ErrMalformedToken
}
if len(keyID) != KeyIDLen || len(secret) != secretLen {
return "", "", ErrMalformedToken
}
if !validKeyID(keyID) || !validSecret(secret) {
return "", "", ErrMalformedToken
}
return keyID, secret, nil
}
// ValidKeyID reports whether s could be a key id. Handlers that take a key id
// from the URL use it to reject junk before querying.
func ValidKeyID(s string) bool { return len(s) == KeyIDLen && validKeyID(s) }
func validKeyID(s string) bool {
for i := 0; i < len(s); i++ {
c := s[i]
if (c >= 'a' && c <= 'z') || (c >= '2' && c <= '7') {
continue
}
return false
}
return true
}
func validSecret(s string) bool {
for i := 0; i < len(s); i++ {
c := s[i]
switch {
case c >= 'a' && c <= 'z', c >= 'A' && c <= 'Z', c >= '0' && c <= '9', c == '-', c == '_':
continue
}
return false
}
return true
}
// HashSecret returns the value stored in api_keys.secret_hash.
//
// A plain SHA-256, not bcrypt or argon2, and that is deliberate. Password
// hashing exists to make low-entropy human-chosen secrets expensive to guess
// offline; this secret is 256 uniformly random bits, so there is no dictionary
// and no brute force to slow down. Running a KDF per request would instead add
// 50-200ms to every API call and hand an unauthenticated client a CPU
// exhaustion attack: each wrong token would force a full key derivation.
func HashSecret(secret string) [32]byte {
return sha256.Sum256([]byte(secret))
}
// SecretMatches compares a presented secret against a stored hash in constant
// time, so a caller cannot learn the hash byte by byte from response timing.
func SecretMatches(secret string, storedHash []byte) bool {
got := HashSecret(secret)
return subtle.ConstantTimeCompare(got[:], storedHash) == 1
}