Add token rotation

This commit is contained in:
2026-09-13 01:09:46 +02:00
parent 48229f15a2
commit aad1b931ba
6 changed files with 461 additions and 89 deletions
+119 -5
View File
@@ -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 <name> [options] generate a token
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 rm <name> [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)"