diff --git a/README.md b/README.md index 3cc64df..8d06eab 100644 --- a/README.md +++ b/README.md @@ -3,56 +3,30 @@ A small self-hosted file drop. Uploads land in a flat data directory, expire on 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. +Built for sharing large binaries with friends so that I don't have to manually manage the `tmp` folder on my generic web server. -- 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, unlock custom URLs, and can grant admin. -- Everything expires unless a token says otherwise. +* One static Go binary, standard library only. +* Anyone who can reach the page may upload, within a size cap and a lifetime. +* Named tokens raise those limits, unlock custom URLs, and can grant admin. ## 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 build +./send token add me --vanity --admin +./send ``` Open , click **Log in**, paste the token. That's it. -`--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: - -``` -curl --data-binary @MyGame.zip \ - -H 'Content-Disposition: attachment; filename="MyGame.zip"' \ - -H 'Authorization: Bearer ' \ - -H 'Vanity: my-game' -H 'Expiry: 7d' \ - http://localhost:8080/api/upload -``` - -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. - -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. +When you 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. ## Options -**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. +**Read `./send --help` rather than this file.** -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 @@ -61,38 +35,3 @@ Every option also reads from `SEND_`-prefixed environment variables. go test ./... go vet ./... ``` - -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 e7865c5..d15d965 100644 --- a/internal/auth/tokens.go +++ b/internal/auth/tokens.go @@ -14,7 +14,6 @@ import ( "io/fs" "os" "path/filepath" - "slices" "sort" "sync" "time" @@ -71,6 +70,7 @@ type Token struct { AllowVanity bool `json:"allow_vanity"` Admin bool `json:"admin"` Created time.Time `json:"created"` + Rotated time.Time `json:"rotated,omitempty"` maxSize *int64 maxExpiry *time.Duration @@ -420,12 +420,7 @@ func (f *File) Add(t *Token) error { return ErrExists } f.byName[t.Name] = t - if t.Chosen() { - f.chosen = append(f.chosen, t) - } else { - f.byHash[t.Hash] = t - } - clear(f.verified) + f.reindexLocked() return f.saveLocked() } @@ -433,14 +428,11 @@ func (f *File) Add(t *Token) error { func (f *File) Remove(name string) error { f.mu.Lock() defer f.mu.Unlock() - t, ok := f.byName[name] - if !ok { + if _, ok := f.byName[name]; !ok { return ErrNotFound } 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) + f.reindexLocked() return f.saveLocked() } @@ -539,3 +531,76 @@ func NewChosen(name, secret string) (*Token, error) { } func now() time.Time { return time.Now().UTC().Truncate(time.Second) } + +// reindexLocked rebuilds the lookup structures from byName, which is the one +// that always holds every entry. Callers hold the write lock. +// +// The slices are rebuilt rather than reused: Lookup takes a reference to +// f.chosen under a read lock and then iterates it without one, so writing into +// the old backing array would be a race. +func (f *File) reindexLocked() { + byHash := make(map[string]*Token, len(f.byName)) + var chosen []*Token + for _, t := range f.byName { + if t.Chosen() { + chosen = append(chosen, t) + } else { + byHash[t.Hash] = t + } + } + f.byHash, f.chosen = byHash, chosen + // Any memoised verification may now refer to a secret that has changed. + clear(f.verified) +} + +// Update applies a change to an existing token, keeping everything the change +// does not touch. This is what makes rotating a secret possible without +// destroying the limits, flags and history attached to the name. +// +// The mutation runs against a copy, so a change that turns out to be invalid +// leaves the stored token exactly as it was. +func (f *File) Update(name string, mutate func(*Token) error) error { + f.mu.Lock() + defer f.mu.Unlock() + + t, ok := f.byName[name] + if !ok { + return ErrNotFound + } + clone := *t + if err := mutate(&clone); err != nil { + return err + } + if err := clone.resolve(); err != nil { + return err + } + *t = clone + f.reindexLocked() + return f.saveLocked() +} + +// SetChosen replaces the token's secret with a passphrase, re-salting it. Any +// session or script still presenting the old secret stops authenticating. +func (t *Token) SetChosen(secret string) error { + replacement, err := NewChosen(t.Name, secret) + if err != nil { + return err + } + t.KDF, t.Salt, t.Iter, t.Hash, t.salt = replacement.KDF, replacement.Salt, + replacement.Iter, replacement.Hash, replacement.salt + t.Rotated = now() + return nil +} + +// SetGenerated replaces the token's secret with a fresh random one, which it +// returns. The token stops being a passphrase if it was one. +func (t *Token) SetGenerated() (string, error) { + replacement, secret, err := NewGenerated(t.Name) + if err != nil { + return "", err + } + t.KDF, t.Salt, t.Iter, t.salt = "", "", 0, nil + t.Hash = replacement.Hash + t.Rotated = now() + return secret, nil +} diff --git a/internal/auth/tokens_test.go b/internal/auth/tokens_test.go index a6b5f7f..678c327 100644 --- a/internal/auth/tokens_test.go +++ b/internal/auth/tokens_test.go @@ -339,3 +339,167 @@ func TestResolvedTracksWhatIsMemoised(t *testing.T) { t.Error("the memo survived a reload of the token file") } } + +// --- rotation ------------------------------------------------------------ + +// Rotating must keep everything the name carries. Losing the limits, the +// flags or the history is exactly what makes remove-and-re-add unusable. +func TestRotateKeepsEverythingButTheSecret(t *testing.T) { + f := newFile(t) + size, expiry := "8GiB", "never" + + tok, original, err := NewGenerated("thayol") + if err != nil { + t.Fatal(err) + } + tok.MaxSize, tok.MaxExpiry = &size, &expiry + tok.AllowVanity, tok.Admin = true, true + if err := f.Add(tok); err != nil { + t.Fatal(err) + } + created := tok.Created + + var replacement string + if err := f.Update("thayol", func(t *Token) error { + s, err := t.SetGenerated() + replacement = s + return err + }); err != nil { + t.Fatal(err) + } + + got := f.Lookup(replacement) + if got == nil { + t.Fatal("the rotated secret does not authenticate") + } + if f.Lookup(original) != nil { + t.Error("the old secret still authenticates after rotation") + } + if *got.MaxSize != size || *got.MaxExpiry != expiry { + t.Error("rotation lost the limits") + } + if !got.AllowVanity || !got.Admin { + t.Error("rotation lost the flags") + } + if !got.Created.Equal(created) { + t.Error("rotation reset the created date") + } + if got.Rotated.IsZero() { + t.Error("rotation was not recorded") + } +} + +// A passphrase must be able to become a different passphrase, with a new salt. +func TestRotateBetweenKinds(t *testing.T) { + f := newFile(t) + tok, generated, err := NewGenerated("thayol") + if err != nil { + t.Fatal(err) + } + if err := f.Add(tok); err != nil { + t.Fatal(err) + } + + // Generated becomes chosen. + if err := f.Update("thayol", func(t *Token) error { return t.SetChosen("first-passphrase") }); err != nil { + t.Fatal(err) + } + if f.Lookup(generated) != nil { + t.Error("the generated secret survived the switch to a passphrase") + } + got := f.Lookup("first-passphrase") + if got == nil || !got.Chosen() { + t.Fatal("the passphrase does not authenticate as a chosen token") + } + firstSalt := got.Salt + + // Chosen becomes a different chosen, with its own salt. + if err := f.Update("thayol", func(t *Token) error { return t.SetChosen("second-passphrase") }); err != nil { + t.Fatal(err) + } + if f.Lookup("first-passphrase") != nil { + t.Error("the previous passphrase still authenticates") + } + got = f.Lookup("second-passphrase") + if got == nil { + t.Fatal("the new passphrase does not authenticate") + } + if got.Salt == firstSalt { + t.Error("rotation reused the old salt") + } + + // And back to generated, dropping the derivation parameters. + var regenerated string + if err := f.Update("thayol", func(t *Token) error { + s, err := t.SetGenerated() + regenerated = s + return err + }); err != nil { + t.Fatal(err) + } + got = f.Lookup(regenerated) + if got == nil || got.Chosen() || got.Salt != "" || got.Iter != 0 { + t.Errorf("switching back to generated left derivation parameters behind: %+v", got) + } +} + +// A rejected change must leave the stored token untouched, not half-applied. +func TestFailedUpdateChangesNothing(t *testing.T) { + f := newFile(t) + tok, secret, err := NewGenerated("thayol") + if err != nil { + t.Fatal(err) + } + if err := f.Add(tok); err != nil { + t.Fatal(err) + } + + // Derived from the constant: what counts as too short moves with the floor. + tooShort := strings.Repeat("a", MinChosenLength-1) + err = f.Update("thayol", func(t *Token) error { + t.AllowVanity = true // a change that would have been fine + return t.SetChosen(tooShort) // and one that is not + }) + if err == nil { + t.Fatal("an invalid rotation was accepted") + } + got := f.Lookup(secret) + if got == nil { + t.Fatal("the original secret stopped working after a failed update") + } + if got.AllowVanity { + t.Error("a failed update left a partial change behind") + } +} + +func TestUpdateUnknownName(t *testing.T) { + f := newFile(t) + if err := f.Update("nobody", func(*Token) error { return nil }); err != ErrNotFound { + t.Errorf("Update on an unknown name = %v, want ErrNotFound", err) + } +} + +// The memo must not keep answering for a secret that has been rotated away. +func TestRotationInvalidatesTheMemo(t *testing.T) { + f := newFile(t) + tok, err := NewChosen("thayol", "the-old-passphrase") + if err != nil { + t.Fatal(err) + } + if err := f.Add(tok); err != nil { + t.Fatal(err) + } + if f.Lookup("the-old-passphrase") == nil { + t.Fatal("setup: the passphrase does not authenticate") + } + if !f.Resolved("the-old-passphrase") { + t.Fatal("setup: the passphrase was not memoised") + } + + if err := f.Update("thayol", func(t *Token) error { return t.SetChosen("the-new-passphrase") }); err != nil { + t.Fatal(err) + } + if f.Lookup("the-old-passphrase") != nil { + t.Error("the memo kept authenticating a rotated-away passphrase") + } +} diff --git a/internal/config/flags.go b/internal/config/flags.go index 186b42d..ca3cf20 100644 --- a/internal/config/flags.go +++ b/internal/config/flags.go @@ -20,6 +20,7 @@ type Set struct { specs []*spec longs map[string]bool shorts map[string]bool + fromEnv map[string]bool } type spec struct { @@ -35,6 +36,7 @@ func NewSet(name, envPrefix string) *Set { envPrefix: envPrefix, longs: map[string]bool{}, shorts: map[string]bool{}, + fromEnv: map[string]bool{}, } s.fs.Usage = func() {} return s @@ -192,6 +194,7 @@ func (s *Set) applyEnv() error { if err := s.fs.Set(sp.long, v); err != nil { return fmt.Errorf("%s: %w", s.envName(sp.long), err) } + s.fromEnv[sp.long] = true } return nil } @@ -208,6 +211,31 @@ func (s *Set) Parse(args []string) error { return s.fs.Parse(args) } +// Changed reports whether an option was given on the command line. +// +// Values picked up from the environment do not count: a command that acts only +// on the options you actually typed must not quietly act on a SEND_ variable +// exported for the server. +func (s *Set) Changed(long string) bool { + if s.fromEnv[long] { + return false + } + short := "" + for _, sp := range s.specs { + if sp.long == long { + short = sp.short + break + } + } + given := false + s.fs.Visit(func(f *flag.Flag) { + if f.Name == long || (short != "" && f.Name == short) { + given = true + } + }) + return given +} + // PrintUsage renders the options in the "-x, --xxx" form the convention implies. func (s *Set) PrintUsage(w io.Writer, header string) { fmt.Fprint(w, header) diff --git a/internal/server/server_test.go b/internal/server/server_test.go index 6fcb8e9..0e31f0b 100644 --- a/internal/server/server_test.go +++ b/internal/server/server_test.go @@ -1729,3 +1729,65 @@ func TestPassphraseSessionsAreMemoised(t *testing.T) { t.Error("the session was not memoised, so every request would derive again") } } + +// Rotating a secret has to end the sessions that were using it, or rotation +// would not actually revoke anything. +func TestRotationEndsLiveSessions(t *testing.T) { + h := newHarness(t, nil) + + // A logged-in browser, and a page render proving the session works. + session := &http.Cookie{Name: tokenCookie, Value: h.token} + req, _ := http.NewRequest("GET", h.ts.URL+"/", nil) + req.AddCookie(session) + resp, err := h.ts.Client().Do(req) + if err != nil { + t.Fatal(err) + } + raw, _ := io.ReadAll(resp.Body) + resp.Body.Close() + if !strings.Contains(string(raw), ">friend<") { + t.Fatal("setup: the session is not logged in") + } + + var replacement string + if err := h.tokens.Update("friend", func(tok *auth.Token) error { + s, err := tok.SetGenerated() + replacement = s + return err + }); err != nil { + t.Fatal(err) + } + + // The old cookie is now just a string. + req, _ = http.NewRequest("GET", h.ts.URL+"/", nil) + req.AddCookie(session) + resp, err = h.ts.Client().Do(req) + if err != nil { + t.Fatal(err) + } + raw, _ = io.ReadAll(resp.Body) + resp.Body.Close() + if strings.Contains(string(raw), ">friend<") { + t.Error("a rotated-away secret still authenticates a session") + } + if !strings.Contains(string(raw), "no longer valid") { + t.Error("the page does not explain that the session ended") + } + + // Uploading with the old secret is refused; the new one works. + old := h.upload(t, []byte("x"), map[string]string{"Authorization": "Bearer " + h.token}) + old.Body.Close() + if old.StatusCode != http.StatusUnauthorized { + t.Errorf("upload with the old secret => %s, want 401", old.Status) + } + fresh := h.upload(t, []byte("x"), map[string]string{ + "Authorization": "Bearer " + replacement, + "Vanity": "after-rotation", + }) + if fresh.StatusCode != http.StatusCreated { + t.Fatalf("upload with the rotated secret => %s", fresh.Status) + } + if res := decode[uploadResult](t, fresh); res.ID != "after-rotation" { + t.Errorf("id = %q: the rotated token lost its vanity permission", res.ID) + } +} diff --git a/token.go b/token.go index de41cc3..3975e32 100644 --- a/token.go +++ b/token.go @@ -10,6 +10,7 @@ import ( "path/filepath" "strings" "text/tabwriter" + "time" "send/internal/auth" "send/internal/config" @@ -20,6 +21,8 @@ const tokenUsage = `send token - manage upload credentials Usage: send token add [options] generate a token send token add --token - read a chosen one from stdin + send token rotate [--token -] replace the secret, keep everything else + send token set [options] change limits and flags in place send token list [options] send token rm [options] @@ -70,14 +73,26 @@ func tokenCommand(args []string) error { if err != nil { return err } - warnIfUnusedDataDir(opts.dataDir, opts.tokensPath) - switch sub { case "add": if name == "" { return errors.New("token add: a name is required") } + // Only worth saying when a file is about to be created somewhere new. + // Every other subcommand reports a wrong --data on its own, by finding + // no such token or an empty list. + warnIfUnusedDataDir(opts.dataDir, opts.tokensPath) return tokenAdd(file, name, opts) + case "rotate": + if name == "" { + return errors.New("token rotate: a name is required") + } + return tokenRotate(file, name, opts) + case "set": + if name == "" { + return errors.New("token set: a name is required") + } + return tokenSet(file, name, opts, fs) case "list": return tokenList(file) case "rm", "remove", "delete": @@ -203,6 +218,98 @@ func tokenAdd(file *auth.File, name string, opts tokenOptions) error { return nil } +// tokenRotate replaces a token's secret while keeping its name, limits, flags +// and history. Anyone still holding the old secret, in a script or in a browser +// session, stops being authenticated the moment this returns. +func tokenRotate(file *auth.File, name string, opts tokenOptions) error { + chosen := opts.chosen + if chosen == "-" { + read, err := readSecret() + if err != nil { + return err + } + chosen = read + } + + var generated string + err := file.Update(name, func(t *auth.Token) error { + if chosen != "" { + return t.SetChosen(chosen) + } + secret, err := t.SetGenerated() + generated = secret + return err + }) + if err != nil { + return err + } + + fmt.Printf("Rotated token %q in %s\n\n", name, file.Path()) + if generated != "" { + fmt.Printf(" %s\n\n", generated) + fmt.Println("This is the only time it is shown; only its hash is stored.") + } + fmt.Println("Anything still using the old secret is now refused, including") + fmt.Println("browser sessions, which will have to log in again.") + return nil +} + +// tokenSet changes limits and flags in place. Only the options actually given +// on the command line are applied, so there is no way to reset something by +// forgetting to mention it. +func tokenSet(file *auth.File, name string, opts tokenOptions, fs *config.Set) error { + var changes []string + err := file.Update(name, func(t *auth.Token) error { + for _, f := range []struct { + flag string + value string + dst **string + }{ + {"max-size", opts.maxSize, &t.MaxSize}, + {"max-expiry", opts.maxExpiry, &t.MaxExpiry}, + {"default-expiry", opts.defExpiry, &t.DefaultExpiry}, + } { + if !fs.Changed(f.flag) { + continue + } + if f.value == "" { + *f.dst = nil // back to inheriting the server default + changes = append(changes, "--"+f.flag+" (inherited)") + continue + } + v := f.value + *f.dst = &v + changes = append(changes, "--"+f.flag+" "+v) + } + for _, f := range []struct { + flag string + value bool + dst *bool + }{ + {"vanity", opts.vanity, &t.AllowVanity}, + {"admin", opts.admin, &t.Admin}, + } { + if !fs.Changed(f.flag) { + continue + } + *f.dst = f.value + changes = append(changes, fmt.Sprintf("--%s=%t", f.flag, f.value)) + } + if fs.Changed("token") { + return errors.New("use \"send token rotate\" to change the secret") + } + return nil + }) + if err != nil { + return err + } + if len(changes) == 0 { + return errors.New("token set: nothing to change; give at least one option") + } + fmt.Printf("Updated token %q: %s\n", name, strings.Join(changes, ", ")) + 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) { @@ -224,13 +331,13 @@ func tokenList(file *auth.File) error { return nil } w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0) - fmt.Fprintln(w, "NAME\tKIND\tMAX SIZE\tMAX EXPIRY\tDEFAULT\tVANITY\tADMIN\tCREATED") + fmt.Fprintln(w, "NAME\tKIND\tMAX SIZE\tMAX EXPIRY\tDEFAULT\tVANITY\tADMIN\tCREATED\tROTATED") for _, t := range tokens { - fmt.Fprintf(w, "%s\t%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\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")) + t.Created.Format("2006-01-02"), date(t.Rotated)) } return w.Flush() } @@ -242,6 +349,13 @@ func kind(t *auth.Token) string { return "generated" } +func date(t time.Time) string { + if t.IsZero() { + return "never" + } + return t.Format("2006-01-02") +} + func inherited(s *string) string { if s == nil { return "(default)"