From 48229f15a2a21bce5228a8816ea00b6ee7e7e91e Mon Sep 17 00:00:00 2001 From: Thayol Date: Sun, 13 Sep 2026 01:01:48 +0200 Subject: [PATCH] Add custom token support --- README.md | 286 +++++++-------------------------- internal/auth/tokens.go | 228 ++++++++++++++++++++++++-- internal/auth/tokens_test.go | 155 ++++++++++++++++++ internal/config/config.go | 2 +- internal/server/admin.go | 2 +- internal/server/delete.go | 22 ++- internal/server/login.go | 4 +- internal/server/pages.go | 6 +- internal/server/server.go | 13 +- internal/server/server_test.go | 105 ++++++++++++ internal/server/upload.go | 2 +- token.go | 160 +++++++++++++----- 12 files changed, 680 insertions(+), 305 deletions(-) diff --git a/README.md b/README.md index adb6702..3cc64df 100644 --- a/README.md +++ b/README.md @@ -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 , 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` | `/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 `, 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 ' \ - -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 - -``` -/ - objects//blob the bytes 0664 - objects//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. diff --git a/internal/auth/tokens.go b/internal/auth/tokens.go index f1f9c23..e7865c5 100644 --- a/internal/auth/tokens.go +++ b/internal/auth/tokens.go @@ -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) } diff --git a/internal/auth/tokens_test.go b/internal/auth/tokens_test.go index f1c6e9e..a6b5f7f 100644 --- a/internal/auth/tokens_test.go +++ b/internal/auth/tokens_test.go @@ -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") + } +} diff --git a/internal/config/config.go b/internal/config/config.go index d14b8e5..46b6bc2 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -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 /tokens.json)") diff --git a/internal/server/admin.go b/internal/server/admin.go index 1016607..3e9dbc6 100644 --- a/internal/server/admin.go +++ b/internal/server/admin.go @@ -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.") diff --git a/internal/server/delete.go b/internal/server/delete.go index 972cd18..481c8ed 100644 --- a/internal/server/delete.go +++ b/internal/server/delete.go @@ -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) } diff --git a/internal/server/login.go b/internal/server/login.go index 3f0da7a..c29b39e 100644 --- a/internal/server/login.go +++ b/internal/server/login.go @@ -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, diff --git a/internal/server/pages.go b/internal/server/pages.go index e4363e7..441939f 100644 --- a/internal/server/pages.go +++ b/internal/server/pages.go @@ -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, }) } diff --git a/internal/server/server.go b/internal/server/server.go index 62c4a3e..6df52af 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -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 diff --git a/internal/server/server_test.go b/internal/server/server_test.go index 63f78e7..6fcb8e9 100644 --- a/internal/server/server_test.go +++ b/internal/server/server_test.go @@ -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") + } +} diff --git a/internal/server/upload.go b/internal/server/upload.go index 9b5c2cc..633cb52 100644 --- a/internal/server/upload.go +++ b/internal/server/upload.go @@ -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 diff --git a/token.go b/token.go index 76d4b27..de41cc3 100644 --- a/token.go +++ b/token.go @@ -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 [options] + send token add [options] generate a token + send token add --token - read a chosen one from stdin send token list [options] send token rm [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 /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 /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)"