Files
uncensored-send/internal/auth/tokens_test.go
T
2026-09-13 01:01:48 +02:00

342 lines
9.6 KiB
Go

package auth
import (
"os"
"path/filepath"
"strings"
"testing"
"time"
"send/internal/config"
)
func defaults() *config.Config {
return &config.Config{
MaxSize: 2 << 30,
MaxExpiry: 72 * time.Hour,
DefaultExpiry: 72 * time.Hour,
}
}
func newFile(t *testing.T) *File {
t.Helper()
f, err := Load(filepath.Join(t.TempDir(), "tokens.json"))
if err != nil {
t.Fatal(err)
}
return f
}
func TestMissingFileIsNotAnError(t *testing.T) {
f := newFile(t)
if len(f.List()) != 0 {
t.Error("a missing token file produced tokens")
}
if f.Lookup("anything") != nil {
t.Error("a missing token file authenticated something")
}
}
func TestAddLookupRemove(t *testing.T) {
f := newFile(t)
secret := "0123456789abcdef0123456789abcdef"
if err := f.Add(&Token{Name: "friend", Hash: HashSecret(secret), AllowVanity: true}); err != nil {
t.Fatal(err)
}
tok := f.Lookup(secret)
if tok == nil || tok.Name != "friend" {
t.Fatalf("Lookup(secret) = %v", tok)
}
if f.Lookup("wrong") != nil || f.Lookup("") != nil {
t.Error("an unknown secret authenticated")
}
// A second token with the same name is refused.
if err := f.Add(&Token{Name: "friend", Hash: HashSecret("other")}); err != ErrExists {
t.Errorf("duplicate name => %v, want ErrExists", err)
}
if err := f.Remove("friend"); err != nil {
t.Fatal(err)
}
if f.Lookup(secret) != nil {
t.Error("a removed token still authenticates")
}
if err := f.Remove("friend"); err != ErrNotFound {
t.Errorf("removing twice => %v, want ErrNotFound", err)
}
}
// The token file holds credential material, so it is the one thing in the data
// directory that must stay owner-only.
func TestFilePermissions(t *testing.T) {
f := newFile(t)
if err := f.Add(&Token{Name: "a", Hash: HashSecret("s")}); err != nil {
t.Fatal(err)
}
info, err := os.Stat(f.Path())
if err != nil {
t.Fatal(err)
}
if perm := info.Mode().Perm(); perm != 0o600 {
t.Errorf("token file mode = %#o, want 0600", perm)
}
// A file loosened by hand must be refused rather than silently used.
if err := os.Chmod(f.Path(), 0o644); err != nil {
t.Fatal(err)
}
if _, err := Load(f.Path()); err == nil {
t.Error("a world-readable token file was accepted")
}
}
func TestLimitsInheritDefaults(t *testing.T) {
c := defaults()
// A token with nothing set behaves like the anonymous tier, except that it
// has a name and may claim vanity names.
bare := &Token{Name: "bare", Hash: HashSecret("bare"), AllowVanity: true}
got := bare.Limits(c)
want := Anonymous(c)
want.Name, want.AllowVanity = "bare", true
if got != want {
t.Errorf("bare token limits = %+v, want %+v", got, want)
}
// Overrides win, including "unlimited".
size, expiry := "8GiB", "never"
rich := &Token{Name: "rich", Hash: HashSecret("rich"), MaxSize: &size, MaxExpiry: &expiry}
if err := rich.resolve(); err != nil {
t.Fatal(err)
}
l := rich.Limits(c)
if l.MaxSize != 8<<30 {
t.Errorf("MaxSize = %d, want 8GiB", l.MaxSize)
}
if l.MaxExpiry != config.Unlimited {
t.Errorf("MaxExpiry = %s, want unlimited", l.MaxExpiry)
}
// The inherited 3d default is still fine under an unlimited maximum.
if l.DefaultExpiry != c.DefaultExpiry {
t.Errorf("DefaultExpiry = %s, want the inherited %s", l.DefaultExpiry, c.DefaultExpiry)
}
}
func TestDefaultExpiryIsClampedToTheMaximum(t *testing.T) {
c := defaults()
short := "1h"
// A token that narrows its maximum below the inherited default must not
// end up handing out the longer inherited lifetime.
tok := &Token{Name: "short", Hash: HashSecret("short"), MaxExpiry: &short}
if err := tok.resolve(); err != nil {
t.Fatal(err)
}
if l := tok.Limits(c); l.DefaultExpiry != time.Hour {
t.Errorf("DefaultExpiry = %s, want it clamped to 1h", l.DefaultExpiry)
}
}
func TestMalformedTokenFileIsRejected(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "tokens.json")
for _, body := range []string{
`[{"name":"a","hash":"not-hex"}]`,
`[{"name":"","hash":"` + HashSecret("s") + `"}]`,
`[{"name":"a","hash":"` + HashSecret("s") + `","max_size":"lots"}]`,
`[{"name":"a","hash":"` + HashSecret("s") + `","max_expiry":"soon"}]`,
`[{"name":"a","hash":"` + HashSecret("1") + `"},{"name":"a","hash":"` + HashSecret("2") + `"}]`,
`not json`,
} {
if err := os.WriteFile(path, []byte(body), 0o600); err != nil {
t.Fatal(err)
}
if _, err := Load(path); err == nil {
t.Errorf("accepted a malformed token file: %s", body)
}
}
}
func TestReloadPicksUpChanges(t *testing.T) {
f := newFile(t)
secret := "aaaa"
if err := f.Add(&Token{Name: "a", Hash: HashSecret(secret)}); err != nil {
t.Fatal(err)
}
// Simulate an edit by another process.
body := `[{"name":"b","hash":"` + HashSecret("bbbb") + `","allow_vanity":true}]`
if err := os.WriteFile(f.Path(), []byte(body), 0o600); err != nil {
t.Fatal(err)
}
// Ensure the mtime actually differs on filesystems with coarse timestamps.
future := time.Now().Add(time.Second)
os.Chtimes(f.Path(), future, future)
if err := f.MaybeReload(); err != nil {
t.Fatal(err)
}
if f.Lookup(secret) != nil {
t.Error("a removed token still authenticates after a reload")
}
if tok := f.Lookup("bbbb"); tok == nil || !tok.AllowVanity {
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")
}
}