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
+77 -12
View File
@@ -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
}