Add token rotation
This commit is contained in:
@@ -3,56 +3,30 @@
|
|||||||
A small self-hosted file drop. Uploads land in a flat data directory, expire on
|
A small self-hosted file drop. Uploads land in a flat data directory, expire on
|
||||||
their own, and are served back as inert attachments.
|
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 so that I don't have to manually manage the `tmp` folder on my generic web server.
|
||||||
normal day here — without a `tmp/` folder that grows forever.
|
|
||||||
|
|
||||||
- One static Go binary, standard library only. No database, no CSS or
|
* One static Go binary, standard library only.
|
||||||
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, unlock custom URLs, and can grant admin.
|
||||||
- Named tokens raise those limits, unlock custom URLs, and can grant admin.
|
|
||||||
- Everything expires unless a token says otherwise.
|
|
||||||
|
|
||||||
## Quickstart
|
## Quickstart
|
||||||
|
|
||||||
```
|
```
|
||||||
go build -o send .
|
go build
|
||||||
./send token add me --token - --vanity --admin # type a passphrase, or omit
|
./send token add me --vanity --admin
|
||||||
./send --port 8080 # --data defaults to ./data
|
./send
|
||||||
```
|
```
|
||||||
|
|
||||||
Open <http://localhost:8080>, click **Log in**, paste the token. That's it.
|
Open <http://localhost:8080>, click **Log in**, paste the token. That's it.
|
||||||
|
|
||||||
`--token -` reads a passphrase you choose from standard input; leave the flag
|
When you put it behind a reverse proxy that terminates TLS, set `--public-url`,
|
||||||
off and a random one is generated and printed once. Either way `--data` must
|
and make sure the proxy neither buffers request bodies nor imposes its own
|
||||||
name the *same directory* the server runs with, or the server will never see
|
upload limit.
|
||||||
the token.
|
|
||||||
|
|
||||||
Uploading from a script:
|
|
||||||
|
|
||||||
```
|
|
||||||
curl --data-binary @MyGame.zip \
|
|
||||||
-H 'Content-Disposition: attachment; filename="MyGame.zip"' \
|
|
||||||
-H 'Authorization: Bearer <token>' \
|
|
||||||
-H 'Vanity: my-game' -H 'Expiry: 7d' \
|
|
||||||
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.
|
|
||||||
|
|
||||||
## Options
|
## Options
|
||||||
|
|
||||||
**Read `./send --help` rather than this file.** It lists every option with its
|
**Read `./send --help` rather than this file.**
|
||||||
default and its environment variable, and unlike a README it cannot drift out
|
|
||||||
of date. `./send token --help` does the same for credentials.
|
|
||||||
|
|
||||||
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.
|
Every option also reads from `SEND_`-prefixed environment variables.
|
||||||
|
|
||||||
## Development
|
## Development
|
||||||
@@ -61,38 +35,3 @@ Every option also reads from `SEND_`-prefixed environment variables.
|
|||||||
go test ./...
|
go test ./...
|
||||||
go vet ./...
|
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.
|
|
||||||
|
|||||||
+77
-12
@@ -14,7 +14,6 @@ import (
|
|||||||
"io/fs"
|
"io/fs"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"slices"
|
|
||||||
"sort"
|
"sort"
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
@@ -71,6 +70,7 @@ type Token struct {
|
|||||||
AllowVanity bool `json:"allow_vanity"`
|
AllowVanity bool `json:"allow_vanity"`
|
||||||
Admin bool `json:"admin"`
|
Admin bool `json:"admin"`
|
||||||
Created time.Time `json:"created"`
|
Created time.Time `json:"created"`
|
||||||
|
Rotated time.Time `json:"rotated,omitempty"`
|
||||||
|
|
||||||
maxSize *int64
|
maxSize *int64
|
||||||
maxExpiry *time.Duration
|
maxExpiry *time.Duration
|
||||||
@@ -420,12 +420,7 @@ func (f *File) Add(t *Token) error {
|
|||||||
return ErrExists
|
return ErrExists
|
||||||
}
|
}
|
||||||
f.byName[t.Name] = t
|
f.byName[t.Name] = t
|
||||||
if t.Chosen() {
|
f.reindexLocked()
|
||||||
f.chosen = append(f.chosen, t)
|
|
||||||
} else {
|
|
||||||
f.byHash[t.Hash] = t
|
|
||||||
}
|
|
||||||
clear(f.verified)
|
|
||||||
return f.saveLocked()
|
return f.saveLocked()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -433,14 +428,11 @@ func (f *File) Add(t *Token) error {
|
|||||||
func (f *File) Remove(name string) error {
|
func (f *File) Remove(name string) error {
|
||||||
f.mu.Lock()
|
f.mu.Lock()
|
||||||
defer f.mu.Unlock()
|
defer f.mu.Unlock()
|
||||||
t, ok := f.byName[name]
|
if _, ok := f.byName[name]; !ok {
|
||||||
if !ok {
|
|
||||||
return ErrNotFound
|
return ErrNotFound
|
||||||
}
|
}
|
||||||
delete(f.byName, name)
|
delete(f.byName, name)
|
||||||
delete(f.byHash, t.Hash)
|
f.reindexLocked()
|
||||||
f.chosen = slices.DeleteFunc(f.chosen, func(c *Token) bool { return c == t })
|
|
||||||
clear(f.verified)
|
|
||||||
return f.saveLocked()
|
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) }
|
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
|
||||||
|
}
|
||||||
|
|||||||
@@ -339,3 +339,167 @@ func TestResolvedTracksWhatIsMemoised(t *testing.T) {
|
|||||||
t.Error("the memo survived a reload of the token file")
|
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")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ type Set struct {
|
|||||||
specs []*spec
|
specs []*spec
|
||||||
longs map[string]bool
|
longs map[string]bool
|
||||||
shorts map[string]bool
|
shorts map[string]bool
|
||||||
|
fromEnv map[string]bool
|
||||||
}
|
}
|
||||||
|
|
||||||
type spec struct {
|
type spec struct {
|
||||||
@@ -35,6 +36,7 @@ func NewSet(name, envPrefix string) *Set {
|
|||||||
envPrefix: envPrefix,
|
envPrefix: envPrefix,
|
||||||
longs: map[string]bool{},
|
longs: map[string]bool{},
|
||||||
shorts: map[string]bool{},
|
shorts: map[string]bool{},
|
||||||
|
fromEnv: map[string]bool{},
|
||||||
}
|
}
|
||||||
s.fs.Usage = func() {}
|
s.fs.Usage = func() {}
|
||||||
return s
|
return s
|
||||||
@@ -192,6 +194,7 @@ func (s *Set) applyEnv() error {
|
|||||||
if err := s.fs.Set(sp.long, v); err != nil {
|
if err := s.fs.Set(sp.long, v); err != nil {
|
||||||
return fmt.Errorf("%s: %w", s.envName(sp.long), err)
|
return fmt.Errorf("%s: %w", s.envName(sp.long), err)
|
||||||
}
|
}
|
||||||
|
s.fromEnv[sp.long] = true
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
@@ -208,6 +211,31 @@ func (s *Set) Parse(args []string) error {
|
|||||||
return s.fs.Parse(args)
|
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.
|
// PrintUsage renders the options in the "-x, --xxx" form the convention implies.
|
||||||
func (s *Set) PrintUsage(w io.Writer, header string) {
|
func (s *Set) PrintUsage(w io.Writer, header string) {
|
||||||
fmt.Fprint(w, header)
|
fmt.Fprint(w, header)
|
||||||
|
|||||||
@@ -1729,3 +1729,65 @@ func TestPassphraseSessionsAreMemoised(t *testing.T) {
|
|||||||
t.Error("the session was not memoised, so every request would derive again")
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import (
|
|||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
"text/tabwriter"
|
"text/tabwriter"
|
||||||
|
"time"
|
||||||
|
|
||||||
"send/internal/auth"
|
"send/internal/auth"
|
||||||
"send/internal/config"
|
"send/internal/config"
|
||||||
@@ -20,6 +21,8 @@ const tokenUsage = `send token - manage upload credentials
|
|||||||
Usage:
|
Usage:
|
||||||
send token add <name> [options] generate a token
|
send token add <name> [options] generate a token
|
||||||
send token add <name> --token - read a chosen one from stdin
|
send token add <name> --token - read a chosen one from stdin
|
||||||
|
send token rotate <name> [--token -] replace the secret, keep everything else
|
||||||
|
send token set <name> [options] change limits and flags in place
|
||||||
send token list [options]
|
send token list [options]
|
||||||
send token rm <name> [options]
|
send token rm <name> [options]
|
||||||
|
|
||||||
@@ -70,14 +73,26 @@ func tokenCommand(args []string) error {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
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")
|
||||||
}
|
}
|
||||||
|
// 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)
|
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":
|
case "list":
|
||||||
return tokenList(file)
|
return tokenList(file)
|
||||||
case "rm", "remove", "delete":
|
case "rm", "remove", "delete":
|
||||||
@@ -203,6 +218,98 @@ func tokenAdd(file *auth.File, name string, opts tokenOptions) error {
|
|||||||
return nil
|
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
|
// readSecret reads a token from standard input, so it need not appear in a
|
||||||
// shell history or in the process list.
|
// shell history or in the process list.
|
||||||
func readSecret() (string, error) {
|
func readSecret() (string, error) {
|
||||||
@@ -224,13 +331,13 @@ 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\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 {
|
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),
|
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"), date(t.Rotated))
|
||||||
}
|
}
|
||||||
return w.Flush()
|
return w.Flush()
|
||||||
}
|
}
|
||||||
@@ -242,6 +349,13 @@ func kind(t *auth.Token) string {
|
|||||||
return "generated"
|
return "generated"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func date(t time.Time) string {
|
||||||
|
if t.IsZero() {
|
||||||
|
return "never"
|
||||||
|
}
|
||||||
|
return t.Format("2006-01-02")
|
||||||
|
}
|
||||||
|
|
||||||
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