Add custom token support

This commit is contained in:
2026-09-13 01:01:48 +02:00
parent 1b77cdd165
commit 48229f15a2
12 changed files with 680 additions and 305 deletions
+211 -17
View File
@@ -3,6 +3,8 @@
package auth
import (
"crypto/pbkdf2"
"crypto/rand"
"crypto/sha256"
"crypto/subtle"
"encoding/hex"
@@ -12,6 +14,7 @@ import (
"io/fs"
"os"
"path/filepath"
"slices"
"sort"
"sync"
"time"
@@ -28,10 +31,39 @@ var (
ErrExists = errors.New("a token with that name already exists")
)
// Key derivation kinds. A generated token is 256 bits of randomness, so a
// plain digest is all it needs: there is no smaller space to search than the
// key space itself. A chosen one is a passphrase, and passphrases are guessable
// and reused elsewhere, so those get a deliberately slow derivation.
const (
KDFSHA256 = "sha256" // implied when the field is absent
KDFPBKDF2 = "pbkdf2-sha256"
// PBKDF2Iterations follows the current OWASP guidance for PBKDF2-HMAC-SHA256.
PBKDF2Iterations = 600_000
minIterations = 100_000
// MinChosenLength is the floor for a token someone picks themselves.
// Shorter than this and the throttle on failed logins is the only thing
// standing between a guesser and the account.
MinChosenLength = 4
maxTokenLength = 256
)
var (
ErrTokenTooShort = fmt.Errorf("a chosen token must be at least %d characters", MinChosenLength)
ErrTokenTooLong = fmt.Errorf("a token must be at most %d characters", maxTokenLength)
)
// Token is one named credential. The pointer fields distinguish "not set, so
// inherit the server default" from "set to zero, meaning unlimited".
type Token struct {
Name string `json:"name"`
Name string `json:"name"`
// KDF is empty for a generated token and KDFPBKDF2 for a chosen one.
KDF string `json:"kdf,omitempty"`
Salt string `json:"salt,omitempty"`
Iter int `json:"iter,omitempty"`
Hash string `json:"hash"`
MaxSize *string `json:"max_size,omitempty"`
MaxExpiry *string `json:"max_expiry,omitempty"`
@@ -43,6 +75,30 @@ type Token struct {
maxSize *int64
maxExpiry *time.Duration
defaultExpiry *time.Duration
salt []byte
}
// Chosen reports whether this credential is a passphrase somebody picked
// rather than a generated secret.
func (t *Token) Chosen() bool { return t.KDF == KDFPBKDF2 }
// Verify checks a presented secret against this token.
func (t *Token) Verify(secret string) bool {
if secret == "" || len(secret) > maxTokenLength {
return false
}
switch t.KDF {
case "", KDFSHA256:
return EqualHash(t.Hash, HashSecret(secret))
case KDFPBKDF2:
sum, err := pbkdf2.Key(sha256.New, secret, t.salt, t.Iter, sha256.Size)
if err != nil {
return false
}
return EqualHash(t.Hash, hex.EncodeToString(sum))
default:
return false
}
}
// resolve parses the human-written limit strings once, at load time, so a
@@ -54,6 +110,23 @@ func (t *Token) resolve() error {
if _, err := hex.DecodeString(t.Hash); err != nil || len(t.Hash) != sha256.Size*2 {
return fmt.Errorf("token %q: hash is not a sha256 hex digest", t.Name)
}
switch t.KDF {
case "", KDFSHA256:
if t.Salt != "" || t.Iter != 0 {
return fmt.Errorf("token %q: salt and iter belong only to %s", t.Name, KDFPBKDF2)
}
case KDFPBKDF2:
salt, err := hex.DecodeString(t.Salt)
if err != nil || len(salt) < 16 {
return fmt.Errorf("token %q: salt must be at least 16 random bytes in hex", t.Name)
}
if t.Iter < minIterations {
return fmt.Errorf("token %q: iter is %d, below the %d minimum", t.Name, t.Iter, minIterations)
}
t.salt = salt
default:
return fmt.Errorf("token %q: unknown kdf %q", t.Name, t.KDF)
}
if t.MaxSize != nil {
n, err := config.ParseSize(*t.MaxSize)
if err != nil {
@@ -125,10 +198,11 @@ func (t *Token) Limits(c *config.Config) Limits {
return l
}
// HashSecret is the one-way transform applied to every secret this service
// stores, both API tokens and per-object delete tokens. The secrets are 256-bit
// random values, so a plain digest is sufficient - there is nothing to brute
// force - and lookup by digest reveals nothing through timing.
// HashSecret is the fast one-way transform, used for generated tokens and for
// per-object delete tokens. Both are 256-bit random values, so a plain digest
// is sufficient - there is nothing to brute force - and lookup by digest
// reveals nothing through timing. Chosen passphrases never go through here;
// see Token.Verify.
func HashSecret(s string) string {
sum := sha256.Sum256([]byte(s))
return hex.EncodeToString(sum[:])
@@ -139,13 +213,24 @@ func EqualHash(a, b string) bool {
return subtle.ConstantTimeCompare([]byte(a), []byte(b)) == 1
}
// maxVerifyCache bounds the memo below. It is cleared wholesale when full,
// which costs one extra derivation per live session and needs no bookkeeping.
const maxVerifyCache = 4096
// File is the token store, backed by a JSON file and reloadable at runtime.
type File struct {
path string
mu sync.RWMutex
byHash map[string]*Token
byName map[string]*Token
mu sync.RWMutex
byHash map[string]*Token // generated tokens, found in one step
byName map[string]*Token
chosen []*Token // passphrases, each needing its own derivation
// verified memoises derivation results, negative ones included, so a
// passphrase costs its full price once rather than on every request.
// Cleared whenever the file is reloaded.
verified map[string]*Token
modTime time.Time
size int64
}
@@ -153,7 +238,12 @@ type File struct {
// Load reads the token file. A missing file is not an error: the service simply
// starts with no credentials and only the anonymous tier available.
func Load(path string) (*File, error) {
f := &File{path: path, byHash: map[string]*Token{}, byName: map[string]*Token{}}
f := &File{
path: path,
byHash: map[string]*Token{},
byName: map[string]*Token{},
verified: map[string]*Token{},
}
if err := f.Reload(); err != nil {
return nil, err
}
@@ -198,17 +288,23 @@ func (f *File) Reload() error {
}
byHash := make(map[string]*Token, len(tokens))
byName := make(map[string]*Token, len(tokens))
var chosen []*Token
for _, t := range tokens {
if _, dup := byName[t.Name]; dup {
return fmt.Errorf("%s: duplicate token name %q", f.path, t.Name)
}
byHash[t.Hash] = t
byName[t.Name] = t
if t.Chosen() {
chosen = append(chosen, t)
} else {
byHash[t.Hash] = t
}
}
f.mu.Lock()
defer f.mu.Unlock()
f.byHash, f.byName = byHash, byName
f.byHash, f.byName, f.chosen = byHash, byName, chosen
clear(f.verified)
if info != nil {
f.modTime, f.size = info.ModTime(), info.Size()
} else {
@@ -242,19 +338,63 @@ func (f *File) MaybeReload() error {
return f.Reload()
}
// Resolved reports whether Lookup can answer for this secret without running a
// key derivation. Callers use it to decide whether the work needs rate limiting.
func (f *File) Resolved(secret string) bool {
if secret == "" {
return true
}
h := HashSecret(secret)
f.mu.RLock()
defer f.mu.RUnlock()
if _, ok := f.byHash[h]; ok {
return true
}
if _, ok := f.verified[h]; ok {
return true
}
return len(f.chosen) == 0 // nothing slow to try, so the answer is already in
}
// Lookup resolves a presented secret to its token, or nil.
//
// Generated tokens are found by digest in one step. A chosen passphrase has a
// salt of its own, so there is no index to look it up in: each candidate has to
// be derived and compared. That is why the result is memoised, and why callers
// should check Resolved first when the secret came from an untrusted source.
func (f *File) Lookup(secret string) *Token {
if secret == "" {
return nil
}
h := HashSecret(secret)
f.mu.RLock()
defer f.mu.RUnlock()
t, ok := f.byHash[h]
if !ok || !EqualHash(t.Hash, h) {
return nil
if t, ok := f.byHash[h]; ok && EqualHash(t.Hash, h) {
f.mu.RUnlock()
return t
}
return t
if t, ok := f.verified[h]; ok {
f.mu.RUnlock()
return t
}
chosen := f.chosen
f.mu.RUnlock()
var found *Token
for _, t := range chosen {
if t.Verify(secret) {
found = t
break
}
}
f.mu.Lock()
if len(f.verified) >= maxVerifyCache {
clear(f.verified)
}
f.verified[h] = found
f.mu.Unlock()
return found
}
// List returns the tokens, name-sorted, for the CLI.
@@ -280,7 +420,12 @@ func (f *File) Add(t *Token) error {
return ErrExists
}
f.byName[t.Name] = t
f.byHash[t.Hash] = t
if t.Chosen() {
f.chosen = append(f.chosen, t)
} else {
f.byHash[t.Hash] = t
}
clear(f.verified)
return f.saveLocked()
}
@@ -294,6 +439,8 @@ func (f *File) Remove(name string) error {
}
delete(f.byName, name)
delete(f.byHash, t.Hash)
f.chosen = slices.DeleteFunc(f.chosen, func(c *Token) bool { return c == t })
clear(f.verified)
return f.saveLocked()
}
@@ -345,3 +492,50 @@ func (f *File) saveLocked() error {
}
return nil
}
// NewGenerated builds a credential from a fresh 256-bit secret, which it also
// returns: this is the only time the secret exists.
func NewGenerated(name string) (*Token, string, error) {
var b [32]byte
if _, err := rand.Read(b[:]); err != nil {
return nil, "", err
}
secret := hex.EncodeToString(b[:])
return &Token{Name: name, Hash: HashSecret(secret), Created: now()}, secret, nil
}
// NewChosen builds a credential from a passphrase somebody picked.
//
// Unlike a generated secret this one is guessable and, realistically, reused
// somewhere else, so it is stored under a slow derivation with a salt of its
// own. Cracking the file should not hand an attacker a password that opens
// something that matters more than this service.
func NewChosen(name, secret string) (*Token, error) {
switch {
case len(secret) < MinChosenLength:
return nil, ErrTokenTooShort
case len(secret) > maxTokenLength:
return nil, ErrTokenTooLong
}
salt := make([]byte, 16)
if _, err := rand.Read(salt); err != nil {
return nil, err
}
sum, err := pbkdf2.Key(sha256.New, secret, salt, PBKDF2Iterations, sha256.Size)
if err != nil {
return nil, err
}
return &Token{
Name: name,
KDF: KDFPBKDF2,
Salt: hex.EncodeToString(salt),
Iter: PBKDF2Iterations,
Hash: hex.EncodeToString(sum),
Created: now(),
// Set here as well as in resolve, so a token is usable the moment it is
// built rather than only after a round trip through the file.
salt: salt,
}, nil
}
func now() time.Time { return time.Now().UTC().Truncate(time.Second) }
+155
View File
@@ -3,6 +3,7 @@ package auth
import (
"os"
"path/filepath"
"strings"
"testing"
"time"
@@ -184,3 +185,157 @@ func TestReloadPicksUpChanges(t *testing.T) {
t.Error("the newly written token was not picked up")
}
}
// --- chosen tokens -------------------------------------------------------
func TestChosenTokenRoundTrip(t *testing.T) {
f := newFile(t)
const passphrase = "godot-friends-2026"
tok, err := NewChosen("thayol", passphrase)
if err != nil {
t.Fatal(err)
}
if err := f.Add(tok); err != nil {
t.Fatal(err)
}
if got := f.Lookup(passphrase); got == nil || got.Name != "thayol" {
t.Fatalf("Lookup(passphrase) = %v", got)
}
if f.Lookup(passphrase+"x") != nil || f.Lookup("") != nil {
t.Error("a wrong passphrase authenticated")
}
}
// A chosen passphrase is guessable and probably reused, so the file must not
// give it up to anyone who reads it.
func TestChosenTokensAreNotStoredUnderAFastDigest(t *testing.T) {
f := newFile(t)
const passphrase = "correct-horse-battery"
tok, err := NewChosen("thayol", passphrase)
if err != nil {
t.Fatal(err)
}
if err := f.Add(tok); err != nil {
t.Fatal(err)
}
raw, err := os.ReadFile(f.Path())
if err != nil {
t.Fatal(err)
}
body := string(raw)
if strings.Contains(body, passphrase) {
t.Fatal("the passphrase is stored in the clear")
}
if strings.Contains(body, HashSecret(passphrase)) {
t.Fatal("the passphrase is stored under a plain sha256, which a wordlist breaks")
}
if !strings.Contains(body, KDFPBKDF2) {
t.Error("the entry does not record which derivation was used")
}
if tok.Iter < PBKDF2Iterations {
t.Errorf("iter = %d, want at least %d", tok.Iter, PBKDF2Iterations)
}
}
// Two people choosing the same passphrase must not produce the same stored
// hash, or cracking one would crack both.
func TestChosenTokensAreSaltedIndividually(t *testing.T) {
a, err := NewChosen("a", "the-same-passphrase")
if err != nil {
t.Fatal(err)
}
b, err := NewChosen("b", "the-same-passphrase")
if err != nil {
t.Fatal(err)
}
if a.Salt == b.Salt {
t.Error("two entries share a salt")
}
if a.Hash == b.Hash {
t.Error("the same passphrase produced the same hash under two entries")
}
if !a.Verify("the-same-passphrase") || !b.Verify("the-same-passphrase") {
t.Error("a salted entry does not verify its own passphrase")
}
}
func TestChosenTokenLengthIsEnforced(t *testing.T) {
// Derived from the constant rather than written out, so tuning the floor
// stays a one-line change instead of a puzzle about which literals moved.
for _, short := range []string{"", strings.Repeat("a", MinChosenLength-1)} {
if _, err := NewChosen("n", short); err != ErrTokenTooShort {
t.Errorf("NewChosen(%d chars) = %v, want ErrTokenTooShort", len(short), err)
}
}
if _, err := NewChosen("n", strings.Repeat("a", MinChosenLength)); err != nil {
t.Errorf("a token at the minimum length was refused: %v", err)
}
if _, err := NewChosen("n", strings.Repeat("a", 300)); err != ErrTokenTooLong {
t.Error("an absurdly long token was accepted")
}
}
// Generated tokens must keep the cheap path: they are 256-bit random values,
// so a derivation would buy nothing and cost a great deal.
func TestGeneratedTokensStayOnTheFastPath(t *testing.T) {
f := newFile(t)
tok, secret, err := NewGenerated("script")
if err != nil {
t.Fatal(err)
}
if tok.Chosen() {
t.Error("a generated token was marked as chosen")
}
if tok.KDF != "" || tok.Salt != "" {
t.Error("a generated token carries derivation parameters it does not need")
}
if err := f.Add(tok); err != nil {
t.Fatal(err)
}
if !f.Resolved(secret) {
t.Error("a generated token needs slow work to resolve")
}
if got := f.Lookup(secret); got == nil || got.Name != "script" {
t.Fatalf("Lookup = %v", got)
}
}
// Resolved is what lets the server decide whether to charge for the work, so
// it has to be honest in both directions.
func TestResolvedTracksWhatIsMemoised(t *testing.T) {
f := newFile(t)
const passphrase = "a-chosen-passphrase"
tok, err := NewChosen("thayol", passphrase)
if err != nil {
t.Fatal(err)
}
if err := f.Add(tok); err != nil {
t.Fatal(err)
}
if f.Resolved(passphrase) {
t.Error("an underived passphrase reported as already resolved")
}
f.Lookup(passphrase)
if !f.Resolved(passphrase) {
t.Error("a derived passphrase was not memoised")
}
// Negative results are memoised too, so repeated junk stays cheap.
f.Lookup("junk-that-is-wrong")
if !f.Resolved("junk-that-is-wrong") {
t.Error("a failed derivation was not memoised")
}
// Reloading invalidates the memo, since the entries may have changed.
if err := f.Reload(); err != nil {
t.Fatal(err)
}
if f.Resolved(passphrase) {
t.Error("the memo survived a reload of the token file")
}
}