// Package auth mints and verifies API tokens. // // A token looks like pgs__. 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 }