// 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 }