Add custom token support
This commit is contained in:
@@ -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
|
Built for sharing large binaries with friends — a 400 MB Godot export is a
|
||||||
normal day here — without a `tmp/` folder that grows forever.
|
normal day here — without a `tmp/` folder that grows forever.
|
||||||
|
|
||||||
- One static Go binary. No database, no dependencies outside the standard
|
- One static Go binary, standard library only. No database, no CSS or
|
||||||
library, no CSS or JavaScript build step.
|
JavaScript build step, nothing to install.
|
||||||
- Anyone who can reach the page may upload, within a size cap and a lifetime.
|
- 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.
|
- Everything expires unless a token says otherwise.
|
||||||
|
|
||||||
## Building
|
## Quickstart
|
||||||
|
|
||||||
```
|
```
|
||||||
go build -o send .
|
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
|
Open <http://localhost:8080>, click **Log in**, paste the token. That's it.
|
||||||
binary.
|
|
||||||
|
|
||||||
## 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.
|
||||||
|
|
||||||
```
|
Uploading from a script:
|
||||||
./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:
|
|
||||||
|
|
||||||
```
|
```
|
||||||
curl --data-binary @MyGame.zip \
|
curl --data-binary @MyGame.zip \
|
||||||
-H 'Content-Disposition: attachment; filename="MyGame.zip"' \
|
-H 'Content-Disposition: attachment; filename="MyGame.zip"' \
|
||||||
-H 'Authorization: Bearer <token>' \
|
-H 'Authorization: Bearer <token>' \
|
||||||
-H 'Vanity: my-game' \
|
-H 'Vanity: my-game' -H 'Expiry: 7d' \
|
||||||
-H 'Expiry: 7d' \
|
http://localhost:8080/api/upload
|
||||||
-H 'Accept: application/json' \
|
|
||||||
https://send.example.com/api/upload
|
|
||||||
```
|
```
|
||||||
|
|
||||||
`Content-Disposition`, `Vanity` and `Expiry` are all optional. Without a vanity
|
The reply carries the download URL and a delete token. Use a generated token
|
||||||
name you get a UUIDv4; vanity names require a token. These headers work with a
|
for scripts — a chosen passphrase is verified with a deliberately slow
|
||||||
`multipart/form-data` body too, where a non-empty form field of the same name
|
derivation, which is wasted on every request a script makes.
|
||||||
overrides them. The reply carries the
|
|
||||||
download URL and a **delete token**, shown exactly once:
|
|
||||||
|
|
||||||
```json
|
In production, put it behind a reverse proxy that terminates TLS, set
|
||||||
{
|
`--public-url`, and make sure the proxy neither buffers request bodies nor
|
||||||
"id": "my-game",
|
imposes its own upload limit.
|
||||||
"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"
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
The same endpoint accepts `multipart/form-data` from the web form. In that shape
|
## Options
|
||||||
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.
|
|
||||||
|
|
||||||
## 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 | |
|
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.
|
||||||
| `GET /d/{id}` | the file, as an attachment; supports resuming |
|
Every option also reads from `SEND_`-prefixed environment variables.
|
||||||
| `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.
|
|
||||||
|
|
||||||
## Development
|
## Development
|
||||||
|
|
||||||
@@ -256,7 +62,37 @@ go test ./...
|
|||||||
go vet ./...
|
go vet ./...
|
||||||
```
|
```
|
||||||
|
|
||||||
The tests cover the parts where a mistake would be expensive: size limits
|
Layout: `main.go` and `token.go` are the CLI; `internal/config` parses options;
|
||||||
against a chunked body with no declared length, vanity collisions, expiry on
|
`internal/store` is the object store; `internal/auth` is credentials;
|
||||||
read, traversal and reserved names, `Content-Disposition` for hostile filenames,
|
`internal/server` is the HTTP surface; `web/` holds the templates and assets,
|
||||||
delete authorisation, symlink escapes and crash debris.
|
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
@@ -3,6 +3,8 @@
|
|||||||
package auth
|
package auth
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"crypto/pbkdf2"
|
||||||
|
"crypto/rand"
|
||||||
"crypto/sha256"
|
"crypto/sha256"
|
||||||
"crypto/subtle"
|
"crypto/subtle"
|
||||||
"encoding/hex"
|
"encoding/hex"
|
||||||
@@ -12,6 +14,7 @@ import (
|
|||||||
"io/fs"
|
"io/fs"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"slices"
|
||||||
"sort"
|
"sort"
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
@@ -28,10 +31,39 @@ var (
|
|||||||
ErrExists = errors.New("a token with that name already exists")
|
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
|
// Token is one named credential. The pointer fields distinguish "not set, so
|
||||||
// inherit the server default" from "set to zero, meaning unlimited".
|
// inherit the server default" from "set to zero, meaning unlimited".
|
||||||
type Token struct {
|
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"`
|
Hash string `json:"hash"`
|
||||||
MaxSize *string `json:"max_size,omitempty"`
|
MaxSize *string `json:"max_size,omitempty"`
|
||||||
MaxExpiry *string `json:"max_expiry,omitempty"`
|
MaxExpiry *string `json:"max_expiry,omitempty"`
|
||||||
@@ -43,6 +75,30 @@ type Token struct {
|
|||||||
maxSize *int64
|
maxSize *int64
|
||||||
maxExpiry *time.Duration
|
maxExpiry *time.Duration
|
||||||
defaultExpiry *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
|
// 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 {
|
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)
|
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 {
|
if t.MaxSize != nil {
|
||||||
n, err := config.ParseSize(*t.MaxSize)
|
n, err := config.ParseSize(*t.MaxSize)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -125,10 +198,11 @@ func (t *Token) Limits(c *config.Config) Limits {
|
|||||||
return l
|
return l
|
||||||
}
|
}
|
||||||
|
|
||||||
// HashSecret is the one-way transform applied to every secret this service
|
// HashSecret is the fast one-way transform, used for generated tokens and for
|
||||||
// stores, both API tokens and per-object delete tokens. The secrets are 256-bit
|
// per-object delete tokens. Both are 256-bit random values, so a plain digest
|
||||||
// random values, so a plain digest is sufficient - there is nothing to brute
|
// is sufficient - there is nothing to brute force - and lookup by digest
|
||||||
// force - and lookup by digest reveals nothing through timing.
|
// reveals nothing through timing. Chosen passphrases never go through here;
|
||||||
|
// see Token.Verify.
|
||||||
func HashSecret(s string) string {
|
func HashSecret(s string) string {
|
||||||
sum := sha256.Sum256([]byte(s))
|
sum := sha256.Sum256([]byte(s))
|
||||||
return hex.EncodeToString(sum[:])
|
return hex.EncodeToString(sum[:])
|
||||||
@@ -139,13 +213,24 @@ func EqualHash(a, b string) bool {
|
|||||||
return subtle.ConstantTimeCompare([]byte(a), []byte(b)) == 1
|
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.
|
// File is the token store, backed by a JSON file and reloadable at runtime.
|
||||||
type File struct {
|
type File struct {
|
||||||
path string
|
path string
|
||||||
|
|
||||||
mu sync.RWMutex
|
mu sync.RWMutex
|
||||||
byHash map[string]*Token
|
byHash map[string]*Token // generated tokens, found in one step
|
||||||
byName map[string]*Token
|
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
|
modTime time.Time
|
||||||
size int64
|
size int64
|
||||||
}
|
}
|
||||||
@@ -153,7 +238,12 @@ type File struct {
|
|||||||
// Load reads the token file. A missing file is not an error: the service simply
|
// 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.
|
// starts with no credentials and only the anonymous tier available.
|
||||||
func Load(path string) (*File, error) {
|
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 {
|
if err := f.Reload(); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -198,17 +288,23 @@ func (f *File) Reload() error {
|
|||||||
}
|
}
|
||||||
byHash := make(map[string]*Token, len(tokens))
|
byHash := make(map[string]*Token, len(tokens))
|
||||||
byName := make(map[string]*Token, len(tokens))
|
byName := make(map[string]*Token, len(tokens))
|
||||||
|
var chosen []*Token
|
||||||
for _, t := range tokens {
|
for _, t := range tokens {
|
||||||
if _, dup := byName[t.Name]; dup {
|
if _, dup := byName[t.Name]; dup {
|
||||||
return fmt.Errorf("%s: duplicate token name %q", f.path, t.Name)
|
return fmt.Errorf("%s: duplicate token name %q", f.path, t.Name)
|
||||||
}
|
}
|
||||||
byHash[t.Hash] = t
|
|
||||||
byName[t.Name] = t
|
byName[t.Name] = t
|
||||||
|
if t.Chosen() {
|
||||||
|
chosen = append(chosen, t)
|
||||||
|
} else {
|
||||||
|
byHash[t.Hash] = t
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
f.mu.Lock()
|
f.mu.Lock()
|
||||||
defer f.mu.Unlock()
|
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 {
|
if info != nil {
|
||||||
f.modTime, f.size = info.ModTime(), info.Size()
|
f.modTime, f.size = info.ModTime(), info.Size()
|
||||||
} else {
|
} else {
|
||||||
@@ -242,19 +338,63 @@ func (f *File) MaybeReload() error {
|
|||||||
return f.Reload()
|
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.
|
// 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 {
|
func (f *File) Lookup(secret string) *Token {
|
||||||
if secret == "" {
|
if secret == "" {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
h := HashSecret(secret)
|
h := HashSecret(secret)
|
||||||
|
|
||||||
f.mu.RLock()
|
f.mu.RLock()
|
||||||
defer f.mu.RUnlock()
|
if t, ok := f.byHash[h]; ok && EqualHash(t.Hash, h) {
|
||||||
t, ok := f.byHash[h]
|
f.mu.RUnlock()
|
||||||
if !ok || !EqualHash(t.Hash, h) {
|
return t
|
||||||
return nil
|
|
||||||
}
|
}
|
||||||
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.
|
// List returns the tokens, name-sorted, for the CLI.
|
||||||
@@ -280,7 +420,12 @@ func (f *File) Add(t *Token) error {
|
|||||||
return ErrExists
|
return ErrExists
|
||||||
}
|
}
|
||||||
f.byName[t.Name] = t
|
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()
|
return f.saveLocked()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -294,6 +439,8 @@ func (f *File) Remove(name string) error {
|
|||||||
}
|
}
|
||||||
delete(f.byName, name)
|
delete(f.byName, name)
|
||||||
delete(f.byHash, t.Hash)
|
delete(f.byHash, t.Hash)
|
||||||
|
f.chosen = slices.DeleteFunc(f.chosen, func(c *Token) bool { return c == t })
|
||||||
|
clear(f.verified)
|
||||||
return f.saveLocked()
|
return f.saveLocked()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -345,3 +492,50 @@ func (f *File) saveLocked() error {
|
|||||||
}
|
}
|
||||||
return nil
|
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) }
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package auth
|
|||||||
import (
|
import (
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -184,3 +185,157 @@ func TestReloadPicksUpChanges(t *testing.T) {
|
|||||||
t.Error("the newly written token was not picked up")
|
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")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -60,7 +60,7 @@ func (c *Config) Register(s *Set) {
|
|||||||
|
|
||||||
s.Size(&c.MaxTotalBytes, "max-total-bytes", "", "unlimited",
|
s.Size(&c.MaxTotalBytes, "max-total-bytes", "", "unlimited",
|
||||||
"refuse uploads once stored data exceeds this total")
|
"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")
|
"refuse uploads when the filesystem has less free space than this")
|
||||||
s.String(&c.TokensPath, "tokens", "", "", "FILE",
|
s.String(&c.TokensPath, "tokens", "", "", "FILE",
|
||||||
"token file location (default <data>/tokens.json)")
|
"token file location (default <data>/tokens.json)")
|
||||||
|
|||||||
@@ -56,7 +56,7 @@ var adminSorts = map[string]func(a, b adminObject) int{
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (s *Server) handleAdmin(w http.ResponseWriter, r *http.Request) {
|
func (s *Server) handleAdmin(w http.ResponseWriter, r *http.Request) {
|
||||||
lim, err := s.limitsFor(credential(r))
|
lim, err := s.limitsFor(r, credential(r))
|
||||||
switch {
|
switch {
|
||||||
case err != nil:
|
case err != nil:
|
||||||
s.fail(w, r, http.StatusUnauthorized, "Unrecognised token.")
|
s.fail(w, r, http.StatusUnauthorized, "Unrecognised token.")
|
||||||
|
|||||||
+10
-12
@@ -34,7 +34,7 @@ func (s *Server) handleDelete(w http.ResponseWriter, r *http.Request) {
|
|||||||
"A delete token or an owning token is required.")
|
"A delete token or an owning token is required.")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if !s.authorised(m, presented) {
|
if !s.authorised(r, m, presented) {
|
||||||
// Only failures are throttled, so a correct token is never delayed.
|
// Only failures are throttled, so a correct token is never delayed.
|
||||||
// The info page is publicly shareable and now carries a credential
|
// The info page is publicly shareable and now carries a credential
|
||||||
// field, which is reason enough not to let it be hammered freely.
|
// 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.
|
// 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 {
|
for _, secret := range presented {
|
||||||
if s.mayDelete(m, secret) {
|
if s.mayDelete(r, m, secret) {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
// mayDelete checks one secret against the object's delete token first, then
|
// mayDelete checks one secret against the object's delete token first - which
|
||||||
// against the token file.
|
// is always a generated value, so that comparison is cheap - and only then
|
||||||
func (s *Server) mayDelete(m *store.Meta, secret string) bool {
|
// 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)) {
|
if auth.EqualHash(m.DeleteHash, auth.HashSecret(secret)) {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
if err := s.tokens.MaybeReload(); err != nil {
|
lim, err := s.limitsFor(r, secret)
|
||||||
s.log.Error("reloading token file", "err", err)
|
if err != nil {
|
||||||
}
|
|
||||||
t := s.tokens.Lookup(secret)
|
|
||||||
if t == nil {
|
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
return t.Admin || (m.Owner != "" && t.Name == m.Owner)
|
return lim.Admin || (m.Owner != "" && lim.Name == m.Owner)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -40,7 +40,7 @@ func (s *Server) handleLoginPage(w http.ResponseWriter, r *http.Request) {
|
|||||||
next := destination(r.URL.Query().Get("next"))
|
next := destination(r.URL.Query().Get("next"))
|
||||||
|
|
||||||
// Already logged in: say so rather than showing an empty form.
|
// 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{
|
s.render(w, http.StatusOK, "login.html", loginPage{
|
||||||
page: s.page(r, "Log in", false),
|
page: s.page(r, "Log in", false),
|
||||||
Next: next,
|
Next: next,
|
||||||
@@ -70,7 +70,7 @@ func (s *Server) handleLogin(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
// Only failures are throttled, so logging in normally is never delayed.
|
// 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 err != nil {
|
||||||
if !s.authLimiter.allow(clientIP(r, s.cfg), s.now()) {
|
if !s.authLimiter.allow(clientIP(r, s.cfg), s.now()) {
|
||||||
s.loginFailed(w, r, next, http.StatusTooManyRequests,
|
s.loginFailed(w, r, next, http.StatusTooManyRequests,
|
||||||
|
|||||||
@@ -57,7 +57,7 @@ type indexPage struct {
|
|||||||
func (s *Server) handleIndex(w http.ResponseWriter, r *http.Request) {
|
func (s *Server) handleIndex(w http.ResponseWriter, r *http.Request) {
|
||||||
// A remembered token is resolved server-side, so the page can show the real
|
// A remembered token is resolved server-side, so the page can show the real
|
||||||
// limits without the cookie ever being readable by a script.
|
// 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
|
stale := false
|
||||||
if err != nil {
|
if err != nil {
|
||||||
// The token was revoked or the file was edited; end the session rather
|
// 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.")
|
s.fail(w, r, http.StatusTooManyRequests, "Too many requests; try again shortly.")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
lim, err := s.limitsFor(credential(r))
|
lim, err := s.limitsFor(r, credential(r))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
s.fail(w, r, http.StatusUnauthorized, "Unrecognised token.")
|
s.fail(w, r, http.StatusUnauthorized, "Unrecognised token.")
|
||||||
return
|
return
|
||||||
@@ -154,7 +154,7 @@ func (s *Server) renderInfo(w http.ResponseWriter, r *http.Request, m *store.Met
|
|||||||
Size: config.FormatSize(m.Size),
|
Size: config.FormatSize(m.Size),
|
||||||
Expires: describeExpiry(m.Expires, s.now()),
|
Expires: describeExpiry(m.Expires, s.now()),
|
||||||
URL: s.objectURL(r, m.ID),
|
URL: s.objectURL(r, m.ID),
|
||||||
CanDelete: s.mayDelete(m, credential(r)),
|
CanDelete: s.mayDelete(r, m, credential(r)),
|
||||||
Error: errMsg,
|
Error: errMsg,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -133,13 +133,22 @@ var errBadToken = errors.New("unrecognised token")
|
|||||||
|
|
||||||
// limitsFor resolves the effective permissions for a presented secret. An empty
|
// limitsFor resolves the effective permissions for a presented secret. An empty
|
||||||
// secret yields the anonymous tier.
|
// 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 == "" {
|
if secret == "" {
|
||||||
return auth.Anonymous(s.cfg), nil
|
return auth.Anonymous(s.cfg), nil
|
||||||
}
|
}
|
||||||
if err := s.tokens.MaybeReload(); err != nil {
|
if err := s.tokens.MaybeReload(); err != nil {
|
||||||
s.log.Error("reloading token file", "err", err)
|
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)
|
t := s.tokens.Lookup(secret)
|
||||||
if t == nil {
|
if t == nil {
|
||||||
return auth.Limits{}, errBadToken
|
return auth.Limits{}, errBadToken
|
||||||
@@ -193,7 +202,7 @@ type page struct {
|
|||||||
// who is logged in and offer only the links they can use.
|
// who is logged in and offer only the links they can use.
|
||||||
func (s *Server) page(r *http.Request, title string, script bool) page {
|
func (s *Server) page(r *http.Request, title string, script bool) page {
|
||||||
p := page{Base: s.cfg.BasePath, Title: title, Script: script}
|
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
|
p.User, p.Admin = lim.Name, lim.Admin
|
||||||
}
|
}
|
||||||
return p
|
return p
|
||||||
|
|||||||
@@ -1624,3 +1624,108 @@ func TestUploadPageKeepsTheOneOffTokenField(t *testing.T) {
|
|||||||
t.Error("the form does not carry the server-rendered size limit")
|
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")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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) {
|
func (s *Server) storeUpload(w http.ResponseWriter, r *http.Request, req uploadRequest, body io.Reader, ip string) {
|
||||||
now := s.now()
|
now := s.now()
|
||||||
|
|
||||||
lim, err := s.limitsFor(req.token)
|
lim, err := s.limitsFor(r, req.token)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
s.fail(w, r, http.StatusUnauthorized, "Unrecognised token.")
|
s.fail(w, r, http.StatusUnauthorized, "Unrecognised token.")
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -1,23 +1,25 @@
|
|||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"bufio"
|
||||||
"errors"
|
"errors"
|
||||||
"flag"
|
"flag"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"io"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
"text/tabwriter"
|
"text/tabwriter"
|
||||||
"time"
|
|
||||||
|
|
||||||
"send/internal/auth"
|
"send/internal/auth"
|
||||||
"send/internal/config"
|
"send/internal/config"
|
||||||
"send/internal/store"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
const tokenUsage = `send token - manage upload credentials
|
const tokenUsage = `send token - manage upload credentials
|
||||||
|
|
||||||
Usage:
|
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 list [options]
|
||||||
send token rm <name> [options]
|
send token rm <name> [options]
|
||||||
|
|
||||||
@@ -29,8 +31,13 @@ Options:
|
|||||||
`
|
`
|
||||||
|
|
||||||
func tokenCommand(args []string) error {
|
func tokenCommand(args []string) error {
|
||||||
if len(args) == 0 {
|
// Asking for help is not a subcommand, and neither is asking for nothing.
|
||||||
return errors.New("token: expected add, list or rm")
|
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:]
|
sub, rest := args[0], args[1:]
|
||||||
|
|
||||||
@@ -41,20 +48,9 @@ func tokenCommand(args []string) error {
|
|||||||
name, rest = rest[0], rest[1:]
|
name, rest = rest[0], rest[1:]
|
||||||
}
|
}
|
||||||
|
|
||||||
var (
|
var opts tokenOptions
|
||||||
dataDir, tokensPath string
|
fs := opts.register()
|
||||||
maxSize, maxExpiry, defExpiry string
|
|
||||||
vanity, admin bool
|
|
||||||
)
|
|
||||||
fs := config.NewSet("send token", config.EnvPrefix)
|
|
||||||
fs.SetOutput(os.Stderr)
|
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 err := fs.Parse(rest); err != nil {
|
||||||
if errors.Is(err, flag.ErrHelp) {
|
if errors.Is(err, flag.ErrHelp) {
|
||||||
@@ -63,25 +59,25 @@ func tokenCommand(args []string) error {
|
|||||||
}
|
}
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if tokensPath == "" {
|
if opts.tokensPath == "" {
|
||||||
tokensPath = dataDir + "/tokens.json"
|
opts.tokensPath = opts.dataDir + "/tokens.json"
|
||||||
}
|
}
|
||||||
|
|
||||||
// Match the server's permissions for anything this command has to create.
|
// Match the server's permissions for anything this command has to create.
|
||||||
setUmask()
|
setUmask()
|
||||||
|
|
||||||
file, err := auth.Load(tokensPath)
|
file, err := auth.Load(opts.tokensPath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
warnIfUnusedDataDir(dataDir, tokensPath)
|
warnIfUnusedDataDir(opts.dataDir, opts.tokensPath)
|
||||||
|
|
||||||
switch sub {
|
switch sub {
|
||||||
case "add":
|
case "add":
|
||||||
if name == "" {
|
if name == "" {
|
||||||
return errors.New("token add: a name is required")
|
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":
|
case "list":
|
||||||
return tokenList(file)
|
return tokenList(file)
|
||||||
case "rm", "remove", "delete":
|
case "rm", "remove", "delete":
|
||||||
@@ -112,33 +108,94 @@ func warnIfUnusedDataDir(dataDir, tokensPath string) {
|
|||||||
dataDir, tokensPath, dataDir)
|
dataDir, tokensPath, dataDir)
|
||||||
}
|
}
|
||||||
|
|
||||||
func tokenAdd(file *auth.File, name, maxSize, maxExpiry, defExpiry string, vanity, admin bool) error {
|
// tokenOptions are the flags every token subcommand shares.
|
||||||
secret, err := store.NewSecret()
|
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 {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
t := &auth.Token{
|
t.AllowVanity = opts.vanity
|
||||||
Name: name,
|
t.Admin = opts.admin
|
||||||
Hash: auth.HashSecret(secret),
|
|
||||||
AllowVanity: vanity,
|
|
||||||
Admin: admin,
|
|
||||||
Created: time.Now().UTC().Truncate(time.Second),
|
|
||||||
}
|
|
||||||
// Only options actually given are recorded; everything else stays absent
|
// Only options actually given are recorded; everything else stays absent
|
||||||
// so it keeps tracking the server's defaults.
|
// so it keeps tracking the server's defaults.
|
||||||
if maxSize != "" {
|
if opts.maxSize != "" {
|
||||||
t.MaxSize = &maxSize
|
t.MaxSize = &opts.maxSize
|
||||||
}
|
}
|
||||||
if maxExpiry != "" {
|
if opts.maxExpiry != "" {
|
||||||
t.MaxExpiry = &maxExpiry
|
t.MaxExpiry = &opts.maxExpiry
|
||||||
}
|
}
|
||||||
if defExpiry != "" {
|
if opts.defExpiry != "" {
|
||||||
t.DefaultExpiry = &defExpiry
|
t.DefaultExpiry = &opts.defExpiry
|
||||||
}
|
}
|
||||||
if err := file.Add(t); err != nil {
|
if err := file.Add(t); err != nil {
|
||||||
return err
|
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("Added token %q to %s\n\n", name, file.Path())
|
||||||
fmt.Printf(" %s\n\n", secret)
|
fmt.Printf(" %s\n\n", secret)
|
||||||
fmt.Println("This is the only time it is shown; only its hash is stored.")
|
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
|
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 {
|
func tokenList(file *auth.File) error {
|
||||||
tokens := file.List()
|
tokens := file.List()
|
||||||
if len(tokens) == 0 {
|
if len(tokens) == 0 {
|
||||||
@@ -153,10 +224,10 @@ func tokenList(file *auth.File) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0)
|
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 {
|
for _, t := range tokens {
|
||||||
fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%s\t%s\t%s\n",
|
fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\n",
|
||||||
t.Name,
|
t.Name, kind(t),
|
||||||
inherited(t.MaxSize), inherited(t.MaxExpiry), inherited(t.DefaultExpiry),
|
inherited(t.MaxSize), inherited(t.MaxExpiry), inherited(t.DefaultExpiry),
|
||||||
yesNo(t.AllowVanity), yesNo(t.Admin),
|
yesNo(t.AllowVanity), yesNo(t.Admin),
|
||||||
t.Created.Format("2006-01-02"))
|
t.Created.Format("2006-01-02"))
|
||||||
@@ -164,6 +235,13 @@ func tokenList(file *auth.File) error {
|
|||||||
return w.Flush()
|
return w.Flush()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func kind(t *auth.Token) string {
|
||||||
|
if t.Chosen() {
|
||||||
|
return "chosen"
|
||||||
|
}
|
||||||
|
return "generated"
|
||||||
|
}
|
||||||
|
|
||||||
func inherited(s *string) string {
|
func inherited(s *string) string {
|
||||||
if s == nil {
|
if s == nil {
|
||||||
return "(default)"
|
return "(default)"
|
||||||
|
|||||||
Reference in New Issue
Block a user