Add custom token support

This commit is contained in:
2026-09-13 01:01:48 +02:00
parent 1b77cdd165
commit 48229f15a2
12 changed files with 680 additions and 305 deletions
+155
View File
@@ -3,6 +3,7 @@ package auth
import (
"os"
"path/filepath"
"strings"
"testing"
"time"
@@ -184,3 +185,157 @@ func TestReloadPicksUpChanges(t *testing.T) {
t.Error("the newly written token was not picked up")
}
}
// --- chosen tokens -------------------------------------------------------
func TestChosenTokenRoundTrip(t *testing.T) {
f := newFile(t)
const passphrase = "godot-friends-2026"
tok, err := NewChosen("thayol", passphrase)
if err != nil {
t.Fatal(err)
}
if err := f.Add(tok); err != nil {
t.Fatal(err)
}
if got := f.Lookup(passphrase); got == nil || got.Name != "thayol" {
t.Fatalf("Lookup(passphrase) = %v", got)
}
if f.Lookup(passphrase+"x") != nil || f.Lookup("") != nil {
t.Error("a wrong passphrase authenticated")
}
}
// A chosen passphrase is guessable and probably reused, so the file must not
// give it up to anyone who reads it.
func TestChosenTokensAreNotStoredUnderAFastDigest(t *testing.T) {
f := newFile(t)
const passphrase = "correct-horse-battery"
tok, err := NewChosen("thayol", passphrase)
if err != nil {
t.Fatal(err)
}
if err := f.Add(tok); err != nil {
t.Fatal(err)
}
raw, err := os.ReadFile(f.Path())
if err != nil {
t.Fatal(err)
}
body := string(raw)
if strings.Contains(body, passphrase) {
t.Fatal("the passphrase is stored in the clear")
}
if strings.Contains(body, HashSecret(passphrase)) {
t.Fatal("the passphrase is stored under a plain sha256, which a wordlist breaks")
}
if !strings.Contains(body, KDFPBKDF2) {
t.Error("the entry does not record which derivation was used")
}
if tok.Iter < PBKDF2Iterations {
t.Errorf("iter = %d, want at least %d", tok.Iter, PBKDF2Iterations)
}
}
// Two people choosing the same passphrase must not produce the same stored
// hash, or cracking one would crack both.
func TestChosenTokensAreSaltedIndividually(t *testing.T) {
a, err := NewChosen("a", "the-same-passphrase")
if err != nil {
t.Fatal(err)
}
b, err := NewChosen("b", "the-same-passphrase")
if err != nil {
t.Fatal(err)
}
if a.Salt == b.Salt {
t.Error("two entries share a salt")
}
if a.Hash == b.Hash {
t.Error("the same passphrase produced the same hash under two entries")
}
if !a.Verify("the-same-passphrase") || !b.Verify("the-same-passphrase") {
t.Error("a salted entry does not verify its own passphrase")
}
}
func TestChosenTokenLengthIsEnforced(t *testing.T) {
// Derived from the constant rather than written out, so tuning the floor
// stays a one-line change instead of a puzzle about which literals moved.
for _, short := range []string{"", strings.Repeat("a", MinChosenLength-1)} {
if _, err := NewChosen("n", short); err != ErrTokenTooShort {
t.Errorf("NewChosen(%d chars) = %v, want ErrTokenTooShort", len(short), err)
}
}
if _, err := NewChosen("n", strings.Repeat("a", MinChosenLength)); err != nil {
t.Errorf("a token at the minimum length was refused: %v", err)
}
if _, err := NewChosen("n", strings.Repeat("a", 300)); err != ErrTokenTooLong {
t.Error("an absurdly long token was accepted")
}
}
// Generated tokens must keep the cheap path: they are 256-bit random values,
// so a derivation would buy nothing and cost a great deal.
func TestGeneratedTokensStayOnTheFastPath(t *testing.T) {
f := newFile(t)
tok, secret, err := NewGenerated("script")
if err != nil {
t.Fatal(err)
}
if tok.Chosen() {
t.Error("a generated token was marked as chosen")
}
if tok.KDF != "" || tok.Salt != "" {
t.Error("a generated token carries derivation parameters it does not need")
}
if err := f.Add(tok); err != nil {
t.Fatal(err)
}
if !f.Resolved(secret) {
t.Error("a generated token needs slow work to resolve")
}
if got := f.Lookup(secret); got == nil || got.Name != "script" {
t.Fatalf("Lookup = %v", got)
}
}
// Resolved is what lets the server decide whether to charge for the work, so
// it has to be honest in both directions.
func TestResolvedTracksWhatIsMemoised(t *testing.T) {
f := newFile(t)
const passphrase = "a-chosen-passphrase"
tok, err := NewChosen("thayol", passphrase)
if err != nil {
t.Fatal(err)
}
if err := f.Add(tok); err != nil {
t.Fatal(err)
}
if f.Resolved(passphrase) {
t.Error("an underived passphrase reported as already resolved")
}
f.Lookup(passphrase)
if !f.Resolved(passphrase) {
t.Error("a derived passphrase was not memoised")
}
// Negative results are memoised too, so repeated junk stays cheap.
f.Lookup("junk-that-is-wrong")
if !f.Resolved("junk-that-is-wrong") {
t.Error("a failed derivation was not memoised")
}
// Reloading invalidates the memo, since the entries may have changed.
if err := f.Reload(); err != nil {
t.Fatal(err)
}
if f.Resolved(passphrase) {
t.Error("the memo survived a reload of the token file")
}
}