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") } } // --- 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") } }