607 lines
17 KiB
Go
607 lines
17 KiB
Go
// Package auth manages the named upload tokens and resolves the effective
|
|
// limits for a request.
|
|
package auth
|
|
|
|
import (
|
|
"crypto/pbkdf2"
|
|
"crypto/rand"
|
|
"crypto/sha256"
|
|
"crypto/subtle"
|
|
"encoding/hex"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"io/fs"
|
|
"os"
|
|
"path/filepath"
|
|
"sort"
|
|
"sync"
|
|
"time"
|
|
|
|
"send/internal/config"
|
|
)
|
|
|
|
// tokenFilePerm is deliberately stricter than the rest of the data directory:
|
|
// this is the one file holding credential material.
|
|
const tokenFilePerm fs.FileMode = 0o600
|
|
|
|
var (
|
|
ErrNotFound = errors.New("no such token")
|
|
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"`
|
|
|
|
// 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"`
|
|
DefaultExpiry *string `json:"default_expiry,omitempty"`
|
|
AllowVanity bool `json:"allow_vanity"`
|
|
Admin bool `json:"admin"`
|
|
Created time.Time `json:"created"`
|
|
Rotated time.Time `json:"rotated,omitempty"`
|
|
|
|
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
|
|
// malformed token file is rejected at startup rather than mid-upload.
|
|
func (t *Token) resolve() error {
|
|
if t.Name == "" {
|
|
return errors.New("token has no name")
|
|
}
|
|
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 {
|
|
return fmt.Errorf("token %q: max_size: %w", t.Name, err)
|
|
}
|
|
t.maxSize = &n
|
|
}
|
|
if t.MaxExpiry != nil {
|
|
d, err := config.ParseDuration(*t.MaxExpiry)
|
|
if err != nil {
|
|
return fmt.Errorf("token %q: max_expiry: %w", t.Name, err)
|
|
}
|
|
t.maxExpiry = &d
|
|
}
|
|
if t.DefaultExpiry != nil {
|
|
d, err := config.ParseDuration(*t.DefaultExpiry)
|
|
if err != nil {
|
|
return fmt.Errorf("token %q: default_expiry: %w", t.Name, err)
|
|
}
|
|
t.defaultExpiry = &d
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// Limits is the effective permission set for one request.
|
|
type Limits struct {
|
|
Name string // "" for an anonymous caller
|
|
MaxSize int64
|
|
MaxExpiry time.Duration
|
|
DefaultExpiry time.Duration
|
|
AllowVanity bool
|
|
Admin bool
|
|
}
|
|
|
|
func (l Limits) Anonymous() bool { return l.Name == "" }
|
|
|
|
// Anonymous returns the limits applied to a caller presenting no credentials.
|
|
func Anonymous(c *config.Config) Limits {
|
|
return Limits{
|
|
MaxSize: c.MaxSize,
|
|
MaxExpiry: c.MaxExpiry,
|
|
DefaultExpiry: c.DefaultExpiry,
|
|
}
|
|
}
|
|
|
|
// Limits resolves a token's permissions against the server defaults. A field
|
|
// the token does not set is inherited, so "the same as anonymous unless
|
|
// configured otherwise" needs no special casing.
|
|
func (t *Token) Limits(c *config.Config) Limits {
|
|
l := Anonymous(c)
|
|
l.Name = t.Name
|
|
l.AllowVanity = t.AllowVanity
|
|
l.Admin = t.Admin
|
|
if t.maxSize != nil {
|
|
l.MaxSize = *t.maxSize
|
|
}
|
|
if t.maxExpiry != nil {
|
|
l.MaxExpiry = *t.maxExpiry
|
|
}
|
|
if t.defaultExpiry != nil {
|
|
l.DefaultExpiry = *t.defaultExpiry
|
|
}
|
|
// An inherited default longer than an explicitly widened maximum would be
|
|
// surprising; clamp rather than reject, since the token file is trusted.
|
|
if l.MaxExpiry != config.Unlimited &&
|
|
(l.DefaultExpiry == config.Unlimited || l.DefaultExpiry > l.MaxExpiry) {
|
|
l.DefaultExpiry = l.MaxExpiry
|
|
}
|
|
return l
|
|
}
|
|
|
|
// 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[:])
|
|
}
|
|
|
|
// EqualHash compares two digests without an early exit.
|
|
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 // 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
|
|
}
|
|
|
|
// 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{},
|
|
verified: map[string]*Token{},
|
|
}
|
|
if err := f.Reload(); err != nil {
|
|
return nil, err
|
|
}
|
|
return f, nil
|
|
}
|
|
|
|
func (f *File) Path() string { return f.path }
|
|
|
|
func (f *File) read() ([]*Token, os.FileInfo, error) {
|
|
info, err := os.Stat(f.path)
|
|
if errors.Is(err, fs.ErrNotExist) {
|
|
return nil, nil, nil
|
|
}
|
|
if err != nil {
|
|
return nil, nil, err
|
|
}
|
|
// Refuse to use credentials the rest of the system can read.
|
|
if perm := info.Mode().Perm(); perm&0o077 != 0 {
|
|
return nil, nil, fmt.Errorf("%s has mode %#o; it must not be group- or world-accessible (chmod 600)", f.path, perm)
|
|
}
|
|
b, err := os.ReadFile(f.path)
|
|
if err != nil {
|
|
return nil, nil, err
|
|
}
|
|
var tokens []*Token
|
|
if err := json.Unmarshal(b, &tokens); err != nil {
|
|
return nil, nil, fmt.Errorf("%s: %w", f.path, err)
|
|
}
|
|
for _, t := range tokens {
|
|
if err := t.resolve(); err != nil {
|
|
return nil, nil, fmt.Errorf("%s: %w", f.path, err)
|
|
}
|
|
}
|
|
return tokens, info, nil
|
|
}
|
|
|
|
// Reload re-reads the token file unconditionally.
|
|
func (f *File) Reload() error {
|
|
tokens, info, err := f.read()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
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)
|
|
}
|
|
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, f.chosen = byHash, byName, chosen
|
|
clear(f.verified)
|
|
if info != nil {
|
|
f.modTime, f.size = info.ModTime(), info.Size()
|
|
} else {
|
|
f.modTime, f.size = time.Time{}, 0
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// MaybeReload re-reads the file only if it looks changed. It is cheap enough to
|
|
// call on every authenticated request.
|
|
func (f *File) MaybeReload() error {
|
|
info, err := os.Stat(f.path)
|
|
if errors.Is(err, fs.ErrNotExist) {
|
|
f.mu.RLock()
|
|
empty := len(f.byHash) == 0
|
|
f.mu.RUnlock()
|
|
if empty {
|
|
return nil
|
|
}
|
|
return f.Reload()
|
|
}
|
|
if err != nil {
|
|
return err
|
|
}
|
|
f.mu.RLock()
|
|
unchanged := info.ModTime().Equal(f.modTime) && info.Size() == f.size
|
|
f.mu.RUnlock()
|
|
if unchanged {
|
|
return nil
|
|
}
|
|
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()
|
|
if t, ok := f.byHash[h]; ok && EqualHash(t.Hash, h) {
|
|
f.mu.RUnlock()
|
|
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.
|
|
func (f *File) List() []*Token {
|
|
f.mu.RLock()
|
|
defer f.mu.RUnlock()
|
|
out := make([]*Token, 0, len(f.byName))
|
|
for _, t := range f.byName {
|
|
out = append(out, t)
|
|
}
|
|
sort.Slice(out, func(i, j int) bool { return out[i].Name < out[j].Name })
|
|
return out
|
|
}
|
|
|
|
// Add appends a token and rewrites the file.
|
|
func (f *File) Add(t *Token) error {
|
|
if err := t.resolve(); err != nil {
|
|
return err
|
|
}
|
|
f.mu.Lock()
|
|
defer f.mu.Unlock()
|
|
if _, dup := f.byName[t.Name]; dup {
|
|
return ErrExists
|
|
}
|
|
f.byName[t.Name] = t
|
|
f.reindexLocked()
|
|
return f.saveLocked()
|
|
}
|
|
|
|
// Remove deletes a token by name and rewrites the file.
|
|
func (f *File) Remove(name string) error {
|
|
f.mu.Lock()
|
|
defer f.mu.Unlock()
|
|
if _, ok := f.byName[name]; !ok {
|
|
return ErrNotFound
|
|
}
|
|
delete(f.byName, name)
|
|
f.reindexLocked()
|
|
return f.saveLocked()
|
|
}
|
|
|
|
// saveLocked writes the token file atomically, with owner-only permissions.
|
|
func (f *File) saveLocked() error {
|
|
tokens := make([]*Token, 0, len(f.byName))
|
|
for _, t := range f.byName {
|
|
tokens = append(tokens, t)
|
|
}
|
|
sort.Slice(tokens, func(i, j int) bool { return tokens[i].Name < tokens[j].Name })
|
|
|
|
b, err := json.MarshalIndent(tokens, "", " ")
|
|
if err != nil {
|
|
return err
|
|
}
|
|
b = append(b, '\n')
|
|
|
|
dir := filepath.Dir(f.path)
|
|
if err := os.MkdirAll(dir, 0o775); err != nil {
|
|
return err
|
|
}
|
|
tmp, err := os.CreateTemp(dir, "."+filepath.Base(f.path)+".*")
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer os.Remove(tmp.Name())
|
|
|
|
if err := tmp.Chmod(tokenFilePerm); err != nil {
|
|
tmp.Close()
|
|
return err
|
|
}
|
|
if _, err := tmp.Write(b); err != nil {
|
|
tmp.Close()
|
|
return err
|
|
}
|
|
if err := tmp.Sync(); err != nil {
|
|
tmp.Close()
|
|
return err
|
|
}
|
|
if err := tmp.Close(); err != nil {
|
|
return err
|
|
}
|
|
if err := os.Rename(tmp.Name(), f.path); err != nil {
|
|
return err
|
|
}
|
|
info, err := os.Stat(f.path)
|
|
if err == nil {
|
|
f.modTime, f.size = info.ModTime(), info.Size()
|
|
}
|
|
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) }
|
|
|
|
// reindexLocked rebuilds the lookup structures from byName, which is the one
|
|
// that always holds every entry. Callers hold the write lock.
|
|
//
|
|
// The slices are rebuilt rather than reused: Lookup takes a reference to
|
|
// f.chosen under a read lock and then iterates it without one, so writing into
|
|
// the old backing array would be a race.
|
|
func (f *File) reindexLocked() {
|
|
byHash := make(map[string]*Token, len(f.byName))
|
|
var chosen []*Token
|
|
for _, t := range f.byName {
|
|
if t.Chosen() {
|
|
chosen = append(chosen, t)
|
|
} else {
|
|
byHash[t.Hash] = t
|
|
}
|
|
}
|
|
f.byHash, f.chosen = byHash, chosen
|
|
// Any memoised verification may now refer to a secret that has changed.
|
|
clear(f.verified)
|
|
}
|
|
|
|
// Update applies a change to an existing token, keeping everything the change
|
|
// does not touch. This is what makes rotating a secret possible without
|
|
// destroying the limits, flags and history attached to the name.
|
|
//
|
|
// The mutation runs against a copy, so a change that turns out to be invalid
|
|
// leaves the stored token exactly as it was.
|
|
func (f *File) Update(name string, mutate func(*Token) error) error {
|
|
f.mu.Lock()
|
|
defer f.mu.Unlock()
|
|
|
|
t, ok := f.byName[name]
|
|
if !ok {
|
|
return ErrNotFound
|
|
}
|
|
clone := *t
|
|
if err := mutate(&clone); err != nil {
|
|
return err
|
|
}
|
|
if err := clone.resolve(); err != nil {
|
|
return err
|
|
}
|
|
*t = clone
|
|
f.reindexLocked()
|
|
return f.saveLocked()
|
|
}
|
|
|
|
// SetChosen replaces the token's secret with a passphrase, re-salting it. Any
|
|
// session or script still presenting the old secret stops authenticating.
|
|
func (t *Token) SetChosen(secret string) error {
|
|
replacement, err := NewChosen(t.Name, secret)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
t.KDF, t.Salt, t.Iter, t.Hash, t.salt = replacement.KDF, replacement.Salt,
|
|
replacement.Iter, replacement.Hash, replacement.salt
|
|
t.Rotated = now()
|
|
return nil
|
|
}
|
|
|
|
// SetGenerated replaces the token's secret with a fresh random one, which it
|
|
// returns. The token stops being a passphrase if it was one.
|
|
func (t *Token) SetGenerated() (string, error) {
|
|
replacement, secret, err := NewGenerated(t.Name)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
t.KDF, t.Salt, t.Iter, t.salt = "", "", 0, nil
|
|
t.Hash = replacement.Hash
|
|
t.Rotated = now()
|
|
return secret, nil
|
|
}
|