Initial commit
This commit is contained in:
@@ -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
|
||||
}
|
||||
@@ -0,0 +1,186 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"send/internal/config"
|
||||
)
|
||||
|
||||
func defaults() *config.Config {
|
||||
return &config.Config{
|
||||
MaxSize: 2 << 30,
|
||||
MaxExpiry: 72 * time.Hour,
|
||||
DefaultExpiry: 72 * time.Hour,
|
||||
}
|
||||
}
|
||||
|
||||
func newFile(t *testing.T) *File {
|
||||
t.Helper()
|
||||
f, err := Load(filepath.Join(t.TempDir(), "tokens.json"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return f
|
||||
}
|
||||
|
||||
func TestMissingFileIsNotAnError(t *testing.T) {
|
||||
f := newFile(t)
|
||||
if len(f.List()) != 0 {
|
||||
t.Error("a missing token file produced tokens")
|
||||
}
|
||||
if f.Lookup("anything") != nil {
|
||||
t.Error("a missing token file authenticated something")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAddLookupRemove(t *testing.T) {
|
||||
f := newFile(t)
|
||||
secret := "0123456789abcdef0123456789abcdef"
|
||||
if err := f.Add(&Token{Name: "friend", Hash: HashSecret(secret), AllowVanity: true}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
tok := f.Lookup(secret)
|
||||
if tok == nil || tok.Name != "friend" {
|
||||
t.Fatalf("Lookup(secret) = %v", tok)
|
||||
}
|
||||
if f.Lookup("wrong") != nil || f.Lookup("") != nil {
|
||||
t.Error("an unknown secret authenticated")
|
||||
}
|
||||
|
||||
// A second token with the same name is refused.
|
||||
if err := f.Add(&Token{Name: "friend", Hash: HashSecret("other")}); err != ErrExists {
|
||||
t.Errorf("duplicate name => %v, want ErrExists", err)
|
||||
}
|
||||
|
||||
if err := f.Remove("friend"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if f.Lookup(secret) != nil {
|
||||
t.Error("a removed token still authenticates")
|
||||
}
|
||||
if err := f.Remove("friend"); err != ErrNotFound {
|
||||
t.Errorf("removing twice => %v, want ErrNotFound", err)
|
||||
}
|
||||
}
|
||||
|
||||
// The token file holds credential material, so it is the one thing in the data
|
||||
// directory that must stay owner-only.
|
||||
func TestFilePermissions(t *testing.T) {
|
||||
f := newFile(t)
|
||||
if err := f.Add(&Token{Name: "a", Hash: HashSecret("s")}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
info, err := os.Stat(f.Path())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if perm := info.Mode().Perm(); perm != 0o600 {
|
||||
t.Errorf("token file mode = %#o, want 0600", perm)
|
||||
}
|
||||
|
||||
// A file loosened by hand must be refused rather than silently used.
|
||||
if err := os.Chmod(f.Path(), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := Load(f.Path()); err == nil {
|
||||
t.Error("a world-readable token file was accepted")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLimitsInheritDefaults(t *testing.T) {
|
||||
c := defaults()
|
||||
|
||||
// A token with nothing set behaves like the anonymous tier, except that it
|
||||
// has a name and may claim vanity names.
|
||||
bare := &Token{Name: "bare", Hash: HashSecret("bare"), AllowVanity: true}
|
||||
got := bare.Limits(c)
|
||||
want := Anonymous(c)
|
||||
want.Name, want.AllowVanity = "bare", true
|
||||
if got != want {
|
||||
t.Errorf("bare token limits = %+v, want %+v", got, want)
|
||||
}
|
||||
|
||||
// Overrides win, including "unlimited".
|
||||
size, expiry := "8GiB", "never"
|
||||
rich := &Token{Name: "rich", Hash: HashSecret("rich"), MaxSize: &size, MaxExpiry: &expiry}
|
||||
if err := rich.resolve(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
l := rich.Limits(c)
|
||||
if l.MaxSize != 8<<30 {
|
||||
t.Errorf("MaxSize = %d, want 8GiB", l.MaxSize)
|
||||
}
|
||||
if l.MaxExpiry != config.Unlimited {
|
||||
t.Errorf("MaxExpiry = %s, want unlimited", l.MaxExpiry)
|
||||
}
|
||||
// The inherited 3d default is still fine under an unlimited maximum.
|
||||
if l.DefaultExpiry != c.DefaultExpiry {
|
||||
t.Errorf("DefaultExpiry = %s, want the inherited %s", l.DefaultExpiry, c.DefaultExpiry)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDefaultExpiryIsClampedToTheMaximum(t *testing.T) {
|
||||
c := defaults()
|
||||
short := "1h"
|
||||
// A token that narrows its maximum below the inherited default must not
|
||||
// end up handing out the longer inherited lifetime.
|
||||
tok := &Token{Name: "short", Hash: HashSecret("short"), MaxExpiry: &short}
|
||||
if err := tok.resolve(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if l := tok.Limits(c); l.DefaultExpiry != time.Hour {
|
||||
t.Errorf("DefaultExpiry = %s, want it clamped to 1h", l.DefaultExpiry)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMalformedTokenFileIsRejected(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "tokens.json")
|
||||
|
||||
for _, body := range []string{
|
||||
`[{"name":"a","hash":"not-hex"}]`,
|
||||
`[{"name":"","hash":"` + HashSecret("s") + `"}]`,
|
||||
`[{"name":"a","hash":"` + HashSecret("s") + `","max_size":"lots"}]`,
|
||||
`[{"name":"a","hash":"` + HashSecret("s") + `","max_expiry":"soon"}]`,
|
||||
`[{"name":"a","hash":"` + HashSecret("1") + `"},{"name":"a","hash":"` + HashSecret("2") + `"}]`,
|
||||
`not json`,
|
||||
} {
|
||||
if err := os.WriteFile(path, []byte(body), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := Load(path); err == nil {
|
||||
t.Errorf("accepted a malformed token file: %s", body)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestReloadPicksUpChanges(t *testing.T) {
|
||||
f := newFile(t)
|
||||
secret := "aaaa"
|
||||
if err := f.Add(&Token{Name: "a", Hash: HashSecret(secret)}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// Simulate an edit by another process.
|
||||
body := `[{"name":"b","hash":"` + HashSecret("bbbb") + `","allow_vanity":true}]`
|
||||
if err := os.WriteFile(f.Path(), []byte(body), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// Ensure the mtime actually differs on filesystems with coarse timestamps.
|
||||
future := time.Now().Add(time.Second)
|
||||
os.Chtimes(f.Path(), future, future)
|
||||
|
||||
if err := f.MaybeReload(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if f.Lookup(secret) != nil {
|
||||
t.Error("a removed token still authenticates after a reload")
|
||||
}
|
||||
if tok := f.Lookup("bbbb"); tok == nil || !tok.AllowVanity {
|
||||
t.Error("the newly written token was not picked up")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user