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
+164
View File
@@ -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")
}
}