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