Add custom token support

This commit is contained in:
2026-09-13 01:01:48 +02:00
parent 1b77cdd165
commit 48229f15a2
12 changed files with 680 additions and 305 deletions
+61 -225
View File
@@ -6,248 +6,54 @@ their own, and are served back as inert attachments.
Built for sharing large binaries with friends — a 400 MB Godot export is a
normal day here — without a `tmp/` folder that grows forever.
- One static Go binary. No database, no dependencies outside the standard
library, no CSS or JavaScript build step.
- One static Go binary, standard library only. No database, no CSS or
JavaScript build step, nothing to install.
- Anyone who can reach the page may upload, within a size cap and a lifetime.
- Named tokens raise those limits and unlock custom URLs.
- Named tokens raise those limits, unlock custom URLs, and can grant admin.
- Everything expires unless a token says otherwise.
## Building
## Quickstart
```
go build -o send .
./send token add me --token - --vanity --admin # type a passphrase, or omit
./send --port 8080 # --data defaults to ./data
```
Go 1.25 or newer. There is nothing to install: the frontend is embedded in the
binary.
Open <http://localhost:8080>, click **Log in**, paste the token. That's it.
## Running
`--token -` reads a passphrase you choose from standard input; leave the flag
off and a random one is generated and printed once. Either way `--data` must
name the *same directory* the server runs with, or the server will never see
the token.
```
./send --data /var/lib/send --listen 127.0.0.1:8080
```
It binds to loopback by default and expects to sit behind a reverse proxy that
terminates TLS. Make sure the proxy does not buffer request bodies and does not
impose its own upload size limit, or large uploads will die before they arrive.
Options take **one hyphen with a single letter** and **two with a full word**:
`-s 4GiB` and `--max-size=4GiB` are the same option, `-max-size` is an error.
Every option can also be set from the environment as `SEND_MAX_SIZE` and so on.
`./send --help` lists them all.
`--port` is a convenience over `--listen`: it replaces only the port, so
`./send -p 9000` listens on `127.0.0.1:9000` and `--listen 0.0.0.0 -p 9000`
listens on all interfaces.
| Option | Default | Meaning |
|---|---|---|
| `-l`, `--listen` | `127.0.0.1:8080` | address to listen on |
| `-p`, `--port` | — | port to listen on, replacing the one in `--listen` |
| `-d`, `--data` | `./data` | data directory |
| `-b`, `--base-url` | `/` | path prefix when mounted under a subdirectory |
| `-u`, `--public-url` | — | absolute base URL used in generated links |
| `-s`, `--max-size` | `2GiB` | per-upload cap; `unlimited` to remove it |
| `-e`, `--max-expiry` | `3d` | longest lifetime a caller may ask for; `never` to remove the cap |
| `--default-expiry` | `3d` | lifetime applied when the caller does not ask |
| `--max-total-bytes` | `unlimited` | whole-store quota |
| `--min-free-bytes` | `1GiB` | refuse uploads below this much free disk |
| `--tokens` | `<data>/tokens.json` | token file |
| `--trusted-proxy` | — | networks whose `X-Forwarded-For` is believed |
| `--sweep-interval` | `1m` | how often expired files are removed |
| `--upload-rate` | `60` | uploads per hour per client |
| `--upload-burst` | `10` | uploads allowed back-to-back |
| `--max-concurrent` | `8` | uploads in flight at once |
Sizes accept `2GiB`, `500MB`, `4G` or a plain byte count. Durations accept `3d`,
`12h`, `90m`, `1w` or `never`.
## Tokens
A token grants its own limits. Anything left unset is inherited from the
server's defaults, so a bare token behaves exactly like the anonymous tier
except that it may claim custom names.
```
./send token add thayol --vanity --max-size 8GiB --max-expiry never
./send token list
./send token rm thayol
```
The token is printed once and never again; only its SHA-256 is stored. Send it
as `Authorization: Bearer <token>`, or paste it into the form's token field.
`tokens.json` may also be edited by hand; the server picks up changes on its own
and on `SIGHUP`. It must stay mode `0600` — the server refuses to start
otherwise, since it holds credential material.
### Logging in
Open **Log in**, paste a token, and the browser keeps it until you log out. The
header then shows who you are, the upload page shows your real limits, and
**Administration** appears if the token is an admin one. Tick *stay logged in*
and it survives a browser restart; leave it unticked and it dies with the
browser, which is the right choice on a machine that is not yours.
The upload form also keeps a collapsed **Use a different token for this upload**
field. That one applies to a single upload and never changes the session, so a
quick upload under another token does not mean logging in and out.
The session cookie is `HttpOnly`, so the page's own script cannot read it — the
server resolves the session and renders it. That is deliberately unlike
`localStorage`, where any script injected into the origin could read the token
straight out. It is also `SameSite=Strict`, so no other site can make your
browser upload or delete anything with it attached.
Nothing about the session involves JavaScript: every page states who you are
because the server rendered it that way. Callers sending `Authorization: Bearer`
are never given a cookie; an API client keeps its own credentials.
## Uploading
From the browser, just use the page. It works with JavaScript disabled; with it
enabled you get a progress bar, drag-and-drop and a copy-link button.
From the command line, `POST /api/upload` with the file as the whole body:
Uploading from a script:
```
curl --data-binary @MyGame.zip \
-H 'Content-Disposition: attachment; filename="MyGame.zip"' \
-H 'Authorization: Bearer <token>' \
-H 'Vanity: my-game' \
-H 'Expiry: 7d' \
-H 'Accept: application/json' \
https://send.example.com/api/upload
-H 'Vanity: my-game' -H 'Expiry: 7d' \
http://localhost:8080/api/upload
```
`Content-Disposition`, `Vanity` and `Expiry` are all optional. Without a vanity
name you get a UUIDv4; vanity names require a token. These headers work with a
`multipart/form-data` body too, where a non-empty form field of the same name
overrides them. The reply carries the
download URL and a **delete token**, shown exactly once:
The reply carries the download URL and a delete token. Use a generated token
for scripts — a chosen passphrase is verified with a deliberately slow
derivation, which is wasted on every request a script makes.
```json
{
"id": "my-game",
"url": "https://send.example.com/d/my-game",
"expires": "2026-09-19T10:00:00Z",
"delete_token": "…",
"delete_url": "https://send.example.com/api/d/my-game/delete"
}
```
In production, put it behind a reverse proxy that terminates TLS, set
`--public-url`, and make sure the proxy neither buffers request bodies nor
imposes its own upload limit.
The same endpoint accepts `multipart/form-data` from the web form. In that shape
the `token`, `expiry` and `vanity` fields **must precede the file part** — the
body is streamed, so the limits have to be known before the first byte of the
file is accepted.
## Options
## Downloading and deleting
**Read `./send --help` rather than this file.** It lists every option with its
default and its environment variable, and unlike a README it cannot drift out
of date. `./send token --help` does the same for credentials.
| Route | |
|---|---|
| `GET /d/{id}` | the file, as an attachment; supports resuming |
| `GET /i/{id}` | a page showing name, size, expiry, digest — and where a delete token is used |
| `POST /api/d/{id}/delete` | delete, with `token=` in the form or `Authorization: Bearer` |
| `GET /login`, `POST /login` | start a browser session with a token |
| `POST /logout` | end it |
| `GET /admin` | administration page; admin tokens only |
| `GET /api/limits` | what the presented credential may do; for scripts, the pages do not use it |
Deleting accepts the object's delete token, the token that uploaded it, or any
admin token.
The delete token is shown once, when the file is uploaded. To use it later,
open the file's info page and expand **Remove this file** — that page is the
link worth keeping, since it holds everything about the file including the way
to withdraw it. Anyone whose own token already owns the file, or who is an
admin, gets a plain button there instead of a field. A wrong token returns to
the same page with the reason rather than to a generic error, and repeated
failures are throttled per address; a correct token is never delayed by
someone else's guessing.
## Administration
An admin token adds a page at `/admin`, linked from the header whenever the
token you are using is one. It is the view that privilege is for: every stored
file, whoever uploaded it, with a delete button on each row.
- Files, with size, owner, upload time and expiry, sortable by column. Expired
files are not listed even if the sweeper has not reached them yet, since they
are already gone as far as anything else is concerned.
- Totals: how many files, how much is stored, how much of the quota is used and
how much disk is left.
- The configured tokens with their limits — names only. Hashes are never
rendered, and there is no way to mint or revoke a token from the page.
Anything that hands out credentials stays in the CLI, off the network.
Deleting from the table returns to the table. A non-admin gets `403` and never
sees the link.
## Data directory
```
<data>/
objects/<id>/blob the bytes 0664
objects/<id>/meta.json name, size, digest, expiry 0664
tokens.json credential hashes 0600
```
Objects and their metadata are group-writable and world-readable, so a second
account or a cleanup script can manage them. `tokens.json` is the deliberate
exception.
There is no index to corrupt: the in-memory index is rebuilt from `meta.json`
files at startup, and an object directory without one is either mid-upload or
the remains of a killed one, invisible either way and swept after 24 hours.
## Notes on the security posture
Worth knowing if you are going to run this somewhere real.
- **Uploads are never sized by what the client claims.** `Content-Length` is
not consulted anywhere; the cap is enforced on bytes actually written, and the
transfer is cut off the moment it is exceeded.
- **Nothing served from `/d/` can execute.** `Content-Type` is always
`application/octet-stream`, the disposition is always `attachment`, and the
response carries `nosniff` and `default-src 'none'; sandbox`. Upload an HTML
file and a browser will download it, not render it.
- **Filenames are metadata, never paths.** Every path is built from a validated
ID: lowercase, 2–64 characters, no separators, no traversal, no route names.
The uploaded filename only reaches the `Content-Disposition` header, where the
ASCII form is built from a character whitelist and the real name rides along
in an RFC 5987 parameter.
- **Object files are opened through an `os.Root`.** Because the data directory
is group-writable, a planted symlink could otherwise redirect a read or a
write outside it. It cannot.
- **Expiry is checked on every read**, not only by the sweeper, so a stalled
sweeper can never serve a file past its lifetime. A missing file and an
expired one give identical 404s.
- **Uploads are published atomically**: the blob is fsynced and renamed into
place before the metadata that advertises it is written, also by rename. A
crash at any point leaves something invisible rather than something broken.
- **Vanity names are claimed before the body is read**, so a collision costs one
round trip rather than a 2 GiB transfer.
- **Slow uploads are fine, stalled ones are not.** There is no server-wide read
timeout — any value large enough for a legitimate 2 GiB upload would be no
protection — so the upload handler maintains a per-read deadline instead.
- **`X-Forwarded-For` is ignored** unless the peer is a configured
`--trusted-proxy`, and then only to skip further trusted hops.
- **The session lives in an `HttpOnly`, `SameSite=Strict` cookie**, not in
`localStorage`, so neither an injected script nor another website can get at
it. `Secure` is set whenever the service knows it is being served over
HTTPS — from `--public-url`, from a TLS connection, or from a trusted proxy's
`X-Forwarded-Proto`.
- **Every `POST` must be same-origin.** `SameSite` covers requests that need a
cookie, but logging in needs none: without this check a hostile page could
sign a visitor into an account it controls and collect what they upload next.
Browsers label their own requests with `Sec-Fetch-Site`, falling back to
`Origin`; a request carrying neither is not a browser and is allowed through,
since a bearer token cannot be attached by a third party anyway.
- **Failed credential attempts are throttled per address** — a wrong delete
token or a wrong login — while correct ones are never delayed, because only
failures consume the budget.
- Rate limiting is per client address, with a separate bound on uploads in
flight. Both are in memory and reset on restart.
Options take **one hyphen with a single letter** and **two with a full word**:
`-s 4GiB` and `--max-size=4GiB` are the same option; `-max-size` is an error.
Every option also reads from `SEND_`-prefixed environment variables.
## Development
@@ -256,7 +62,37 @@ go test ./...
go vet ./...
```
The tests cover the parts where a mistake would be expensive: size limits
against a chunked body with no declared length, vanity collisions, expiry on
read, traversal and reserved names, `Content-Disposition` for hostile filenames,
delete authorisation, symlink escapes and crash debris.
Layout: `main.go` and `token.go` are the CLI; `internal/config` parses options;
`internal/store` is the object store; `internal/auth` is credentials;
`internal/server` is the HTTP surface; `web/` holds the templates and assets,
embedded at build time.
A few invariants worth knowing before changing anything:
- **Upload size is never taken from `Content-Length`.** It is enforced on bytes
actually written, and the transfer is cut off the moment it is exceeded.
- **Nothing served from `/d/` may execute.** Always `application/octet-stream`,
always an attachment, always `nosniff` and `default-src 'none'; sandbox`.
- **Filenames are metadata, never paths.** Every path is built from a validated
ID. `store.CleanID` is the only function allowed to turn input into a path
element, and object files are opened through an `os.Root`.
- **Expiry is checked on every read**, not only by the sweeper, and a missing
file and an expired one answer identically.
- **Uploads are published atomically**: the blob is fsynced and renamed into
place before the metadata that advertises it, also by rename. A crash leaves
something invisible rather than something broken.
- **Generated tokens are hashed with SHA-256; chosen ones get PBKDF2** with
their own salt. A 256-bit random value has nothing to crack, while a
passphrase is guessable and probably reused elsewhere. The derivation is
memoised per process and rate limited, so it cannot be used as an amplifier.
- **The session cookie is `HttpOnly` and `SameSite=Strict`**, and every `POST`
must be same-origin — `SameSite` does not cover logging in, which needs no
cookie to submit.
- **The app pages and the download responses have different CSP policies.**
The app one must permit `connect-src` or the upload script is blocked; the
download one must grant nothing at all. Both are pinned by tests.
The tests cover the places where a mistake is expensive: size limits against a
body with no declared length, vanity collisions, expiry on read, traversal and
reserved names, `Content-Disposition` for hostile filenames, delete
authorisation, symlink escapes, crash debris, CSRF, and credential handling.
+211 -17
View File
@@ -3,6 +3,8 @@
package auth
import (
"crypto/pbkdf2"
"crypto/rand"
"crypto/sha256"
"crypto/subtle"
"encoding/hex"
@@ -12,6 +14,7 @@ import (
"io/fs"
"os"
"path/filepath"
"slices"
"sort"
"sync"
"time"
@@ -28,10 +31,39 @@ var (
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"`
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"`
@@ -43,6 +75,30 @@ type Token struct {
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
@@ -54,6 +110,23 @@ func (t *Token) resolve() error {
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 {
@@ -125,10 +198,11 @@ func (t *Token) Limits(c *config.Config) Limits {
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.
// 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[:])
@@ -139,13 +213,24 @@ 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
byName map[string]*Token
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
}
@@ -153,7 +238,12 @@ type File struct {
// 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{}}
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
}
@@ -198,17 +288,23 @@ func (f *File) Reload() error {
}
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)
}
byHash[t.Hash] = t
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 = byHash, byName
f.byHash, f.byName, f.chosen = byHash, byName, chosen
clear(f.verified)
if info != nil {
f.modTime, f.size = info.ModTime(), info.Size()
} else {
@@ -242,19 +338,63 @@ func (f *File) MaybeReload() error {
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()
defer f.mu.RUnlock()
t, ok := f.byHash[h]
if !ok || !EqualHash(t.Hash, h) {
return nil
if t, ok := f.byHash[h]; ok && EqualHash(t.Hash, h) {
f.mu.RUnlock()
return t
}
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.
@@ -280,7 +420,12 @@ func (f *File) Add(t *Token) error {
return ErrExists
}
f.byName[t.Name] = t
f.byHash[t.Hash] = t
if t.Chosen() {
f.chosen = append(f.chosen, t)
} else {
f.byHash[t.Hash] = t
}
clear(f.verified)
return f.saveLocked()
}
@@ -294,6 +439,8 @@ func (f *File) Remove(name string) error {
}
delete(f.byName, name)
delete(f.byHash, t.Hash)
f.chosen = slices.DeleteFunc(f.chosen, func(c *Token) bool { return c == t })
clear(f.verified)
return f.saveLocked()
}
@@ -345,3 +492,50 @@ func (f *File) saveLocked() error {
}
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) }
+155
View File
@@ -3,6 +3,7 @@ package auth
import (
"os"
"path/filepath"
"strings"
"testing"
"time"
@@ -184,3 +185,157 @@ func TestReloadPicksUpChanges(t *testing.T) {
t.Error("the newly written token was not picked up")
}
}
// --- chosen tokens -------------------------------------------------------
func TestChosenTokenRoundTrip(t *testing.T) {
f := newFile(t)
const passphrase = "godot-friends-2026"
tok, err := NewChosen("thayol", passphrase)
if err != nil {
t.Fatal(err)
}
if err := f.Add(tok); err != nil {
t.Fatal(err)
}
if got := f.Lookup(passphrase); got == nil || got.Name != "thayol" {
t.Fatalf("Lookup(passphrase) = %v", got)
}
if f.Lookup(passphrase+"x") != nil || f.Lookup("") != nil {
t.Error("a wrong passphrase authenticated")
}
}
// A chosen passphrase is guessable and probably reused, so the file must not
// give it up to anyone who reads it.
func TestChosenTokensAreNotStoredUnderAFastDigest(t *testing.T) {
f := newFile(t)
const passphrase = "correct-horse-battery"
tok, err := NewChosen("thayol", passphrase)
if err != nil {
t.Fatal(err)
}
if err := f.Add(tok); err != nil {
t.Fatal(err)
}
raw, err := os.ReadFile(f.Path())
if err != nil {
t.Fatal(err)
}
body := string(raw)
if strings.Contains(body, passphrase) {
t.Fatal("the passphrase is stored in the clear")
}
if strings.Contains(body, HashSecret(passphrase)) {
t.Fatal("the passphrase is stored under a plain sha256, which a wordlist breaks")
}
if !strings.Contains(body, KDFPBKDF2) {
t.Error("the entry does not record which derivation was used")
}
if tok.Iter < PBKDF2Iterations {
t.Errorf("iter = %d, want at least %d", tok.Iter, PBKDF2Iterations)
}
}
// Two people choosing the same passphrase must not produce the same stored
// hash, or cracking one would crack both.
func TestChosenTokensAreSaltedIndividually(t *testing.T) {
a, err := NewChosen("a", "the-same-passphrase")
if err != nil {
t.Fatal(err)
}
b, err := NewChosen("b", "the-same-passphrase")
if err != nil {
t.Fatal(err)
}
if a.Salt == b.Salt {
t.Error("two entries share a salt")
}
if a.Hash == b.Hash {
t.Error("the same passphrase produced the same hash under two entries")
}
if !a.Verify("the-same-passphrase") || !b.Verify("the-same-passphrase") {
t.Error("a salted entry does not verify its own passphrase")
}
}
func TestChosenTokenLengthIsEnforced(t *testing.T) {
// Derived from the constant rather than written out, so tuning the floor
// stays a one-line change instead of a puzzle about which literals moved.
for _, short := range []string{"", strings.Repeat("a", MinChosenLength-1)} {
if _, err := NewChosen("n", short); err != ErrTokenTooShort {
t.Errorf("NewChosen(%d chars) = %v, want ErrTokenTooShort", len(short), err)
}
}
if _, err := NewChosen("n", strings.Repeat("a", MinChosenLength)); err != nil {
t.Errorf("a token at the minimum length was refused: %v", err)
}
if _, err := NewChosen("n", strings.Repeat("a", 300)); err != ErrTokenTooLong {
t.Error("an absurdly long token was accepted")
}
}
// Generated tokens must keep the cheap path: they are 256-bit random values,
// so a derivation would buy nothing and cost a great deal.
func TestGeneratedTokensStayOnTheFastPath(t *testing.T) {
f := newFile(t)
tok, secret, err := NewGenerated("script")
if err != nil {
t.Fatal(err)
}
if tok.Chosen() {
t.Error("a generated token was marked as chosen")
}
if tok.KDF != "" || tok.Salt != "" {
t.Error("a generated token carries derivation parameters it does not need")
}
if err := f.Add(tok); err != nil {
t.Fatal(err)
}
if !f.Resolved(secret) {
t.Error("a generated token needs slow work to resolve")
}
if got := f.Lookup(secret); got == nil || got.Name != "script" {
t.Fatalf("Lookup = %v", got)
}
}
// Resolved is what lets the server decide whether to charge for the work, so
// it has to be honest in both directions.
func TestResolvedTracksWhatIsMemoised(t *testing.T) {
f := newFile(t)
const passphrase = "a-chosen-passphrase"
tok, err := NewChosen("thayol", passphrase)
if err != nil {
t.Fatal(err)
}
if err := f.Add(tok); err != nil {
t.Fatal(err)
}
if f.Resolved(passphrase) {
t.Error("an underived passphrase reported as already resolved")
}
f.Lookup(passphrase)
if !f.Resolved(passphrase) {
t.Error("a derived passphrase was not memoised")
}
// Negative results are memoised too, so repeated junk stays cheap.
f.Lookup("junk-that-is-wrong")
if !f.Resolved("junk-that-is-wrong") {
t.Error("a failed derivation was not memoised")
}
// Reloading invalidates the memo, since the entries may have changed.
if err := f.Reload(); err != nil {
t.Fatal(err)
}
if f.Resolved(passphrase) {
t.Error("the memo survived a reload of the token file")
}
}
+1 -1
View File
@@ -60,7 +60,7 @@ func (c *Config) Register(s *Set) {
s.Size(&c.MaxTotalBytes, "max-total-bytes", "", "unlimited",
"refuse uploads once stored data exceeds this total")
s.Size(&c.MinFreeBytes, "min-free-bytes", "", "1GiB",
s.Size(&c.MinFreeBytes, "min-free-bytes", "", "10GiB",
"refuse uploads when the filesystem has less free space than this")
s.String(&c.TokensPath, "tokens", "", "", "FILE",
"token file location (default <data>/tokens.json)")
+1 -1
View File
@@ -56,7 +56,7 @@ var adminSorts = map[string]func(a, b adminObject) int{
}
func (s *Server) handleAdmin(w http.ResponseWriter, r *http.Request) {
lim, err := s.limitsFor(credential(r))
lim, err := s.limitsFor(r, credential(r))
switch {
case err != nil:
s.fail(w, r, http.StatusUnauthorized, "Unrecognised token.")
+10 -12
View File
@@ -34,7 +34,7 @@ func (s *Server) handleDelete(w http.ResponseWriter, r *http.Request) {
"A delete token or an owning token is required.")
return
}
if !s.authorised(m, presented) {
if !s.authorised(r, m, presented) {
// Only failures are throttled, so a correct token is never delayed.
// The info page is publicly shareable and now carries a credential
// field, which is reason enough not to let it be hammered freely.
@@ -110,27 +110,25 @@ func (s *Server) deleteCredentials(w http.ResponseWriter, r *http.Request) []str
}
// authorised reports whether any of the presented secrets may delete m.
func (s *Server) authorised(m *store.Meta, presented []string) bool {
func (s *Server) authorised(r *http.Request, m *store.Meta, presented []string) bool {
for _, secret := range presented {
if s.mayDelete(m, secret) {
if s.mayDelete(r, m, secret) {
return true
}
}
return false
}
// mayDelete checks one secret against the object's delete token first, then
// against the token file.
func (s *Server) mayDelete(m *store.Meta, secret string) bool {
// mayDelete checks one secret against the object's delete token first - which
// is always a generated value, so that comparison is cheap - and only then
// against the token file, which may cost a derivation.
func (s *Server) mayDelete(r *http.Request, m *store.Meta, secret string) bool {
if auth.EqualHash(m.DeleteHash, auth.HashSecret(secret)) {
return true
}
if err := s.tokens.MaybeReload(); err != nil {
s.log.Error("reloading token file", "err", err)
}
t := s.tokens.Lookup(secret)
if t == nil {
lim, err := s.limitsFor(r, secret)
if err != nil {
return false
}
return t.Admin || (m.Owner != "" && t.Name == m.Owner)
return lim.Admin || (m.Owner != "" && lim.Name == m.Owner)
}
+2 -2
View File
@@ -40,7 +40,7 @@ func (s *Server) handleLoginPage(w http.ResponseWriter, r *http.Request) {
next := destination(r.URL.Query().Get("next"))
// Already logged in: say so rather than showing an empty form.
if lim, err := s.limitsFor(cookieCredential(r)); err == nil && !lim.Anonymous() {
if lim, err := s.limitsFor(r, cookieCredential(r)); err == nil && !lim.Anonymous() {
s.render(w, http.StatusOK, "login.html", loginPage{
page: s.page(r, "Log in", false),
Next: next,
@@ -70,7 +70,7 @@ func (s *Server) handleLogin(w http.ResponseWriter, r *http.Request) {
return
}
// Only failures are throttled, so logging in normally is never delayed.
lim, err := s.limitsFor(token)
lim, err := s.limitsFor(r, token)
if err != nil {
if !s.authLimiter.allow(clientIP(r, s.cfg), s.now()) {
s.loginFailed(w, r, next, http.StatusTooManyRequests,
+3 -3
View File
@@ -57,7 +57,7 @@ type indexPage struct {
func (s *Server) handleIndex(w http.ResponseWriter, r *http.Request) {
// A remembered token is resolved server-side, so the page can show the real
// limits without the cookie ever being readable by a script.
lim, err := s.limitsFor(cookieCredential(r))
lim, err := s.limitsFor(r, cookieCredential(r))
stale := false
if err != nil {
// The token was revoked or the file was edited; end the session rather
@@ -96,7 +96,7 @@ func (s *Server) handleLimits(w http.ResponseWriter, r *http.Request) {
s.fail(w, r, http.StatusTooManyRequests, "Too many requests; try again shortly.")
return
}
lim, err := s.limitsFor(credential(r))
lim, err := s.limitsFor(r, credential(r))
if err != nil {
s.fail(w, r, http.StatusUnauthorized, "Unrecognised token.")
return
@@ -154,7 +154,7 @@ func (s *Server) renderInfo(w http.ResponseWriter, r *http.Request, m *store.Met
Size: config.FormatSize(m.Size),
Expires: describeExpiry(m.Expires, s.now()),
URL: s.objectURL(r, m.ID),
CanDelete: s.mayDelete(m, credential(r)),
CanDelete: s.mayDelete(r, m, credential(r)),
Error: errMsg,
})
}
+11 -2
View File
@@ -133,13 +133,22 @@ var errBadToken = errors.New("unrecognised token")
// limitsFor resolves the effective permissions for a presented secret. An empty
// secret yields the anonymous tier.
func (s *Server) limitsFor(secret string) (auth.Limits, error) {
//
// Verifying a chosen passphrase costs a deliberately slow key derivation, which
// makes an unverified credential an amplifier: a few requests a second carrying
// junk would keep a core busy. So a request that would need that work has to
// pay for it out of the same budget as a failed login. The result is memoised,
// so a real session derives once and every later request is a map lookup.
func (s *Server) limitsFor(r *http.Request, secret string) (auth.Limits, error) {
if secret == "" {
return auth.Anonymous(s.cfg), nil
}
if err := s.tokens.MaybeReload(); err != nil {
s.log.Error("reloading token file", "err", err)
}
if !s.tokens.Resolved(secret) && !s.authLimiter.allow(clientIP(r, s.cfg), s.now()) {
return auth.Limits{}, errBadToken
}
t := s.tokens.Lookup(secret)
if t == nil {
return auth.Limits{}, errBadToken
@@ -193,7 +202,7 @@ type page struct {
// who is logged in and offer only the links they can use.
func (s *Server) page(r *http.Request, title string, script bool) page {
p := page{Base: s.cfg.BasePath, Title: title, Script: script}
if lim, err := s.limitsFor(cookieCredential(r)); err == nil {
if lim, err := s.limitsFor(r, cookieCredential(r)); err == nil {
p.User, p.Admin = lim.Name, lim.Admin
}
return p
+105
View File
@@ -1624,3 +1624,108 @@ func TestUploadPageKeepsTheOneOffTokenField(t *testing.T) {
t.Error("the form does not carry the server-rendered size limit")
}
}
// A chosen passphrase has to work everywhere a generated token does: at the
// login form, on an upload, and as a session.
func TestChosenPassphraseWorksEndToEnd(t *testing.T) {
h := newHarness(t, nil)
const passphrase = "godot-friends-2026"
tok, err := auth.NewChosen("memorable", passphrase)
if err != nil {
t.Fatal(err)
}
tok.AllowVanity = true
if err := h.tokens.Add(tok); err != nil {
t.Fatal(err)
}
resp := h.postForm(t, "/login", url.Values{"token": {passphrase}, "persist": {"1"}}, nil)
resp.Body.Close()
if resp.StatusCode != http.StatusSeeOther {
t.Fatalf("login with a passphrase => %s", resp.Status)
}
cookie := findCookie(resp, tokenCookie)
if cookie == nil {
t.Fatal("no session was started")
}
req, _ := http.NewRequest("POST", h.ts.URL+"/api/upload", strings.NewReader("x"))
req.Header.Set("Accept", "application/json")
req.Header.Set("Vanity", "chosen-upload")
req.AddCookie(cookie)
up, err := h.ts.Client().Do(req)
if err != nil {
t.Fatal(err)
}
if up.StatusCode != http.StatusCreated {
t.Fatalf("upload with a passphrase session => %s", up.Status)
}
if res := decode[uploadResult](t, up); res.ID != "chosen-upload" {
t.Errorf("id = %q, want chosen-upload", res.ID)
}
}
// Deriving a passphrase is expensive by design, which makes an unverified
// credential an amplifier unless the work is charged for. Junk must not be
// able to buy unlimited derivations.
func TestUnverifiedCredentialsCannotForceUnlimitedDerivations(t *testing.T) {
h := newHarness(t, nil)
tok, err := auth.NewChosen("memorable", "a-chosen-passphrase")
if err != nil {
t.Fatal(err)
}
if err := h.tokens.Add(tok); err != nil {
t.Fatal(err)
}
h.authLimiter = newLimiter(1, 3)
// Distinct junk on every request, so the memo never answers.
for i := range 6 {
req, _ := http.NewRequest("GET", h.ts.URL+"/", nil)
req.AddCookie(&http.Cookie{Name: tokenCookie, Value: fmt.Sprintf("junk-%d", i)})
resp, err := h.ts.Client().Do(req)
if err != nil {
t.Fatal(err)
}
resp.Body.Close()
// The page still renders; it just renders as anonymous.
if resp.StatusCode != http.StatusOK {
t.Fatalf("page %d => %s", i, resp.Status)
}
}
if h.tokens.Resolved("junk-5") {
t.Error("a derivation ran past the budget")
}
}
// The memo means a live session pays the derivation once, not per request.
func TestPassphraseSessionsAreMemoised(t *testing.T) {
h := newHarness(t, nil)
const passphrase = "a-chosen-passphrase"
tok, err := auth.NewChosen("memorable", passphrase)
if err != nil {
t.Fatal(err)
}
if err := h.tokens.Add(tok); err != nil {
t.Fatal(err)
}
if h.tokens.Resolved(passphrase) {
t.Fatal("resolved before anything verified it")
}
resp := h.get(t, "/", "")
resp.Body.Close()
req, _ := http.NewRequest("GET", h.ts.URL+"/", nil)
req.AddCookie(&http.Cookie{Name: tokenCookie, Value: passphrase})
first, err := h.ts.Client().Do(req)
if err != nil {
t.Fatal(err)
}
first.Body.Close()
if !h.tokens.Resolved(passphrase) {
t.Error("the session was not memoised, so every request would derive again")
}
}
+1 -1
View File
@@ -190,7 +190,7 @@ func filenameFromDisposition(h string) string {
func (s *Server) storeUpload(w http.ResponseWriter, r *http.Request, req uploadRequest, body io.Reader, ip string) {
now := s.now()
lim, err := s.limitsFor(req.token)
lim, err := s.limitsFor(r, req.token)
if err != nil {
s.fail(w, r, http.StatusUnauthorized, "Unrecognised token.")
return
+119 -41
View File
@@ -1,23 +1,25 @@
package main
import (
"bufio"
"errors"
"flag"
"fmt"
"io"
"os"
"path/filepath"
"strings"
"text/tabwriter"
"time"
"send/internal/auth"
"send/internal/config"
"send/internal/store"
)
const tokenUsage = `send token - manage upload credentials
Usage:
send token add <name> [options]
send token add <name> [options] generate a token
send token add <name> --token - read a chosen one from stdin
send token list [options]
send token rm <name> [options]
@@ -29,8 +31,13 @@ Options:
`
func tokenCommand(args []string) error {
if len(args) == 0 {
return errors.New("token: expected add, list or rm")
// Asking for help is not a subcommand, and neither is asking for nothing.
if len(args) == 0 || isHelp(args[0]) {
tokenUsageTo(os.Stdout)
if len(args) == 0 {
return errors.New("token: expected add, list or rm")
}
return flag.ErrHelp
}
sub, rest := args[0], args[1:]
@@ -41,20 +48,9 @@ func tokenCommand(args []string) error {
name, rest = rest[0], rest[1:]
}
var (
dataDir, tokensPath string
maxSize, maxExpiry, defExpiry string
vanity, admin bool
)
fs := config.NewSet("send token", config.EnvPrefix)
var opts tokenOptions
fs := opts.register()
fs.SetOutput(os.Stderr)
fs.String(&dataDir, "data", "d", "./data", "DIR", "directory holding the data")
fs.String(&tokensPath, "tokens", "", "", "FILE", "token file location (default <data>/tokens.json)")
fs.String(&maxSize, "max-size", "s", "", "SIZE", "per-upload cap for this token; 'unlimited' to remove it")
fs.String(&maxExpiry, "max-expiry", "e", "", "DURATION", "longest lifetime this token may request; 'never' to remove the cap")
fs.String(&defExpiry, "default-expiry", "", "", "DURATION", "lifetime applied when this token does not ask for one")
fs.Bool(&vanity, "vanity", "", false, "allow this token to claim custom names")
fs.Bool(&admin, "admin", "", false, "allow this token to delete anyone's files")
if err := fs.Parse(rest); err != nil {
if errors.Is(err, flag.ErrHelp) {
@@ -63,25 +59,25 @@ func tokenCommand(args []string) error {
}
return err
}
if tokensPath == "" {
tokensPath = dataDir + "/tokens.json"
if opts.tokensPath == "" {
opts.tokensPath = opts.dataDir + "/tokens.json"
}
// Match the server's permissions for anything this command has to create.
setUmask()
file, err := auth.Load(tokensPath)
file, err := auth.Load(opts.tokensPath)
if err != nil {
return err
}
warnIfUnusedDataDir(dataDir, tokensPath)
warnIfUnusedDataDir(opts.dataDir, opts.tokensPath)
switch sub {
case "add":
if name == "" {
return errors.New("token add: a name is required")
}
return tokenAdd(file, name, maxSize, maxExpiry, defExpiry, vanity, admin)
return tokenAdd(file, name, opts)
case "list":
return tokenList(file)
case "rm", "remove", "delete":
@@ -112,33 +108,94 @@ func warnIfUnusedDataDir(dataDir, tokensPath string) {
dataDir, tokensPath, dataDir)
}
func tokenAdd(file *auth.File, name, maxSize, maxExpiry, defExpiry string, vanity, admin bool) error {
secret, err := store.NewSecret()
// tokenOptions are the flags every token subcommand shares.
type tokenOptions struct {
dataDir string
tokensPath string
maxSize string
maxExpiry string
defExpiry string
chosen string
vanity bool
admin bool
}
func (o *tokenOptions) register() *config.Set {
fs := config.NewSet("send token", config.EnvPrefix)
fs.String(&o.dataDir, "data", "d", "./data", "DIR", "directory holding the data")
fs.String(&o.tokensPath, "tokens", "", "", "FILE", "token file location (default <data>/tokens.json)")
fs.String(&o.maxSize, "max-size", "s", "", "SIZE", "per-upload cap for this token; 'unlimited' to remove it")
fs.String(&o.maxExpiry, "max-expiry", "e", "", "DURATION", "longest lifetime this token may request; 'never' to remove the cap")
fs.String(&o.defExpiry, "default-expiry", "", "", "DURATION", "lifetime applied when this token does not ask for one")
fs.String(&o.chosen, "token", "t", "", "VALUE",
"use this token instead of a generated one; \"-\" reads it from standard input")
fs.Bool(&o.vanity, "vanity", "", false, "allow this token to claim custom names")
fs.Bool(&o.admin, "admin", "", false, "allow this token to delete anyone's files")
return fs
}
// isHelp recognises the spellings people actually type.
func isHelp(arg string) bool {
switch arg {
case "help", "-h", "--help":
return true
}
return false
}
func tokenUsageTo(w io.Writer) {
var opts tokenOptions
opts.register().PrintUsage(w, tokenUsage)
}
func tokenAdd(file *auth.File, name string, opts tokenOptions) error {
chosen := opts.chosen
if chosen == "-" {
read, err := readSecret()
if err != nil {
return err
}
chosen = read
}
var (
t *auth.Token
secret string
err error
)
if chosen != "" {
t, err = auth.NewChosen(name, chosen)
secret = chosen
} else {
t, secret, err = auth.NewGenerated(name)
}
if err != nil {
return err
}
t := &auth.Token{
Name: name,
Hash: auth.HashSecret(secret),
AllowVanity: vanity,
Admin: admin,
Created: time.Now().UTC().Truncate(time.Second),
}
t.AllowVanity = opts.vanity
t.Admin = opts.admin
// Only options actually given are recorded; everything else stays absent
// so it keeps tracking the server's defaults.
if maxSize != "" {
t.MaxSize = &maxSize
if opts.maxSize != "" {
t.MaxSize = &opts.maxSize
}
if maxExpiry != "" {
t.MaxExpiry = &maxExpiry
if opts.maxExpiry != "" {
t.MaxExpiry = &opts.maxExpiry
}
if defExpiry != "" {
t.DefaultExpiry = &defExpiry
if opts.defExpiry != "" {
t.DefaultExpiry = &opts.defExpiry
}
if err := file.Add(t); err != nil {
return err
}
if chosen != "" {
fmt.Printf("Added token %q to %s\n\n", name, file.Path())
fmt.Println("It is stored under a slow key derivation, so a leak of the token")
fmt.Println("file does not hand over the passphrase itself. Guessing it online is")
fmt.Println("rate limited, but a weak choice is still a weak choice.")
return nil
}
fmt.Printf("Added token %q to %s\n\n", name, file.Path())
fmt.Printf(" %s\n\n", secret)
fmt.Println("This is the only time it is shown; only its hash is stored.")
@@ -146,6 +203,20 @@ func tokenAdd(file *auth.File, name, maxSize, maxExpiry, defExpiry string, vanit
return nil
}
// readSecret reads a token from standard input, so it need not appear in a
// shell history or in the process list.
func readSecret() (string, error) {
line, err := bufio.NewReader(os.Stdin).ReadString('\n')
if err != nil && !errors.Is(err, io.EOF) {
return "", err
}
secret := strings.TrimRight(line, "\r\n")
if secret == "" {
return "", errors.New("no token on standard input")
}
return secret, nil
}
func tokenList(file *auth.File) error {
tokens := file.List()
if len(tokens) == 0 {
@@ -153,10 +224,10 @@ func tokenList(file *auth.File) error {
return nil
}
w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0)
fmt.Fprintln(w, "NAME\tMAX SIZE\tMAX EXPIRY\tDEFAULT\tVANITY\tADMIN\tCREATED")
fmt.Fprintln(w, "NAME\tKIND\tMAX SIZE\tMAX EXPIRY\tDEFAULT\tVANITY\tADMIN\tCREATED")
for _, t := range tokens {
fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%s\t%s\t%s\n",
t.Name,
fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\n",
t.Name, kind(t),
inherited(t.MaxSize), inherited(t.MaxExpiry), inherited(t.DefaultExpiry),
yesNo(t.AllowVanity), yesNo(t.Admin),
t.Created.Format("2006-01-02"))
@@ -164,6 +235,13 @@ func tokenList(file *auth.File) error {
return w.Flush()
}
func kind(t *auth.Token) string {
if t.Chosen() {
return "chosen"
}
return "generated"
}
func inherited(s *string) string {
if s == nil {
return "(default)"