init
This commit is contained in:
@@ -0,0 +1,134 @@
|
||||
// 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
|
||||
}
|
||||
Reference in New Issue
Block a user