Add token rotation
This commit is contained in:
+77
-12
@@ -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
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user