135 lines
4.7 KiB
Go
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
|
|
}
|