Files
2026-08-16 21:18:45 -05:00

83 lines
2.4 KiB
Go

package auth
import (
"crypto/rand"
"crypto/sha256"
"crypto/subtle"
"encoding/base64"
"encoding/hex"
"errors"
"fmt"
"strings"
)
// TokenPrefixLen is how many characters of a token are stored in the clear to
// narrow the database lookup. It is not a secret: it only identifies which row
// to compare against.
const TokenPrefixLen = 8
// tokenLabel prefixes every issued token so a leaked string is recognisable in
// logs and secret scanners.
const tokenLabel = "vibedns_"
// Token is a freshly minted API credential.
type Token struct {
Secret string // shown to the operator exactly once
Prefix string // stored in the clear, used to find the row
Hash string // stored, never reversible
}
// GenerateToken creates a 256-bit API token.
func GenerateToken() (Token, error) {
buf := make([]byte, 32)
if _, err := rand.Read(buf); err != nil {
return Token{}, fmt.Errorf("generate API token: %w", err)
}
body := base64.RawURLEncoding.EncodeToString(buf)
secret := tokenLabel + body
return Token{
Secret: secret,
Prefix: body[:TokenPrefixLen],
Hash: HashToken(secret),
}, nil
}
// HashToken hashes an API token with SHA-256.
//
// Unlike a human-chosen password, an API token is 256 bits of output from a
// CSPRNG, so there is no low-entropy guess space for an attacker to search: a
// fast hash is sufficient and, unlike Argon2id, can be computed on every API
// request without adding tens of milliseconds and 64 MiB of allocation to each
// one.
func HashToken(secret string) string {
sum := sha256.Sum256([]byte(secret))
return hex.EncodeToString(sum[:])
}
// TokenPrefix extracts the lookup prefix from a presented token.
func TokenPrefix(secret string) (string, error) {
body := strings.TrimPrefix(secret, tokenLabel)
if len(body) < TokenPrefixLen {
return "", errors.New("API token is malformed")
}
return body[:TokenPrefixLen], nil
}
// VerifyToken compares a presented token against a stored hash in constant
// time.
func VerifyToken(storedHash, secret string) bool {
got := HashToken(secret)
return subtle.ConstantTimeCompare([]byte(got), []byte(storedHash)) == 1
}
// RandomKey returns n cryptographically random bytes, base64 encoded. It backs
// the CSRF signing key.
func RandomKey(n int) (string, error) {
buf := make([]byte, n)
if _, err := rand.Read(buf); err != nil {
return "", fmt.Errorf("generate random key: %w", err)
}
return base64.RawStdEncoding.EncodeToString(buf), nil
}