Initial commit

This commit is contained in:
2026-09-12 23:26:07 +02:00
commit a180fe4b52
35 changed files with 4921 additions and 0 deletions
+347
View File
@@ -0,0 +1,347 @@
// Package auth manages the named upload tokens and resolves the effective
// limits for a request.
package auth
import (
"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")
)
// 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"`
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"`
maxSize *int64
maxExpiry *time.Duration
defaultExpiry *time.Duration
}
// 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)
}
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 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.
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
}
// 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
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{}}
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))
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
}
f.mu.Lock()
defer f.mu.Unlock()
f.byHash, f.byName = byHash, byName
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()
}
// Lookup resolves a presented secret to its token, or nil.
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
}
return t
}
// 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.byHash[t.Hash] = t
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()
t, ok := f.byName[name]
if !ok {
return ErrNotFound
}
delete(f.byName, name)
delete(f.byHash, t.Hash)
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
}